Сам бот — це п'ятнадцять рядків коду. Урок про інше: який файл відвідувач твого сайту побачить, а який — ні
У дев'ятому класі бот був таблицею заготовок. Учень тиснув кнопку — бот видавав готове речення. Усе це лежало прямо в сторінці.
Тепер відповідь пише модель — чужа програма на чужому сервері. Щоб до неї звернутися, потрібен ключ. Це довгий рядок символів, який доводить, що за запит платиш саме ти. І одразу постає нове питання. Де цей ключ лежить, поки бот працює?
Відповідь на весь урок коротка. Ключ лежить в одному файлі на твоєму
сервері — ~/.env. У браузер він не потрапляє ніколи
й ні в якому вигляді. Решта уроку — наслідки цього правила.
| Етап | Хв | Що робимо |
|---|---|---|
| Демо «F12» | 15 | Витягуємо ключ навчального бота за 20 секунд через панель мережі |
| Архітектура проксі | 15 | Дві схеми — де саме проходить межа довіри між браузером і сервером |
| Персона й контекст | 15 | Пишемо роль бота, межі й формулу відмови в редакторі з живим прев'ю |
Практика: system.md | 15 | Складаємо системний промпт ≥200 символів, тестуємо в прев'ю |
| Практика: проксі | 30 | Пишемо chat.php, що читає ключ із ~/.env, перевіряємо код на захардкоджений ключ |
| Практика: фронтенд | 15 | Робимо сторінку чату, що звертається лише до свого /api/chat.php |
| Git-гігієна | 10 | .gitignore, git status — перевіряємо, що .env не потрапив в історію |
| Автоперевірка | 5 | 🔒 no_key_front, key_in_env, gitignore, git_history |
| Разом | 120 |
Слова «проксі», «токен» і «системний промпт» поки нічого не означають. Розберемо їх по черзі, кожне на своєму місці.
браузер ходить прямо до моделі
chat.js;браузер ходить до твого chat.php
~/.env уже на сервері;/api/chat.php;system.md: роль бота, межі, формула відмови;api/chat.php — проксі, який бере ключ
із ~/.env і додає його вже на сервері;bot/index.html + bot/chat.js,
яка звертається лише до твого власного API;Authorization
у запитах браузера немає взагалі;.gitignore і переконаєшся, що .env
не потрапив ані у робочу теку, ані в історію комітів.bot/index.html,
bot/chat.js, api/chat.php,
bot/system.md. Поруч, у теці репозиторію, лежить
.gitignore. А файл ~/.env ти створюєш
по SSH, прямо на сервері. У ~/www він не потрапляє
ніколи. По FTP його теж не заливають: FTP-клієнт уміє «синхронізувати теку»
і легко забере зайве.Це не про вразливість і не про недогляд. Це про те, як улаштований веб: браузер не може виконати код, якого він не отримав
Коли ти відкриваєш сторінку, сервер віддає її вміст файл за файлом:
HTML, CSS, кожен .js. Браузер мусить мати повний текст
скрипта, інакше він його не виконає. Отже, цей текст є і в тебе,
і в кожного, хто відкрив ту саму адресу.
Подивитися на нього можна трьома способами. Жоден із них не потребує нічого, крім самого браузера.
<html>
і </html>, включно з інлайновими <script>/bot/chat.js відкривається як
звичайна сторінка: це такий самий файл, як index.htmlТому «ключ у JavaScript» і «ключ опублікований на сайті» означають те саме. Просто сказано двома різними способами.
Твій сайт відкритий лише за посиланням, яке ти нікому не давав,
і в robots.txt написано Disallow: /.
У chat.js лежить ключ. Наскільки це безпечно?
Нижче — навчальний бот. Перемикач угорі вибирає, як він улаштований усередині, кнопка запускає звичайний огляд сторінки відвідувачем
Спочатку постав «ключ у фронтенді» і натисни кнопку. Порахуй, у скількох місцях ключ видно. Потім спробуй кожен спосіб «сховати» його і дивись, як міняється число. І аж наприкінці перемкни на «через проксі». Ті самі три місця — і порожньо.
Інспектор пройде трьома місцями, у які дивиться будь-який відвідувач: вихідний код сторінки, файл скрипта і вкладка «Мережа».
Секунди на таймері — це темп анімації, а не заміряний час живої людини. Важливе тут не число. Важливо, що дій рівно три і жодна з них не є зламом: усі три файли сервер віддає сам, кожному, хто попросив.
.js. Досить розгорнути один запит у вкладці «Мережа».
Разом із ключем там же видно й системний промпт: він їде в тілі
того самого запиту.Однокласник каже: «я вимкну праву кнопку миші й перехоплю клавішу F12 у скрипті, тоді панель розробника не відкриють». Що з цим не так?
Кожна спроба сховати ключ у фронтенді додає рівно один крок до пошуку. Нуль захисту, плюс хибне відчуття, що захист є
Причина для всіх способів одна. Щоб скрипт міг скористатися ключем, він мусить у якусь мить тримати його у відкритому вигляді. А те, що бачить скрипт, бачить і людина.
Досить зупинити скрипт у налагоджувачі саме в цю мить. Або взагалі не читати код, а подивитися на запит, який уже пішов: ключ там у заголовку, звичайним текстом.
| Спроба | Що насправді | Скільки додаткових дій |
|---|---|---|
мінімізація chat.js |
прибираються пробіли й переноси; рядок із ключем лишається рядком | 0 (Ctrl+F по sk-) |
| обфускація, «незрозумілі» імена змінних | імена змінних не ховають значення; ключ видно у вкладці «Мережа» без читання коду взагалі | 0 |
ключ у base64 |
це кодування, а не шифрування: atob() поруч у тому самому файлі |
1 |
| ключ по частинах, склеюється в коді | склеєний рядок видно в налагоджувачі й у самому запиті | 1 |
окремий config.js + .gitignore |
.gitignore ховає файл від git, а не від nginx: файл усе одно віддається по HTTP |
1 |
| ключ забирається запитом із сервера при старті сторінки | цей запит теж видно у «Мережі»: ти зробив ендпойнт, який роздає ключ усім | 0 |
Ключ ніхто не «зламує». Його просто знаходять. По інтернету цілодобово
ходять боти й шукають рядки характерного вигляду — ті, що починаються
на sk- чи AIza.
Вони перебирають публічні репозиторії, файли .js
і випадково викладені .env. Знайдений ключ потрапляє
в чужі руки за години, іноді за хвилини. Далі відбувається ось що.
chat.js,
у чаті класу, у коміті, на скріншоті — байдуже де. Далі вважай його чужим.
Порядок дій такий: відкликати → перевипустити → замінити рядок
у ~/.env. І тільки потім розбиратися, як він туди потрапив.
Повний план реагування ти напишеш на уроці 14, у файлі
incident.md.Ти виніс ключ із chat.js в окремий файл
config.js. Потім додав рядок config.js
у .gitignore і переконався: git status
цього файла не показує. Ключ у безпеці?
Слово страшне, суть проста: замість «браузер → модель» стає
«браузер → твій chat.php → модель»
Уяви бібліотеку з закритим сховищем. Ти кажеш бібліотекарці, яку книжку хочеш. Вона йде у сховище своїм ключем і виносить книжку тобі. Ключа тобі ніхто не дає. Ти навіть не знаєш, на якій полиці лежала книжка.
Такий посередник у вебі зветься проксі: він приймає запит
від одного і переадресовує другому. Твій chat.php —
саме така бібліотекарка. І в нього є властивість, якої в браузера немає:
він працює на машині, до якої маєш доступ тільки ти. Тому ключ
можна тримати там.
Браузер після цього не знає ні адреси API моделі, ні назви моделі,
ні ключа. Він знає рівно одне. Є адреса /api/chat.php,
туди надсилають {"message": "…"} і отримують відповідь.
message. Решту запиту
до моделі він складає сам.Учень зробив chat.php, який приймає від браузера повний
JSON запиту до моделі. Проксі лише додає до нього заголовок із ключем.
Ключ у ~/.env, у браузері його немає. Що не так?
Кожна перевірка коштує мікросекунди твого сервера і рятує від запиту, який коштує грошей
Порядок перевірок не випадковий. Спершу йдуть найдешевші: подивитися на метод і тип вмісту можна за мікросекунду. Потім ті, що потребують розбору тіла запиту — JSON і довжина повідомлення.
Далі перевірка частоти: вона вже лізе на диск за лічильником, тому коштує дорожче. І аж останнім іде єдиний по-справжньому дорогий крок — похід до моделі. Той, за який платять грошима.
POST. Інакше твій ендпойнт
відкриють переходом за посиланням — і кожен такий перехід буде запитом
до моделіapplication/json. Це відсікає
надіслані форми й випадкові звернення, які інакше провалилися б
у json_decode і пішли далі на nullОбмеження частоти має простий побутовий двійник. У шкільній їдальні хліб лежить у спільному кошику. Взяти можна скільки треба, але не всю буханку за раз — інакше наступним не лишиться. Правило «10 запитів за хвилину з однієї адреси» робить те саме. Англійською таке обмеження звуть rate limit.
maxlength="400" у полі вводу й лічильник символів
у chat.js корисні: вони пояснюють користувачу правила.
Але діють вони лише на того, хто справді відкрив твою сторінку.
Хто надішле запит командою curl, твій JS не виконує взагалі.
Це те саме правило, що на уроці 4 про перевірку форми.У chat.js стоїть maxlength="400", лічильник
символів і затримка 3 секунди між надсиланнями. Наскільки це захищає
твою квоту?
chat.phpВмикай перевірки по одній і дивись, як росте код — і що написано під кожною вимкненою
Почни з усіх вимкнених. Це той самий «зручний» варіант, який видає AI-редактор, коли не поставити йому умов. Він працює. І він же за одну ніч здатен з'їсти квоту всього класу.
Лістинг у власному блоці, а не в терміналі: у PHP символ
$ на початку рядка значущий, і кнопка копіювання термінала
зрізала б його як промпт.
parse_ini_file читає ~/.env і бере звідти
рядок MODEL_API_KEY=…. Сам файл лежить поза
~/www. Друге: заголовок із ключем у різних провайдерів
зветься по-різному — Authorization: Bearer … або
x-api-key: …. Подивись у документації тієї моделі, ключ
до якої видали тобі. Третє: браузеру повертається тільки текст
відповіді. Якщо віддати сиру відповідь API цілком, туди поїдуть
службові поля, які браузера не стосуються..env. Буває гірше — дублює його
в chat.js «для тестів». Тому перед заливанням прожени
по своїй теці пошук: grep -rn "sk-\|Bearer \|AIza\|hf_" ~/www.
Вивід має бути порожній. Тільки тоді заливай.Проксі написаний правильно, ключ у ~/.env, усі перевірки
на місці. Але ~/.env має права 644.
Чим це погано, якщо файл лежить поза ~/www
і по HTTP не віддається?
Персона бота — це файл. Питання лише в тому, хто має право цей файл переписати перед відправкою запиту
Системний промпт задає, ким бот себе вважає. Там записана його роль, тон, межі теми й формула відмови. У дев'ятому класі ти писав це прямо в скрипті сторінки. Для навчального бота такого досить. Для бота, за запити якого платиш ти, — уже ні.
Причина не в таємності. Причина в тому, хто складає запит до моделі. Якщо промпт лежить у фронтенді, запит складає браузер відвідувача. Отже, відвідувач може замінити промпт на будь-який інший — і твій ключ оплатить що завгодно.
Якщо промпт лежить на сервері, відвідувач надсилає тільки поле
message. Усе решта до запиту додає chat.php.
system.md
лежить у ~/www/bot/ і віддається по HTTP. Але прочитати
і підмінити — різні дії. Грошей коштує саме друга.~/www/bot/system.md
У цьому модулі промпт лежить у публічній теці. Так вимагає здача: учитель
має його прочитати, і критерій перевіряє саме цей шлях. Отже,
прочитати твій промпт зможе будь-хто. Будувати захист на тому,
що він таємний, не вийде. У дорослому проєкті такий файл кладуть поза
веб-теку — наприклад, у ~/data/system.md. Тоді по HTTP його
не видно. А поки що пиши промпт так, ніби його вже повісили на дошку:
без ключів, без паролів, без прізвищ.system.mdКритерій вимагає не менше 200 символів. Це мінімум, а не мета. Без третього й четвертого пунктів бот погоджується майже на все, скільки б символів там не було.
Учень переніс системний промпт із chat.js
у system.md на сервері. Він каже: «тепер його ніхто
не побачить». У чому він помиляється — і що таки змінилося?
Коротко, як факт, який тобі знадобиться на уроці 14
Ти бачив, як у чат-ботах текст ніби набирається на очах, по кілька слів. Це не анімація. Модель справді пише відповідь по частинах і віддає кожну частину одразу, не чекаючи кінця.
Такий режим зветься стрімінг (streaming). Технічно це один HTTP-запит, відповідь на який не закривається до кінця генерації. Звичайний запит поводиться інакше: чекає всю відповідь і повертає її одним шматком.
| Звичайна відповідь | Стрімінг | |
|---|---|---|
| Що бачить користувач | павза, потім увесь текст | текст з'являється по словах |
| Коли проксі бачить відповідь | цілком, до того як віддати | шматками, уже віддаючи попередні |
| Перевірка відповіді на сервері | можлива: є весь текст | складна: половину вже відправлено |
| Складність коду проксі | десяток рядків | помітно більше: буфер, події, обрив |
Ось у чому проблема. Фільтр на виході не дає боту сказати зайвого. Але працює він над готовим текстом, а в стрімінгу готового тексту немає.
Проксі помічає заборонений фрагмент — а попередні речення вже в браузері. Забрати їх звідти неможливо. Виходить вибір із двох. Або тримаєш увесь текст у себе до кінця і втрачаєш сенс стрімінгу. Або перевіряєш по шматках і миришся з тим, що фільтр спрацює із запізненням.
chat.php сьогодні працює без стрімінгу.
Він чекає повну відповідь і віддає її одним шматком. Так простіше
й надійніше, а на уроці 14 фільтр виводу додасться одним рядком.
«Друкування» при цьому можна вдати у chat.js: виводити
готовий текст по кілька символів. Виглядає так само, а перевірка
лишається на сервері.Учень увімкнув стрімінг, «щоб було як у справжніх ботів». І додав у проксі перевірку: якщо у відповіді трапиться заборонене слово, обірвати. Чому ця перевірка працює гірше, ніж він думає?
Гроші рахують не за запитами, а за токенами: окремо за те, що ти надіслав, і окремо за те, що модель написала
Модель не бачить тексту так, як ти. Вона ріже його на шматочки завбільшки приблизно з частину слова. Такий шматочок і зветься токен.
Для латиниці грубе наближення таке: близько 4 символів на токен.
Для кирилиці помітно гірше — приблизно 2 символи. Тому те саме
речення українською коштує дорожче за англійське. Це саме наближення,
а не правило. Точну кількість дає окремий запит до API, який рахує токени:
у Claude це /v1/messages/count_tokens.
Ціни оголошують за мільйон токенів, окремо за вхідні й вихідні. Вихідні всюди дорожчі. Дивись тарифи своєї моделі. Числа в калькуляторі нижче — реальні ціни трьох моделей Claude станом на вересень 2026. Вони тут лише як порядок величини.
—
5 запитів за секунду протягом 10 хвилин — це 3000 запитів, які зробить п'ять рядків коду з чужого ноутбука
Порівняй два числа: «за 30 днів» і «скрипт у циклі». Друге зазвичай більше. І набирається воно за десять хвилин. Тому обмеження частоти в проксі — не педантизм. Це єдине, що стоїть між твоїм ключем і чиєюсь цікавістю.
setInterval. Сервер при цьому почувається чудово:
навантаження мізерне. Дізнаєшся ти з рахунку — або з того, що модель
раптом почала відповідати «квоту вичерпано».Твій бот працює нормально, сервер не навантажений, сторінка відкривається миттєво. Але за ніч квота вигоріла. Куди дивитися першим ділом?
Учень хоче здешевити бота і прибирає з запиту всю історію діалогу. Тепер у кожному запиті лише системний промпт і останнє питання. Що він на цьому виграв і що втратив?
Чотири текстові файли їдуть по FTP. П'ятий створюється по SSH і в публічну частину не потрапляє ніколи
| Файл | Куди | Що в ньому |
|---|---|---|
bot/index.html | FTP → ~/www/bot/ | сторінка чату: поле вводу, кнопка, список реплік |
bot/chat.js | FTP → ~/www/bot/ | надсилає {"message":…} на свій API. Ключа немає |
api/chat.php | FTP → ~/www/api/ | проксі: перевірки, ключ із .env, похід до моделі |
bot/system.md | FTP → ~/www/bot/ | персона бота, ≥200 символів |
.gitignore | у теку репозиторію | рядок .env і все, що не має потрапити в git |
~/.env | тільки SSH | MODEL_API_KEY=…, права 600 |
const box = document.getElementById('log');
const inp = document.getElementById('msg');
const send = document.getElementById('send');
function add(who, text){
const p = document.createElement('p');
p.className = 'm ' + who;
p.textContent = text; // textContent, не innerHTML: правило уроку 4
box.appendChild(p);
box.scrollTop = box.scrollHeight;
}
send.onclick = async () => {
const text = inp.value.trim();
if(!text) return;
if(text.length > 400){ add('sys', 'Задовге повідомлення'); return; }
add('me', text);
inp.value = '';
send.disabled = true;
add('sys', 'бот друкує…');
try{
const r = await fetch('/api/chat.php', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({ message: text })
});
const data = await r.json();
box.lastChild.remove(); // прибрати «друкує…»
if(r.status === 429){ add('sys', 'Забагато запитів, зачекай хвилину'); }
else if(r.status === 413){ add('sys', 'Повідомлення задовге'); }
else if(!r.ok){ add('sys', 'Помилка: ' + (data.error || r.status)); }
else { add('bot', data.reply); }
}catch(e){
box.lastChild.remove();
add('sys', 'Немає зв\'язку із сервером');
}
send.disabled = false;
};
Зверни увагу на дві речі. Перша: fetch іде на
відносну адресу /api/chat.php. Це твій власний сервер
і твій власний домен, жодних заголовків із ключем тут немає.
Друга: перевірка довжини в цьому файлі теж є. Так і має бути — вона підказує користувачу, що повідомлення завелике, ще до надсилання. Справжня перевірка все одно стоїть на сервері.
~/.env не заливають по FTP
FTP-клієнт уміє «синхронізувати теку» і робить це охоче. Один необережний
клік — і .env опиняється в ~/www. Звідти його
віддадуть по HTTP за прямим посиланням. Правило просте: файл із секретом
народжується там, де живе, і нікуди не подорожує. А права 600
потрібні тому, що на цій машині ще 60 акаунтів. Права 644
означали б «читати може кожен, хто зайшов на сервер»..gitignore і те, чого він не вміє# секрети .env .env.* !.env.example config.js *.key *.pem # сміття node_modules/ .DS_Store
У .gitignore є одна властивість, про яку постійно
забувають. Він діє на файли, за якими git ще не стежить. Якщо
.env уже потрапив у коміт, рядок в ігнорі його звідти
не прибере. Git продовжить стежити за файлом просто тому, що вже стежить.
Історія комітів схожа на класний журнал. Оцінку можна виправити
на новій сторінці, але попередня сторінка нікуди не зникає — її досить
перегорнути назад. Ключ, який хоч раз потрапив у коміт, дістають
однією командою git log -p.
.env → помітив → видалив файл наступним комітом →
запушив → заспокоївся. Ключ при цьому лишився в історії. Кожен,
хто клонує репозиторій, дістане його командою git log -p.
Чистити історію складно й ненадійно. Правильна дія одна: негайно
відкликати ключ і перевипустити новий. Історію чистять уже потім,
для порядку, — а не щоб урятувати ключ.Репозиторій приватний, доступ мають лише двоє однокласників.
В історії комітів лежить .env із робочим ключем.
Чи можна вважати ключ безпечним, поки репозиторій приватний?
Кроки 1—6 і 12—16 — у себе на комп'ютері та по SSH. По FTP їдуть чотири файли з кроків 8—11. Галочки зберігаються
~/www, ні в ~/upload,
ні в git. Не з'являється в чаті класу, на скріншоті, у повідомленні
вчителю чи в коментарі коду «щоб не забути».
Якщо він десь там уже опинився, вважай його чужим. Перша дія —
відкликати ключ, а не виправляти файл.Чекер надсилає боту тестове повідомлення, сканує
~/www/bot/ на рядки, схожі на ключі, і дивиться git.
Спроби не обмежені
Демонстраційний режим: результат згенеровано для показу.
На сервері ця кнопка викликає POST /api/check, який запускає
/opt/lessons/13/check.sh від імені учня.
/api/chat.php,
і заголовка Authorization серед них немає жодного;stat -c '%a' ~/.env — має бути 600;system.md — і просить назвати три запити,
на які бот зобов'язаний відмовити, та прочитати формулу відмови;chat.php рядок,
який відсікає задовге повідомлення, і поясни, чому саме такий код»;git log --all -p у твоєму репозиторії — на предмет
значення ключа в історії.Це те, що бачить учитель, коли виставляє оцінку.
| Складник | Вага | Результат |
|---|
.gitignore ховає файл від git, а не від nginx;chat.js,
і його ніхто не перечитав — це блокуючий провал
no_key_front;.env «правильно», а в коментарі
index.html «щоб не забути»;system.md — щоб виправити одне слово, треба
перезаливати код;.gitignore створено після першого коміту:
.env уже відстежується, ігнор не діє;~/.env має права 644 —
читає будь-який акаунт на спільному сервері;chat.js —
обходиться одним запитом curl;chat.php віддає браузеру сиру відповідь API
цілком, разом зі службовими полями й повідомленнями про помилки,
у яких видно назву моделі та внутрішні ідентифікатори.Допиши в ~/www/bot/system.md три приклади запитів,
на які бот має відмовити. Не категорії, а дослівні фрази — такі, які
справді напишуть однокласники. Для кожної вкажи, якими словами бот
відмовляє і куди переадресовує.
Другим абзацом опиши, що зробиш у перші п'ять хвилин, якщо побачиш
свій ключ на чужому скріншоті. Потрібні чотири дії, у правильному
порядку. Вони стануть основою файла incident.md
на уроці 14.
Ціни й ліміти перевіряй самостійно: вони змінюються частіше, ніж виходять підручники
fetch() і CORS — чому запит із браузера
напряму до чужого API або блокується політикою, або несе твої
заголовки відкрито, і чому запит зі свого сервера цих обмежень не має.
developer.mozilla.org · CORS405,
413, 415, 429 і 502,
які повертає проксі з конструктора кроку 7.
developer.mozilla.org · HTTP status429 Too Many
Requests і заголовка Retry-After.
rfc-editor.org/rfc/rfc6585parse_ini_file, json_decode,
mb_strlen, file_put_contents
з FILE_APPEND | LOCK_EX — усі функції з лістингу
конструктора.
php.net · parse_ini_filecurl_setopt, опції CURLOPT_TIMEOUT
і CURLOPT_CONNECTTIMEOUT — таймаути в секундах;
без них запит чекає стільки, скільки триватиме з'єднання.
php.net · curl_setoptgitignore(5) — прямо сказано, що правила діють
на невідстежувані файли; вже закомітований файл ігнор не прибирає.
Це джерело твердження з кроку 11.
git-scm.com/docs/gitignore/v1/messages/count_tokens), а не формула. Наближення
«≈4 символи на токен для латиниці, ≈2 для кирилиці» у калькуляторі —
саме наближення, і воно підписане на сторінці.
docs.claude.com · Token countingПовний список із поясненнями, що звідки взято і що є
ілюстративним прикладом, — у файлі urok-13-джерела.md
поруч із цією сторінкою.