Заняття 08 · місяць 2

Ламаємо сервіс сусіда, лагодимо свій

90 хвилин

Мета заняття: за домовленістю спробувати зламати сервіс однокласника, знайти в себе те саме й навчитися читати записи, які твоя програма лишає про свою роботу.

Мій сайт
http://91.219.61.4/u/<логін>/
Мій кабінет
http://91.219.61.4/k/<логін>/
Вхід на сервер
ssh <логін>@91.219.61.4

Замість <логін> підставляєш свій логін виду bohdan.g9: ім’я, крапка, номер групи. Кутові дужки не пишуться. Кабінет — сторінка, де проти кожного критерію стоїть відмітка, зарахований він чи ні. У прикладах на цій сторінці твій логін — bohdan.g9, а логін однокласника, сервіс якого ти перевіряєш, — marta.g9. Обидва читай як справжні логіни й виправляй у кожній команді, яку копіюєш.

Що ти зробиш сьогодні

Перші двадцять хвилин практики ти працюватимеш із сервісом однокласника. Надсилатимеш йому запити, від яких програма може завершитися з помилкою: дуже довгий текст у полі, лапки й інші символи, які щось означають у мові запитів до бази, від’ємні числа, чужий і неіснуючий номер запису в адресі. Кожну знахідку запишеш у файл BUGS.md того, чий сервіс перевіряв.

Наступні тридцять сім хвилин — свій сервіс. Підключиш готовий файл log.py, який записує роботу програми у ~/logs/app.log, допишеш кілька повідомлень у власний код, додаси обробник, після якого будь-яка несподівана помилка стає відповіддю з кодом, а не обірваним з’єднанням, і проженеш по своєму сервісу той самий набір запитів, яким тебе перевіряв сусід.

Баги, які знайшов у тебе сусід, ти лагодитимеш удома. На занятті на це часу немає, і це запланована частина роботи, а не відставання.

Правила, які не обговорюються Перевіряти можна тільки того однокласника, який сам дав тобі адресу свого сервісу й тестовий логін до нього. Тільки через HTTP — тобто звичайними запитами до сайту, з браузера або командою curl. Заходити в чужий акаунт через ssh, підбирати чужий пароль, відкривати чужі файли на сервері не можна: за це відраховують з курсу. Про це говорили на занятті 01; сьогодні це правило вперше стосується того, що ти справді робиш руками.

Теорія

1. Коди 4xx і 5xx: хто саме помилився

Кожна відповідь сервера починається з тризначного числа — коду відповіді. Ти вже бачив кілька: 200 (усе гаразд), 401 (потрібно увійти, заняття 07), 404 (такої адреси або запису немає), 422 (дані не пройшли перевірку, заняття 05), 500 і 502 (щось не так на сервері).

Перша цифра ділить усі коди на групи. Сьогодні важливі дві.

Групу визначає одне: чи була ситуація передбачена автором коду. Порожня назва в полі — ситуація передбачена: у моделі стоїть min_length=1, FastAPI відповідає 422, у журналі нічого поганого не з’являється, сервіс працює далі. Якщо ж на порожню назву приходить 500, значить, до перевірки справа не дійшла і програма зупинилася на чомусь, чого автор не очікував.

500 Internal Server Error
Твоя програма отримала запит, почала його виконувати й завершилася з помилкою. Причина завжди в коді ендпоінта: неправильна назва стовпця в SQL, ділення на нуль, звернення до rows[0], коли rows порожній.
502 Bad Gateway
Це відповідає не твоя програма, а nginx: він передав їй запит і не отримав нічого. Найчастіше означає, що служба club-api зупинена або саме в цю секунду перезапускається.
4xx і 5xx
Скорочений запис групи кодів: 4xx — це 400, 401, 404, 422 і решта, що починаються з чотирьох. Іксами позначають будь-які цифри.

Сьогоднішня перевірка шукає саме 5xx. Кожен запит, на який твій сервіс відповів кодом із п’ятірки на початку, — це знахідка, і саме її сусід запише тобі у BUGS.md.

Питання на розуміння

Марта надіслала в сервіс Богдана запит на додавання запису, де в полі days замість числа стояло слово «три». Сервіс відповів 500. Чия це помилка і що вона означає?

2. Лог: те, що програма записує про свою роботу

Коли програма працює на твоєму комп’ютері, ти бачиш її вивід у вікні термінала. На сервері club-api запущена у фоні: вікна немає й дивитися нема куди. Рядок print("сюди дійшли") нікуди не зникає — його забирає systemd, частина системи, яка запускає служби й стежить за ними (заняття 02), — і складає у свій журнал. Прочитати можна командою journalctl --user -u club-api, ти вже це робив.

Незручності починаються, коли таких рядків стає багато.

Усе це додає модуль logging — він входить у Python, ставити нічого не треба. Замість print пишуть log.info(...) або log.error(...), а модуль сам дописує до рядка час, рівень і назву місця.

Лог (журнал)
Рядки, які програма пише про свою роботу: що вона зробила, з якими даними і чим це скінчилося. Лог не бачить відвідувач сайту — його читає той, хто цей сервіс тримає.
Рівень повідомлення
Позначка важливості на початку рядка. Використовуємо три: INFO — звичайна подія, усе за планом; WARNING — щось незвичне, але сервіс упорався (неправильний пароль, звернення до неіснуючого запису); ERROR — помилка, яку треба виправляти в коді.
logging
Стандартний модуль Python для таких записів. Одне налаштування на початку роботи програми — і далі всі рядки з усіх файлів ідуть в одному форматі й в одне місце.
journalctl
Команда, яка показує те, що systemd зібрав від служби. Ключ -u club-api обмежує вивід однією службою, -n 30 — останніми тридцятьма рядками.

Один рядок логу після сьогоднішнього налаштування виглядатиме так:

2026-09-06 14:03:11  ERROR     app  DELETE /api/habits/8 — необроблена помилка

Час, рівень, назва місця, повідомлення. Далі, коли сервіс поводиться дивно, ти не здогадуєшся, а дивишся: о котрій це почалося і що саме тоді відбувалося.

Що писати в повідомленні Рядок щось пішло не так не допоможе нікому, зокрема й тобі через тиждень. Придатне повідомлення відповідає на три питання: яка дія, з якими даними, чим скінчилася. створено запис id=41 title='Пробіжка' — придатне. помилка — ні. І окреме правило: паролів, вмісту кук і секретів у лог не пишуть ніколи. Лог лежить у файлі, який читають кілька людей, і потрапивши туди один раз, пароль лишається там надовго.
Питання на розуміння

