До списку уроків
10 клас · Урок 3 з 16

Вебхук і секрет у .env: форма стукає до твого сервера

виконано0%
Крок 1

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

Модуль I · IT Skills (Cloud Services & Cyber Security) · тема 2, урок 1 з 2 · 120 хвилин

Досі твій сайт лише віддавав сторінки. Сьогодні він уперше почне приймати дані. Від Google, автоматично, щойно хтось надішле анкету. Це і є вебхук.

Ти збереш ланцюг із п’яти ланок. Дані з чужої форми самі приїжджатимуть у твій файл. І перший у житті секрет ляже так, як його кладуть дорослі.

Після уроку ти вмієш

Слова «вебхук», «токен», .env і «JSONL» поки нічого не означають — розберемо їх по черзі.

Хід уроку

ЕтапХвЩо робимо
Вхід і мотивація5Дивимося ланцюг «форма → твій файл» цілком
Вебхук проти опитування10Хто кого питає; часова шкала з вісьмома марними запитами
POST і коди відповіді15Анатомія запиту, 200, 401, 403, 405, 422, інспектор запиту
Токен і ~/.env15openssl rand, права 600, сканер секретів
Практика: форма і токен20Google Form, Script Properties, ~/.env по SSH
Практика: inbox.php25Шість перевірок, README-контракт, заливання по FTP
Практика: три тести curl15401, 403, 200 і рівно +1 рядок у leads.jsonl
Автоперевірка й підсумок15Чотири 🔒-критерії доводимо до зеленого, ДЗ
Разом120

У кінці уроку в тебе працює:

Що куди їде

ФайлДе живеЯк туди потрапляє
inbox.php~/www/api/пишеш у себе на комп’ютері, заливаєш по FTP
README.md~/www/api/пишеш у себе на комп’ютері, заливаєш по FTP
.env~/ (домашня тека)створюєш по SSH просто на сервері — по FTP ніколи
leads.jsonl~/data/створює й дописує сам inbox.php
Межа модуля лишається На сервер їде тільки легкий текст: .php, .md, .html, .json. Секрети й приватні дані не їдуть по FTP узагалі — .env ти набираєш у SSH-сесії, а leads.jsonl сервер пише собі сам.
Тека практики зветься за темою Автоперевірка цього уроку запускається як check 02 — за номером теми, а не уроку. Урок третій, тема друга. Не шукай lesson-03.
Крок 2

Вебхук: не ти питаєш, а до тебе стукають

Одна зміна напрямку — і зникає ціла категорія роботи

Є два способи дізнатися, що прийшла нова анкета.

Опитування (polling). Твоя програма щохвилини питає Google: «є щось нове?» У 99 випадках зі 100 відповідь — «ні». Ти витрачаєш запити, трафік і ліміти. А новина все одно приходить із запізненням до хвилини.

Вебхук (webhook). Ти один раз кажеш Google свою адресу. Далі мовчиш. Хтось надсилає форму — і Google сам стукає на цю адресу. Дані він приносить у тілі запиту. Нуль зайвих запитів, затримка — секунди.

Опитування: 8 запитів, 1 корисний 0 хв8 хв ніні ніні ніні ні є! анкету надіслали тут — дізнався аж тут ↑ Вебхук: 1 запит, він же корисний анкету надіслали — Google стукає в ту саму секунду 0 хв8 хв
Ілюстративна шкала. Головна різниця не в кількості крапок, а в напрямку стрілки: при опитуванні ініціативу тримаєш ти, при вебхуку — джерело події.

Чому вебхуку потрібна публічна адреса

Стукати можна лише в двері, які видно з вулиці. Твій ноутбук сидить за домашнім роутером. З інтернету його не знайти: адреси, за якою можна прийти, у нього просто немає. Тому в тебе є сабдомен 91.219.61.4/s/<логін>. Це і є твої двері.

