Единственная страница, с которой стоит начинать Алтай
Кому интересно побродить в наушниках — проект легко гуглится в Яндексе по фразе «там где река ломает камни» (первая ссылка).
Кому интересно побродить в наушниках — проект легко гуглится в Яндексе по фразе «там где река ломает камни» (первая ссылка).
Совсем недавно я начал делать бесплатную образовательную платформу: курсы с заданиями, которые проверяются автоматически, без оплат и «первого урока в подарок». Сейчас там больше двадцати курсов и пять тысяч заданий, ими пользуются живые люди, а платформа выдаёт именные сертификаты с кодом проверки подлинности.
Недавно я провёл аудит собственного кода — и нашёл дыру, из-за которой сертификат можно было получить одним запросом из консоли браузера, не решив ни единой задачи. Ошибка оказалась не в проверке прав и не в валидации: она была в архитектурном решении, которое я принял осознанно и считал удачным. Об этом и хочу рассказать — вместе с двумя другими решениями, которые, наоборот, себя оправдали.
Платформа состоит из хаба (список курсов, прогресс, кабинет) и самих курсов. Курсы очень разные: в одном человек пишет Python, в другом собирает программу из блоков, в третьем двигает фигуры на шахматной доске. Задания тоже разные — выбор ответа, соответствия, свободный текст, код.
Когда я это начинал, ключевым было желание не переписывать сервер каждый раз, когда добавляется новый курс. Отсюда родилось решение, которое казалось мне красивым:
Сервер не знает содержимого курсов. Курс сам считает свой прогресс и присылает результат.
Выглядело это так. Курс собирает «скелет» — список разделов, уроков и набранных баллов, — и отправляет его вместе с данными:
{
"summary": [
{ "id": "s1", "hasQuiz": true, "quizBest": 80,
"lessons": [ { "id": "l1-1", "score": 100 },
{ "id": "l1-2", "score": 40 } ] }
]
}
Сервер получает это, считает средний процент по формуле, общей для всей платформы, и сохраняет. Добавление курса не требовало ни строчки серверного кода — достаточно положить папку с данными. Три года такой архитектуры в вебе называют «тонкий сервер», и в задачах вроде синхронизации заметок она работает прекрасно.
В комментарии к этому коду у меня было написано:
/* Процент считаем сами по присланному скелету: клиент не может нарисовать себе 100%, не решив задачи. */
Комментарий врал. Причём врал ровно наполовину, и это худший вид неправды в коде.
Сервер действительно не брал процент из запроса — он считал его сам. Но считал он его по данным, которые прислал тот же самый браузер. Скелет курса — это тоже данные из запроса.
То есть достаточно было отправить скелет из одного раздела с одним уроком:
fetch("/api/progress/python", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
track: "start:21",
summary: [{ id: "x", lessons: [{ id: "y", score: 100 }] }]
})
});
Средний балл по присланному скелету — сто процентов. Сервер честно посчитал, честно сохранил. Дальше человек открывает страницу сертификата, сервер видит percent >= 100, подписывает код HMAC своим секретом и выдаёт настоящий PDF, который проходит публичную проверку подлинности на сайте.
Отдельная ирония: слияние прогресса, написанное как раз для честного случая (две открытые вкладки не должны затирать достижения друг друга), помогало и здесь — форма скелета задавалась входящими данными, поэтому список разделов можно было не только подделать, но и сократить, чтобы процент подскочил.
Тем же способом накручивался опыт платформы. Он считался по количеству ключей в объекте решённых заданий:
for (const lesson of Object.values(data.lessons || {})) {
tasks += Object.keys(lesson.tasks || {}).length;
}
Тысяча выдуманных уроков по тысяче выдуманных заданий в каждом — и человек мгновенно получал высшее звание платформы. Комментарий над этим кодом гласил: «его нельзя накрутить с клиента — сервер берёт только то, что реально сохранено». Формально верно. Фактически «сохранено» означало «прислано».
Дыра прожила год, и я хочу быть точным в причине. Дело не в невнимательности: каждая отдельная строчка тут правильная.
Права проверяются — маршрут требует авторизации.
Значение из запроса не берётся — процент вычисляется на сервере.
Данные нормализуются — числа обрезаются в диапазон 0–100, строки приводятся к строкам.
Формула прогресса одна на всю платформу, чтобы цифры нигде не разъезжались.
Все проверки на месте. Не хватало одной-единственной вещи: источника правды о том, из чего состоит курс. Сервер спрашивал об этом браузер, а браузер — это не собеседник, а канал, по которому приходят чужие данные.
Формулирую то, чему меня это научило, максимально коротко:
Клиент может присылать факты о себе («я решил вот это задание»). Он не должен присылать правила, по которым эти факты интерпретируются («а всего заданий было одно»).
Мне кажется, эту границу легко потерять именно в «тонком сервере». Когда сознательно решаешь, что сервер не знает предметную область, очень трудно потом заметить, что часть предметной области ему всё-таки нужна — не вся, а ровно та, что участвует в проверках.
Сервер должен был узнать состав курсов, но так, чтобы не потерять исходное преимущество: добавление курса по-прежнему не должно требовать правок бэкенда.
Данные курсов лежат обычными js-файлами рядом с самим курсом — их читает браузер. Значит, их может прочитать и сервер: тем же кодом, в песочнице node:vm, без выполнения чего-либо опасного (там только объявления данных).
const sandbox = { document: { addEventListener() {} }, console };
vm.createContext(sandbox);
for (const file of dataFiles) vm.runInContext(fs.readFileSync(file, "utf8"), sandbox);
const data = sandbox.COURSE_DATA;
Дальше сервер прогоняет эти данные через тот же самый модуль, что и браузер (shared/tracks.js подключается и там, и там), получает эталонный состав ветки и кэширует его — состав меняется только вместе с выкладкой.
Присланный скелет теперь не принимается, а прикладывается к эталонному: лишние разделы отбрасываются, недостающие уроки добавляются нулями.
function alignToSkeleton(summary, truth) {
const bySection = new Map(summary.map((s) => [s.id, s]));
return truth.map((sec) => {
const got = bySection.get(sec.id);
const byLesson = new Map((got ? got.lessons : []).map((l) => [l.id, l]));
return {
id: sec.id,
hasQuiz: sec.hasQuiz,
quizBest: got ? clampPct(got.quizBest) : 0,
lessons: sec.lessons.map((l) => ({
id: l.id,
score: clampPct((byLesson.get(l.id) || {}).score),
})),
};
});
}
Проверка после правки: тот же запрос из консоли даёт ноль процентов вместо ста. Десять тысяч выдуманных заданий дают ноль опыта и звание «Новичок» вместо «Легенды».
Побочный эффект, о котором стоит предупредить, если будете делать так же: у части людей проценты изменятся. У меня из семнадцати записей поменялись две — одна выросла, другая уменьшилась (17% оказались честными 11%). Я не стал «замораживать» завышенные значения, потому что на проценте висит выдача сертификата, и заморозка вернула бы ту же дыру с другой стороны. Вместо этого написал разовый пересчёт и применил его сразу, чтобы цифра не менялась у человека посреди занятия.
Самое неприятное открытие: пока я закрывал эту дыру, у меня уже была написана новая функция с ровно той же ошибкой.
Это тренажёр — режим бесконечной практики: человеку подбираются задачи по темам, за верные ответы растёт «уверенность» по каждой теме, копятся очки и трофеи. Клиент проверял ответ сам и отправлял вердикт:
Я написал это буквально за неделю до аудита — и написал так, потому что «проверка ответа же на клиенте, движок уроков всегда так делал». В уроках это некритично: там прогресс всё равно упирается в состав курса. В тренажёре — критично: очки идут в общий опыт платформы.
Теперь вердикт выносит сервер, а клиент присылает сам ответ:
if (kind === "multi") {
const got = [...new Set(answer.map(Number))].sort((a, b) => a - b);
const need = [...(t.answers || [])].map(Number).sort((a, b) => a - b);
return got.length === need.length && got.every((v, i) => v === need[i]);
}
И — что не менее важно — правильные ответы больше не уходят клиенту вовсе. Иначе серверная проверка обходится за две секунды: подсмотреть ответ в ответе запроса и отправить его же.
delete t.answer;
delete t.answers;
if (Array.isArray(t.pairs)) t.pairs = t.pairs.map((p) => ({ left: p.left }));
Разбор ошибки («верный ответ был такой») приходит вместе с вердиктом — то есть уже после того, как человек ответил.
Чтобы рассказ не выглядел как сплошное покаяние, два решения, которые за год ни разу не подвели.
Курс сам режется под возраст ученика. У каждого курса есть ветки: «с нуля» и «подтянуть знания», для 6–14, 15–20, 21+ и так далее. Разница не в том, что детям показывают меньше — а в том, что урок должен помещаться в академический час этого возраста: тридцать минут для младших, сорок пять для взрослых. Поэтому задания отбираются сначала по сложности, потом по времени — пока урок не набрал свой бюджет:
function fitToBudget(parts, budget) { /* берём задания, пока влезают в минуты */ }
Излишки остаются в курсе и достаются другим веткам. Практическое следствие: задания надо писать с запасом, и это не расточительство — один и тот же урок даёт полный час и восьмилетке, и взрослому, просто из разных наборов.
Проверка кода без бэкенда. Python выполняется в браузере через Pyodide, JavaScript — нативно, SQL — через sqlite, собранный в WebAssembly. Сервера для запуска пользовательского кода нет вовсе, а значит нет и целого класса проблем: песочниц, лимитов, очередей, кода, который майнит.
Единственное место, где я от этого отступил, — тренажёр SQL для незарегистрированных: там запрос выполняется на сервере, чтобы страница работала без загрузки многомегабайтного WebAssembly. И этот компромисс немедленно потребовал того, чего не требовало браузерное исполнение: ограничения частоты и отсева тяжёлых запросов, потому что синхронный DatabaseSync на время работы держит весь процесс, а SELECT COUNT(*) FROM t,t,t,t,t пишется одной строкой.
Три вещи, которые я теперь проверяю в чужом и своём коде первым делом.
Найти все места, где сервер принимает от клиента не факты, а структуру. Список того, из чего состоит сущность; количество элементов; набор допустимых значений. Это и есть предметная область, и она должна жить на сервере, даже если очень хочется «тонкий сервер».
Прочитать комментарии как утверждения и проверить каждое. У меня оба комментария («нельзя нарисовать себе 100%», «нельзя накрутить с клиента») были написаны честно и оба оказались ложными после того, как код рядом чуть изменился. Комментарий, обещающий безопасность, — это тест, который никто не запускает.
Проверять новые фичи тем же списком, что и старые. Дыра в тренажёре появилась не потому, что я о ней не знал, — я закрывал такую же в соседнем файле. Она появилась потому, что новый код писался по образцу старого.
Платформа бесплатная и остаётся бесплатной; если интересно посмотреть, как выглядит описанное — ссылка в профиле. Буду рад, если кто-то из читателей найдёт ещё что-нибудь: об ошибках в собственном коде приятнее узнавать от людей, чем от школьника, которому очень нужен сертификат.
У меня родилась идея сделать что-то в стиле пошаговой бродилки. Пока что в локации "Вулкан". Некоторые скажут, что это похоже на остров в одной онлайн игре, на что я скажу, что это похоже на остров в одной онлайн-игре. Только красивее :)
И, да, так как у нас всё ещё демо-версия я разместил ВРОДЕ КАК базовый рабочий функционал... и даже групповые бои! Музыку в игре включайте... по желанию :)
Ссылка на игру: https://deathmasters.ru
!!! И, да, это БЕТА-БЕТА-ТЕСТ. Да, назову именно так) Потому что это тест в процессе создания игры. Делаем всё на лету. В игре есть ошибки, они могут мешать играть, быстро написать игру, да ещё и с реальным мультиплеером - непростая задача. Решаю всё по мере поступления в течение 1-2 часов, иногда 1-2 дней.
Я живу на Алтае. И мне давно набило оскомину, как наш край показывают в сети: шаблонные открытки, деревянные ложки, мед в пластиковых ведерках и одинаковые сувенирные туры.
Хотелось показать Алтай настоящим. Суровым, древним, монументальным. Поэтому я решил перенести 900 км реального Чуйского тракта в интерактивное веб-путешествие: от первых скальных ворот до кипящего порога Аккем и вершины Белухи.
Что получилось сделать прямо в коде:
— Полноценная экспедиция с альтиметром: вверху экрана работает живая шкала высот и координат. Вы реально поднимаетесь от 290 метров до Семинского перевала (1717 м) и пика Белухи (4506 м).
— Реки и стихия на коде (без тяжелых видео): цвет ледниковой бирюзы Катуни, туманы, сланцевые слои Марса Кызыл-Чина рассчитываются в реальном времени.
— Живой саундскейп в наушниках: прямо в браузере синтезируется низкий суб-бас алтайского горлового пения (65 Гц), гул перевалов и треск костра, которые плавно перетекают в реальные записи ветра, шума рек и степных птиц.
— Интерактив: в урочище Калбак-Таш курсор превращается в фонарик, которым можно буквально наощупь искать 5000-летние наскальные петроглифы в скале; на слиянии рек вода дает круги от пальца, а на смартфонах работает гироскоп (свет фар и угол обзора колышутся при наклоне телефона).
— Тумблер «Лето ⇄ Осень»: нажимаете кнопку — и за пару секунд весь сайт вспыхивает золотом лиственниц, а бирюзовая Катунь темнеет до глубокого сапфира.
Делал в одиночку, чисто для души и земляков. Денег не прошу, туры не продаю, рекламы на сайте нет.
Надевайте наушники, включайте звук и заходите побродить по нашему тракту.
Кто бывал на Чуйском тракте — как вам атмосфера? Буду рад отзывам в комментариях!
Шаг 1: спросить «а точно?». Пока вроде норм.
Шаг 2: удалить. Тоже норм.
Шаг 3: сообщить «А восстановить уже нельзя. Бебебебебе».
Шаг 4: тут же противоречить этому, потому что прямо рядом с этими словами - кнопка для восстановления. Кнопка, кстати, довольно плохо заметная.
Шаг 5: убрать это сообщение через 3 секунды. Чтобы пользователь, думающий над вашими противоречиями, точно не успел ни что-либо понять, ни тем более решить, что ему делать.
А теперь выдохнем. И пока выдыхаем - я вероломно напомню: графические пользовательские интерфейсы появились в середине 1970-х, и где-то рядом с ними должна была появиться наука об удобстве пользователя, также известная как UX/UI. Иными словами, ПРОШЛО ОКОЛО 50 ЛЕТ ЭВОЛЮЦИИ ЭТОГО НАПРАВЛЕНИЯ В ДИЗАЙНЕ блеать!
Как можно, имея вокруг столько примеров удачных или просто хороших интерфейсов, в 2026-м году СНАЧАЛА удалять данные, а ПОТОМ писать, что их нельзя будет восстановить??? Любой сука школьник сделает лучше. Это даже не детский сад.
Огорчает главным образом не сам факт, что в дезигне и в разработке решения принимают клинические безнадёжные дебилы, к этому за последние лет 15 попривыкли (страшные слова). Но эти же дебилы вот за это получают весьма немаленькие зарплаты.
пы.сы. Я ничего важного у себя не удалил, просто чистил ненужные чаты и обратил внимание.
Всем привет.
Давно, ещё тогда, когда компьютеры связывались друг с другом модемами через телефон., буквально в доинтернетные времена, была игрушка Death Masters.
И очень уж она мне нравилась. Знаете, это такая дьябла на минималках, только текстом. С безумным ограничением в несколько текстовых боёв в день. Немного апгрейдов оружия и брони. Ну, фактически, там гейплея и было только что 15 монстров в день, плюс возможность убить другого игрока.
Учитывая, что лимит на старых ББС был в среднем 20 минут в день, заходили именно за тем, чтобы быстро провести бои и убить кого-нибудь, если сил хватит. Поднасрать, знаете:)
Решил я её перенести в интернет, но сделать всё такой же текстовой. Дальше что-то пошло не так. В процессе понял, что... ну не катит! Старая игрушка брала простотой, но то был "социокультурный феномен", прямо как фильм Брат. Сейчас это не зайдёт.
Понял я, что хочется и на монстра посмотреть. И графика даёт некоторые преимущества, такие как прогресс-бары и лоадеры, плюс всякие более вменяемые спецэффекты.
И потом, посмотрите на этого пупсика, разве он не хорош?
Как этот толстячок может обманывать? :)
Или вот, например, боёвка. Да это же праздник какой-то! Вначале бой был просто автоматический. Потом я решил всё-таки в него добавить инвентарь. Всё как в реально жизни, не выхожу из дома без "средней капли крови" в кармане!
Так за небольшой период времени родился играбельный прототип. Прототип обзавёлся собственным доменом.
И тут я подумал... да, можно потратить пару лет на разработку в одно лицо, выложить некий готовый продукт. А можно жить процессом :) Тем более в наше время разработка, кхм, несколько ускорилась, тем более более что движок игры Laravel+Vue. Да, то, на чём я обычно пишу бизнес-системы (CRM-ERP), решено использовать в качестве золотого молотка для написания игры. Плюс ИИшечка в помощь, конечно.
Очень, очень трудно писать игры, когда никогда этого не делал. И дело не в технологиях. Сценарий, что это? Баланс, как его считать? Квесты... да мне ИИ насыпало 25 случайных локаций, где монстры бросают кости, понимаете? Пришло удалить почти все и рожать мысли с нуля. ИИ тут не поможет. И каждая мысль - это целый сценарий! В голову приходит образ стола с 3 монетами в углублениях, и ещё одно углубление пустует. Ну глупость же, да? А мысль не отпускает.
И я думаю такой - вот логично положить 1 монету в углубление. И... что? Зачем? :)
А если забрать 3 монеты, вот же они... Но у героя и так 15.000 монет, зачем ему ещё 3?
А что, если положить надо 100 монет? Но это ведь глупость какая-то, откуда он знает, что нужно 100 монет? А на трех других углах тоже по 100 монет лежит? Герой их что, считает за секунду?
И тут БАМС - мысль!) Делай монеты из разного материала. Пускай будут медные, серебряные и золотые! 10.000 медных = 100 серебряных = 1 золотая! И тут уже появляется какой-то смысл. Может быть 3 медных монетки в углу... и герой может положить туда медную или золотую, и это уже будет давать совсем другой эффект. Не каждый странник отдаст золотую монету просто так! А забрать 3 золотых - хороший куш, верно же? :)
Вот так оно и происходит, в общем. Прикинул, что лучше потратить годик на разработку, но с живыми душами в игре, чтобы не наблюдать в игре вот эту потрясающую картину одинокого создателя:
В общем, жду всех на бета-тест. Предложения приветствуются. Раздел баг-репортов открыт. Реагирую быстро, новое каждый день.
Впереди "социальный" вопрос общения, рейдов и др. Впрочем, чат уже есть, можно использовать вместо телеграма ;)
Игра делалась под дескптоп, потом, в очередной раз, что-то пошло не так. Обнаружил я это, сидя на диване, когда понял, что залипаю в игру с телефона. И жена зависает, показывая подруге костяную собаку. Пришлось чуть допилить адаптив под пачку размеров. И вот имеем, что на десктопе меню вот такое:
А на мобилке вот такое:
Ну, всё в процессе доработки.
В общем, заходите ;)
UPD: сервер лежит, так как хостер подумал, что несколько пикабушников - это ДДОС атака)
UPD2: перенесено на другой хостинг, запас прочности огромный, и в это время первый сервер всё ещё лежит с поломанной стойкой. Пикабушники - сила. Вдесятером сломали целую стойку.