Данило скаржиться: «сервіс іноді віддає 500, а іноді ні». У коді він розставив print("тут") у п’яти місцях. Чому це не допомагає знайти причину?

3. SQL-ін’єкція: коли введений текст стає частиною команди

Запит до бази — це звичайний текст. База отримує рядок на кшталт SELECT * FROM owner WHERE login = 'bohdan.g9' і виконує його. Небезпека виникає тоді, коли програма складає цей рядок, приклеюючи до нього те, що ввела людина: база отримує один суцільний текст і не може відрізнити, де тут команда автора, а де дані відвідувача.

Ось як це виглядає. Уяви, що вхід написаний так:

rows = query("SELECT * FROM owner WHERE login = '" + login + "'")

Якщо людина ввела bohdan.g9, у базу піде очікуваний запит:

SELECT * FROM owner WHERE login = 'bohdan.g9'

А якщо вона ввела ' OR '1'='1, то в базу піде вже інше:

SELECT * FROM owner WHERE login = '' OR '1'='1'

Умова '1'='1' істинна завжди, тому запит поверне всі рядки таблиці власників. Функція входу отримає перший із них і вважатиме, що людина увійшла. Пароля вона при цьому не знала — вона знала, що її текст потрапить у запит як його частина.

SQL-ін’єкція
Ситуація, коли текст, який людина ввела в поле або дописала в адресу, потрапляє в запит до бази як частина команди й змінює зміст цієї команди.
Параметризований запит
Запит, у якому замість значень стоять знаки питання, а самі значення передаються окремо, другим аргументом. Текст запиту й дані йдуть до бази різними шляхами, тому дані ніколи не стають командою — що б у них не було.

Параметризований варіант того самого рядка:

rows = query("SELECT * FROM owner WHERE login = ?", (login,))

Тепер текст ' OR '1'='1 — це просто значення. База шукатиме власника, чий логін дослівно дорівнює рядку ' OR '1'='1, не знайде його й поверне порожній результат. Саме так має бути.

У твоєму сервісі це вже зроблено — але не скрізь, а там, куди ти не втручався Файл db.py і всі приклади із занять 04, 05 і 07 передають значення окремо, через знаки питання. Небезпечним місце стає тоді, коли хтось складає запит сам — через + або через f-рядок: query(f"SELECT * FROM habit WHERE id = {item_id}"). На практиці ми пошукаємо такі рядки у твоєму коді командою grep.

Одне уточнення, щоб не було плутанини. Знаком питання передають значення: номер, назву, дату. Назву таблиці або стовпця так передати не можна — вони є частиною самого тексту запиту, і там f-рядок доречний. Але підставляти туди можна лише те, що написав ти сам: назву зі свого project.yaml, а не рядок, що прийшов із поля на сторінці.

Питання на розуміння

У сервісі Софії є пошук: query(f"SELECT * FROM habit WHERE title LIKE '%{text}%'"), де text приходить із поля на сторінці. Софія каже: «поле для пошуку маленьке, туди все одно нічого довгого не введеш». Що з цим не так?

4. URL і його параметри

URL — повна адреса, за якою браузер або curl звертається до сервера. Досі ти набирав її цілком і не розбирав на частини. Сьогодні розібрати доведеться: саме через окремі частини адреси й перевіряють чужий сервіс.

http://91.219.61.4/u/bohdan.g9/api/habits?limit=10&order=new
http://
Схема — набір правил, за якими браузер спілкується з сервером. У нас поки що http, на занятті 10 стане https.
91.219.61.4
Адреса сервера в мережі. Тут вона числова; замість неї могло б стояти доменне ім’я.
/u/bohdan.g9/api/habits
Шлях. За першими двома частинами шляху nginx розуміє, чиєму сервісу віддати запит, решту розбирає вже твій код.
?limit=10&order=new
Параметри. Починаються зі знака питання, кожен має вигляд «назва=значення», а між собою вони розділяються знаком &.

Параметр доходить до твоєї функції тоді, коли в неї є аргумент із такою самою назвою: def list_habits(limit: int = 20) отримає limit=10. Якщо аргумента з такою назвою немає, FastAPI просто не бере параметр до уваги — сервіс відповідає так само, як і без нього.

Частина шляху теж буває значенням. В адресі /api/habits/8 число 8 — це ідентифікатор запису, у коді ендпоінта він записаний як {habit_id}. Тобто в адресу потрапляють дані, і потрапляють вони туди від того, хто надсилає запит.

Це і є головна думка цього блоку. Усе, що написано в URL, пише той, хто робить запит. Посилання на сторінці ведуть тільки на наявні записи, але сторінка тут ні до чого: адресу набирають руками в браузері або підставляють у curl. Тому в шляху може опинитися -1, abc, 999999 або ідентифікатор чужого запису, а в параметрі — текст із лапкою всередині. Сервіс має відповісти на кожен такий запит осмислено: 404, якщо запису немає, 422, якщо значення не того типу. Код 500 тут означає, що про такий випадок автор не подумав.

Ще одна деталь. Пробіл, лапка й українські літери в адресі напряму стояти не можуть, їх записують кодом: пробіл — %20, апостроф — %27. Робити це самому не треба, curl уміє сам:

curl -s --get "http://91.219.61.4/u/marta.g9/api/habits" --data-urlencode "q=' OR 1=1 --"

--get велить надіслати GET, а --data-urlencode — додати параметр до адреси, замінивши в значенні всі символи, яких там бути не може. Без цього частину рядка забрав би собі bash, а решту сервер прочитав би не так, як ти задумав.

Питання на розуміння

Сервіс Софії показує один запис за адресою /api/habits/8. На сторінці є посилання тільки на ті записи, які справді є в списку. Марта набрала в браузері /api/habits/-1 і отримала 500. Софія відповідає: «такого посилання на моїй сторінці немає, звідки їй узагалі взяти таку адресу». Чому це не відповідь?

5. journalctl: коли саме почалися помилки

Із заняття 02 твій сервіс працює як служба club-api: його запускає systemd — частина системи, яка тримає програми ввімкненими й перезапускає їх. Усе, що служба друкує, systemd забирає собі й складає в журнал із позначкою часу. Команда, яка цей журнал показує, — journalctl.