Три вимоги до адреси, яку ти віддаєш чужому сервісу:

Питання

Ти щохвилини оновлюєш вкладку з таблицею відповідей і дивишся, чи прийшла нова анкета. Що саме змінить вебхук у цій картині?

Питання

Ти вписав у Apps Script адресу http://localhost/api/inbox.php. У журналі виконань — помилка з’єднання. Чому?

Увесь ланцюг однією схемою

хмара Google твій VPS Google Form Apps Script HTTPS POST inbox.php leads.jsonl учень тисне «Надіслати» тригер on form submit заголовки + тіло JSON твій приймач у ~/www/api/ один рядок у ~/data/ тут і тільки тут перевіряється X-Auth-Token поза DocumentRoot Токен народжується в ~/.env, потрапляє в Apps Script один раз і далі їде тільки в заголовку.
П’ять ланок. Три перші тобі не належать — форма й скрипт живуть у Google. Твоя відповідальність починається з четвертої: усе, що прилетіло, ти вважаєш чужим і недовіреним, доки не перевірив токен.
Крок 3

Із чого складається запит, який до тебе прилетить

Метод, шлях, заголовки, порожній рядок, тіло. Чотири частини — і кожна щось означає

HTTP-запит — це звичайний текст, який їде по мережі. Відкриваєш сторінку — браузер шле GET: «дай мені це». Apps Script надсилає анкету — він шле POST: «на, візьми це й зроби щось».

POST /api/inbox.php HTTP/1.1 Host: 91.219.61.4/s/koval.d.10b Content-Type: application/json X-Auth-Token: 3f9c1a…8b2e Content-Length: 118 User-Agent: Google-Apps-Script {"name":"Оля","email":"olia@example.com", "message":"Хочу в гурток","form":"Запис"} стартовий рядок заголовки порожній рядок тіло метод + шлях + версія службові дані про запит і про того, хто стукає межа «заголовки / тіло» корисний вантаж рівно Content-Length байтів
Точно такий текст ти зараз збереш руками в інспекторі нижче. Заголовок X-Auth-Token — власна вигадка твого контракту: префікс X- традиційно означає «нестандартний, домовлений між нами».

Чому токен їде в заголовку, а не в тілі й не в адресі

Куди покласти токенЩо з ним стається
?token=… в URLпотрапляє в логи доступу nginx, в історію браузера, у заголовок Referer — тобто зберігається у трьох місцях відразу
у тілі JSONпрацює, але щоб дістати токен, треба спершу розібрати тіло — а тіло приходить від невідомо кого
у заголовкуперевіряється до розбору тіла, у стандартні логи не пишеться, шифрується разом із рештою запиту по HTTPS
Питання

Ти виклав адресу /api/inbox.php у публічному README на GitHub і поки не поставив жодної перевірки. Що станеться раніше за перший справжній запис?

Крок 4

Коди відповіді: що твій сервер каже тому, хто стукає

Код — це не «помилка». Це коротка інструкція, що робити далі

Той, хто до тебе стукає, — програма. Вона не читає українською. Єдине, що вона розуміє, — тризначне число у відповіді. Від нього залежить, повторить вона запит чи назавжди його викине.

КодНазваЩо це означає для того, хто стукає
200OKприйнято й записано. Повторювати не треба
401Unauthorizedти взагалі не показав ключа. Додай заголовок і спробуй ще раз
403Forbiddenключ показав, але не той. Повторювати з тим самим ключем безглуздо
422Unprocessable Contentзапит зрозумілий, ключ правильний, але вміст не годиться: не JSON або бракує поля. Виправ дані
429Too Many Requestsзанадто часто. Зачекай і повтори — скільки саме, скаже заголовок Retry-After

Ще один код побачиш майже одразу: 405 Method Not Allowed. Він прилетить, щойно ти відкриєш /api/inbox.php у браузері. Браузер шле GET, а твій приймач бере лише POST.

