Мета заняття: за домовленістю спробувати зламати сервіс однокласника, знайти в себе те саме й навчитися читати записи, які твоя програма лишає про свою роботу.
Замість <логін> підставляєш свій
логін виду bohdan.g9: ім’я, крапка, номер групи. Кутові дужки не пишуться.
Кабінет — сторінка, де проти кожного критерію стоїть відмітка, зарахований він чи ні.
У прикладах на цій сторінці твій логін — bohdan.g9, а логін однокласника,
сервіс якого ти перевіряєш, — marta.g9. Обидва читай як справжні логіни й
виправляй у кожній команді, яку копіюєш.
Перші двадцять хвилин практики ти працюватимеш із сервісом однокласника. Надсилатимеш
йому запити, від яких програма може завершитися з помилкою: дуже довгий текст у полі,
лапки й інші символи, які щось означають у мові запитів до бази, від’ємні числа, чужий і
неіснуючий номер запису в адресі. Кожну знахідку запишеш у файл BUGS.md того,
чий сервіс перевіряв.
Наступні тридцять сім хвилин — свій сервіс. Підключиш готовий файл log.py,
який записує роботу програми у ~/logs/app.log, допишеш кілька повідомлень у
власний код, додаси обробник, після якого будь-яка несподівана помилка стає відповіддю з
кодом, а не обірваним з’єднанням, і проженеш по своєму сервісу той самий набір запитів,
яким тебе перевіряв сусід.
Баги, які знайшов у тебе сусід, ти лагодитимеш удома. На занятті на це часу немає, і це запланована частина роботи, а не відставання.
curl. Заходити в чужий акаунт через ssh, підбирати
чужий пароль, відкривати чужі файли на сервері не можна: за це відраховують з курсу. Про це
говорили на занятті 01; сьогодні це правило вперше стосується того, що ти справді робиш
руками.
Кожна відповідь сервера починається з тризначного числа — коду відповіді. Ти вже бачив кілька: 200 (усе гаразд), 401 (потрібно увійти, заняття 07), 404 (такої адреси або запису немає), 422 (дані не пройшли перевірку, заняття 05), 500 і 502 (щось не так на сервері).
Перша цифра ділить усі коди на групи. Сьогодні важливі дві.
Групу визначає одне: чи була ситуація передбачена автором коду. Порожня назва
в полі — ситуація передбачена: у моделі стоїть min_length=1, FastAPI відповідає
422, у журналі нічого поганого не з’являється, сервіс працює далі. Якщо ж на порожню назву
приходить 500, значить, до перевірки справа не дійшла і програма зупинилася на чомусь, чого
автор не очікував.
rows[0], коли rows порожній.club-api зупинена або саме в цю секунду
перезапускається.4xx — це 400, 401, 404, 422 і решта, що
починаються з чотирьох. Іксами позначають будь-які цифри.Сьогоднішня перевірка шукає саме 5xx. Кожен запит, на який твій сервіс відповів кодом із
п’ятірки на початку, — це знахідка, і саме її сусід запише тобі у BUGS.md.
Марта надіслала в сервіс Богдана запит на додавання запису, де в полі
days замість числа стояло слово «три». Сервіс відповів 500. Чия це помилка і
що вона означає?
Коли програма працює на твоєму комп’ютері, ти бачиш її вивід у вікні термінала. На сервері
club-api запущена у фоні: вікна немає й дивитися нема куди. Рядок
print("сюди дійшли") нікуди не зникає — його забирає systemd, частина системи, яка
запускає служби й стежить за ними (заняття 02), — і складає у свій журнал. Прочитати можна
командою journalctl --user -u club-api, ти вже це робив.
Незручності починаються, коли таких рядків стає багато.
помилка в списку не відповідає на
головне питання: коли це почалося. Сервіс міг працювати нормально три дні, а зламатися після
твоєї правки о 14:30.Усе це додає модуль logging — він входить у Python, ставити нічого не треба.
Замість print пишуть log.info(...) або log.error(...), а
модуль сам дописує до рядка час, рівень і назву місця.
INFO — звичайна
подія, усе за планом; WARNING — щось незвичне, але сервіс упорався
(неправильний пароль, звернення до неіснуючого запису); ERROR — помилка, яку
треба виправляти в коді.loggingjournalctl-u club-api
обмежує вивід однією службою, -n 30 — останніми тридцятьма рядками.Один рядок логу після сьогоднішнього налаштування виглядатиме так:
2026-09-06 14:03:11 ERROR app DELETE /api/habits/8 — необроблена помилкаЧас, рівень, назва місця, повідомлення. Далі, коли сервіс поводиться дивно, ти не здогадуєшся, а дивишся: о котрій це почалося і що саме тоді відбувалося.
щось пішло не так не допоможе нікому, зокрема й тобі через тиждень.
Придатне повідомлення відповідає на три питання: яка дія, з якими даними, чим скінчилася.
створено запис id=41 title='Пробіжка' — придатне.
помилка — ні. І окреме правило: паролів, вмісту кук і секретів у лог не пишуть
ніколи. Лог лежить у файлі, який читають кілька людей, і потрапивши туди один раз, пароль
лишається там надовго.
Данило скаржиться: «сервіс іноді віддає 500, а іноді ні». У коді він розставив
print("тут") у п’яти місцях. Чому це не допомагає знайти причину?
Запит до бази — це звичайний текст. База отримує рядок на кшталт
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' істинна завжди, тому запит поверне всі рядки таблиці власників.
Функція входу отримає перший із них і вважатиме, що людина увійшла. Пароля вона при цьому не
знала — вона знала, що її текст потрапить у запит як його частина.
Параметризований варіант того самого рядка:
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 приходить із поля на сторінці. Софія каже: «поле
для пошуку маленьке, туди все одно нічого довгого не введеш». Що з цим не так?
URL — повна адреса, за якою браузер або curl звертається до
сервера. Досі ти набирав її цілком і не розбирав на частини. Сьогодні розібрати доведеться:
саме через окремі частини адреси й перевіряють чужий сервіс.
http://91.219.61.4/u/bohdan.g9/api/habits?limit=10&order=newhttp://http, на занятті 10 стане https.91.219.61.4/u/bohdan.g9/api/habits?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. Софія відповідає: «такого посилання на моїй
сторінці немає, звідки їй узагалі взяти таку адресу». Чому це не відповідь?
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--no-pagerq.Питання, на яке журнал відповідає найкраще, — коли це почалося. Сервіс, який не працює останні дві хвилини, і сервіс, який не працює з учорашнього вечора, — це різні задачі. Порядок дій один і той самий:
systemctl --user status club-api --no-pager. Рядок
active (running) означає, що вона працює, failed — що вона
зупинилася й не піднялася.git log --since "2 hours ago" --oneline. Найчастіше причина саме там.Записи про роботу сервісу будуть у двох місцях, і різниця між ними важлива. У файл
~/logs/app.log, який ти налаштуєш у практиці, потрапляє тільки те, що написала
твоя програма. У журналі systemd є ще й рядки uvicorn про кожен запит,
повідомлення про запуск та зупинку служби й текст помилки, через яку служба не змогла
запуститися. Тому коли у файлі порожньо, це саме по собі відповідь: до першого рядка твого
коду справа не дійшла, а причину треба шукати в журналі.
Данило каже: «сайт не відкривається взагалі, у відповідь приходить 502. Я
подивився ~/logs/app.log — за сьогодні там жодного рядка, отже, помилок немає».
Де насправді причина і чому у файлі порожньо?
На занятті хтось обов’язково спробує надіслати багато запитів підряд і подивитися, чи витримає сервіс однокласника. Витримає — і корисно розуміти, чому.
uvicorn — програма, яка приймає запити з мережі й передає їх твоєму коду;
саме вона запускає main.py і працює як служба club-api. Запити вона
обробляє один за одним, і кожен займає кілька мілісекунд. Десять запитів поспіль — це десята
частка секунди роботи, сто — приблизно секунда. У практиці є команда, яка це показує, і
подивитися на неї варто: видно, що відповіді не міняються й коди лишаються ті самі.
Як спосіб зламу це не працює, і причини дві. Перша: сервіс нічого не тримає між запитами. Відповів — і від запиту нічого не лишилося, тож накопичуватися нема чому. Друга: кількість, від якої сервер справді перестає відповідати, вимірюється тисячами запитів за секунду з багатьох машин одночасно. З одного термінала стільки не надішлеш, а якби надіслав — стало б повільніше всій групі, бо сервер спільний, і в журналі було б видно, з якого логіна це йшло. Тому в практиці стоїть саме десять запитів, а не тисяча.
Сервіс кладе не кількість, а вміст. Один-єдиний запит, після якого код зупиняється з помилкою, дає 500 — і саме такі запити ми сьогодні й шукаємо.
Тарас надіслав на сервіс однокласниці сто запитів GET підряд. Усі
сто відповіли 200, сервіс працює далі. Тарас записує їй у BUGS.md: «надіслав
100 запитів, сервіс витримав». Що не так із цим записом?
У прикладах — таблиця habit і ендпоінти /api/habits із занять 04
і 05. Підставляй свої назви: вони записані у project.yaml у розділах
database і endpoints. Логін однокласника в прикладах —
marta.g9.
Пари складає викладач. З однокласником, який дістався тобі в пару, ви обмінюєтесь трьома речами, і кожен записує їх собі:
http://91.219.61.4/u/marta.g9/;project.yaml, наприклад
/api/habits, і назви полів, які він приймає;Тестовий логін — це окремий запис у твоїй таблиці owner,
який ти створюєш тільки на сьогодні й прибираєш наприкінці заняття. Свій особистий пароль не
називають нікому й ніколи, зокрема й однокласникові, і зокрема на одне заняття.
POST і DELETE у тебе закриті. Без входу сусід
перевірить рівно дві речі: чи відкривається список і що відповідає форма входу на
неправильний пароль. Це три запити, і заняття на них не побудуєш. З тестовим логіном він
дістається до всього, що приймає дані, — а саме там і трапляються помилки, які ми сьогодні
шукаємо.
Користуємось тим самим скриптом, що й на занятті 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. Продиктуй однокласникові
логін, пароль і адресу сайту; він продиктує свої.
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: файл для багів, знайдених на перевірці"Далі всі команди звертаються до чужої адреси. Щоб не переписувати її щоразу, заведи дві
змінні. Вони живуть до кінця сеансу 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.jsonprintf '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.jsoncat > файл <<'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. Від’ємна кількість днів не має сенсу, а число з двадцяти цифр не вміщується в тип, з яким працює база.
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 такий запис не пишуть: усі відповіді правильні, писати в рядку
«Причина» нема чого.
systemctl --user restart club-api.
Три докладно описані баги цінніші за десять рядків «щось не працює»: рядок «Причина»
оцінює людина, і оцінює саме його точність.
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: три баги від Марти з перевірки"Двадцять хвилин на чужому сервісі скінчилися. Далі — тридцять сім хвилин на своєму: записи про роботу програми, обробники помилок і той самий набір запитів, тепер уже до себе. Баги, які знайшов у тебе однокласник, у ці тридцять сім хвилин не входять — це домашнє завдання, і так задумано.
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")LOG_DIR.mkdir(exist_ok=True) створює теку ~/logs, якщо її
немає. exist_ok=True означає «не скаржитися, якщо тека вже є», — команду можна
виконувати щоразу під час запуску.RotatingFileHandler — запис у файл з обмеженням розміру. Коли
app.log доростає до мільйона байтів (це приблизно 1 МБ), він
перейменовується на app.log.1, і починається новий. Файлів
.1, .2, .3 зберігається три, старіші видаляються.
Без цього обмеження файл ріс би, поки на диску є місце, а диск на сервері спільний для всієї
групи.StreamHandler(sys.stdout) — той самий рядок додатково друкується у
стандартний вивід. Служба працює у фоні, тому цей вивід забирає systemd, і той самий рядок
видно ще й через journalctl.if root.handlers: return — захист від подвоєння. Якщо
setup_logging() викликати двічі, у списку опиниться два однакові записувачі, і
кожен рядок буде надрукований двічі.%(asctime)s, %(levelname)s, %(name)s,
%(message)s — місця, куди logging підставляє час, рівень, назву
місця й саме повідомлення. Запис -8s означає «доповнити пробілами до восьми
символів», щоб стовпчик у файлі був рівний.main.pynano ~/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 обидва випадки виглядали б однаково порожньо.
У рядку про невдалий вхід записується логін і не записується пароль. Це навмисно: логін потрібен, щоб зрозуміти, чи хтось перебирає імена, а пароль у файлі, який читають кілька людей, лишатися не повинен.
Кожен 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})@app.exception_handler(Exception) над
функцією читається так: «якщо десь сталася будь-яка помилка, виклич цю функцію».log.exception(...) — те саме, що log.error(...), але
додатково записує трасування: перелік рядків коду, через які пройшло
виконання, аж до того рядка, де все зупинилося. Це і є місце, яке треба виправляти.async def замість def: обробник помилок FastAPI викликає
інакше, ніж звичайну функцію, і вимагає саме такого запису. Ендпоінтів це не стосується,
вони лишаються як були.getattr(exc, "detail", "") — узяти в exc поле
detail, а якщо такого поля немає, узяти порожній рядок. Поле є тоді, коли 404
підняв твій код рядком raise HTTPException(status_code=404, detail="Такого запису
немає"): цей текст ми лишаємо, він точніший. Коли ж людина набрала адресу, якої не
існує, 404 піднімає сам FastAPI і кладе туди англійське Not Found — цей рядок
замінюємо своїм.Перезапусти службу й переконайся, що вона піднялася:
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 ....
Автоперевірка виконує фіксований набір поганих запитів. Ось той самий набір у вигляді скрипта, щоб ти побачив результат раніше за неї. Це готова заготовка; змінити треба два рядки вгорі.
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);"Номери підстав свої, з першої команди. Записи, які додав однокласник, теж прибери.
Тепер перевіримо те, про що йшлося в третій частині теорії: чи немає у твоєму коді запиту, складеного склеюванням рядків.
cd ~/app
grep -n "query(f\|execute(f" *.py
grep -n "SELECT.*+\|DELETE.*+\|INSERT.*+" *.pygrep шукає текст у файлах, -n друкує номер рядка,
*.py означає «в усіх файлах з розширенням .py». Обидві команди мають нічого не
надрукувати. Порожня відповідь тут — правильна відповідь.
Якщо щось знайшлося, перепиши цей рядок так, щоб значення передавалися окремо. Було:
rows = query(f"SELECT * FROM habit WHERE title LIKE '%{text}%'")Стало:
rows = query("SELECT * FROM habit WHERE title LIKE ?", ("%" + text + "%",))Знаки відсотка лишаються частиною значення — це вони роблять пошук за частиною слова, і в
тексті запиту їм не місце. Кома після text усередині дужок обов’язкова: без неї
Python вважатиме це просто дужками навколо рядка, а не набором з одного значення.
Записи мають бути вже на місці — ти щойно надіслав півтора десятка запитів.
tail -n 20 ~/logs/app.logtail показує кінець файлу, -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 їх немає, там тільки те, що написала твоя програма.
Заняття скінчилося — тестового логіна в базі бути не має.
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, до теорії. |
Основне. Закрити всі баги, які знайшов у тебе однокласник. Порядок на кожен баг однаковий:
Запит: і переконайся, що баг відтворюється.grep ERROR ~/logs/app.log | tail -n 5.
У трасуванні шукай останній рядок з main.py — це рядок, на якому все
зупинилося.max_length у моделі, а не обрізання рядка в коді ендпоінта.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-pagerImportError: 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.logsetup_logging() викликається двічі — найчастіше тому, що рядок скопійовано і в
main.py, і в db.py або auth.py. Перевір:
grep -n "setup_logging()" ~/app/*.pyВиклик має бути рівно один, у main.py. Рядок if root.handlers: return
у заготовці саме від цього й захищає, але він не допоможе, якщо файл log.py
скопійовано в кількох варіантах під різними іменами.
Обробник із кроку 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Кука не доїжджає. Найчастіше в 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 errorgrep -i шукає, не розрізняючи великих і малих літер. Якщо в журналі є запис, а
зараз баг не повторюється, значить, ти вже щось виправив між тим і цим — так теж буває, і це
нормальний результат.
sqlite3 пише «database is locked»Хтось звертається до файлу бази просто зараз — найчастіше твоя ж служба під час запиту.
Почекай кілька секунд і повтори команду. Якщо повторюється щоразу, перевір, чи не лишилося
відкритого sqlite3 в іншому вікні термінала: вийти з нього — .quit.