journalctl --user -u club-api -n 30 --no-pager
journalctl --user -u club-api --since "10 minutes ago" --no-pager
journalctl --user -u club-api -p err --no-pager
journalctl --user -u club-api -f
--user
Шукати серед служб, запущених від твого імені. Без цього ключа команда шукає серед системних служб, до яких у тебе доступу немає, і не знаходить нічого.
-u club-api
Обмежити вивід однією службою. Без цього ключа підуть записи від усіх твоїх програм.
-n 30
Останні тридцять рядків. Цікаве майже завжди в кінці.
--since
Показати записи, новіші за вказаний момент. Приймає і "10 minutes ago", і точний час у вигляді "2026-09-06 14:00".
-p err
Лишити тільки записи рівня «помилка» і вище.
-f
Не завершуватися, а дописувати нові рядки, щойно вони з’являються. Вихід — Ctrl+C.
--no-pager
Надрукувати все одразу. Без цього ключа довгий вивід відкривається в окремому режимі перегляду, з якого виходять клавішею q.

Питання, на яке журнал відповідає найкраще, — коли це почалося. Сервіс, який не працює останні дві хвилини, і сервіс, який не працює з учорашнього вечора, — це різні задачі. Порядок дій один і той самий:

  1. Подивитися стан служби: systemctl --user status club-api --no-pager. Рядок active (running) означає, що вона працює, failed — що вона зупинилася й не піднялася.
  2. Знайти в журналі найраніший запис про помилку й запам’ятати його час.
  3. Подивитися, що ти міняв незадовго до цього часу: git log --since "2 hours ago" --oneline. Найчастіше причина саме там.

Записи про роботу сервісу будуть у двох місцях, і різниця між ними важлива. У файл ~/logs/app.log, який ти налаштуєш у практиці, потрапляє тільки те, що написала твоя програма. У журналі systemd є ще й рядки uvicorn про кожен запит, повідомлення про запуск та зупинку служби й текст помилки, через яку служба не змогла запуститися. Тому коли у файлі порожньо, це саме по собі відповідь: до першого рядка твого коду справа не дійшла, а причину треба шукати в журналі.

Питання на розуміння

Данило каже: «сайт не відкривається взагалі, у відповідь приходить 502. Я подивився ~/logs/app.log — за сьогодні там жодного рядка, отже, помилок немає». Де насправді причина і чому у файлі порожньо?

6. Чому «десять запитів поспіль» нічого не ламає

На занятті хтось обов’язково спробує надіслати багато запитів підряд і подивитися, чи витримає сервіс однокласника. Витримає — і корисно розуміти, чому.

uvicorn — програма, яка приймає запити з мережі й передає їх твоєму коду; саме вона запускає main.py і працює як служба club-api. Запити вона обробляє один за одним, і кожен займає кілька мілісекунд. Десять запитів поспіль — це десята частка секунди роботи, сто — приблизно секунда. У практиці є команда, яка це показує, і подивитися на неї варто: видно, що відповіді не міняються й коди лишаються ті самі.

Як спосіб зламу це не працює, і причини дві. Перша: сервіс нічого не тримає між запитами. Відповів — і від запиту нічого не лишилося, тож накопичуватися нема чому. Друга: кількість, від якої сервер справді перестає відповідати, вимірюється тисячами запитів за секунду з багатьох машин одночасно. З одного термінала стільки не надішлеш, а якби надіслав — стало б повільніше всій групі, бо сервер спільний, і в журналі було б видно, з якого логіна це йшло. Тому в практиці стоїть саме десять запитів, а не тисяча.

Сервіс кладе не кількість, а вміст. Один-єдиний запит, після якого код зупиняється з помилкою, дає 500 — і саме такі запити ми сьогодні й шукаємо.

Питання на розуміння

Тарас надіслав на сервіс однокласниці сто запитів GET підряд. Усі сто відповіли 200, сервіс працює далі. Тарас записує їй у BUGS.md: «надіслав 100 запитів, сервіс витримав». Що не так із цим записом?

Практика

У прикладах — таблиця habit і ендпоінти /api/habits із занять 04 і 05. Підставляй свої назви: вони записані у project.yaml у розділах database і endpoints. Логін однокласника в прикладах — marta.g9.

1

Домовитися й обмінятися трьома речами

Пари складає викладач. З однокласником, який дістався тобі в пару, ви обмінюєтесь трьома речами, і кожен записує їх собі:

  1. адреса сайту: http://91.219.61.4/u/marta.g9/;
  2. адреса ендпоінта зі списком із project.yaml, наприклад /api/habits, і назви полів, які він приймає;
  3. тестовий логін і пароль до нього.

Тестовий логін — це окремий запис у твоїй таблиці owner, який ти створюєш тільки на сьогодні й прибираєш наприкінці заняття. Свій особистий пароль не називають нікому й ніколи, зокрема й однокласникові, і зокрема на одне заняття.

Навіщо взагалі видавати логін Із заняття 07 POST і DELETE у тебе закриті. Без входу сусід перевірить рівно дві речі: чи відкривається список і що відповідає форма входу на неправильний пароль. Це три запити, і заняття на них не побудуєш. З тестовим логіном він дістається до всього, що приймає дані, — а саме там і трапляються помилки, які ми сьогодні шукаємо.
2

Створити тестовий логін у себе

Користуємось тим самим скриптом, що й на занятті 07. Він уже вміє додавати другого власника — саме гілка INSERT у ньому для цього й потрібна.

ssh bohdan.g9@91.219.61.4
cd ~/app
/opt/club/venv/bin/python set-password.py

На питання відповідай так:

Логін власника: test.guest
Пароль: proba-08-proba
Ще раз: proba-08-proba

Пароль тут навмисно простий і не таємний: він живе одне заняття, і його все одно доведеться продиктувати однокласникові. Головне — щоб він не збігався з жодним твоїм справжнім паролем. Перевір, що в таблиці стало два рядки:

sqlite3 ~/data/app.db "SELECT login FROM owner;"

Маєш побачити bohdan.g9 і test.guest. Продиктуй однокласникові логін, пароль і адресу сайту; він продиктує свої.

3

Завести BUGS.md у себе

Це файл, у який записують знайдені баги. Заповнює його той, хто баг знайшов, і підписується своїм іменем.

nano ~/app/BUGS.md

Створи файл із заголовком і одним прикладом, за яким видно потрібний формат. Приклад залиш — він не заважає й нагадує про порядок рядків:

# Знайдені баги