На наступному уроці 422 звузиться Сьогодні приймач віддає 422 у двох випадках. Перший — тіло не розібралося як JSON. Другий — бракує обов’язкового поля. На уроці 4 ми їх розділимо: нерозбірне тіло дістане 400, а 422 лишиться для правильного JSON із поганими значеннями. Тоді ж ти оновиш README.md одним рядком.
401 і 403 плутають частіше за все 401 — «я не знаю, хто ти»: заголовка немає взагалі. 403 — «я знаю, хто ти, і тобі не можна»: заголовок є, значення не збігається. Різниця не косметична: у першому випадку клієнту варто дослати ключ, у другому — здатися.

Дерево рішень твого обробника

1 · запитів з цього IP за годину ≤ 20? 2 · метод — POST? 3 · заголовок X-Auth-Token є? 4 · значення збігається з ~/.env? 5 · тіло розбирається як JSON? 6 · усі обов’язкові поля на місці? записуємо рядок у ~/data/leads.jsonl тактак тактак так ніні ніні ніні 429 405 401 403 422 422 200 завелика частота хтось відкрив адресу в браузері заголовка немає взагалі токен чужий тіло не JSON бракує поля рядок дописано Порядок важливий: найдешевші перевірки — першими, розбір тіла — останнім.
Крок 1 ти додаси наступного уроку — тут він показаний, щоб було видно, куди він стане. Кроки 2—6 ти пишеш сьогодні. Порядок не випадковий: відкидати чужий запит треба до того, як витратив на нього роботу.
Питання

Твій обробник на будь-яку проблему відповідає 200 ok — «щоб Apps Script не лаявся». Чим це закінчиться?

Крок 5

Інспектор запиту

Склади запит — і подивись, на якому саме кроці він помре

Нижче — модель твого обробника. Обираєш, що покласти в запит, і тиснеш «Надіслати». Далі покроково видно, як приймач його перевіряє. Який код повернеться. Що потрапить у лог. І чи додасться рядок у leads.jsonl.

Інспектор запиту до /api/inbox.php
запит ще не надсилали

Інспектор — модель, а не справжня мережа: він показує ту саму послідовність перевірок, що й код inbox.php нижче. Числа в рядку логу (IP, довжина, час) — ілюстративні.

Питання

Apps Script шле contentType: 'application/x-www-form-urlencoded', а приймач чекає JSON. Токен при цьому правильний. Що поверне приймач і що з’явиться в leads.jsonl?

Крок 6

Навіщо взагалі токен

Твоя адреса — не таємниця. Таємниця — ключ до неї

Типове заперечення: «адресу знаю тільки я, навіщо перевірка?» Адресу знаєш не тільки ти. Публічні адреси знаходять автоматично. За списками виданих сертифікатів HTTPS. За чужими логами й посиланнями. Просто перебором типових шляхів: /api/, /webhook.php, /inbox.php. Це роблять цілодобово й не проти тебе особисто — сканують усе підряд.

Що буває з ендпойнтом без перевірки:

Найпростіший захист — спільний секрет (shared secret). Обидві сторони знають один рядок. Той, хто стукає, кладе його в заголовок. Це не криптографія й не підпис. Хтось підгляне цей рядок — і стукатиме замість тебе. Тому це перший рівень, а не останній. Справжній підпис HMAC з міткою часу ти зробиш на уроці 12.

Порівнюй токени через hash_equals() Звичайне == порівнює рядки посимвольно й зупиняється на першій розбіжності — тобто відповідає трохи швидше, коли перші символи вгадані. Це називають атакою по часу. hash_equals() витрачає однаковий час на будь-яке значення.

Як народжується токен

Токен має бути випадковим і довгим. Не «myschool2026», не дата народження, не назва класу. Генерує його машина:

SSH на сервері
$ openssl rand -hex 24 a7f3c9e21b04d85f6a1c3e7b90d24f8ec5b16a3d92e70f4c

