isho
isho
Прототип витрины игр для русскоязычного рынка: каталог проектов, страницы авторов, скриншоты, дневники, форум, личные сообщения и внешние ссылки на сборки. Без платежей; модерация форума доступна администраторам.
Стек
- API: Crystal 1.19 + Lucky 1.5 в API-only режиме, Avram, PostgreSQL 16.
- Фронтенд: Nuxt 4 (Vue 3 + TypeScript), SSR для публичных страниц.
- Медиа: Latch + ImageMagick, локальный диск в прототипе. Интерфейс хранилища совпадает с S3, поэтому переезд — вопрос конфигурации.
- Сессии: непрозрачный токен в httpOnly cookie + SHA-256 хеш в таблице
auth_sessions.
Быстрый старт (Windows, dev)
Нужны: Crystal 1.19.x, Node 22+, PostgreSQL 16 (или Docker), ImageMagick 7.
# 1. База
docker compose up -d db
# 2. Зависимости и база
cd api
shards install
crystal build tasks.cr -o bin/tasks.exe
.\bin\tasks.exe db.create
.\bin\tasks.exe db.migrate
.\bin\tasks.exe db.seed.sample_data
cd ..\web
npm install
cd ..
# 3. Оба сервера одной командой
.\dev.ps1 # API :4000, web :3000
dev.ps1 собирает и запускает API, ждёт /api/v1/health, затем поднимает Nuxt. Ctrl+C останавливает оба сервера. По отдельности: cd api; .\script\dev-api.ps1 и cd web; npm run dev.
Демо-аккаунт после сида: demo@isho.local / password1234.
Веб-сборки локально по умолчанию живут на одном origin с сайтом. Чтобы проверить их как на сервере (отдельный origin на проект, кеш и сейвы игры), запустите API с MEDIA_ORIGIN=http://localhost:4000: play_url станет абсолютным и укажет на <slug>.localhost:4000 (браузеры резолвят *.localhost в 127.0.0.1), а песочница — с allow-same-origin.
ImageMagick на Windows
Scoop-сборка не прописывает пути к конфигам. api/script/dev-api.ps1 выставляет MAGICK_HOME, MAGICK_CONFIGURE_PATH и MAGICK_CODER_MODULE_PATH автоматически. Если ImageMagick установлен в другое место, поправьте путь в скрипте.
Структура
api/ Lucky API
src/actions/api/v1 маршруты
src/models Avram-модели
src/operations запись в БД
src/serializers JSON
src/uploaders Latch-загрузчик скриншотов
db/migrations миграции
tasks/ задачи (db.seed.sample_data)
web/ Nuxt 4
app/pages страницы
app/components компоненты
app/composables useApi / useAuth / useTheme / useFormat
docker-compose.yml PostgreSQL
API
Все ответы — JSON. Ошибки: {"error":{"code","message","details"}}.
Вход (POST /api/v1/sign_ins) принимает user.login — почту или ник без учёта регистра; user.email поддерживается для старых клиентов.
| Метод | Путь | Доступ |
|---|---|---|
| POST | /api/v1/sign_ups |
публично |
| POST | /api/v1/sign_ins |
публично |
| DELETE | /api/v1/sign_ins |
вход |
| GET | /api/v1/me |
вход |
| PATCH | /api/v1/me |
вход |
| PATCH | /api/v1/me/theme |
вход |
| GET | /api/v1/me/projects |
вход |
| GET | /api/v1/projects |
публично |
| GET | /api/v1/projects/:slug |
публично (черновик — владельцу) |
| POST | /api/v1/projects |
вход |
| PATCH | /api/v1/projects/:slug |
владелец |
| DELETE | /api/v1/projects/:slug |
владелец |
| POST | /api/v1/projects/:slug/publish |
владелец |
| POST | /api/v1/projects/:slug/unpublish |
владелец |
| POST | /api/v1/projects/:slug/screenshots |
владелец, multipart image_file |
| PATCH | /api/v1/projects/:slug/screenshots/:id |
владелец |
| DELETE | /api/v1/projects/:slug/screenshots/:id |
владелец |
| PUT | /api/v1/projects/:slug/screenshots/order |
владелец |
| POST | /api/v1/markdown_images |
вход, multipart image_file |
| POST | /api/v1/projects/:slug/downloads |
владелец |
| DELETE | /api/v1/projects/:slug/downloads/:id |
владелец |
| GET | /api/v1/projects/:slug/devlogs |
публично (черновик — владельцу) |
| GET | /api/v1/projects/:slug/devlogs/:devlog_slug |
публично (черновик — владельцу) |
| POST | /api/v1/projects/:slug/devlogs |
владелец |
| PATCH | /api/v1/projects/:slug/devlogs/:id |
владелец |
| DELETE | /api/v1/projects/:slug/devlogs/:id |
владелец |
| GET | /api/v1/forum/sections |
публично |
| GET | /api/v1/forum/sections/:section_slug |
публично |
| GET | /api/v1/forum/sections/:section_slug/topics/:topic_slug |
публично (черновик — владельцу) |
| POST | /api/v1/forum/sections/:section_slug/topics |
вход |
| PATCH | /api/v1/forum/topics/:id |
автор или админ |
| DELETE | /api/v1/forum/topics/:id |
админ |
| POST | /api/v1/forum/topics/:id/posts |
вход |
| POST | /api/v1/forum/topics/:id/read |
вход |
| PATCH | /api/v1/forum/posts/:id |
автор или админ |
| DELETE | /api/v1/forum/posts/:id |
админ |
| POST | /api/v1/forum/posts/:id/reactions |
вход |
| GET | /api/v1/forum/posts/:id/reactions (kind) |
публично |
| GET | /api/v1/projects/:slug/forum/topics |
публично (черновик — владельцу) |
| POST | /api/v1/projects/:slug/forum/topics |
вход |
| GET | /api/v1/jams (phase, q, page) |
публично |
| GET | /api/v1/jams/:slug |
публично (черновик — организатору) |
| POST | /api/v1/jams |
вход, подтверждённая почта |
| PATCH | /api/v1/jams/:slug |
организатор |
| DELETE | /api/v1/jams/:slug |
организатор, только черновик |
| POST | /api/v1/jams/:slug/publish |
организатор |
| POST | /api/v1/jams/:slug/cancel |
организатор |
| POST | /api/v1/jams/:slug/banner |
организатор, multipart image_file |
| POST | /api/v1/jams/:slug/criteria |
организатор |
| PATCH | /api/v1/jams/:slug/criteria/:id |
организатор |
| DELETE | /api/v1/jams/:slug/criteria/:id |
организатор |
| GET | /api/v1/jams/:slug/entries (page, sort, status=all) |
публично (снятые — организатору) |
| GET | /api/v1/jams/:slug/eligible_projects |
вход |
| POST | /api/v1/jams/:slug/entries |
вход, подтверждённая почта |
| DELETE | /api/v1/jams/:slug/entries/:id |
автор до дедлайна, организатор |
| POST | /api/v1/jams/:slug/entries/:id/disqualify |
организатор |
| POST | /api/v1/jams/:slug/entries/:id/restore |
организатор |
| GET | /api/v1/jams/:slug/scoring |
вход, подтверждённая почта |
| PUT | /api/v1/jams/:slug/entries/:id/scores |
вход, подтверждённая почта |
| GET | /api/v1/jams/:slug/entries/:id/feedback |
публично после судейства (имена — организатору) |
| GET | /api/v1/jams/:slug/results (preview=true) |
публично (превью — организатору) |
| GET | /api/v1/projects/:slug/jams |
публично |
| GET | /api/v1/my_jams |
вход |
| GET | /api/v1/users/:username |
публично |
| GET | /api/v1/messages |
вход |
| GET | /api/v1/messages/:username (before, after) |
вход |
| POST | /api/v1/messages/:username |
вход, подтверждённая почта |
| POST | /api/v1/messages/:username/read |
вход |
| GET | /api/v1/tags (q, limit, min_projects) |
публично |
| GET | /api/v1/health |
публично |
Тема интерфейса хранится в users.theme для аккаунта и в cookie isho_theme для устройства. Гость без сохранённого выбора видит тему системы; data-theme на <html> выставляет Nuxt ещё на SSR, поэтому при загрузке нет вспышки.
Форум состоит из разделов (forum_sections), тем (forum_topics) и сообщений (forum_posts). Разделы образуют два уровня: корневой раздел и подраздел (forum_sections.parent_id). Адрес остаётся плоским (/forum/:slug), родитель нужен для группировки, хлебных крошек и блока «Подразделы»; group_title — заголовок только над корневыми разделами. Разделы создаются задачей db.seed.required_data; список правится в api/tasks/db/seed/forum_sections.cr. Тема может быть привязана к проекту (forum_topics.project_id) и тогда попадает в раздел projects. Тело темы — её первое сообщение. Ответы и темы пишутся в Markdown и рендерятся на фронте тем же markdown-it, что и дневники. Цитата хранит снимок автора и текста на момент ответа, поэтому переживает редактирование или удаление оригинала.
Прочитанное хранится в forum_topic_reads: read_count — сколько сообщений темы пользователь уже видел. Отсутствие строки значит, что тема не открывалась. Фронт отмечает прочитанным последнее сообщение просмотренной страницы через POST /api/v1/forum/topics/:id/read, поэтому открытие последней страницы закрывает тему целиком. Автор последнего ответа денормализован в forum_topics.last_post_user_id и показывается в списке тем.
Лайки и дизлайки лежат в forum_post_reactions (одна реакция на пользователя и сообщение, kind — like или dislike). Счётчики не хранятся в сообщениях: их считает ForumReactions, поэтому удаление пользователя или сообщения не оставляет мусора. POST /api/v1/forum/posts/:id/reactions ставит голос, повторный клик снимает его, а другой символ заменяет; за своё сообщение голосовать нельзя. Список проголосовавших для попапа отдаёт GET /api/v1/forum/posts/:id/reactions?kind=like|dislike — последние 10 и признак truncated.
Администраторы (users.is_admin) могут удалять любые сообщения и темы; авторы редактируют свои сообщения и название темы, но удалять их может только администратор. Уведомлений об удалении пока нет. При удалении пользователя его темы и сообщения удаляются: каскад в БД и операция DeleteUserContent, которая пересчитывает счётчики тем. Почта удалённого аккаунта попадает в чёрный список и не может зарегистрироваться снова, пока админ не уберёт её в окне модерации.
Вместо удаления администратор может забанить пользователя на срок с причиной. Контент и публикации при этом остаются, а забаненный может войти и читать, но не может создавать и править проекты, дневники и сообщения форума. История банов хранится в user_bans, срок истекает сам; снять бан раньше можно кнопкой «Разбанить».
Личные сообщения (private_messages) хранят автора, получателя, текст и read_at. Отдельной таблицы диалогов нет: переписка — это все сообщения между двумя аккаунтами. Кнопка «Переписка» в профиле ведёт на /messages/:username, список диалогов живёт на /messages, а в шапке показывается счётчик непрочитанных. Писать может только подтверждённый незабаненный пользователь, не чаще 20 сообщений в минуту; читать и получать сообщения может любой вошедший. Редактирования, удаления и реакций нет, Markdown рендерится так же, как на форуме. Переписка приватна для всех, включая администраторов. Обновление без polling: чат и список перезагружаются при открытии и по кнопке; read_at ставится при открытии чата. Удаление аккаунта уносит его сообщения каскадом.
Джемы (jams) — отдельный раздел сайта /jams. У джема есть описание, правила, призы, тема, баннер и три даты: старт, конец приёма заявок и конец судейства. Черновик виден только организатору; публикация требует заполненных дат и хотя бы одного критерия (jam_criteria). Фаза (upcoming, open, judging, finished, canceled) вычисляется из дат, фоновых задач нет. В день старта тема перестаёт скрываться. При публикации в одной транзакции создаётся раздел форума под корнем jams (он же «Джемы»), а заголовок раздела следует за названием джема. Баннеры хранятся через Latch (media/jams/...) и входят в квоту аккаунта.
Заявки (jam_entries) — это проекты. Один пользователь может подать несколько проектов, но один проект входит в джем один раз. Подача доступна в фазе open; проект должен быть опубликован и принадлежать автору. Отозвать заявку можно до дедлайна, после — только организатор. Организатор снимает заявку с причиной и возвращает её; снятые и скрытые проекты не попадают в публичный список и счётчик, но видны автору и организатору. На странице проекта есть блок «Участие в джемах».
Оценки (jam_scores) — балл и текстовый отзыв по каждому критерию. Голосовать может любой подтверждённый пользователь, кроме своей заявки и только в фазе judging. Режим community считает баллы всех, participants — только тех, у кого есть поданная заявка; остальные голоса всё равно приносят автору отзывы. После первой оценки критерии, шкала, режим и даты фиксируются. После судейства JamResults автоматически считает средневзвешенные суммы, места (равные итоги делят место) и счётчики. Публично видны агрегаты и места, а после судейства — ещё и отзывы по критериям. Отзывы анонимны для всех, кроме организатора: он видит авторов оценок для модерации. Победителей администратор пишет в results_note — это отдельный текст, а не вычисленное место.
Production
Готовые скрипты лежат в deploy/ (подробности — deploy/README.md). Сборка идёт локально в WSL (Ubuntu 24.04), на сервер уезжают только release-бинарники Lucky и серверный бандл Nuxt:
$env:ISHO_SSH_PASSWORD = '<пароль root>'
.\deploy\deploy.ps1 # собрать и раскатать, http://<ip>
.\deploy\deploy.ps1 -SampleData # плюс демо-проекты и demo@isho.local
.\deploy\deploy.ps1 -SiteAddress https://isho.duckdns.org # домен + TLS
На сервере bootstrap.sh ставит PostgreSQL, Node.js 22, Caddy и ImageMagick, создаёт БД, пользователя isho, swap и службы isho-api, isho-web, caddy. Секреты и параметры окружения — в /etc/isho/api.env и /etc/isho/web.env; при повторном деплое они сохраняются. API слушает 127.0.0.1:4000 (порт задаётся HOST/PORT), Nuxt — 127.0.0.1:3000, наружу смотрит только Caddy.
Caddy отдаёт /api/* и /media/* в Lucky, всё остальное — в Nuxt. Так браузер работает с одним origin, а cookie остаются first-party. Для S3 нужно заменить Latch::Storage::FileSystem на S3-хранилище в api/config/latch.cr и раздавать медиа через CDN.
Браузерные сборки живут на отдельных origin'ах: база -MediaAddress https://isho-games.duckdns.org и у каждого проекта свой хост <slug>.isho-games.duckdns.org (Caddy отдаёт на них только /media/web/*). Игра в песочнице сохраняет свой origin и поэтому может пользоваться Cache Storage, IndexedDB и localStorage, которых opaque origin не даёт, — а cookie сайта на эти хосты не отправляются. Один origin на проект: сейвы и кеш игр не смешиваются, а все сборки проекта делят хост, так что прогресс переживает обновление версии. База — отдельное имя, а не поддомен сайта: поддомен делит registrable domain и может ставить cookie на родителя. Сертификаты проектных хостов выдаются on-demand, Caddy спрашивает API (/internal/tls/allow). Сжатые файлы Unity (.br, .gz) уходят с Content-Encoding и типом исходного файла, иначе nosniff не даёт загрузить framework.
Ограничения прототипа
- Скачиваемые файлы не загружаются: только внешние https-ссылки (Google Drive, Dropbox и т.п.).
- Скриншоты: JPEG/PNG/WebP до 10 МБ, до 20 на проект.
- Публикация проектов без модерации; черновики видны только владельцу.
- Проект можно пометить как 18+ и указать использование ИИ (без ИИ, частичное, значительное, полное). Гости не видят 18+; в
/settingsпользователь скрывает такие проекты и ограничивает показ по уровню ИИ. Владелец и администратор видят всё. - Описания проектов, дневники и форум — Markdown (
markdown-it, сырой HTML отключён): изображения, Rutube-видео через, спойлеры:::spoiler…:::и подсветка кода. Картинку можно вставить из буфера обмена, перетащить в редактор или загрузить файлом (PNG/JPEG/WebP/GIF до 10 МБ): редактор загружает её черезPOST /api/v1/markdown_imagesи вставляет обычную ссылку. Загруженные, но так и не вставленные в сохранённый текст картинки удаляет задачаdb.cleanup_markdown_images(по умолчанию — старше 7 дней, без--yesработает как сухой прогон). Короткие записи дневника (до 600 символов) показываются на странице проекта целиком, длинные — отдельной страницей со ссылкой из ленты. - Форум: без вложений, уведомлений, закрепления и закрытия тем, поиска и rate limit. Удаление сообщений и тем — только администраторами.
- Личные сообщения: без правки, удаления, реакций, уведомлений на почту, жалоб и блокировок; поиска по переписке нет. Картинки из Markdown лежат в общем хранилище, и ссылка на них открывается у любого, кто её знает.
- Джемы: страница, правила, критерии, баннер, публикация, раздел форума, заявки проектами, оценки с отзывами, автоматические итоги и места.
- Оплаты, S3, ресурсы, номинации и приз зрительских симпатий — следующие этапы.
- UI на русском, валюта и платежи не подключены.
Тесты
cd api
.\script\spec.ps1
crystal spec на Windows падает с Process terminated abnormally (Crystal 1.19), поэтому скрипт собирает spec/all_specs.cr в bin/specs.exe и запускает его напрямую. Спеки используют отдельную БД isho_test (создаётся автоматически) на сервисе db-test — это ещё один Postgres на порту 5433, данные которого лежат в tmpfs: набор очищает все таблицы перед каждым примером, а на docker-томе такая очистка стоит ~0,5 с на пример. Скрипт сам поднимает db-test и берёт блокировку, поэтому два прогона не мешают друг другу (второй ждёт). Аргументы после имени скрипта уходят в раннер: .\script\spec.ps1 -e Slugify. Спеки используют in-memory хранилище Latch. Для обработки изображений в тестах нужен ImageMagick в PATH.
Стресс-тест
Проверка на объёме: 1000 пользователей, 1000 проектов, 1000 тегов, 1000 тем форума и по 200 сообщений в каждой. Данные создаются и удаляются задачами db.stress.seed / db.stress.cleanup, а stress/probe.mjs замеряет API и SSR. Подробности и команды для VPS — в stress/README.md.
isho
- 0
- 0
- 0
- 0
- 0
- about 5 hours ago
- October 31, 2026
Sat, 31 Oct 2026 14:40:11 GMT