Заповнює той, хто знайшов. Один баг — один блок із п’яти рядків.
Рядок «Статус» міняє на «закрито» власник, коли виправив.
Рядок «Перевірив» дописує той, хто знайшов, коли переконався, що баг справді закрито.

## Баг 0. Приклад запису, за яким видно формат
Знайшов: —
Запит: POST /api/habits, тіло {"title": "", "days": 3}
Відповідь: 500
Причина: у моделі NewHabit немає обмеження min_length, а в коді немає перевірки на порожній рядок
Статус: приклад

П’ять рядків обов’язкові, і рядок Причина — головний із них. Саме його оцінює людина, а не програма. «Щось зламалося» причиною не є. Причина — це назва місця в коді й того, чого там бракує: «у функції delete_habit немає перевірки, що запис існує», «у моделі немає верхньої межі довжини назви».

Одразу після створення файл треба зберегти в git, щоб він не загубився:

cd ~/app
git add BUGS.md
git commit -m "BUGS.md: файл для багів, знайдених на перевірці"
4

Двадцять хвилин на сервісі однокласника

Далі всі команди звертаються до чужої адреси. Щоб не переписувати її щоразу, заведи дві змінні. Вони живуть до кінця сеансу ssh; якщо з’єднання обірветься, задай їх наново.

S=http://91.219.61.4/u/marta.g9
L=/api/habits
J="Content-Type: application/json"
C=/tmp/cookie-marta.txt

Спершу увійди тестовим логіном, який дав однокласник:

curl -s -c "$C" -X POST "$S/api/login" -H "$J" \
     -d '{"login":"test.guest","password":"proba-08-proba"}'

-c (від cookie jar) зберігає куку, яку видав сервер, у вказаний файл. -b у наступних командах бере куку з цього файлу й додає до запиту — те саме, що робить браузер сам. У відповідь має прийти {"ok":true,...}. Якщо прийшло 401, звір логін і пароль з однокласником посимвольно.

Тепер сам набір. Виконуй по черзі й дивись на код у кінці кожної відповіді. Команда -w "\n--- %{http_code}\n" дописує цей код окремим рядком.

Дуже довгий текст у полі

printf '{"title":"%s","days":3}' "$(printf 'a%.0s' $(seq 1 5000))" > /tmp/long.json
curl -s -w "\n--- %{http_code}\n" -b "$C" -X POST "$S$L" -H "$J" -d @/tmp/long.json

printf 'a%.0s' $(seq 1 5000) друкує літеру a п’ять тисяч разів. -d @/tmp/long.json — тіло запиту взяти з файлу; писати такий рядок прямо в команді незручно. Очікувана відповідь — 422: у моделі має стояти max_length. Якщо прийшло 200, текст на п’ять тисяч символів опинився в базі. Якщо 500 — це баг.

Лапка й текст, схожий на команду до бази

cat > /tmp/sql.json <<'EOF'
{"title": "' OR 1=1 --", "days": 3}
EOF
curl -s -w "\n--- %{http_code}\n" -b "$C" -X POST "$S$L" -H "$J" -d @/tmp/sql.json

cat > файл <<'EOF' кладе у файл усе до рядка EOF. Лапки навколо EOF потрібні, щоб bash не чіпав символи всередині. Правильна відповідь тут — 200 і запис, у якому назва дослівно дорівнює ' OR 1=1 --. Це означає, що текст сприйняли як текст. Відповідь 500 означає, що запит до бази склали склеюванням рядків.

Не ті типи даних

curl -s -w "\n--- %{http_code}\n" -b "$C" -X POST "$S$L" -H "$J" \
     -d '{"title": 12345, "days": "три"}'

Очікується 422. Число замість тексту й слово замість числа — обидва випадки має спинити модель.

Від’ємне й дуже велике число

curl -s -w "\n--- %{http_code}\n" -b "$C" -X POST "$S$L" -H "$J" \
     -d '{"title":"перевірка","days":-5}'
curl -s -w "\n--- %{http_code}\n" -b "$C" -X POST "$S$L" -H "$J" \
     -d '{"title":"перевірка","days":99999999999999999999}'

Обидва мають дати 422. Від’ємна кількість днів не має сенсу, а число з двадцяти цифр не вміщується в тип, з яким працює база.

Порожнє тіло й не JSON

curl -s -w "\n--- %{http_code}\n" -b "$C" -X POST "$S$L" -H "$J" -d '{}'
curl -s -w "\n--- %{http_code}\n" -b "$C" -X POST "$S$L" -H "$J" -d 'просто текст'

Обидва — 422. Другий випадок трапляється в житті частіше, ніж здається: так виглядає запит від програми з помилкою в коді.

Чужий ідентифікатор у шляху

Спершу подивись, які номери записів узагалі існують. Список відкритий для всіх, і в ньому видно id кожного запису:

curl -s "$S$L"

Тепер домовтеся вголос: власник називає один номер, який не шкода. Видаляти інші записи не можна — це його дані, і відновлювати їх довелося б руками. Якщо він не хоче втрачати жодного, попроси створити для цього окремий запис із назвою «можна видаляти». Підстав названий номер у шлях:

curl -s -o /dev/null -w "%{http_code}\n" -b "$C" -X DELETE "$S$L/8"

Прийде 200, і запис зникне. Помилки в коді тут немає: у сервісі один власник, а тестовий логін, який тобі дали, має рівно ті самі права, що й він. Але знахідка є, і її варто записати: сервіс перевіряє тільки те, що відвідувач увійшов, і не перевіряє, кому належить запис. Поки власник один, це нічому не заважає. Щойно користувачів стане двоє, будь-хто зможе стирати чужі записи, знаючи лише їхні номери з відкритого списку.

Параметри в адресі

Те, про що йшлося в четвертій частині теорії. Спочатку параметри, яких сервіс не чекає:

curl -s -o /dev/null -w "%{http_code}\n" "$S$L?limit=10"
curl -s -o /dev/null -w "%{http_code}\n" "$S$L?limit=-1"
curl -s -o /dev/null -w "%{http_code}\n" "$S$L?limit=abc"
curl -s -o /dev/null -w "%{http_code}\n" "$S$L?limit=99999999999999999999"

Якщо в ендпоінті списку немає аргументу з назвою limit, усі чотири дадуть 200 і той самий список: параметр нікуди не потрапляє. Це правильна поведінка, знахідки тут немає.

А от якщо однокласник зробив пошук або сортування — назву параметра він скаже сам, зазвичай це q, search або order, — підстав туди спершу текст із лапкою, а потім дуже довгий рядок:

curl -s -o /dev/null -w "%{http_code}\n" --get "$S$L" --data-urlencode "q=' OR 1=1 --"
curl -s -o /dev/null -w "%{http_code}\n" --get "$S$L" --data-urlencode "q=$(printf 'a%.0s' $(seq 1 5000))"

Очікується 200 і звичайний або порожній список: параметр — це значення, а не команда. Код 500 на першому рядку означає, що з параметра склали текст запиту до бази. Це найважливіша знахідка з можливих, і в рядку «Причина» так і пиши: запит до бази складено склеюванням рядків, значення треба передати окремо через знак питання.

Номер запису, якого немає, і номер, що не є числом

curl -s -o /dev/null -w "%{http_code}\n" -b "$C" -X DELETE "$S$L/999999"
curl -s -o /dev/null -w "%{http_code}\n" -b "$C" -X DELETE "$S$L/abc"
curl -s -o /dev/null -w "%{http_code}\n" -b "$C" -X DELETE "$S$L/-1"

Очікується 404, 422 і 404. -o /dev/null викидає тіло відповіді, лишаючи тільки код: коли відповідь не цікава, так читати зручніше.

Адреса, якої не існує, і запит без входу

curl -s -o /dev/null -w "%{http_code}\n" "$S/api/habitz"
curl -s -o /dev/null -w "%{http_code}\n" -X POST "$S$L" -H "$J" -d '{"title":"без куки","days":1}'

Перше — 404. Друге — 401: у цій команді немає -b "$C", тобто куки, і сервіс має відмовити. Якщо тут прийшло 200, це найважливіша знахідка сьогоднішнього дня: закриття з заняття 07 не працює.

Десять запитів поспіль

Те, про що йшлося в шостій частині теорії. Подивитися варто, знахідкою це не буде:

for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code} " "$S$L"; done; echo

Надрукується десять кодів 200 підряд, і сервіс працюватиме далі. Запис for ... do ... done — це цикл у bash: команда між do і done виконується десять разів, а $i щоразу дорівнює наступному числу. Саме число тут не потрібне, потрібна лише кількість повторів.

Більше десяти не роби. Сервер спільний на всю групу, і від сотень запитів за секунду стане повільніше всім, а в журналі буде видно, з якого логіна це йшло. У BUGS.md такий запис не пишуть: усі відповіді правильні, писати в рядку «Причина» нема чого.

Що записувати як баг Будь-яку відповідь із кодом 5xx. Будь-яку відповідь 200 там, де очікувався 401 або 422. Якщо сервіс перестав відповідати взагалі — теж баг, і в такому разі скажи власникові одразу, щоб він підняв службу командою systemctl --user restart club-api. Три докладно описані баги цінніші за десять рядків «щось не працює»: рядок «Причина» оцінює людина, і оцінює саме його точність.
5

Записати знахідки у BUGS.md власника

Записує той, хто знайшов, — своїми словами й під своїм іменем. Найпростіший порядок: власник відкриває в себе nano ~/app/BUGS.md і на дві хвилини віддає клавіатуру тому, хто перевіряв.

## Баг 1. Назва на 5000 символів зберігається цілком
Знайшов: Марта (marta.g9)
Запит: POST /api/habits, тіло {"title": "aaaa…" (5000 символів), "days": 3}
Відповідь: 200, запис у списку
Причина: у моделі NewHabit у поля title немає max_length, тому перевірка не спрацьовує
Статус: відкрито

## Баг 2. Видалення неіснуючого запису дає 500
Знайшов: Марта (marta.g9)
Запит: DELETE /api/habits/999999
Відповідь: 500
Причина: у delete_habit немає SELECT перед DELETE, тому rows[0] звертається до порожнього списку
Статус: відкрито

Формулювання не пом’якшуй і не виправляй за того, хто знайшов: рядок «Причина» оцінює людина, і оцінює саме його точність. Власник ставить Статус: закрито, коли виправить, а на занятті 09 той, хто знайшов, перевіряє й дописує рядок Перевірив: Марта (marta.g9), 2026-09-13.

Коли записи внесено, збережи файл:

cd ~/app
git add BUGS.md
git commit -m "BUGS.md: три баги від Марти з перевірки"

Двадцять хвилин на чужому сервісі скінчилися. Далі — тридцять сім хвилин на своєму: записи про роботу програми, обробники помилок і той самий набір запитів, тепер уже до себе. Баги, які знайшов у тебе однокласник, у ці тридцять сім хвилин не входять — це домашнє завдання, і так задумано.

6

Підключити log.py

Це готова заготовка. Створи файл і встав текст цілком, нічого в ньому не змінюючи.

nano ~/app/log.py
"""Налаштування записів про роботу програми. Готова заготовка — не редагуємо.

Кожен рядок потрапляє у два місця:
  ~/logs/app.log     — файл, який читають командою tail
  стандартний вивід  — його забирає systemd, читається через journalctl
"""
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path

LOG_DIR = Path.home() / "logs"
LOG_DIR.mkdir(exist_ok=True)
LOG_FILE = LOG_DIR / "app.log"

FORMAT = "%(asctime)s  %(levelname)-8s %(name)s  %(message)s"
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"


def setup_logging():
    """Викликається один раз, коли програма запускається."""
    root = logging.getLogger()
    if root.handlers:                 # уже налаштовано — другий раз не треба
        return
    root.setLevel(logging.INFO)
    form = logging.Formatter(FORMAT, DATE_FORMAT)

    to_file = RotatingFileHandler(
        LOG_FILE, maxBytes=1_000_000, backupCount=3, encoding="utf-8"
    )
    to_file.setFormatter(form)
    root.addHandler(to_file)

    to_stdout = logging.StreamHandler(sys.stdout)
    to_stdout.setFormatter(form)
    root.addHandler(to_stdout)


log = logging.getLogger("app")

Що тут відбувається

7

Увімкнути записи в main.py

nano ~/app/main.py

У верхню частину файлу, під наявні імпорти, додай два рядки:

from log import log, setup_logging

setup_logging()

Виклик має стояти саме тут, вище за ендпоінти: коли uvicorn читає main.py під час запуску, він виконує його згори вниз, і налаштування має відбутися до першого запиту.

Тепер дописуємо самі повідомлення. Три місця, по одному рядку в кожне.

У функції додавання, після того як запис створено:

    log.info("створено запис id=%s title=%r", new_id, title)