-hex 24 — це 24 випадкові байти, записані як 48 шістнадцяткових символів. Підібрати такий рядок перебором неможливо. Варіантів 2 у степені 192 — число з 58 цифр.

Питання

Однокласник каже: «я поставив токен 10b2026, його ніхто не вгадає, бо про наш клас ніхто не знає». Що з цим не так?

Крок 7

Секрет у .env: чому не в коді

Три різні шляхи, якими код витікає — і жоден із них не про злам

Написати $token = "a7f3c9e2…"; просто в inbox.php здається безпечним: цей файл же на сервері. Проблема в тому, що код не лишається на сервері.

Шлях перший: код читають люди

Ти покажеш файл однокласнику, попросиш допомоги в чаті, скинеш скрін учителю, вставиш у AI-редактор. Токен їде разом із кодом у кожну копію. І жодну з цих копій ти вже не контролюєш.

Шлях другий: код потрапляє в git

Комміт — це запис назавжди. Ти видалиш рядок наступним коммітом, а старий лишиться в історії. Команда git log -p покаже його будь-кому, хто має репозиторій. На GitHub такі рядки знаходять автоматично. Секрет, залитий у публічний репозиторій, зазвичай починають використовувати за хвилини, а не за дні.

Шлях третій: код віддається браузером

Це найгірший випадок. Частина файлів у ~/www/ сервером не виконується: .js, .json, .txt, .env, .bak. Такий файл віддають кожному, хто попросить. Ламати нічого не треба — досить знати ім’я.

91.219.61.4/s/koval.d.10b/config.js // налаштування форми const ENDPOINT = "/api/inbox.php"; const CLASS_ID = "10B"; const RETRY = 3; const TOKEN = "a7f3c9e21b04d85f…"; Файл віддається кожному. Ламати нічого не треба. Ctrl+U — перегляд коду сторінки DevTools → Network → config.js кеш пошуковика і вебархів git push — і копія назавжди в історії Як має бути ~/.env (права 600, поза www) WEBHOOK_TOKEN=a7f3c9e2… inbox.php $t = parse_ini_file(...)['WEBHOOK_TOKEN']; У коді лишається звернення до змінної. Значення живе в одному файлі, який ніколи нікуди не їде.
Ключове слово — «поруч із index.html». Файл усередині ~/www/ віддається по HTTP за замовчуванням: щоб його прочитати, достатньо знати ім’я.
Питання

Ти поклав токен у ~/www/config.js поруч із index.html. Хто його вже прочитав?

Права 600: що це насправді означає

На сервері живуть три класи, разом 63 учні. Файл із правами 644 (-rw-r--r--) читає будь-хто з них однією командою cat. Для секрету потрібне 600.

ПраваСимвольноХто читає твій .env
600-rw-------тільки ти. Це єдиний правильний варіант
640-rw-r-----ти й уся твоя група
644-rw-r--r--кожен із 64 акаунтів сервера
666-rw-rw-rw-кожен із 64 акаунтів, і ще й переписати може

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

створити .env правильно, SSH
$ touch ~/.env $ chmod 600 ~/.env $ printf 'WEBHOOK_TOKEN=%s\n' "$(openssl rand -hex 24)" > ~/.env $ stat -c %a ~/.env 600 $ cat ~/.env WEBHOOK_TOKEN=a7f3c9e21b04d85f6a1c3e7b90d24f8ec5b16a3d92e70f4c
Чому chmod стоїть до запису Файл створюється з правами за замовчуванням, і між створенням і chmod є вікно, коли він читається всіма. Секунда — теж вікно. Тому спершу закриваємо, потім пишемо.
Питання

Ти зробив ls -l ~/.env і бачиш -rw-r--r--. На сервері зараз 64 акаунти трьох класів. Що це означає практично?

.gitignore і .env.example

