Мета заняття: розділити «дивитися можна всім» і «редагувати можу тільки я», не зберігаючи при цьому пароля у відкритому вигляді.
Замість <логін> підставляєш свій
логін виду bohdan.g9: ім’я, крапка, номер групи. Кутові дужки не пишуться.
Кабінет — сторінка, де проти кожного критерію стоїть відмітка, зарахований він чи ні.
У прикладах на цій сторінці скрізь написано bohdan.g9 — читай це як свій
логін і виправляй у кожній команді, яку копіюєш.
Зараз твій сервіс приймає команди від будь-кого. Будь-хто, хто знає адресу, може відкрити
термінал і однією командою додати або стерти запис у твоїй базі — і сервіс не спитає, хто
це. Сьогодні ти закриєш зміни: POST і DELETE працюватимуть тільки
після входу з паролем, а GET лишиться відкритим, щоб сторінку могли дивитися
всі.
Пароль ти не зберігатимеш. У базі опиниться його хеш — рядок, з якого сам пароль не
відновлюється, але звірити введений пароль із ним можна. Секретний ключ, яким сервіс
підтверджує, що ти вже ввів пароль, зберігатиметься в окремому файлі .env,
і ти перевіриш, що цей файл не потрапив у git.
Ці два слова звучать схоже, їх постійно плутають, а означають вони різні речі.
У школі це видно щодня. Пропуск на вході — автентифікація: охоронець упізнає, що ти учень цієї школи. Але з тим самим пропуском тебе не пустять у кабінет директора й до серверної: це вже авторизація, і вона в кожного своя.
В електронному щоденнику так само. Ти входиш зі своїм паролем — це автентифікація. Далі бачиш свої оцінки й не бачиш чужих, а вчитель тим самим щоденником оцінки виставляє. Логін однаково перевірений в обох, права різні.
Сьогодні ми робимо просту схему: у сервісу є один власник — ти. Автентифікація — перевірка твого пароля. Авторизація — правило «змінювати дані може лише власник, дивитися може будь-хто».
Марта увійшла в шкільний електронний щоденник зі своїм паролем. Вона відкрила свої оцінки, а коли спробувала відкрити сторінку «оцінки всього класу», система написала «доступ заборонено». Яка з двох перевірок спрацювала в другому випадку?
Найпростіше рішення — записати пароль у стовпець password і при вході
порівняти рядки. Так робити не можна, і причина не в теорії. Бази витікають: помилка в коді,
украдений бекап, забутий відкритий порт — так називають номер, за яким
сервер розрізняє програми, що приймають запити з мережі; якщо номер, на якому працює база,
доступний з інтернету, під’єднатися до неї може будь-хто. Якщо в базі лежали паролі, той,
хто її отримав, має готовий список «логін — пароль». Найгірше не те, що він зайде у твій сервіс. Найгірше те, що
більшість людей ставить той самий пароль у пошті й соцмережах, і тепер він відкриє їх теж.
Тому зберігають не пароль, а хеш — результат перетворення тексту на рядок фіксованої довжини за жорстким правилом. Перетворення працює тільки в один бік: з пароля хеш обчислити легко, з хеша пароль — ні.
Подивись, як це виглядає. Виконай ці три команди на сервері одну за одною. Python тут
запускається довгим шляхом /opt/club/venv/bin/python, а не коротким
python3: це окремо встановлений Python, спільний для всієї групи, і саме в нього
адміністратор поставив бібліотеки курсу. Далі на цій сторінці буде тільки цей шлях, бо твій
сервіс на сервері працює з тим самим Python.
/opt/club/venv/bin/python -c "import hashlib; print(hashlib.sha256('sonechko7'.encode()).hexdigest())"
/opt/club/venv/bin/python -c "import hashlib; print(hashlib.sha256('sonechko7'.encode()).hexdigest())"
/opt/club/venv/bin/python -c "import hashlib; print(hashlib.sha256('Sonechko7'.encode()).hexdigest())"Перші дві команди дадуть однаковий рядок із 64 символів — правило жорстке, той самий текст завжди дає той самий результат. Третя відрізняється від них однією великою літерою, а рядок вийде зовсім інший, без жодної схожості. Саме на цих двох властивостях і будується перевірка пароля.
Вхід відбувається так: людина надсилає пароль, сервер обчислює його хеш і порівнює з тим, що лежить у базі. Збіглося — пароль правильний. При цьому сам пароль сервер ніде не залишає: у базі як лежав хеш, так і лежить.
У новинах написали, що з форуму, де ти зареєстрований, витекла таблиця
користувачів. У стовпці пароля там рядки виду $2b$12$Kx... завдовжки рівно 60
символів. Що це означає для того, хто цю таблицю отримав?
sha256 для паролів малоЗ sha256, який ти щойно запускав, є дві проблеми, і обидві практичні.
Перша: він швидкий. Звичайний комп’ютер рахує сотні мільйонів таких хешів за секунду. Для
перевірки цілості файлів, задля якої sha256 і зроблений, це добре. Для паролів
швидкість шкідлива: той, хто перебирає варіанти, перевіряє їх із такою самою швидкістю.
Друга: однаковий пароль дає однаковий хеш завжди й у всіх. Тобто в украденій таблиці видно, що двадцять людей мають один і той самий пароль. А ще заздалегідь порахований словник популярних паролів разом із їхніми хешами відкриває всі ці записи одним пошуком.
Обидві проблеми розв’язує bcrypt — алгоритм, зроблений спеціально для паролів. Він робить дві речі:
Запусти двічі й порівняй результат:
/opt/club/venv/bin/python -c "import bcrypt; print(bcrypt.hashpw(b'sonechko7', bcrypt.gensalt()).decode())"
/opt/club/venv/bin/python -c "import bcrypt; print(bcrypt.hashpw(b'sonechko7', bcrypt.gensalt()).decode())"Пароль той самий, а рядки різні — сіль щоразу нова. При цьому перевірка спрацює для обох, бо сіль лежить усередині самого рядка. Ось із чого він складається:
$2b$12$eImiTXuWVxfM37uY4JANjQ.O5AbcDefGhIjKlMnOpQrStUvWxYz01
$2b$ назва алгоритму — bcrypt
12 скільки разів рахувати: 2 у 12-му степені, тобто 4096 повторів
наступні 22 сіль
останній 31 сам хешРазом рівно 60 символів. Початок $2b$ — це позначка версії bcrypt: її ставить
та бібліотека, з якою ми працюємо. У старіших рядках трапляються ще $2a$ і
$2y$ — той самий алгоритм із дрібними відмінностями. Автоматика курсу перевіряє
саме $2b$, бо саме його дає наша бібліотека; за цим початком і довжиною видно,
що в базі лежить хеш.
Питання «а чи не можна написати хешування самому» має коротку відповідь: ні. bcrypt роками перевіряли фахівці з безпеки, усі знайдені помилки в ньому виправлені, і власна реалізація до цієї перевіреності нічого не додасть.
Андрій і Соломія домовилися й поставили у своїх сервісах абсолютно однаковий
пароль. Вони відкрили свої бази, подивилися стовпець password_hash і побачили
два різні рядки, хоча обидва починаються з $2b$12$. Чому рядки не збіглися?
Є деталь, через яку тема здається складнішою, ніж вона є. HTTP — набір правил, за якими браузер спілкується з сервером, — не має пам’яті. Кожен запит приходить окремо, сам по собі, і сервер не знає, чи бачив він цього відвідувача хвилину тому. Відкрив сторінку — запит. Натиснув кнопку — новий запит, і для сервера він ніяк не пов’язаний із попереднім.
Якби пам’яті не було зовсім, пароль довелося б надсилати з кожним запитом. Тому роблять інакше: пароль перевіряють один раз, а далі сервер видає браузеру короткий рядок, який діє обмежений час і замінює пароль у наступних запитах.
Тепер головне питання: що покласти в куку, щоб її не можна було підробити. Якщо записати
просто bohdan.g9, будь-хто відкриє інструменти розробника, впише туди свій
рядок — і сервер повірить.
Тому в куку кладуть три частини: логін, час закінчення сесії й підпис. Підпис — це хеш від перших двох частин разом із секретним ключем, який знає тільки твій сервер:
bohdan.g9:1800000000:9Kx2p_wQ7hRt4mLdVzB1cYsNfGjE8aUoI3rTnP0qXeMСереднє число — час закінчення сесії, записаний так, як його зберігає
комп’ютер: скільки секунд минуло від 1 січня 1970 року. 1800000000 — це
15 січня 2027 року.
Отримавши таку куку, сервер бере перші дві частини, рахує підпис заново своїм ключем і порівнює. Хтось поміняв логін на чужий або відсунув час на місяць уперед — підпис перестає збігатися, і сервер відповідає «увійди». Підібрати правильний підпис без ключа не вийде: для цього треба знати сам ключ.
Ще одна деталь, яку ми ввімкнемо: кука позначається як HttpOnly. Це означає,
що код на сторінці її не прочитає — вона доступна лише браузеру, який надсилає її серверу.
Якщо на сторінку колись потрапить чужий скрипт, прочитати цю куку він не зможе.
Олег увійшов у свій сервіс на уроці, закрив вкладку, а ввечері вдома відкрив сайт знову з того самого ноутбука — пароля не питали, кнопки «видалити» на місці. Його сусід у той самий вечір відкрив цю саму адресу зі свого телефона й кнопок не побачив. Що відрізняє ці два випадки?
Закрити треба зміни. Перегляд лишається доступним усім: якщо пароль питати й за читання, сайт не побачить ніхто, крім тебе, і показувати його буде нікому.
Перед розподілом варто знати, як запит узагалі доходить до твого коду.
nginx — програма на сервері, яка приймає всі запити з інтернету й передає
кожен потрібній програмі за адресою: усе, що починається на /u/bohdan.g9/, вона
віддає твоєму сервісу. Сам сервіс працює на сервері як служба — програма,
яку система запускає сама, тримає ввімкненою постійно й перезапускає за командою. Усе, що
служба друкує під час роботи, система складає в журнал: там видно рядки про
запуск і текст помилок. У практиці ти й перезапускатимеш службу, і читатимеш журнал.
Ендпоінт — пара «метод плюс адреса», яку вміє обробляти твій сервіс.
GET /api/habits і POST /api/habits — два різні ендпоінти з
однаковою адресою, і закривати їх можна окремо. Розподіл на сьогодні такий:
| Ендпоінт | Хто може | Чому |
|---|---|---|
| GET /api/habits | будь-хто | Це вміст сайту. Його дивляться друзі, рідні й автоматика курсу. |
| POST /api/habits | лише власник | Додає запис у базу. |
| DELETE /api/habits/{id} | лише власник | Стирає запис назавжди. |
| POST /api/login | будь-хто | Це і є вхід. Якби він був закритий, увійти не міг би ніхто. |
| GET /api/ping | будь-хто | Перевірка, що сервіс відповідає. Її робить автоматика курсу без пароля. |
Тепер про те, як перевірку прикріплюють до ендпоінта. У FastAPI для цього є
залежність — функція, яку фреймворк викликає перед тілом ендпоінта. Якщо
вона відмовляє, тіло ендпоінта не виконується взагалі: запит зупиняється на вході. Нашу
залежність зватимуть require_owner; у практиці ти додаси її до
POST і DELETE, а GET лишиш без неї.
Коли гість намагається зробити те, на що не має права, сервер відповідає кодом. Коди відповідей ти бачив на занятті 05, тут додаються три нові:
POST без куки.Ліза закрила автентифікацією всі ендпоінти підряд, включно з
GET /api/habits. Сама вона входить і все бачить. Мама відкрила сайт зі свого
телефона й побачила порожню сторінку, а в журналі служби проти її запиту стоїть 401.
Що тут не так?
Далі в практиці ти зробиш так, щоб гість не бачив форми додавання й кнопок «видалити»: вони будуть сховані стилем CSS. Це зручність, а не захист. Стилі й розмітка виконуються в браузері відвідувача, і він може змінити там усе: відкрити інструменти розробника (клавіша F12), прибрати правило, що ховає кнопку, і натиснути її.
Відмовити у видаленні може перевірка на сервері: до неї відвідувач не дістає, бо код
сервера виконується не в його браузері. Це та сама думка, що й на занятті 05 із валідацією:
атрибут required у формі підказує людині, поки вона заповнює поля, а рішення
про запис ухвалює сервер.
Тарас відкрив сайт однокласниці. Кнопок «видалити» не видно — вони сховані
через CSS. Він натиснув F12, знайшов правило, яке їх ховає, вимкнув його, кнопка з’явилася,
і він на неї натиснув. Ендпоінт DELETE у неї закритий залежністю
require_owner. Що станеться?
.envСекрет — будь-яке значення, яке дає доступ і тому не має бути відоме нікому, крім твоєї програми. Сьогодні це ключ, яким підписуються куки. Далі в житті це паролі до баз даних, ключі до платіжних систем, токени ботів. Токен тут — довгий випадковий рядок, який програма надсилає сервісу замість пароля, щоб той її впустив.
Секрет не пишуть у коді, і причина конкретна: код лежить у git, а git зберігає всю
історію. Нагадаю два слова з заняття 03. Репозиторій — тека проєкту разом
із прихованою текою .git, у якій git тримає всі попередні стани файлів.
Коміт — збережений стан усіх файлів проєкту на певний момент, із датою,
автором і твоїм описом зміни; історія проєкту складається саме з комітів, і кожен із них
можна відкрити пізніше.
Тому файл, який хоч раз потрапив у коміт, читається звідти назавжди — навіть якщо наступним комітом ти його прибрав. Один рядок дістає стару версію:
git show HEAD~5:main.pyТому секрети виносять у файл .env поруч із кодом, а сам файл додають до
.gitignore — списку того, що git не відстежує. Назва .env — від
слова environment, «оточення»: усередині звичайний текст, по рядку на значення.
SESSION_SECRET=8f3c1a94e07b25d6f8a1c0b73e9d4526a7f0c8b1d2e3f4a596071829304a5b6cКрапка на початку назви означає, що файл прихований: ls його не покаже,
потрібно ls -a. Це не захист, просто традиція для службових файлів.
Порядок дій тут важливий, і саме на ньому найчастіше помиляються. Спочатку рядок у
.gitignore, потім створення файлу з секретом. Навпаки — і є всі шанси зробити
git add ., не подивившись, що саме додається.
Ігор написав ключ прямо в main.py і зробив коміт. Через тиждень
він переніс ключ у .env, додав .env до .gitignore і
закомітив зміну з повідомленням «прибрав секрет із коду». Ключ лишився тим самим. Чи досить
цього?
У прикладах — таблиця habit з полями id, title,
days і ендпоінти з заняття 05. Підставляй свої назви: вони записані у
project.yaml у розділах database й endpoints.
.env у .gitignoreРобимо це до того, як файл із секретом узагалі з’явиться.
ssh bohdan.g9@91.219.61.4
cd ~/app
git check-ignore -v .envgit check-ignore -v питає в git: «чи ігноруєш ти цей файл, і через яке
правило». Якщо в заготовці все на місці, у відповідь буде рядок на кшталт
.gitignore:11:.env .env — тобто правило знайдено в одинадцятому рядку файлу.
Якщо у відповідь порожньо, правила немає, додай його:
echo ".env" >> .gitignore
git add .gitignore
git commit -m "gitignore: закрив .env до того, як його створив">> дописує рядок у кінець файлу. Одна кутова дужка
> замість двох стерла б увесь файл і лишила там один рядок, тому дужок тут
саме дві.
.env із секретомcd ~/app
/opt/club/venv/bin/python -c "import secrets; print('SESSION_SECRET=' + secrets.token_hex(32))" > .env
chmod 600 .env
cat .envsecrets.token_hex(32) дає 64 випадкові символи. Модуль
secrets — це стандартний модуль Python саме для таких значень: на відміну від
звичного random, його результат не можна передбачити, знаючи попередні.
Ключ вигадувати самому не треба, і брати щось «легше запам’ятати» теж не треба — цей рядок
ніхто не вводитиме руками.
chmod 600 ставить права «читати й писати може лише власник файлу». Інші
користувачі сервера — а на ньому вся твоя група — не прочитають його навіть із цікавості.
Тепер перевір найважливіше: git не має бачити цього файлу.
git status --shortУ списку не повинно бути жодного рядка з .env. Якщо він там є з позначкою
?? — вернись до кроку 1, правило в .gitignore не спрацювало.
sqlite3 ~/data/app.db "CREATE TABLE IF NOT EXISTS owner (
id INTEGER PRIMARY KEY,
login TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
);"
sqlite3 ~/data/app.db ".schema owner"Стовпець називається password_hash, а не password — щоб, коли
через місяць відкриєш цей код, не було жодного сумніву, що там лежить. UNIQUE
означає, що двох рядків з однаковим логіном база не дозволить.
IF NOT EXISTS дозволяє виконати команду вдруге: якщо таблиця вже створена,
sqlite3 просто нічого не робить і помилки не буде. Це знадобиться, якщо доведеться
повернутися до цього кроку.
Друга команда друкує те, що реально створилося. Якщо у відповідь порожньо — таблиця не створилася, дивись текст помилки вище.
auth.pyЦе готова заготовка. Створи файл і встав текст цілком, нічого в ньому не змінюючи.
nano ~/app/auth.py"""Автентифікація. Готова заготовка — цей файл не редагуємо.
Дає чотири речі:
hash_password / check_password — зробити хеш пароля і звірити з ним введений
make_session — вміст куки після успішного входу
current_owner — хто увійшов, або None; помилки не викидає
require_owner — залежність, що закриває ендпоінт: гість дістає 401
"""
import base64
import hashlib
import hmac
import os
import time
from pathlib import Path
import bcrypt
from fastapi import Cookie, HTTPException
COOKIE_NAME = "club_session"
SESSION_HOURS = 12
def _load_env(path):
""".env — звичайний текстовий файл, по рядку «КЛЮЧ=значення»."""
if not path.exists():
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ[key.strip()] = value.strip()
_load_env(Path(__file__).parent / ".env")
SECRET = os.environ.get("SESSION_SECRET", "")
if not SECRET:
raise RuntimeError("У файлі ~/app/.env немає рядка SESSION_SECRET")
def hash_password(plain):
"""З пароля робить хеш, який можна зберігати в базі."""
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def check_password(plain, stored):
"""Звіряє введений пароль із хешем із бази. Повертає True або False."""
try:
return bcrypt.checkpw(plain.encode("utf-8"), stored.encode("ascii"))
except (ValueError, AttributeError):
return False
def _sign(data):
"""Підпис рядка секретом із .env."""
mac = hmac.new(SECRET.encode("utf-8"), data.encode("utf-8"), hashlib.sha256)
return base64.urlsafe_b64encode(mac.digest()).decode("ascii").rstrip("=")
def make_session(login):
"""Вміст куки: логін, час закінчення і підпис."""
until = int(time.time()) + SESSION_HOURS * 3600
data = login + ":" + str(until)
return data + ":" + _sign(data)
def _read_session(value):
"""Розбирає куку. Повертає логін або None, якщо кука підроблена чи стара."""
parts = (value or "").split(":")
if len(parts) != 3:
return None
login, until, sign = parts
expected = _sign(login + ":" + until)
if not hmac.compare_digest(sign.encode("utf-8"), expected.encode("ascii")):
return None
try:
if int(until) < time.time():
return None
except ValueError:
return None
return login
def current_owner(club_session: str = Cookie(default="")):
"""Логін того, хто увійшов, або None."""
return _read_session(club_session)
def require_owner(club_session: str = Cookie(default="")):
"""Залежність FastAPI: гість сюди не проходить."""
login = _read_session(club_session)
if not login:
raise HTTPException(status_code=401, detail="Потрібно увійти")
return login_load_env читає .env рядок за рядком і кладе значення у
змінні оточення програми. Змінні оточення — це набір пар
«назва=значення», який операційна система передає програмі під час запуску; програма
читає їх через os.environ. Усього п’ять рядків коду: .env —
звичайний текстовий файл, ніякого особливого формату в ньому немає.os.environ[...] = ... і перекриває те, що могло прийти в оточенні служби
ззовні. Якби замість присвоєння стояло setdefault, головним лишилося б
зовнішнє значення, а .env був би мовчки проігнорований — сервіс підписував
би куки не тим ключем, який ти щойно поклав у файл.SESSION_SECRET порожній, модуль одразу зупиняє програму з
поясненням. Це навмисно: сервіс, що працює з порожнім ключем, підписував би куки
порожнім рядком, і підробити їх зміг би будь-хто.hmac.compare_digest порівнює рядки замість звичайного ==.
Ця функція витрачає однаковий час незалежно від того, де саме рядки розійшлися; звичайне
порівняння відпадає на першому відмінному символі, і за часом відповіді підпис можна
поступово вгадати..encode.
Кука приходить від відвідувача, і в ній може бути будь-що, зокрема літери поза
латиницею; на таких рядках hmac.compare_digest зупиняється з помилкою
TypeError, і сервіс відповів би 500 замість 401. З байтами такого не
буває.require_owner — та сама залежність, про яку йшлося в теорії. FastAPI
викликає її перед тілом ендпоінта, і якщо вона підняла
HTTPException, до ендпоінта справа взагалі не доходить.club_session названий так само, як кука в
COOKIE_NAME. Це не збіг: FastAPI шукає куку саме за іменем аргументу.Пароль ніде не набирається як частина команди. Причин дві. Усе, що ти набрав у
терміналі, записується у файл історії ~/.bash_history і читається звідти
будь-коли. І поки команда виконується, її рядок разом з усіма значеннями видно в списку
процесів (ps aux) кожному, хто зайшов на цей сервер, — а на ньому вся твоя
група. Тому пишемо маленький скрипт, який питає пароль окремо й не показує його на
екрані.
nano ~/app/set-password.py"""Записує власника і хеш його пароля в базу. Запускати з теки ~/app."""
import getpass
import sys
from auth import hash_password
from db import query, execute
login = input("Логін власника: ").strip()
first = getpass.getpass("Пароль: ")
again = getpass.getpass("Ще раз: ")
if first != again:
sys.exit("Паролі не збігаються, нічого не записано")
if len(first) < 8:
sys.exit("Пароль коротший за 8 символів, візьми довший")
if len(first.encode("utf-8")) > 72:
sys.exit("Пароль довший за 72 байти — bcrypt такий не бере, візьми коротший")
stored = hash_password(first)
if query("SELECT id FROM owner WHERE login = ?", (login,)):
execute("UPDATE owner SET password_hash = ? WHERE login = ?", (stored, login))
print("Пароль оновлено для", login)
else:
execute("INSERT INTO owner (login, password_hash) VALUES (?, ?)", (login, stored))
print("Власника додано:", login)Запускаємо. Шлях до Python той самий, що й у теорії: /opt/club/venv/bin/python,
бо bcrypt лежить саме в цьому оточенні. Команду cd ~/app
пропускати не можна: скрипт бере auth.py і db.py з поточної теки,
а db.py шукає там-таки project.yaml зі шляхом до бази.
cd ~/app
/opt/club/venv/bin/python set-password.pygetpass.getpass не показує символи під час набору — це нормально, курсор
просто стоїть на місці. Логін бери свій, bohdan.g9. Пароль вигадай новий:
не той, що від пошти чи від акаунта в грі. Вісім символів — це нижня межа, яку приймає
скрипт; бери довше, фразу з кількох слів. Верхня межа теж є: bcrypt працює щонайбільше з
72 байтами пароля, а українська літера займає два байти, тож 36 українських літер — це вже
межа. Скрипт про це попередить і нічого не запише.
Перевір, що в базі опинилося:
sqlite3 ~/data/app.db "SELECT login, password_hash FROM owner;"Ти маєш побачити свій логін і рядок, що починається з $2b$12$. Якщо там
видно сам пароль — щось пішло не так, повернись до кроку 4.
main.pynano ~/app/main.pyСпочатку імпорти у верхній частині файлу. Було:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from db import query, executeСтало:
from fastapi import FastAPI, HTTPException, Depends, Response
from pydantic import BaseModel, Field
from db import query, execute
from auth import (COOKIE_NAME, SESSION_HOURS, check_password, current_owner,
make_session, require_owner)Depends — те, чим позначають залежність. Response — об’єкт
відповіді; він потрібен, щоб покласти в неї куку.
Далі додай нижче в файлі три ендпоінти. Рядок із COOKIE_PATH постав перед
ними:
COOKIE_PATH = "/u/bohdan.g9/" # ← свій логін
class LoginData(BaseModel):
login: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=1, max_length=200)
@app.post("/api/login")
def login(data: LoginData, response: Response):
"""Перевіряє пароль і видає куку сесії."""
rows = query("SELECT * FROM owner WHERE login = ?", (data.login,))
if not rows or not check_password(data.password, rows[0]["password_hash"]):
raise HTTPException(status_code=401, detail="Невірний логін або пароль")
response.set_cookie(
COOKIE_NAME,
make_session(data.login),
max_age=SESSION_HOURS * 3600,
httponly=True,
samesite="lax",
path=COOKIE_PATH,
)
return {"ok": True, "login": data.login}
@app.post("/api/logout")
def logout(response: Response):
"""Прибирає куку — далі сервіс бачить звичайного гостя."""
response.delete_cookie(COOKIE_NAME, path=COOKIE_PATH)
return {"ok": True}
@app.get("/api/me")
def me(owner=Depends(current_owner)):
"""Сторінка питає це при відкритті: показувати кнопки чи форму входу."""
return {"logged_in": owner is not None, "login": owner}path=COOKIE_PATH обмежує, куди браузер надсилатиме куку. Сайти всієї
групи стоять на одній адресі 91.219.61.4, і без цього рядка браузер додавав
би твою куку й до запитів на сайти сусідів. Підстав туди свій логін точно так, як в
адресі сайту, разом із косими рисками з обох боків.max_age береться з того самого SESSION_HOURS, що й
в auth.py. Число має бути одне на два місця: якби ти вписав його тут руками
й потім змінив у auth.py, кука в браузері жила б довше або менше, ніж підпис
усередині неї.samesite="lax" звужує випадки, коли браузер додає твою куку до запитів,
що йдуть із чужого сайту. При звичайному переході за посиланням кука ще додається, а от
POST із чужої сторінки й фонові запити її вже не отримають. Саме це нам і
потрібно: без цього стороння сторінка могла б надіслати POST до твого
сервісу від твого імені, поки ти увійшов.secure
У списку немає параметра secure=True, який велить браузеру надсилати куку
лише через захищене з’єднання. Зараз сайт працює по звичайному http, і з цим
параметром кука не дійшла б узагалі. Повернемося до нього на занятті 10, коли адреса
стане https.
POST і DELETEЗнайди свої ендпоінти з заняття 05 і додай в кожен один аргумент. Було:
@app.post("/api/habits")
def create_habit(item: NewHabit):Стало:
@app.post("/api/habits")
def create_habit(item: NewHabit, owner: str = Depends(require_owner)):Так само для видалення:
@app.delete("/api/habits/{habit_id}")
def delete_habit(habit_id: int, owner: str = Depends(require_owner)):Тіло функцій не змінюється, змінна owner усередині поки не потрібна. Коли
запит приходить без правильної куки, require_owner зупиняє його раніше, ніж
почне виконуватися перший рядок функції.
Ендпоінт списку не чіпай. GET /api/habits,
GET /api/ping і POST /api/login лишаються без залежності — інакше
сайт перестане відкриватися для всіх, включно з автоматикою курсу.
project.yamlАвтоматика не вгадує назв — вона читає їх звідси. Заповни два місця:
nano ~/app/project.yamlendpoints:
list: "GET /api/habits" # уже є з заняття 05
create: "POST /api/habits" # уже є з заняття 05
delete: "DELETE /api/habits/{id}" # уже є з заняття 05
login: "POST /api/login" # додаєш сьогодні
auth: # цього розділу ще немає, додаєш цілком
table: owner
password_field: password_hashЦе той самий файл, який ти заповнював на заняттях 01, 04 і 05. Розділ
endpoints у ньому вже є: у нього дописуєш один рядок login.
Другий розділ із назвою endpoints створювати не можна — два однакові ключі
верхнього рівня ламають файл, і служба після цього не запуститься. Розділ
auth новий, його додаєш цілком.
Рядки list, create і delete показані тут тому, що
автоматика бере адреси саме з них: у критеріях нижче вони згадуються як
endpoints.create і endpoints.list. Якщо якогось із цих рядків у
тебе немає, допиши й його.
Відступи в YAML робляться пробілами; табуляція ламає файл. Текст після # —
коментар, копіювати його не обов’язково.
Твій сервіс запущено на сервері як службу з назвою club-api. Керує службами
команда systemctl, а --user означає «служба належить моєму
обліковому запису»: така служба працює від твого імені, і прав адміністратора для неї не
потрібно. Поки ти не перезапустиш службу, вона виконує старий код — правки в
main.py самі собою не застосовуються.
systemctl --user restart club-api
systemctl --user status club-apiУ відповіді має бути active (running). Якщо там failed, читай
журнал. journalctl друкує те, що служба вивела під час роботи;
-u club-api каже, про яку саме службу, а -n 30 — скільки останніх
рядків показати. Причину зупинки завжди видно в останніх рядках:
journalctl --user -u club-api -n 30Швидка перевірка, що служба справді відповідає:
curl -s http://127.0.0.1/u/bohdan.g9/api/pingТут ти повториш руками рівно те, що робитиме автоматика курсу. Параметр
-i друкує заголовки відповіді разом із кодом, тому він стоїть майже в кожній
команді.
Перше: анонімний POST має отримати 401.
curl -i -X POST http://127.0.0.1/u/bohdan.g9/api/habits \
-H "Content-Type: application/json" \
-d '{"title":"Спроба зайти без пароля","days":3}'Заголовок Content-Type: application/json каже серверу, у якому вигляді
записане тіло запиту. JSON — текстовий формат для передавання даних:
значення пишуться парами «назва: значення» у фігурних дужках, текст — у подвійних лапках,
числа — без них. Саме в цьому форматі твій сервіс приймає й повертає дані з заняття 05.
Перший рядок відповіді має бути HTTP/1.1 401 Unauthorized. Якщо там 200 —
залежність не додалася, вернись до кроку 7. Якщо 422 — сервер устиг перевірити поля тіла
запиту, а отже залежності на ендпоінті немає: коли вона є, вона зупиняє анонімний запит
раніше, ніж черга доходить до полів, і код буде 401 навіть при неправильних назвах полів.
Ще 422 буває, якщо JSON у команді набрано з помилкою — наприклад, загублено лапку.
Друге: GET має працювати без входу.
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1/u/bohdan.g9/api/habitsТут -o /dev/null викидає тіло відповіді, а -w "%{http_code}"
друкує самий лише код. Має надрукуватися 200.
Третє: вхід. Пароль не має стати частиною команди curl:
розгорнутий рядок команди видно в ps aux усім, хто зайшов на цей сервер. Тому
тіло запиту складає Python і передає його curl напряму:
read -s -p "Пароль: " PW; echo
PW="$PW" /opt/club/venv/bin/python -c "import json, os; print(json.dumps({'login': 'bohdan.g9', 'password': os.environ['PW']}))" \
| curl -i -c ~/cookies.txt -X POST http://127.0.0.1/u/bohdan.g9/api/login \
-H "Content-Type: application/json" -d @-Розберемо по частинах. read -s читає рядок і не показує його на екрані.
PW="$PW" перед командою передає значення тільки цьому запуску Python.
json.dumps складає з логіна й пароля правильне тіло запиту — воно лишиться
правильним, навіть якщо в паролі є лапки або зворотна коса риска. Вертикальна риска
| віддає цей текст наступній команді, а -d @- означає «взяти тіло
запиту з того, що прийшло по цьому каналу». -c ~/cookies.txt велить curl
зберегти отриману куку у файл. У відповіді має бути 200 і заголовок
set-cookie: club_session=... — саме його браузер у звичайній роботі
запам’ятовує сам.
Четверте: той самий POST, але з кукою.
curl -i -b ~/cookies.txt -X POST http://127.0.0.1/u/bohdan.g9/api/habits \
-H "Content-Type: application/json" \
-d '{"title":"Запис від власника","days":3}'-b — це «взяти куки з файлу й додати до запиту». Тепер має бути 200 і
новий запис у відповіді. Прибери за собою:
unset PW
rm ~/cookies.txtСторінка лежить у ~/www/index.html. Редагувати її можна двома способами.
Перший — прямо на сервері командою nano ~/www/index.html. Другий — у звичному
редакторі на своєму комп’ютері, під’єднавшись до сервера по FTP. FTP —
набір правил для передавання файлів між комп’ютером і сервером; редактор, під’єднаний по
FTP, показує файли сервера так, ніби вони лежать у тебе на комп’ютері. У налаштуваннях
з’єднання постав адресу 91.219.61.4, порт 21 і шифрування «явний FTP через
TLS». TLS — те саме шифрування, що працює в адресах https:
воно закриває від сторонніх у мережі і пароль, і вміст файлів. Логін і пароль ті самі,
якими ти заходиш на сервер по SSH, тобто командою
ssh bohdan.g9@91.219.61.4 з кроку 1.
Розмітка форми. Кожне поле має свій <label> — вимога з заняття 06
діє й тут:
<section id="owner-box">
<form id="login-form">
<label for="f-login">Логін</label>
<input id="f-login" type="text" autocomplete="username">
<label for="f-pass">Пароль</label>
<input id="f-pass" type="password" autocomplete="current-password">
<button type="submit">Увійти</button>
</form>
<button type="button" id="logout-btn" class="only-owner">Вийти</button>
<p id="login-msg"></p>
</section>type="password" ховає символи крапками під час набору.
autocomplete підказує браузеру, що це за поле, — тоді він пропонує зберегти
пароль і підставити його наступного разу.
Тепер правила стилю. Додай їх у <style> своєї сторінки:
.only-owner { display: none; }
body.is-owner .only-owner { display: inline-block; }
body.is-owner form.only-owner { display: block; }
body.is-owner #login-form { display: none; }Класом only-owner познач те, що має бачити лише власник: форму додавання
запису й кнопки «видалити». Третє правило потрібне саме для форми. Тег
<form> — блоковий, він займає всю ширину рядка; з
display: inline-block із другого правила форма після входу стиснулася б по
вмісту й стала поруч із сусіднім текстом. У третьому правилі названо ще й сам тег
form, тому браузер вважає його точнішим і застосовує до форми саме його.
Кнопки «видалити» створюються кодом, тож у блоці з заняття 05 до рядка
del.textContent = 'видалити'; додай ще один:
del.classList.add('only-owner');. Тут потрібен саме
classList.add: присвоєння del.className = 'only-owner' замінило б
усі класи кнопки, зокрема ті, які ти поставив їй на занятті 05.
Готовий блок для входу. Додається в кінці сторінки, після блоку з заняття 05, перед
закривальним </body>:
<script>
const AUTH = '/u/bohdan.g9/api'; // ← свій логін
const loginForm = document.getElementById('login-form');
const loginMsg = document.getElementById('login-msg');
const logoutBtn = document.getElementById('logout-btn');
async function refreshOwner() {
const res = await fetch(AUTH + '/me');
const data = await res.json();
document.body.classList.toggle('is-owner', data.logged_in);
loginMsg.textContent = data.logged_in ? 'Ви увійшли як ' + data.login : '';
}
loginForm.addEventListener('submit', async function (event) {
event.preventDefault();
const res = await fetch(AUTH + '/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
login: document.getElementById('f-login').value,
password: document.getElementById('f-pass').value
})
});
document.getElementById('f-pass').value = '';
if (res.ok) {
await refreshOwner();
} else {
loginMsg.textContent = 'Невірний логін або пароль';
}
});
logoutBtn.addEventListener('click', async function () {
await fetch(AUTH + '/logout', { method: 'POST' });
await refreshOwner();
});
refreshOwner();
</script>AUTH — початок адреси, за якою сторінка звертається до твого сервісу.
Набір таких адрес і називають API сервісу: це перелік того, що сторінка
може в нього попросити. Шлях записано повністю, від кореня сайту. Якби тут стояло коротке
'api', браузер добудовував би адресу відносно поточної сторінки, і при
відкритті без косої риски в кінці (http://91.219.61.4/u/bohdan.g9) вийшло б
/u/api/me — такої адреси на сервері немає.JSON.stringify перетворює об’єкт JavaScript на текст у форматі JSON —
той самий формат, у якому ти надсилав дані командою curl у кроці 10.refreshOwner питає в сервера /api/me: чи є в цього
відвідувача дійсна кука. Відповідь — true або false — вмикає
або вимикає клас is-owner на теґу <body>, а вже за цим
класом стилі показують або ховають кнопки.submit надсилає логін і пароль на /api/login.
Поле пароля очищається одразу після надсилання, незалежно від результату, — щоб він не
лишався на екрані.HttpOnly код сторінки її навіть не бачить.refreshOwner() виконується один раз при відкритті
сторінки — тому кнопки з’являються самі, якщо ти входив кілька годин тому.Перевір у браузері: відкрий http://91.219.61.4/u/bohdan.g9/. До входу
форми додавання й кнопок «видалити» немає, список видно. Після входу вони з’являються без
перезавантаження. Потім відкрий цю саму адресу в приватному вікні: там куки немає, тож
сторінка має виглядати як для гостя.
.envcd ~/app
git status --short
git add auth.py set-password.py main.py project.yaml .gitignore
git commit -m "auth: вхід за паролем, POST і DELETE лише для власника"
git push origin mainУ git status файлу .env бути не повинно взагалі. Файли
перелічуй по одному, як тут: git add . додає все підряд, і саме так секрети
найчастіше й потрапляють у коміти.
Остаточна перевірка — та сама, яку зробить автоматика. Команда шукає .env в
усій історії, у всіх гілках:
git log --all --oneline -- .envПорожня відповідь — усе гаразд, файл ніколи не був у комітах. Якщо вивелися рядки з комітами, переходь до розділу «Якщо щось не працює», пункт про секрет в історії.
Сторінку в ~/www зберігай окремо, якщо ти підключав цю теку до git на
минулих заняттях.
Обміняйтеся адресами сайтів. Сусід зі свого місця виконує дві команди проти твого сервісу — підставляючи твій логін в адресу:
curl -s -o /dev/null -w "GET %{http_code}\n" http://91.219.61.4/u/bohdan.g9/api/habits
curl -s -o /dev/null -w "POST %{http_code}\n" -X POST http://91.219.61.4/u/bohdan.g9/api/habits \
-H "Content-Type: application/json" -d '{"title":"сусід","days":1}'Правильний результат: GET 200 і POST 401. Якщо
POST повернув 200 — сусід щойно додав запис у твою базу, і критерій не
зарахується.
Ще одна перевірка, вже цікавіша. Хай сусід увійде у свій сервіс, візьме свою
куку й спробує з нею твій POST. Відповідь усе одно буде 401: підпис у його
куці зроблено його ключем із його .env, а твій сервер рахує підпис своїм.
Саме тому ключ у кожного власний і нікому не показується.
Чотири критерії заняття 07, усі чотири перевіряє програма. Перевірка запускається за
розкладом, тому відмітка в кабінеті з’являється не в ту саму секунду, коли ти виконав
команду. Адреси ендпоінтів програма бере з project.yaml, а назву таблиці
власника й поле з паролем — із розділу auth.
| Код | Критерій | Що це означає | Не зараховано — що робити |
|---|---|---|---|
| L07.1 | анонімний POST дає 401 | Програма надсилає POST на адресу з endpoints.create без
жодної куки й дивиться на код відповіді. Має бути рівно 401. Відповідь 200 означає, що
будь-хто в мережі досі може дописувати у твою базу. |
Повтори перший запит із кроку 10 практики. Якщо прийшло 200 — в ендпоінті немає
owner: str = Depends(require_owner) або правку не зберегли й службу не
перезапустили. Якщо 422 — сервер дійшов до перевірки полів тіла запиту, а отже
залежності на ендпоінті немає: із нею анонімний запит зупинився б раніше з кодом 401.
Якщо 500 — читай
journalctl --user -u club-api -n 30, найчастіша причина тут
ImportError у рядку from auth import .... |
| L07.2 | пароль у базі має вигляд хеша | Програма читає стовпець, указаний у auth.password_field, і дивиться на
формат значення: 60 символів, початок $2b$. Крім того, вона перевіряє, що
значення не збігається з жодним із простих варіантів на кшталт самого логіна. |
Подивись, що лежить у базі:
sqlite3 ~/data/app.db "SELECT login, password_hash FROM owner;". Якщо там
видно пароль як текст — запис зроблено повз set-password.py, наприклад
руками через INSERT. Перезапиши: /opt/club/venv/bin/python
set-password.py. Якщо стовпець порожній, перевір, що в
project.yaml у auth.password_field написана та сама назва,
що в таблиці. |
| L07.3 | .env відсутній у git-історії | Програма виконує в твоєму репозиторії пошук по всій історії, а не лише по поточному
стану файлів. Достатньо одного давнього коміта з .env, щоб критерій не
зарахувався: секрет із такого коміта читається однією командою. |
Перевір сам: git log --all --oneline -- .env. Порожньо — усе гаразд, і
справа в чомусь іншому: подивись, чи не потрапив ключ у main.py
(git log -p -S SESSION_SECRET покаже коміти, де цей рядок з’являвся).
Якщо коміти з .env є, дій за пунктом «Секрет потрапив у коміт» нижче. |
| L07.4 | GET публічний і працює без входу | Програма робить звичайний GET на адресу з endpoints.list
без куки і чекає 200 із твоїм списком. Ця перевірка навмисно стоїть поруч із першою:
закрити зміни легко, важче закрити тільки їх. |
Перевір:
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1/u/bohdan.g9/api/habits.
Якщо 401 — на ендпоінті списку стоїть зайва залежність require_owner,
прибери її. Якщо 404 — адреса в endpoints.list не збігається з тією, що в
коді. Якщо 500 — служба запустилася, але зупиняється з помилкою вже на самому запиті;
причину шукай у журналі. |
Основне. Спробуй зайти у свій сервіс так, як це зробила б стороння людина, і переконайся, що змінити нічого не вдається. Найпростіший спосіб — відкрити свій сайт у приватному вікні браузера або в іншому браузері: там немає твоєї куки, тож ти для сервісу звичайний гість.
Зроби щонайменше чотири спроби й після кожної подивись на код відповіді у вкладці Network (клавіша F12):
POST командою curl без куки.Створи файл ~/app/SECURITY.md і запиши туди результати. Формат такий —
по рядку на спробу, обов’язково з кодом відповіді:
# Що я перевіряв
## Спроба 1. Переглянути список без входу
Що робив: відкрив http://91.219.61.4/u/bohdan.g9/ у приватному вікні.
Відповідь сервера: GET /api/habits — 200, список видно.
Висновок: так і має бути, читання відкрите для всіх.
## Спроба 2. Натиснути «видалити», прибравши стиль через F12
Що робив: ...
Відповідь сервера: DELETE /api/habits/4 — 401.
Висновок: ...Пиши те, що справді побачив, а не те, що мало статися. Якщо десь прийшло 200 там, де ти чекав 401, — це знахідка, і саме її треба записати. На занятті 08 ви шукатимете такі знахідки в сервісах одне одного, і цей файл — твій список того, що вже перевірено.
Додай файл у git:
cd ~/app
git add SECURITY.md
git commit -m "SECURITY: чотири спроби доступу без входу та відповіді сервера"Друге. Заміни пароль на довший — фразу з трьох-чотирьох слів замість
восьми символів. Тримайся межі bcrypt: 72 байти, а українська літера займає два байти, тож
фраза має вкластися приблизно в 35 літер разом із пробілами. Якщо перебереш, скрипт скаже
про це й нічого не запише. Переконайся, що після заміни старий пароль більше не підходить,
а новий працює: так ти заразом перевіриш, що set-password.py оновлює наявний
запис, а не додає другий.
cd ~/app
/opt/club/venv/bin/python set-password.py
sqlite3 ~/data/app.db "SELECT COUNT(*) FROM owner;"Команда cd ~/app тут обов’язкова з тієї самої причини, що й у кроці 5:
скрипт бере auth.py, db.py і project.yaml з поточної
теки. Без неї він або зупиниться з помилкою, або звернеться не до тієї бази.
Друга команда має надрукувати 1. Якщо 2 — ти ввів інший логін,
і в базі тепер два власники; зайвий рядок прибери.
systemctl --user status club-api показує failed. Причина завжди
в останніх рядках журналу:
journalctl --user -u club-api -n 30RuntimeError: У файлі ~/app/.env немає рядка SESSION_SECRET — файл
.env не створився, порожній або лежить не в ~/app. Перевір:
cat ~/app/.env. Файл прихований, у звичайному ls його не видно,
потрібно ls -a ~/app.ModuleNotFoundError: No module named 'auth' — файл названо інакше або
лежить не поруч із main.py. Обидва мають бути в ~/app.ModuleNotFoundError: No module named 'bcrypt' — служба запускається не тим
Python. Перевір, що бібліотека є в спільному оточенні:
/opt/club/venv/bin/python -c "import bcrypt; print('ok')". Якщо й тут помилка,
скажи викладачеві: бібліотеки в це оточення ставить адміністратор, самому нічого
встановлювати не треба.IndentationError — збилися відступи під час вставляння коду в
nano. Усередині функції рівно чотири пробіли, змішувати пробіли з табуляцією
не можна.Кука видана, але не повертається назад. Відкрий F12, вкладку Application (у Firefox —
Storage), розділ Cookies. Якщо club_session там є, дивись його стовпець Path.
Найчастіша причина — у COOKIE_PATH написано не те. Значення має точно
збігатися з адресою сторінки: /u/bohdan.g9/, зі своїм логіном і з косими рисками
з обох боків. Якщо там лишилося bohdan.g9 зі зразка, кука зберігається під
чужий шлях, і до запитів твого сервісу браузер її не додає.
Якщо куки в списку немає взагалі, подивись у вкладці Network відповідь на
/api/login: чи є в ній заголовок set-cookie. Якщо заголовка немає,
у коді ендпоінта login немає аргументу response: Response або
рядка response.set_cookie(...).
Перевір по черзі три речі. Перша — чи є взагалі запис у базі й чи той у ньому логін:
sqlite3 ~/data/app.db "SELECT id, login FROM owner;"Логін має збігатися символ у символ із тим, що ти надсилаєш, — без пробілів на кінці й з тією самою крапкою перед номером групи.
Друга — чи справді в базі хеш:
sqlite3 ~/data/app.db "SELECT password_hash FROM owner;"Рядок має починатися з $2b$12$ і мати 60 символів. Якщо там текст пароля,
запис робили не через set-password.py.
Третя — чи не помилився ти в самому паролі. Перевір його окремо, без сервісу:
cd ~/app
read -s -p "Пароль: " PW; echo
PW="$PW" /opt/club/venv/bin/python -c "
import os, bcrypt
from db import query
row = query('SELECT password_hash FROM owner')[0]
print(bcrypt.checkpw(os.environ['PW'].encode(), row['password_hash'].encode()))
"
unset PWPW="$PW" перед командою передає значення тільки цьому запуску Python і нікуди
більше. Має надрукуватися True. Якщо False — пароль інший, ніж ти
думаєш.
Простіше й надійніше — просто перезаписати пароль скриптом
set-password.py ще раз.
Порядок дій саме такий, і перший пункт найважливіший.
1. Замінити секрет. Старий вважай відомим стороннім, він більше не
секрет. У .env може бути й інше, крім SESSION_SECRET, тому файл не
перезаписуємо цілком, а міняємо в ньому один рядок:
cd ~/app
touch ~/env.new
chmod 600 ~/env.new
grep -v '^SESSION_SECRET=' .env >> ~/env.new
/opt/club/venv/bin/python -c "import secrets; print('SESSION_SECRET=' + secrets.token_hex(32))" >> ~/env.new
mv ~/env.new .env
cat .env
systemctl --user restart club-apigrep -v переписує в новий файл усі рядки, крім старого
SESSION_SECRET; далі туди дописується новий ключ, і новий файл стає на місце
старого. Проста команда ... > .env з однією кутовою дужкою стерла б решту
рядків .env разом зі старим ключем.
Після заміни всі раніше видані куки перестають діяти — доведеться увійти заново. Так і має бути.
2. Прибрати файл з історії. Якщо він потрапив тільки в останній коміт:
git rm --cached .env
git commit --amend --no-edit--cached прибирає файл з-під нагляду git, не видаляючи його з диска.
--amend переписує останній коміт замість того, щоб робити новий.
Якщо цей коміт ти вже відправляв на сервер командою git push origin main
(крок 12), то після --amend звичайний git push сервер відхилить:
на сервері лежить попередня версія цього коміта, і git не замінює її мовчки. Тоді потрібно:
git push --force origin main--force велить серверу взяти твою версію історії замість тієї, що в нього
збережена. Робити так можна лише у своєму репозиторії й лише коли з ним більше ніхто не
працює.
Якщо коміт із .env глибше в історії, треба пройти по всіх комітах:
FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch --force --index-filter \
"git rm --cached --ignore-unmatch .env" --prune-empty -- --all
rm -rf .git/refs/original
git push --force origin --allКоманду filter-branch сучасний git вважає застарілою. Без рядка
FILTER_BRANCH_SQUELCH_WARNING=1 він спершу друкує довге попередження й чекає
близько десяти секунд, перш ніж почати роботу; це не зависання, але з цим рядком ні
попередження, ні паузи не буде.
Команда переписує історію, тому номери всіх комітів зміняться. Роби її лише у своєму
репозиторії й лише коли впевнений, що .env справді там є. Після цього ще раз
перевір: git log --all --oneline -- .env — команда має нічого не вивести.
Відкрий F12, вкладку Network, знайди запит me і подивись його відповідь.
Якщо там {"logged_in": false}, а ти щойно увійшов, — проблема в куці, дивись
пункт про 401 після входу вище.
Якщо відповідь правильна, а кнопки все одно не такі, як треба, справа в класі. Перевір три
речі: клас only-owner справді стоїть на формі додавання; рядок
del.classList.add('only-owner'); додано в блок із заняття 05; у стилях є всі
три правила — .only-owner { display: none; },
body.is-owner .only-owner { ... } і
body.is-owner form.only-owner { ... }. Якщо в консолі (вкладка Console) є рядок
Cannot read properties of null, код не знайшов елемент: id у
розмітці написано інакше, ніж у getElementById.
Таблиця не створилася або створилася в іншому файлі бази. Подивись, що взагалі є в твоїй базі:
sqlite3 ~/data/app.db ".tables"Якщо owner у списку немає, повтори крок 3 практики й уважно прочитай, що
відповість команда. Якщо база називається інакше, дивись project.yaml, розділ
database, поле file: db.py бере шлях саме звідти,
тобто ~/data/ плюс ця назва.