У функції видалення, там, де запису не знайшлося:

    if not rows:
        log.warning("спроба видалити неіснуючий запис id=%s", habit_id)
        raise HTTPException(status_code=404, detail="Такого запису немає")

У функції входу, там, де пароль не підійшов:

    if not rows or not check_password(data.password, rows[0]["password_hash"]):
        log.warning("невдалий вхід, логін=%r", data.login)
        raise HTTPException(status_code=401, detail="Невірний логін або пароль")

Зверни увагу на дві речі. Перша: значення передаються через кому, а не вставляються у f-рядок. logging підставить їх сам, і тільки тоді, коли повідомлення справді записується. Друга: %r друкує значення в лапках — title='' одразу видно як порожній рядок, а title=' ' — як два пробіли. Із %s обидва випадки виглядали б однаково порожньо.

У рядку про невдалий вхід записується логін і не записується пароль. Це навмисно: логін потрібен, щоб зрозуміти, чи хтось перебирає імена, а пароль у файлі, який читають кілька людей, лишатися не повинен.

8

Один обробник на всі несподівані помилки

Кожен 500 — це помилка, якої автор не передбачив. Передбачити всі неможливо, але можна зробити дві речі: записати подробиці до себе в лог і не віддавати їх назовні. Додай у main.py, нижче за setup_logging(), але вище за ендпоінти:

from fastapi import Request
from fastapi.responses import JSONResponse


@app.exception_handler(Exception)
async def unexpected_error(request: Request, exc: Exception):
    """Спрацьовує, коли ендпоінт завершився з помилкою, якої ніхто не чекав."""
    log.exception("%s %s — необроблена помилка", request.method, request.url.path)
    return JSONResponse(
        status_code=500,
        content={"detail": "Внутрішня помилка сервера"},
    )


@app.exception_handler(404)
async def not_found(request: Request, exc):
    """Неіснуюча адреса або неіснуючий запис."""
    log.info("404  %s %s", request.method, request.url.path)
    detail = getattr(exc, "detail", "")
    if not detail or detail == "Not Found":
        detail = "Такої адреси або запису немає"
    return JSONResponse(status_code=404, content={"detail": detail})

Що це за код

Обробник не робить баг меншим Запит, який дав 500, і далі дає 500 — просто тепер у логу є рядок із трасуванням, а відвідувач не бачить внутрішніх подробиць. Виправляти причину все одно треба, і саме це буде домашнім завданням.

Перезапусти службу й переконайся, що вона піднялася:

systemctl --user restart club-api
systemctl --user status club-api --no-pager

У виводі має бути active (running). Якщо failed — дивись journalctl --user -u club-api -n 30 --no-pager: майже завжди це NameError через те, що обробник поставлено вище за рядок app = FastAPI(), або ImportError через одруківку в from log import ....

9

Прогнати той самий набір по своєму сервісу

Автоперевірка виконує фіксований набір поганих запитів. Ось той самий набір у вигляді скрипта, щоб ти побачив результат раніше за неї. Це готова заготовка; змінити треба два рядки вгорі.

nano ~/app/bad-requests.sh
#!/usr/bin/env bash
# Набір поганих запитів. Жоден із них не має давати код 5xx.
set -u

BASE="http://127.0.0.1/u/bohdan.g9"    # ← свій логін
LIST="/api/habits"                     # ← свій ендпоінт зі списком

J="Content-Type: application/json"
tmp=$(mktemp -d)
cookie="$tmp/cookie.txt"
bad=0

read -rp "Логін власника: " LOGIN
read -rsp "Пароль: " PASS
echo

curl -s -o /dev/null -c "$cookie" -X POST "$BASE/api/login" -H "$J" \
     -d "{\"login\":\"$LOGIN\",\"password\":\"$PASS\"}"

printf '{"title":"%s","days":3}' "$(printf 'a%.0s' $(seq 1 5000))" > "$tmp/long.json"
cat > "$tmp/sql.json" <<'EOF'
{"title": "' OR 1=1 --", "days": 3}
EOF

try() {                       # try "опис" аргументи-для-curl…
  desc="$1"; shift
  code=$(curl -s -o /dev/null -w "%{http_code}" "$@")
  case "$code" in
    5*) echo "  БАГ  $desc → $code"; bad=$((bad + 1)) ;;
    *)  echo "  ok   $desc → $code" ;;
  esac
}

try "список"                -b "$cookie" "$BASE$LIST"
try "адреси не існує"       -b "$cookie" "$BASE/api/habitz"
try "параметр limit=abc"    -b "$cookie" "$BASE$LIST?limit=abc"
try "параметр limit=-1"     -b "$cookie" "$BASE$LIST?limit=-1"
try "порожня назва"         -b "$cookie" -X POST "$BASE$LIST" -H "$J" -d '{"title":"","days":3}'
try "назва 5000 символів"   -b "$cookie" -X POST "$BASE$LIST" -H "$J" -d @"$tmp/long.json"
try "лапка й текст запиту"  -b "$cookie" -X POST "$BASE$LIST" -H "$J" -d @"$tmp/sql.json"
try "не ті типи"            -b "$cookie" -X POST "$BASE$LIST" -H "$J" -d '{"title":12345,"days":"три"}'
try "від’ємне число"        -b "$cookie" -X POST "$BASE$LIST" -H "$J" -d '{"title":"x","days":-5}'
try "число з 20 цифр"       -b "$cookie" -X POST "$BASE$LIST" -H "$J" -d '{"title":"x","days":99999999999999999999}'
try "порожнє тіло"          -b "$cookie" -X POST "$BASE$LIST" -H "$J" -d '{}'
try "не JSON"               -b "$cookie" -X POST "$BASE$LIST" -H "$J" -d 'просто текст'
try "видалити 999999"       -b "$cookie" -X DELETE "$BASE$LIST/999999"
try "видалити abc"          -b "$cookie" -X DELETE "$BASE$LIST/abc"
try "видалити -1"           -b "$cookie" -X DELETE "$BASE$LIST/-1"
try "POST без куки"         -X POST "$BASE$LIST" -H "$J" -d '{"title":"без куки","days":1}'
try "DELETE без куки"       -X DELETE "$BASE$LIST/1"

rm -rf "$tmp"
echo
echo "запитів з кодом 5xx: $bad"
exit $((bad > 0))

Дозволь запуск і виконай:

chmod +x ~/app/bad-requests.sh
cd ~/app
./bad-requests.sh