Якщо твій проєкт лежить у git, .env має бути в .gitignore до першого комміту. Файл .gitignore — це список того, що git ігнорує. Сам він у репозиторій їде, і в цьому сенс. Правило спрацює в кожного, хто клонує проєкт.

.gitignore
.env .env.local data/ *.jsonl

Разом із ним у репозиторій кладуть .env.example — той самий файл, але без значень. Він відповідає на питання «які змінні потрібні, щоб це запустилося», і не розкриває жодної.

.env.example — їде в git
# згенеруй своє: openssl rand -hex 24 WEBHOOK_TOKEN= LEADS_PATH=
Пізно доданий .gitignore не рятує Якщо .env уже потрапив хоча б в один комміт, він лишається в історії назавжди, і додавання правила нічого не змінює. Єдина правильна реакція — вважати токен скомпрометованим і перевипустити його: згенерувати новий, замінити в ~/.env і в налаштуваннях Apps Script.
Питання

Ти додав .env у .gitignore — але вже після того, як один раз закоммітив його. Достатньо?

Питання

Навіщо тримати в репозиторії .env.example, якщо сам .env туди не потрапляє?

Змінні оточення проти конфігу

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

ОзнакаФайл конфігуЗмінна оточення
Де живе значенняу файлі на дискуу пам’яті процесу, який запустив програму
Ризик витокуфайл можна випадково залити в git або в wwwу файли не потрапляє, але видно в /proc і в списку процесів на деяких системах
Різні значення для тесту й боютреба два файлиміняється одним рядком запуску
Зручність для нас сьогодніпрацює і без доступу до налаштувань сервератреба керувати конфігурацією PHP-FPM, а sudo в тебе немає

.env — компроміс між ними. Це файл, який виглядає як список змінних оточення. Ідея прийшла з методології «Дванадцять факторів». Правило там просте: налаштування живуть окремо від коду. Код однаковий у всіх, а налаштування в кожного свої. Ми беремо це правило й робимо його найпростішим доступним способом.

Крок 8

Сканер секретів

Встав свій код — сторінка підсвітить рядки, які не можна нікуди віддавати

Такі самі інструменти працюють у GitHub і в багатьох CI. Змісту коду вони не розуміють. Вони шукають форму: довгі випадкові рядки й присвоєння змінним із підозрілими іменами. Тому й помиляються в обидва боки. Іноді підсвітять невинне, іноді пропустять справжнє. Останнє слово завжди за тобою.

Сканер секретів
Сканер бачить форму, а не сенс Довгий hex-рядок може бути хешем комміту, а може — твоїм токеном. Сканер підсвітить обидва. Правило просте: якщо ти не можеш пояснити, звідки взявся довгий випадковий рядок у коді, — він там зайвий.
Питання

Сканер підсвітив у твоєму inbox.php рядок $fallback = "a7f3c9e2…24f8e"; — ти вписав його «на випадок, якщо .env не прочитається». Чим це небезпечно саме тут?

Крок 9

Куди лягають дані: JSONL і межа веб-кореня

Два рішення, які приймають один раз і потім не переробляють

Чому JSONL, а не CSV

JSONL (JSON Lines) — це файл, у якому один рядок = один самодостатній JSON-об’єкт. Ніякої обгортки, ніякого масиву навколо, ніякої коми в кінці рядка.

СитуаціяCSVJSONL
У повідомленні є коматреба брати значення в лапки, інакше рядок поїде по колонкахкома всередині рядка нічого не ламає
У повідомленні є перенос рядкаодин запис займає кілька фізичних рядків — wc -l уже брешепереноси екрануються як \n, запис лишається одним рядком
Додали нове полеміняється заголовок, старі рядки стають несуміснимиу нових рядках просто з’являється новий ключ
Дописати записможна, але формат не гарантує коректностідописування — штатний режим формату
Читати частинамитреба тримати стан парсерачитаєш по рядку, кожен розбирається окремо
Відкрити в Excelпрацює одразупотрібна конвертація