Пароль скрипт питає щоразу й ніде не зберігає: усе, що набране в самому рядку команди, лягає в ~/.bash_history і читається звідти будь-коли, а read -rsp не показує символів і в історію не потрапляє.

Очікуваний вигляд виводу:

  ok   список → 200
  ok   адреси не існує → 404
  ok   порожня назва → 422
  БАГ  назва 5000 символів → 500
  ok   лапка й текст запиту → 200
  ...
запитів з кодом 5xx: 1

Кожен рядок «БАГ» виправляй одразу, поки пам’ятаєш контекст. Найчастіші причини й що робити — у розділі «Якщо щось не працює» нижче.

Скрипт додає кілька записів у твою базу — це неминуче, він же справді надсилає POST. Подивись останні й прибери зайві:

sqlite3 ~/data/app.db "SELECT id, substr(title,1,40) FROM habit ORDER BY id DESC LIMIT 10;"
sqlite3 ~/data/app.db "DELETE FROM habit WHERE id IN (41,42,43);"

Номери підстав свої, з першої команди. Записи, які додав однокласник, теж прибери.

10

Пошукати склеєні запити до бази

Тепер перевіримо те, про що йшлося в третій частині теорії: чи немає у твоєму коді запиту, складеного склеюванням рядків.

cd ~/app
grep -n "query(f\|execute(f" *.py
grep -n "SELECT.*+\|DELETE.*+\|INSERT.*+" *.py

grep шукає текст у файлах, -n друкує номер рядка, *.py означає «в усіх файлах з розширенням .py». Обидві команди мають нічого не надрукувати. Порожня відповідь тут — правильна відповідь.

Якщо щось знайшлося, перепиши цей рядок так, щоб значення передавалися окремо. Було:

rows = query(f"SELECT * FROM habit WHERE title LIKE '%{text}%'")

Стало:

rows = query("SELECT * FROM habit WHERE title LIKE ?", ("%" + text + "%",))

Знаки відсотка лишаються частиною значення — це вони роблять пошук за частиною слова, і в тексті запиту їм не місце. Кома після text усередині дужок обов’язкова: без неї Python вважатиме це просто дужками навколо рядка, а не набором з одного значення.

11

Прочитати те, що записалося

Записи мають бути вже на місці — ти щойно надіслав півтора десятка запитів.

tail -n 20 ~/logs/app.log

tail показує кінець файлу, -n 20 — останні двадцять рядків. Читати лог із початку немає сенсу: цікаве завжди в кінці.

Самі помилки:

grep ERROR ~/logs/app.log | tail -n 5
grep ERROR ~/logs/app.log | head -n 1

Друга команда друкує перший ERROR у файлі — саме він відповідає на питання «коли це почалося». Далі дивишся, що ти міняв незадовго до цього часу.

Дивитися, як рядки з’являються просто зараз:

tail -f ~/logs/app.log

-f лишає команду працювати й дописує нові рядки, щойно вони з’являються. Зручно, коли з іншого вікна надсилаєш запити й одразу бачиш, що записав сервіс. Вийти — Ctrl+C.

Те саме через systemd, з обмеженням за часом:

journalctl --user -u club-api -n 30 --no-pager
journalctl --user -u club-api --since "10 minutes ago" --no-pager

--since показує лише записи, новіші за вказаний момент; --no-pager друкує все одразу, без гортання. У журналі systemd, крім твоїх рядків, є ще й повідомлення uvicorn про кожен запит і рядки про запуск та зупинку служби — у файлі app.log їх немає, там тільки те, що написала твоя програма.

12

Прибрати тестовий логін і зберегти роботу

Заняття скінчилося — тестового логіна в базі бути не має.

sqlite3 ~/data/app.db "DELETE FROM owner WHERE login = 'test.guest';"
sqlite3 ~/data/app.db "SELECT login FROM owner;"

Друга команда має надрукувати один рядок — твій логін. Поки test.guest лишається в таблиці, у твого сервісу двоє власників з однаковими правами, і пароль другого знає ще одна людина.

Тепер коміт:

cd ~/app
git add log.py main.py bad-requests.sh BUGS.md
git commit -m "логування у ~/logs/app.log, обробники 500 і 404, набір поганих запитів"
git push origin master

Файл ~/logs/app.log у git не потрапляє — він лежить поза текою ~/app. Це навмисно: лог змінюється щосекунди, і в історії версій йому місця немає.

Що перевіряє автоматика

Чотири критерії заняття 08. Три перевіряє програма, четвертий — однокласник, і не сьогодні, а на занятті 09. Перевірка запускається за розкладом, тому відмітка в кабінеті з’являється не в ту саму секунду, коли ти виконав команду. Адреси ендпоінтів програма бере з project.yaml.

КодКритерійЩо це означаєНе зараховано — що робити
L08.1 жоден поганий запит не дає 5xx Програма надсилає той самий набір запитів, що й скрипт із кроку 9, і дивиться лише на перші цифри кодів. Жодна відповідь не має починатися з п’ятірки. Який саме код прийшов замість 500 — 400, 401, 404 чи 422 — не перевіряється: важливо, що сервіс відповів осмислено, а не зупинився. Запусти ./bad-requests.sh сам і працюй з рядками «БАГ» по черзі. На кожен із них у логу є запис: grep ERROR ~/logs/app.log | tail -n 5. У трасуванні шукай останній рядок, у якому названо main.py — це і є місце помилки. Якщо всі запити дають 502, річ не в коді: служба не працює, дивись systemctl --user status club-api --no-pager.
L08.2 у ~/logs/ є записи з часом Програма дивиться, що файл ~/logs/app.log існує, не порожній, а його рядки починаються з дати й часу у форматі 2026-09-06 14:03:11. Заразом перевіряє, що файл дописувався сьогодні, а не лишився з попереднього тижня. Перевір: tail -n 5 ~/logs/app.log. Порожньо або файлу немає — setup_logging() не викликається; звір крок 7, виклик має стояти в main.py на верхньому рівні, а не всередині функції. Рядки є, але без часу — у log.py змінено FORMAT; поверни рядок із кроку 6 дослівно. Файл є, але старий — після правок не перезапустили службу: systemctl --user restart club-api, потім надішли будь-який запит.
L08.3 у BUGS.md щонайменше три баги з описом причини Програма рахує блоки, що починаються рядком ## Баг і містять усі п’ять рядків: Знайшов:, Запит:, Відповідь:, Причина:, Статус:. Блок зараховується, якщо текст після Причина: довший за 20 символів. Блок-приклад із рядком Статус: приклад не рахується. Відкрий файл і звір із форматом із кроку 3: найчастіше бракує рядка Відповідь: або двокрапки після слова. Двокрапка обов’язкова, за нею програма і знаходить рядок. Якщо блоків менше трьох, значить, під час перевірки знайшли менше трьох знахідок — домовтеся з однокласником і проженіть решту запитів із кроку 4 до кінця.
L08.4 однокласник підтвердив, що баги закрито Це єдиний критерій заняття, який ставить людина. На занятті 09 той, хто знайшов баги, відкриває твій BUGS.md, повторює свої ж запити й дивиться на відповіді. Якщо баг справді закрито, він дописує рядок Перевірив: зі своїм іменем і датою. Це домашнє завдання, і зараховують його наступного разу. До заняття 09 виправ усі баги, поміняй Статус: відкрито на Статус: закрито й переконайся сам, що ті самі запити тепер дають очікувані коди. Хто не встиг — доробляє на початку заняття 09, до теорії.