А чому не один великий leads.json із масивом? Щоб додати запис у масив, треба прочитати весь файл, розібрати його, дописати елемент і переписати файл цілком. Два запити одночасно — і половина даних зникла. У JSONL дописування — одна операція. Система робить її атомарно: відкриває файл у режимі append і додає рядок у кінець.

Один рядок файлу ~/data/leads.jsonl { "ts":"2026-09-02T09:14:07+00:00" "name":"Оля" "email":"olia@ex.com" "message":"Хочу в гурток" } коли — ставить сервер, а не клієнт що прийшло з форми, обрізане до 100 символів ключі однакові в усіх рядках кома й лапки всередині значення екрануються кінець рядка \n — і це вся «розділова» логіка формату {"ts":"…09:14:07…","name":"Оля",…} ← запис 1 {"ts":"…09:16:31…","name":"Тарас",…} ← запис 2, дописаний у кінець {"ts":"…09:22:04…","name":"Ніна",…} ← запис 3, попередні два не чіпали
Кожен рядок читається окремо. Якщо один запис зіпсується, решта файлу лишиться придатною — на відміну від одного великого JSON-масиву, де зламана дужка робить нечитабельним увесь файл.
Питання

Ти пишеш анкети в leads.csv. Приходить повідомлення «Привіт, я з 10-Б, хочу в гурток, бажано вівторок» — з комами й переносом рядка. Що станеться зі звітом, який рахує рядки?

~/data/ проти ~/www/data/

Nginx віддає по HTTP лише те, що лежить усередині DocumentRoot. Для тебе це тека ~/www/. Усе, що поза нею, з інтернету недосяжне в принципі. Не тому що заборонено. А тому що з адреси в браузері туди просто не веде жоден шлях.

Домашня тека /home/koval.d.10b www/ — DocumentRoot, віддається по HTTP index.html api/inbox.php api/README.md data/leads.jsonl ← так робити не можна поза DocumentRoot — HTTP сюди не заходить .env (600) data/leads.jsonl (600) GET /data/leads.jsonl → 200 OK, увесь файл будь-хто у світі, без пароля, за прямим посиланням GET /../data/leads.jsonl → 404 шлях не існує для веб-сервера: за межі кореня він не виходить GET /.env → 403 або 404 (правило сервера на файли, що починаються з крапки) Перевір сам: відкрий у браузері свій сабдомен + /.env і + /data/ і подивись, що віддасть сервер.
Правило запам’ятовується одним реченням: у www/ лежить те, що ти готовий показати всьому світу. Усе інше — рівнем вище.
Класична помилка цього уроку leads.jsonl кладуть у ~/www/data/, «щоб зручно було переглядати в браузері». Зручно стає всім: одне посилання — і всі анкети однокласників у чужих руках. Це блокуючий критерій data_outside: поки він червоний, роботу не приймуть.
Питання

Ти хочеш дивитися накопичені анкети з телефона. Найпростіше — перенести leads.jsonl у ~/www/data/. Який наслідок і що робити натомість?

Крок 10

Код: приймач, відправник і контракт

Три файли. Кожен рядок ти маєш уміти пояснити вголос

~/www/api/inbox.php

Кроки в коді пронумеровані так само, як гілки дерева рішень вище.

inbox.php
<?php declare(strict_types=1); // 0. звідки беремо секрет: __DIR__ = ~/www/api, два рівні вгору = ~ $home = dirname(__DIR__, 2); $env = @parse_ini_file($home . '/.env'); $token = $env['WEBHOOK_TOKEN'] ?? ''; function reply(int $code, string $status, string $msg = '') { http_response_code($code); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['status' => $status, 'message' => $msg], JSON_UNESCAPED_UNICODE); exit; } // 2. метод if ($_SERVER['REQUEST_METHOD'] !== 'POST') reply(405, 'method_not_allowed'); // 3. заголовок узагалі є? $got = $_SERVER['HTTP_X_AUTH_TOKEN'] ?? ''; if ($got === '') reply(401, 'no_token', 'потрібен заголовок X-Auth-Token'); // 4. збігається? hash_equals — стале в часі порівняння if ($token === '' || !hash_equals($token, $got)) reply(403, 'bad_token'); // 5. тіло розбирається як JSON? $raw = file_get_contents('php://input'); $data = json_decode($raw, true); if (!is_array($data)) reply(422, 'bad_json', json_last_error_msg()); // 6. усі поля на місці? foreach (['name', 'email', 'message'] as $f) { if (!isset($data[$f]) || trim((string)$data[$f]) === '') reply(422, 'missing_field', $f); } // 7. один рядок JSONL у ~/data/ — поза www $row = [ 'ts' => gmdate('c'), 'name' => mb_substr(trim($data['name']), 0, 100), 'email' => mb_substr(trim($data['email']), 0, 100), 'message' => mb_substr(trim($data['message']), 0, 1000), ]; $line = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; file_put_contents($home . '/data/leads.jsonl', $line, FILE_APPEND | LOCK_EX); reply(200, 'ok');

Що тут варто прочитати уважно:

Тека ~/data/ має існувати file_put_contents створює файл, але не теку. Якщо ~/data/ немає, запис мовчки провалиться, а відповідь усе одно буде 200. Створи теку заздалегідь: mkdir -p ~/data && chmod 700 ~/data.

Apps Script: відправник

У Google теж є свій «.env» — Script Properties. Токен кладеш туди через меню налаштувань проєкту. У коді лишається саме звернення до нього.