Домашнє завдання

Основне. Закрити всі баги, які знайшов у тебе однокласник. Порядок на кожен баг однаковий:

  1. Повтори запит із рядка Запит: і переконайся, що баг відтворюється.
  2. Знайди в логу відповідний запис: grep ERROR ~/logs/app.log | tail -n 5. У трасуванні шукай останній рядок з main.py — це рядок, на якому все зупинилося.
  3. Виправ причину, а не наслідок. Якщо 500 дає довгий текст, правильне виправлення — max_length у моделі, а не обрізання рядка в коді ендпоінта.
  4. Перезапусти службу, повтори той самий запит і подивись на код.
  5. Постав у BUGS.md Статус: закрито.

Наприкінці прожени весь набір ще раз — уже без жодного рядка «БАГ»:

cd ~/app
./bad-requests.sh

І збережи:

git add -A
git commit -m "закрито баги з перевірки: межа довжини назви, перевірка існування запису"
git push origin master

Повідомлення коміта пиши по суті: що саме виправлено. «fix» і «правки» не годяться, це критерій із заняття 03.

Додатково. Додай у SECURITY.md, який ти завів на занятті 07, розділ «Що знайшов однокласник» — три-чотири рядки про те, які саме запити зупиняли твій сервіс і чого бракувало в коді. Через місяць, коли будеш писати RUNBOOK.md, цей список стане в пригоді.

Якщо щось не працює

Служба не піднімається після додавання log.py

Читай перший рядок з Error у журналі, а не останній:

journalctl --user -u club-api -n 30 --no-pager

ImportError: cannot import name 'setup_logging' from 'log' — файл log.py збережено не повністю або функцію названо інакше; звір із кроком 6. NameError: name 'app' is not defined — обробники помилок стоять вище за рядок app = FastAPI(); перенеси їх нижче. ModuleNotFoundError: No module named 'log' — файл лежить не в ~/app: перевір ls ~/app/log.py.

У ~/logs/app.log порожньо

Три причини, за частотою. Перша: setup_logging() не викликається — рядок забули або він опинився всередині функції, яку ніхто не викликає. Друга: службу не перезапустили після правки, і працює старий код. Третя: після правок ще не було жодного запиту — надішли хоч один:

systemctl --user restart club-api
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1/u/bohdan.g9/api/habits
tail -n 5 ~/logs/app.log

Кожен рядок у логу з’являється двічі

setup_logging() викликається двічі — найчастіше тому, що рядок скопійовано і в main.py, і в db.py або auth.py. Перевір:

grep -n "setup_logging()" ~/app/*.py

Виклик має бути рівно один, у main.py. Рядок if root.handlers: return у заготовці саме від цього й захищає, але він не допоможе, якщо файл log.py скопійовано в кількох варіантах під різними іменами.

Довгий текст усе одно дає 500

Обробник із кроку 8 повертає 500 і записує трасування — але саму помилку він не виправляє. Дивись, на чому зупинилося:

grep -A 15 ERROR ~/logs/app.log | tail -n 20

-A 15 друкує ще п’ятнадцять рядків після знайденого — трасування займає кілька рядків. Найчастіша причина тут — sqlite3.IntegrityError або помилка про довжину: у таблиці стовпець описано з обмеженням, а модель його пропускає. Виправлення — max_length у моделі NewHabit, як на занятті 05:

class NewHabit(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    days: int = Field(ge=1, le=365)

./bad-requests.sh: Permission denied або bad interpreter

Перше — файлу не поставили ознаку «можна запускати»: chmod +x ~/app/bad-requests.sh. Друге — повідомлення bad interpreter: /usr/bin/env bash^M: файл редагували у Windows, і в кінці рядків стоїть зайвий символ. Прибрати:

sed -i 's/\r$//' ~/app/bad-requests.sh
./bad-requests.sh

Скрипт показує 401 на всіх запитах, хоча логін і пароль правильні

Кука не доїжджає. Найчастіше в BASE написана адреса без логіна або з іншим логіном: кука видається на шлях /u/bohdan.g9/ (рядок COOKIE_PATH у main.py із заняття 07), і на інші шляхи браузер і curl її не надсилають. Звір рядок BASE у скрипті зі своїм логіном посимвольно.

Друга причина — у паролі є подвійні лапки або зворотна скісна: тоді тіло запиту перестає бути правильним JSON. На час перевірки постав собі пароль без цих символів.

Однокласник каже, що баг є, а в тебе не відтворюється

Звір три речі: адресу цілком, метод (POST чи DELETE) і тіло запиту дослівно. Найчастіше різниця саме в тілі: одне поле пропущене або назва поля написана інакше. Попроси показати команду curl повністю й виконай її без змін.

Якщо й так не відтворюється — подивись у лог за той час, коли він перевіряв:

journalctl --user -u club-api --since "40 minutes ago" --no-pager | grep -i error

grep -i шукає, не розрізняючи великих і малих літер. Якщо в журналі є запис, а зараз баг не повторюється, значить, ти вже щось виправив між тим і цим — так теж буває, і це нормальний результат.

sqlite3 пише «database is locked»

Хтось звертається до файлу бази просто зараз — найчастіше твоя ж служба під час запиту. Почекай кілька секунд і повтори команду. Якщо повторюється щоразу, перевір, чи не лишилося відкритого sqlite3 в іншому вікні термінала: вийти з нього — .quit.