Ключ має збігатися з назвою питання буква в букву e.namedValues — це словник, у якому ключ дорівнює тексту питання у формі. Скопіюй назву з форми, а не набирай руками. Апостроф у слові «Ім’я» теж має бути той самий. Інакше v['Ім’я'] поверне undefined, поле поїде порожнім, а приймач відповість 422 missing_field.
Код.gs
const ENDPOINT = 'http://91.219.61.4/s/<логін>/api/inbox.php'; function onFormSubmit(e) { const v = e.namedValues; // {"Ім’я": ["Оля"], ...} const body = { name: (v['Ім’я'] || [''])[0], email: (v['Email'] || [''])[0], message: (v['Повідомлення'] || [''])[0], form: 'Запис у гурток' }; const token = PropertiesService.getScriptProperties() .getProperty('WEBHOOK_TOKEN'); const res = UrlFetchApp.fetch(ENDPOINT, { method: 'post', contentType: 'application/json', // саме JSON, не форма headers: { 'X-Auth-Token': token }, payload: JSON.stringify(body), muteHttpExceptions: true // щоб побачити 401/403/422 }); console.log(res.getResponseCode(), res.getContentText()); }
Простий тригер тут не спрацює Функція з іменем onFormSubmit сама по собі — «простий» тригер, а простим тригерам заборонено звертатися до сервісів, що потребують дозволу, — і UrlFetchApp саме такий. Тригер треба створити встановлюваним: у редакторі скрипта «Тригери» → «Додати тригер» → подія «При надсиланні форми», і дати дозвіл, коли Google його запитає.

~/www/api/README.md — контракт

Контракт — це відповідь на питання «як до тебе стукати». Без нього твій ендпойнт не зможе використати навіть ти сам за два тижні.

README.md
# POST /api/inbox.php Приймає одну анкету з Google Form через Apps Script. ## Запит Метод: POST Заголовки: Content-Type: application/json; charset=utf-8 X-Auth-Token: <значення WEBHOOK_TOKEN з ~/.env> Тіло: {"name": "...", "email": "...", "message": "..."} Усі три поля обов’язкові й непорожні. ## Відповіді 200 ok рядок дописано в ~/data/leads.jsonl 401 no_token заголовка X-Auth-Token немає 403 bad_token заголовок є, значення не збігається 405 method_not_allowed метод не POST (напр. відкрито в браузері) 422 bad_json тіло не розбирається як JSON 422 missing_field бракує обов’язкового поля; яке — у message ## Що НЕ зберігається Токен у цьому репозиторії не наводиться. Дані лежать поза www.

Перевірка руками: три curl

Запускай їх у SSH-сесії — там справжній curl. Заміни <логін> і <токен> на свої.

без токена → 401
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://91.219.61.4/s/<логін>/api/inbox.php -H 'Content-Type: application/json' -d '{"name":"Тест","email":"t@example.com","message":"перевірка"}' 401
чужий токен → 403
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://91.219.61.4/s/<логін>/api/inbox.php -H 'Content-Type: application/json' -H 'X-Auth-Token: not-my-token' -d '{"name":"Тест","email":"t@example.com","message":"перевірка"}' 403
правильний токен → 200 і +1 рядок
$ wc -l ~/data/leads.jsonl 7 /home/koval.d.10b/data/leads.jsonl $ curl -s -X POST http://91.219.61.4/s/<логін>/api/inbox.php -H 'Content-Type: application/json' -H "X-Auth-Token: $(grep WEBHOOK_TOKEN ~/.env | cut -d= -f2)" -d '{"name":"Тест","email":"t@example.com","message":"перевірка"}' {"status":"ok","message":""} $ wc -l ~/data/leads.jsonl 8 /home/koval.d.10b/data/leads.jsonl
Windows: curl — це не curl У Windows PowerShell 5.1 слово curl — псевдонім Invoke-WebRequest, і всі ключі -X, -H, -d там означають зовсім інше. Якщо запускаєш у PowerShell, пиши curl.exe повністю. У Git Bash, macOS, Linux і в SSH-сесії на сервері — звичайний curl.
Питання

Ти прибрав з inbox.php рядок із hash_equals і поставив if ($token != $got). Функціонально нічого не змінилося: правильний токен проходить, чужий — ні. Що ти втратив?

Крок 11

Практика: збери ланцюг

Кроки 1—2 — у браузері в Google, 3—6 — у SSH-сесії, 7—9 — у себе на комп’ютері, 10—16 — знову сервер і форма. Галочки зберігаються, сторінку можна закрити.

Що по FTP не їде ніколи ~/.env — набирається в SSH-сесії просто на сервері. ~/data/leads.jsonl — створює сам приймач. Якщо ти бачиш .env у вікні FileZilla серед файлів для завантаження — зупинись і видали його звідти.
Крок 12

Автоперевірка

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

Демонстраційний режим: результат згенеровано для показу. На сервері ця кнопка викликає POST /api/check, який запускає check 02 від імені учня.

Що вчитель дивиться на твоєму екрані

Крок 13

Шкала виконання

Це те, що бачить учитель, коли виставляє оцінку.

СкладникВагаРезультат
Виконано
0%
Рекомендований бал за 12-бальною

Чотири речення, які варто запам’ятати

Де найчастіше помиляються

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

Допиши ~/www/api/README.md. Опиши всі коди відповіді свого приймача. І що кожен означає для того, хто стукає. Не «401 — помилка», а «401 — заголовка X-Auth-Token немає взагалі; треба дослати заголовок, повторювати без нього безглуздо».

Додай наприкінці абзац-гіпотезу до наступного уроку. Що станеться, якщо в поле «Ім’я» вставити <script>alert(1)</script>, а твоя сторінка потім покаже це ім’я?

Джерела

Звідки взяті визначення й коди

Усе на цій сторінці — зі стандартів і офіційної документації. Перевіряти дозволено й корисно.

Повний список із поясненнями, що звідки взято, — у файлі urok-03-джерела.md поруч із цією сторінкою.