diff --git a/.env.example b/.env.example index 3e0b177..e799688 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Indexium — root .env.example (docker-compose + local dev) +# Indexium - root .env.example (docker-compose + local dev) # --- Postgres (docker-compose service "postgres") --- POSTGRES_DB=indexium diff --git a/README.md b/README.md index ad98954..80acc78 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Indexium > Лёгкий, дешёвый и устойчивый к лимитам GitHub индексатор модов Minecraft. -> Бэкенд — **асинхронный событийный индексатор**: не хранит тяжёлые `.jar`, индексирует метаданные из GitHub Releases CDN и отдаёт быстрые JSON-ответы. +> Бэкенд - **асинхронный событийный индексатор**: не хранит тяжёлые `.jar`, индексирует метаданные из GitHub Releases CDN и отдаёт быстрые JSON-ответы. ## Архитектура (TL;DR) @@ -22,7 +22,7 @@ GitHub --webhook release.published--> Ingestion API (HMAC check) --> Redis Queue └── docker-compose.yml # (WIP) Postgres + Redis ``` -Почему монорепо: см. [`docs/git-strategy.md`](docs/git-strategy.md) — один clone, атомарные изменения API+UI, один CI. +Почему монорепо: см. [`docs/git-strategy.md`](docs/git-strategy.md) - один clone, атомарные изменения API+UI, один CI. ## Быстрый старт (локально) @@ -58,12 +58,12 @@ bun run dev ## Документация -- [`docs/architecture.md`](docs/architecture.md) — компоненты, lifecycle релиза, обход лимитов -- [`docs/database-schema.md`](docs/database-schema.md) — схема БД + индексы -- [`docs/api-spec.md`](docs/api-spec.md) — Public REST API v1 -- [`docs/deployment.md`](docs/deployment.md) — деплой, бэкапы -- [`docs/git-strategy.md`](docs/git-strategy.md) — почему монорепо и как работать с ним -- [`todo.md`](todo.md) — роадмап по фазам +- [`docs/architecture.md`](docs/architecture.md) - компоненты, lifecycle релиза, обход лимитов +- [`docs/database-schema.md`](docs/database-schema.md) - схема БД + индексы +- [`docs/api-spec.md`](docs/api-spec.md) - Public REST API v1 +- [`docs/deployment.md`](docs/deployment.md) - деплой, бэкапы +- [`docs/git-strategy.md`](docs/git-strategy.md) - почему монорепо и как работать с ним +- [`todo.md`](todo.md) - роадмап по фазам ## Лицензия @@ -71,4 +71,4 @@ TBD ## Контакты / Issues -Используй GitHub Issues для багов и фич. Перед PR — `cargo fmt && cargo clippy`. +Используй GitHub Issues для багов и фич. Перед PR - `cargo fmt && cargo clippy`. diff --git a/docs/README.md b/docs/README.md index 6d8559a..f9efd54 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,4 @@ -# docs — Индекс документации Indexium +# docs - Индекс документации Indexium | Документ | Описание | |----------|----------| @@ -12,12 +12,12 @@ | [analytics.md](analytics.md) | bStats-аналог: SDK, ingestion, daily агрегаты, приватность | | [adr/003-search-engine.md](adr/003-search-engine.md) | ADR-003: Postgres FTS + pg_trgm вместо Meilisearch | | [adr/004-auth-strategy.md](adr/004-auth-strategy.md) | ADR-004: GitHub-only + PAT (+ Device Flow Phase 2) | -| [adr/005-analytics.md](adr/005-analytics.md) | ADR-005: bStats аналог — Postgres + daily_salt | +| [adr/005-analytics.md](adr/005-analytics.md) | ADR-005: bStats аналог - Postgres + daily_salt | ## Как добавлять доки - Новые ADR: `docs/adr/NNN-kebab-title.md` по шаблону ниже. -- Диаграммы — Mermaid внутри markdown (рендерится в GitHub). +- Диаграммы - Mermaid внутри markdown (рендерится в GitHub). ### Шаблон ADR diff --git a/docs/adr/001-monorepo.md b/docs/adr/001-monorepo.md index 82a8805..8d340a6 100644 --- a/docs/adr/001-monorepo.md +++ b/docs/adr/001-monorepo.md @@ -7,9 +7,9 @@ В корне два пакета: `indexium-backend` (Rust/Axum) и `indexium-frontend` (SvelteKit). Нужно решить как организовать git: один репозиторий на всё или два отдельных. Backend уже имел пустой `.git` без коммитов, фронт без гита. ## Рассмотренные варианты -1. **Монорепо** — один `.git` в корне. -2. **Полирепо** — два независимых репозитория. -3. **Submodules** — корневой репо + сабмодули. +1. **Монорепо** - один `.git` в корне. +2. **Полирепо** - два независимых репозитория. +3. **Submodules** - корневой репо + сабмодули. ## Решение Выбрать **монорепо**. Удалить `indexium-backend/.git`, инициализировать `Indexium/.git` в корне. См. `docs/git-strategy.md`. diff --git a/docs/adr/002-async-indexer.md b/docs/adr/002-async-indexer.md index 63c194a..e81fcd6 100644 --- a/docs/adr/002-async-indexer.md +++ b/docs/adr/002-async-indexer.md @@ -4,10 +4,10 @@ Статус: Принято ## Контекст -Нужен дешёвый и устойчивый к лимитам GitHub способ индексировать моды. Хранить `.jar` у себя дорого, проксировать трафик — упрёмся в bandwidth и rate limits. +Нужен дешёвый и устойчивый к лимитам GitHub способ индексировать моды. Хранить `.jar` у себя дорого, проксировать трафик - упрёмся в bandwidth и rate limits. ## Решение -Бэкенд не хранит артефакты. GitHub Releases CDN — источник правды для файлов. Мы только: +Бэкенд не хранит артефакты. GitHub Releases CDN - источник правды для файлов. Мы только: - принимаем webhook `release.published` (HMAC + queue + 202), - воркер читает zip central directory через Range Request, - парсит манифест (`fabric.mod.json` и т.д.), @@ -18,9 +18,9 @@ ## Последствия - Плюс: минимальный storage, нет egress costs, +5k–12.5k RPH через GitHub App. -- Минус: зависимость от доступности GitHub CDN (приемлемо — моды и так там). +- Минус: зависимость от доступности GitHub CDN (приемлемо - моды и так там). - Вынесен malware-скан и SHA-256 сверка как обязательные. ## Альтернативы -- Хранить файлы у себя (S3) — отклонено: дорого, дублирование. -- Полный pull `.jar` на каждый релиз — отклонено: трафик, медленно. +- Хранить файлы у себя (S3) - отклонено: дорого, дублирование. +- Полный pull `.jar` на каждый релиз - отклонено: трафик, медленно. diff --git a/docs/adr/003-search-engine.md b/docs/adr/003-search-engine.md index e51145e..f88a723 100644 --- a/docs/adr/003-search-engine.md +++ b/docs/adr/003-search-engine.md @@ -24,10 +24,10 @@ ## Альтернативы (отклонены на MVP) -- **Meilisearch** — отличный typo-tolerance, но +1 сервис, нужен отдельный деплой и синк. -- **Elasticsearch / OpenSearch** — оверхед по RAM/диску, нужен кластер даже для малого объёма. -- **SQLite FTS** — не подходит, уже Postgres как основной. +- **Meilisearch** - отличный typo-tolerance, но +1 сервис, нужен отдельный деплой и синк. +- **Elasticsearch / OpenSearch** - оверхед по RAM/диску, нужен кластер даже для малого объёма. +- **SQLite FTS** - не подходит, уже Postgres как основной. ## Ссылки -- `docs/database-schema.md` — секция 2 (триггер `mods_search_vector_update`), секция 3 (примеры FTS + fuzzy). -- `docs/architecture.md` — секция 4 (Поиск без Elasticsearch). +- `docs/database-schema.md` - секция 2 (триггер `mods_search_vector_update`), секция 3 (примеры FTS + fuzzy). +- `docs/architecture.md` - секция 4 (Поиск без Elasticsearch). diff --git a/docs/adr/004-auth-strategy.md b/docs/adr/004-auth-strategy.md index 262ef21..01cff1d 100644 --- a/docs/adr/004-auth-strategy.md +++ b/docs/adr/004-auth-strategy.md @@ -1,4 +1,4 @@ -# ADR-004: Стратегия авторизации и профилей — GitHub-only + PAT (+ Device Flow позже) +# ADR-004: Стратегия авторизации и профилей - GitHub-only + PAT (+ Device Flow позже) Дата: 2026-09-06 Статус: Принято (MVP) / Запроектировано (Phase 2) @@ -9,22 +9,22 @@ ## Решение **MVP:** -- Вход только GitHub OAuth (`read:user`, `user:email`), JWT httpOnly cookie, без паролей. Читатели — anonymous. +- Вход только GitHub OAuth (`read:user`, `user:email`), JWT httpOnly cookie, без паролей. Читатели - anonymous. - PAT с `SHA256` хранением и скоупами `read:mods|write:mods|webhooks:manage` для CI/лаунчеров. - `verified` бейдж если репо публичное + лицензия + GitHub App установлен. - Sponsors (GitHub/Patreon/Ko-fi) + star/follow (in-app) + SVG badges (`/v1/badges/:slug/downloads.svg`). **Phase 2 (спроектировано, не кодим сейчас):** -- Device Code Flow (RFC 8628) — Indexium как OAuth2 Provider для лаунчеров (`/oauth/device/code` → `/activate`). +- Device Code Flow (RFC 8628) - Indexium как OAuth2 Provider для лаунчеров (`/oauth/device/code` → `/activate`). - Discord linked_account + бот роли `Verified Modder`. - Collections/Modlists с экспортом Prism/packwiz, Activity Feed, аналитика по версиям/лоадерам, PGP проверка. **Backlog:** краш-логи, лидерборды, Profile README. ## Альтернативы -- Google/email логин — отклонён для MVP (публикация всё равно требует GitHub, лишняя сложность). -- Сразу Device Flow — отклонён ( +2 недели, PAT покрывает 80% кейсов). -- Discord как логин — отклонён (только linked). +- Google/email логин - отклонён для MVP (публикация всё равно требует GitHub, лишняя сложность). +- Сразу Device Flow - отклонён ( +2 недели, PAT покрывает 80% кейсов). +- Discord как логин - отклонён (только linked). ## Последствия - Плюс: минимум GDPR, нет паролей, доказуемое владение репо, CLI готов через PAT. diff --git a/docs/adr/005-analytics.md b/docs/adr/005-analytics.md index 25c826a..d132cc7 100644 --- a/docs/adr/005-analytics.md +++ b/docs/adr/005-analytics.md @@ -4,7 +4,7 @@ Статус: Принято (дизайн) / К реализации в Phase 3 ## Контекст -Скачивания накручиваются CI, нужна честная метрика популярности — активные установки в рантайме. bStats де-факто стандарт для Minecraft модов: lightweight SDK → POST gzip JSON → агрегация. Пользователь предложил полный дизайн с `server_uuid`, daily_salt, `mod_telemetry_pings` + `mod_daily_stats`, opt-out и сортировкой `active_servers`. +Скачивания накручиваются CI, нужна честная метрика популярности - активные установки в рантайме. bStats де-факто стандарт для Minecraft модов: lightweight SDK → POST gzip JSON → агрегация. Пользователь предложил полный дизайн с `server_uuid`, daily_salt, `mod_telemetry_pings` + `mod_daily_stats`, opt-out и сортировкой `active_servers`. ## Решение - **SDK:** MIT Java/Kotlin модуль `dev.indexium:analytics` ~15KB, `IndexiumMetrics(slug)` + `SimplePie`, уважает `-Dindexium.analytics.disable=true` и `config/indexium.json`. @@ -14,14 +14,14 @@ - **Приватность:** не храним IP, daily_salt ротация (не трекать сквозь дни), `custom_charts` ≤5 ключей, opt-out на клиенте. ## Альтернативы -- Сторонний bStats.org — отклонён (внешняя зависимость, нет контроля, нет breakdown по нашим лоадерам). -- ClickHouse сразу — отклонён (оверхед для MVP, Postgres хватает). -- Хранить сырые пинги навсегда — отклонён (раздувание, достаточно daily агрегата). +- Сторонний bStats.org - отклонён (внешняя зависимость, нет контроля, нет breakdown по нашим лоадерам). +- ClickHouse сразу - отклонён (оверхед для MVP, Postgres хватает). +- Хранить сырые пинги навсегда - отклонён (раздувание, достаточно daily агрегата). ## Последствия - Плюс: честная сортировка `active_servers`, графики для авторов, бейджи, без сторонних сервисов. - Минус: +2 таблицы, крон-агрегация, SDK нужно публиковать в Maven Central. -- План: сначала Axum handler + агрегация, потом SDK (или наоборот — можно параллельно). +- План: сначала Axum handler + агрегация, потом SDK (или наоборот - можно параллельно). ## Ссылки - `docs/analytics.md` diff --git a/docs/analytics.md b/docs/analytics.md index 2fe42a2..6caeb77 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -1,6 +1,6 @@ -# Indexium Analytics — собственный аналог bStats +# Indexium Analytics - собственный аналог bStats -> Даём мододелам встроенную аналитику рантайма (активные серверы/клиенты, MC/Java/OS) без сторонних сервисов. Indexium получает честную метрику популярности — не по скачиваниям (накручиваются CI), а по реальным установкам. +> Даём мододелам встроенную аналитику рантайма (активные серверы/клиенты, MC/Java/OS) без сторонних сервисов. Indexium получает честную метрику популярности - не по скачиваниям (накручиваются CI), а по реальным установкам. Основано на твоей схеме + правки под KISS/SOLID/приватность. @@ -8,15 +8,15 @@ ## 1. Как работает bStats (база) -1. **SDK в моде** — фоновый таймер каждые 30–60 мин собирает `mc_version, loader, java_version, os, player_count, server_uuid, custom_charts` → `POST` gzip JSON асинхронно, не блокируя главный поток. -2. **Ingestion** — бэкенд валидирует, rate-limit по `server_hash`, анонимизирует `server_uuid`. -3. **Aggregation** — сырые пинги → часовые/суточные агрегаты (Time Series), сырые удаляются по TTL. +1. **SDK в моде** - фоновый таймер каждые 30–60 мин собирает `mc_version, loader, java_version, os, player_count, server_uuid, custom_charts` → `POST` gzip JSON асинхронно, не блокируя главный поток. +2. **Ingestion** - бэкенд валидирует, rate-limit по `server_hash`, анонимизирует `server_uuid`. +3. **Aggregation** - сырые пинги → часовые/суточные агрегаты (Time Series), сырые удаляются по TTL. --- ## 2. Indexium реализация -### 2.1 Клиент — Lightweight Java/Kotlin модуль +### 2.1 Клиент - Lightweight Java/Kotlin модуль ```java // Fabric/NeoForge initialize() @@ -27,7 +27,7 @@ metrics.addCustomChart(new SimplePie("config_type", () -> config.getType())); **Что собираем (allow-list, ничего лишнего):** - `mc_version` (1.20.1), `loader` (fabric/neoforge/forge/quilt), `loader_version` -- `java_version` (21.0.2), `os` (linux/windows/macos — без детальной версии), `arch` (x64/arm64) +- `java_version` (21.0.2), `os` (linux/windows/macos - без детальной версии), `arch` (x64/arm64) - `player_count` (0 на клиенте, N на сервере), `server_uuid` (генерим раз, храним в `config/indexium-uuid.txt`) - `mod_version` (из `fabric.mod.json`), `custom_charts` (String→String, до 5 ключей, до 32 символов) @@ -37,9 +37,9 @@ SDK: ~15KB, без зависимостей, `CompletableFuture` + `HttpURLConne ### 2.2 API -- `POST /api/v1/analytics/submit` — пинг от мода (gzip JSON, `Content-Encoding: gzip` опционально) -- `GET /api/v1/mods/:slug/analytics?range=7d|30d|90d` — графики для SvelteKit -- `GET /api/v1/badges/:slug/servers.svg` — бейдж активных серверов +- `POST /api/v1/analytics/submit` - пинг от мода (gzip JSON, `Content-Encoding: gzip` опционально) +- `GET /api/v1/mods/:slug/analytics?range=7d|30d|90d` - графики для SvelteKit +- `GET /api/v1/badges/:slug/servers.svg` - бейдж активных серверов Пример payload (как в твоём ТЗ): ```json @@ -57,9 +57,9 @@ SDK: ~15KB, без зависимостей, `CompletableFuture` + `HttpURLConne } ``` -### 2.3 Хранение — PostgreSQL (MVP) → TimescaleDB/ClickHouse при росте +### 2.3 Хранение - PostgreSQL (MVP) → TimescaleDB/ClickHouse при росте -На MVP хватает Postgres + daily агрегат (как ты предложил). Сырые пинги храним 30 дней, агрегаты — навсегда. +На MVP хватает Postgres + daily агрегат (как ты предложил). Сырые пинги храним 30 дней, агрегаты - навсегда. ```sql -- Полуагрегат: один пинг = одна строка, TTL 30 дней через cron @@ -90,14 +90,14 @@ CREATE TABLE mod_daily_stats ( **Агрегация:** воркер-кроном раз в час: `INSERT INTO mod_daily_stats ... ON CONFLICT DO UPDATE` группировкой по `server_hash` (последний пинг сервера за день). Через `pg_cron` или tokio `interval` в бэкенде. -**Масштаб:** при >10M пингов/мес — мигрируем на TimescaleDB hypertable (`create_hypertable('mod_telemetry_pings','pinged_at')`) или ClickHouse. Схема не меняется. +**Масштаб:** при >10M пингов/мес - мигрируем на TimescaleDB hypertable (`create_hypertable('mod_telemetry_pings','pinged_at')`) или ClickHouse. Схема не меняется. ### 2.4 Защита и анонимность (критично) - **Хеш + daily_salt:** `server_hash = sha256(server_uuid + salt_for_today)`. Соль ротируется в `analytics_salts(date, salt)`, храним 2 дня. Нельзя трекать сервер сквозь дни, но можно считать уникальные за день. -- **Не храним IP:** `tower_http::TraceLayer` без IP, `X-Forwarded-For` игнорируем, в логах — `/analytics/submit 200` без IP. -- **Opt-Out:** SDK проверяет в порядке: JVM флаг `-Dindexium.analytics.disable=true` → `global_privacy.json` (`.minecraft/config/indexium.json { enabled:false }`) → `config//indexium.json`. Если любой `false` — не шлём. -- **Rate limit:** Redis `SET server_hash:mod_slug NX EX 900` — 1 пинг / 15 мин. Ответ `429` с `Retry-After`, SDK бэкофф 30 мин. +- **Не храним IP:** `tower_http::TraceLayer` без IP, `X-Forwarded-For` игнорируем, в логах - `/analytics/submit 200` без IP. +- **Opt-Out:** SDK проверяет в порядке: JVM флаг `-Dindexium.analytics.disable=true` → `global_privacy.json` (`.minecraft/config/indexium.json { enabled:false }`) → `config//indexium.json`. Если любой `false` - не шлём. +- **Rate limit:** Redis `SET server_hash:mod_slug NX EX 900` - 1 пинг / 15 мин. Ответ `429` с `Retry-After`, SDK бэкофф 30 мин. - **Валидация:** `mod_slug` должен существовать, `mc_version`/`loader` из allow-list, `custom_charts` ≤5 ключей, `player_count` 0–10000. Иначе `400`. --- @@ -106,14 +106,14 @@ CREATE TABLE mod_daily_stats ( 1. **Live Charts (SvelteKit + LayerChart/Chart.js):** `GET /mods/:slug/analytics?range=30d` → `{ daily: [{date, active_servers, active_players}], breakdown: {mc_versions, loaders, os} }`. Графики: активные серверы (линия), разбивка по MC (пончик), лоадерам (бар). 2. **Badge:** `https://api.indexium.example.com/v1/badges/sodium-extra/servers.svg` → `Active Servers: 1.2k` (из `mod_daily_stats` за вчера, кэш 1h). -3. **Сортировка "Real-world Usage":** `GET /mods?sort=active_servers` — `ORDER BY (SELECT active_servers FROM mod_daily_stats WHERE date = CURRENT_DATE -1)`, а не по скачиваниям. Фильтр против накрутки CI. +3. **Сортировка "Real-world Usage":** `GET /mods?sort=active_servers` - `ORDER BY (SELECT active_servers FROM mod_daily_stats WHERE date = CURRENT_DATE -1)`, а не по скачиваниям. Фильтр против накрутки CI. --- ## 4. Что не делаем (чтобы не стать spyware) -- Не собираем ник, чат, координаты, список всех модов без явного согласия (если включим — отдельный `custom_charts` с opt-in). +- Не собираем ник, чат, координаты, список всех модов без явного согласия (если включим - отдельный `custom_charts` с opt-in). - Не fingerprint'им по железу. -- SDK открыт (MIT) — любой может проверить что шлём (как bStats — код на GitHub). +- SDK открыт (MIT) - любой может проверить что шлём (как bStats - код на GitHub). См. `adr/005-analytics.md`, `api-spec.md` §Analytics, `database-schema.md` §telemetry. diff --git a/docs/api-spec.md b/docs/api-spec.md index c6cddef..05da0cd 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -1,8 +1,8 @@ -# Public REST API — Indexium v1 +# Public REST API - Indexium v1 Base URL: `https://api.indexium.example.com/api/v1` (локально `http://localhost:3000/api/v1`) -Все ответы — `application/json`. Пагинация — `page`/`limit` (MVP) → cursor позже. Кэш — `Cache-Control: public, max-age=60`, `ETag`. +Все ответы - `application/json`. Пагинация - `page`/`limit` (MVP) → cursor позже. Кэш - `Cache-Control: public, max-age=60`, `ETag`. --- @@ -30,12 +30,12 @@ Headers: Body: raw JSON от GitHub. -**202** — принято в очередь +**202** - принято в очередь ```json { "status": "accepted", "delivery_id": "..." } ``` -**401** — неверная подпись -**409** — уже обработано (идемпотентность) +**401** - неверная подпись +**409** - уже обработано (идемпотентность) Логика: HMAC проверка → дедуп по `delivery_id` → push в Redis Streams → 202. @@ -105,18 +105,18 @@ Query params: ] } ``` -**404** — `{"error":"mod_not_found"}` +**404** - `{"error":"mod_not_found"}` ### `GET /mods/:slug/icon` Отдаёт иконку мода. Воркер при индексации извлекает `assets//icon.png` (или `icon` из `fabric.mod.json` → путь внутри jar) → сохраняет в кэш/проксирует. -- **200** — `image/png` / `image/webp` с `Cache-Control: public, max-age=86400`, `ETag`. Если иконки нет → `302` на `raw.githubusercontent.com` fallback или дефолтная заглушка. -- **404** — мод не найден. +- **200** - `image/png` / `image/webp` с `Cache-Control: public, max-age=86400`, `ETag`. Если иконки нет → `302` на `raw.githubusercontent.com` fallback или дефолтная заглушка. +- **404** - мод не найден. -> Альтернатива на MVP: не хранить иконку у себя, а отдавать `icon_url` как прямую ссылку `https://raw.githubusercontent.com////src/main/resources/assets/...`. Эндпоинт `/icon` тогда — 302 редирект + кэш заголовков. +> Альтернатива на MVP: не хранить иконку у себя, а отдавать `icon_url` как прямую ссылку `https://raw.githubusercontent.com////src/main/resources/assets/...`. Эндпоинт `/icon` тогда - 302 редирект + кэш заголовков. -### `POST /mods/resolve` — пакетный резолв для лаунчеров +### `POST /mods/resolve` - пакетный резолв для лаунчеров Принимает список модов + окружение, возвращает дерево прямых скачиваний и зависимостей (для Prism / Modrinth-compatible клиентов). @@ -159,7 +159,7 @@ Query params: - `version: "latest"` → резолвит последнюю совместимую с `game_version` + `loader`. - Транзитивные зависимости резолвятся рекурсивно (BFS, max depth 20, защита от циклов). -- **422** — несовместимая комбинация `game_version`/`loader`. +- **422** - несовместимая комбинация `game_version`/`loader`. - Кэшируется по ключу `resolve:gv:loader:hash(mods)` TTL 60s. ### `GET /mods/:slug/versions/:version` @@ -193,9 +193,9 @@ Body: Логика: проверить что токен имеет доступ к репо → fetch `fabric.mod.json` из default branch → создать запись `mods` → повесить webhook. -**201** — создан -**409** — уже импортирован -**422** — манифест не найден +**201** - создан +**409** - уже импортирован +**422** - манифест не найден --- @@ -204,14 +204,14 @@ Body: ### `GET /auth/github` → 302 redirect на GitHub OAuth ### `GET /auth/github/callback?code=...` → обмен code→token, установка httpOnly cookie / JWT -### `POST /auth/tokens` (auth) — PAT creation +### `POST /auth/tokens` (auth) - PAT creation Body: `{ "name": "ci-token", "scopes": ["read:mods","write:mods"], "expires_in_days": 30 }` → `201 { token: "idx_...", id, expires_at }` (токен показывается 1 раз, храним hash). `Authorization: Bearer idx_...` для API. -### `GET /auth/tokens` / `DELETE /auth/tokens/:id` — список/отзыв. +### `GET /auth/tokens` / `DELETE /auth/tokens/:id` - список/отзыв. ### Device Flow (Phase 2, RFC 8628) - `POST /oauth/device/code` → `{ device_code, user_code: "ABCD-1234", verification_uri: "https://indexium.example.com/activate", expires_in: 600 }` -- `GET /activate` (frontend) — ввод `user_code` → consent → `POST /oauth/device/verify { user_code }` +- `GET /activate` (frontend) - ввод `user_code` → consent → `POST /oauth/device/verify { user_code }` - `POST /oauth/token` grant_type=`urn:ietf:params:oauth:grant-type:device_code` → `{ access_token, refresh_token }` - Лаунчер поллит `/oauth/token` до получения токена. @@ -219,16 +219,16 @@ Body: `{ "name": "ci-token", "scopes": ["read:mods","write:mods"], "expires_in_d ## Profiles & Social -### `GET /u/:login` / `GET /org/:login` — публичный профиль (кэш 60s, ISR) -### `POST /mods/:slug/star` / `DELETE /mods/:slug/star` — звезда (auth) -### `POST /u/:login/follow` / `DELETE /u/:login/follow` — подписка на автора с опционально `?game_version=1.20.1&loader=fabric` +### `GET /u/:login` / `GET /org/:login` - публичный профиль (кэш 60s, ISR) +### `POST /mods/:slug/star` / `DELETE /mods/:slug/star` - звезда (auth) +### `POST /u/:login/follow` / `DELETE /u/:login/follow` - подписка на автора с опционально `?game_version=1.20.1&loader=fabric` ### `GET /collections` / `POST /collections` (auth, body: `{ title, description, mods: [{slug, version}] }`) ### `GET /collections/:slug` / `GET /collections/:slug/export?format=prism|packwiz` -### `GET /v1/badges/:slug/downloads.svg` / `GET /v1/badges/:slug/version.svg` — SVG виджет для README (public, кэш 1h) +### `GET /v1/badges/:slug/downloads.svg` / `GET /v1/badges/:slug/version.svg` - SVG виджет для README (public, кэш 1h) ## Analytics (bStats аналог, см. docs/analytics.md) -### `POST /api/v1/analytics/submit` — пинг от мода (gzip опционально) +### `POST /api/v1/analytics/submit` - пинг от мода (gzip опционально) Headers: `Content-Type: application/json`, `Content-Encoding: gzip` (optional) Body: ```json @@ -246,7 +246,7 @@ Body: } ``` - Валидация: `mod_slug` exists, allow-list версий/лоадеров, `custom_charts` ≤5 ключей. -- Анонимизация: `server_hash = sha256(server_uuid + daily_salt)` — IP не храним. +- Анонимизация: `server_hash = sha256(server_uuid + daily_salt)` - IP не храним. - Rate limit: 1 пинг / 15 мин per `server_hash+mod_slug` (Redis `SET NX EX 900`) → `429`. - Opt-Out: respect `-Dindexium.analytics.disable=true` на клиенте. - **200** `{ "status": "ok" }` **400** validation **429** rate_limited @@ -272,13 +272,13 @@ Public, кэш `public, max-age=300`. ``` **404** mod_not_found. Источник: `mod_daily_stats`. -### `GET /api/v1/badges/:slug/servers.svg` — бейдж активных серверов (как downloads.svg, кэш 1h) +### `GET /api/v1/badges/:slug/servers.svg` - бейдж активных серверов (как downloads.svg, кэш 1h) SVG `Active Servers: 1.2k` из `mod_daily_stats` за вчера. ## Sponsors & Badges - `GET /u/:login` отдаёт `sponsors: { github, patreon, kofi, bmc }` и `badges: ["early_adopter","verified"]` -- Бейджи выдаются воркером (`badges` таблица), SVG — динамически. +- Бейджи выдаются воркером (`badges` таблица), SVG - динамически. --- @@ -315,4 +315,4 @@ SVG `Active Servers: 1.2k` из `mod_daily_stats` за вчера. ## OpenAPI -Спека будет жить в `indexium-backend/openapi.yaml` (генерировать из Axum через `utoipa` когда созреет). На MVP — этот markdown как источник правды. +Спека будет жить в `indexium-backend/openapi.yaml` (генерировать из Axum через `utoipa` когда созреет). На MVP - этот markdown как источник правды. diff --git a/docs/architecture.md b/docs/architecture.md index 308e65e..4e7dc3d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,7 +1,7 @@ -# Архитектура Indexium — Асинхронный событийный индексатор +# Архитектура Indexium - Асинхронный событийный индексатор > Цель: сделать сервис максимально лёгким, дешёвым в обслуживании и устойчивым к ограничениям GitHub. -> Принцип: бэкенд **не хранит** тяжёлые файлы — артефакты отдаются с GitHub Releases CDN, мы валидируем, индексируем метаданные и выдаём быстрые JSON-ответы. +> Принцип: бэкенд **не хранит** тяжёлые файлы - артефакты отдаются с GitHub Releases CDN, мы валидируем, индексируем метаданные и выдаём быстрые JSON-ответы. --- @@ -37,9 +37,9 @@ ``` ### Потоки данных -1. **Ingestion** — GitHub шлёт webhook → Ingestion API валидирует HMAC → кладёт job в очередь → отвечает `202`. -2. **Indexing** — Worker читает job → делает `Range Request` к `.jar` → парсит манифест → пишет в Postgres → инвалидирует кэш. -3. **Serving** — Лаунчер / Web UI дергает `GET /api/v1/mods` → читаем Redis → miss → Postgres FTS → кэшируем 60s → отдаём JSON. +1. **Ingestion** - GitHub шлёт webhook → Ingestion API валидирует HMAC → кладёт job в очередь → отвечает `202`. +2. **Indexing** - Worker читает job → делает `Range Request` к `.jar` → парсит манифест → пишет в Postgres → инвалидирует кэш. +3. **Serving** - Лаунчер / Web UI дергает `GET /api/v1/mods` → читаем Redis → miss → Postgres FTS → кэшируем 60s → отдаём JSON. --- @@ -47,14 +47,14 @@ | Слой | Технология | Почему именно это? | | --- | --- | --- | -| **Backend Core** | **Rust (Axum)** | Минимальный memory footprint, высокий throughput, быстрый async I/O при парсинге. Альтернатива Go (Fiber) — допустима на Phase 2. | +| **Backend Core** | **Rust (Axum)** | Минимальный memory footprint, высокий throughput, быстрый async I/O при парсинге. Альтернатива Go (Fiber) - допустима на Phase 2. | | **Main Database** | **PostgreSQL** | Нативный FTS, `JSONB` для зависимостей, `pg_trgm` для fuzzy, `pgvector` опционально для семантики. | | **Cache & Queue** | **Redis / Valkey** | Одновременно брокер очередей (Streams/PubSub) и L2-кэш популярных эндпоинтов. | | **Worker Engine** | **Rust Background Worker (tokio)** | Потребляет webhook-события, скачивает только zip-header stream, валидирует байткод. | | **Auth & Security** | **GitHub App / OAuth 2.0** | Вход только через GitHub, без локальных паролей. | | **Frontend** | **SvelteKit + TypeScript** | SSR, маленький бандл, быстрый dev. | -> Обоснование против Elasticsearch/Meilisearch на старте: `to_tsvector` + `pg_trgm` переваривают десятки тысяч модов с <5ms без отдельного кластера. Миграция на внешний поиск — только если FTS упрётся. +> Обоснование против Elasticsearch/Meilisearch на старте: `to_tsvector` + `pg_trgm` переваривают десятки тысяч модов с <5ms без отдельного кластера. Миграция на внешний поиск - только если FTS упрётся. --- @@ -68,9 +68,9 @@ ### 3.2 Обработка webhook (Ingestion & Job Dispatch) 1. GitHub шлёт `release.published`. 2. **Ingestion API** валидирует `X-Hub-Signature-256` (HMAC SHA-256 с `WEBHOOK_SECRET`), проверяет идемпотентность по `X-GitHub-Delivery`. -3. Кладёт задачу в Redis Queue, отвечает `202 Accepted` за <50ms (чтобы не висеть по таймауту GitHub — 10s). +3. Кладёт задачу в Redis Queue, отвечает `202 Accepted` за <50ms (чтобы не висеть по таймауту GitHub - 10s). -### 3.3 Работа воркера-индексатора (Worker Execution) — детальный алгоритм `jar_parser.rs` +### 3.3 Работа воркера-индексатора (Worker Execution) - детальный алгоритм `jar_parser.rs` > Цель: не скачивать весь `.jar` (может быть 20–50MB), а прочитать только нужный манифест через 2–3 Range-запроса. @@ -102,15 +102,15 @@ download_url = "https://github.com/owner/repo/releases/download/v1.2.3/mod-1.2.3 ``` **Детали реализации:** -1. `HEAD` — обязателен, чтобы получить `Content-Length` и убедиться `Accept-Ranges: bytes`. Таймаут 5s, retry 2. -2. Последние 64KB достаточно для EOCD даже для jar с 10k файлов (EOCD в конце). Если не найден — fallback к последним 128KB. +1. `HEAD` - обязателен, чтобы получить `Content-Length` и убедиться `Accept-Ranges: bytes`. Таймаут 5s, retry 2. +2. Последние 64KB достаточно для EOCD даже для jar с 10k файлов (EOCD в конце). Если не найден - fallback к последним 128KB. 3. Central Directory читается одним запросом (обычно 5–30KB). Парсим `central_dir_offset/size` из EOCD. -4. Манифест — 4-й запрос только если нужен (часто 1–3KB). Иконка — опционально 5-й запрос, кэшируется и отдаётся через `GET /mods/:slug/icon`. -5. Все `GET` — `reqwest` с `header("Range", ...)`, проверка `206 Partial Content`, иначе fallback к полному скачиванию с лимитом 10MB. +4. Манифест - 4-й запрос только если нужен (часто 1–3KB). Иконка - опционально 5-й запрос, кэшируется и отдаётся через `GET /mods/:slug/icon`. +5. Все `GET` - `reqwest` с `header("Range", ...)`, проверка `206 Partial Content`, иначе fallback к полному скачиванию с лимитом 10MB. **Ошибки:** `412` если `Accept-Ranges` != bytes → full download; `404` на Range → retry без Range; повреждённый ZIP → помечаем `suspicious` и DLQ. -Код: `indexium-backend/src/worker/jar_parser.rs` — чистые функции `find_eocd()`, `parse_central_dir()`, `fetch_manifest()` без I/O в тестах. +Код: `indexium-backend/src/worker/jar_parser.rs` - чистые функции `find_eocd()`, `parse_central_dir()`, `fetch_manifest()` без I/O в тестах. ### 3.4 Агрегация и индексация (Storage & Cache Invalidation) 1. Сохраняет версию в `mod_versions` (см. `database-schema.md`). @@ -122,8 +122,8 @@ download_url = "https://github.com/owner/repo/releases/download/v1.2.3/mod-1.2.3 ## 4. Обход ключевых ограничений (Edge Cases) ### GitHub Rate Limits -- Запросы воркеров — от имени **GitHub App Installs** (5k–12.5k RPH на инсталл vs 60 RPH анонимных). -- Скачивание не проксируем — выдаём клиентам прямые CDN-ссылки, трафик не идёт через нас. +- Запросы воркеров - от имени **GitHub App Installs** (5k–12.5k RPH на инсталл vs 60 RPH анонимных). +- Скачивание не проксируем - выдаём клиентам прямые CDN-ссылки, трафик не идёт через нас. ### Безопасность (Malware Protection) - Сверка `SHA-256` ассета с `.sha256` если есть. @@ -216,4 +216,4 @@ CREATE INDEX idx_versions_lookup ON mod_versions USING GIN (game_versions, loade --> [SvelteKit :5173] (SSR, fetch API) ``` -Все сервисы — `docker compose` локально, один VPS в проде. +Все сервисы - `docker compose` локально, один VPS в проде. diff --git a/docs/auth-profiles.md b/docs/auth-profiles.md index 4938966..d8a170d 100644 --- a/docs/auth-profiles.md +++ b/docs/auth-profiles.md @@ -1,10 +1,10 @@ -# Профили, аккаунты и авторизация — дизайн Indexium +# Профили, аккаунты и авторизация - дизайн Indexium -> Цель: максимально лёгкая, но крутая система профилей без паролей, где GitHub — источник правды. +> Цель: максимально лёгкая, но крутая система профилей без паролей, где GitHub - источник правды. --- -## 1. TL;DR — рекомендуем для MVP +## 1. TL;DR - рекомендуем для MVP **Авторизация: только GitHub OAuth / GitHub App.** Никаких паролей, email+пароль, Google и т.д. на старте. @@ -15,10 +15,10 @@ | 1 клик, нет форм регистрации | Отсекаем тех у кого нет GitHub (но они и моды не публикуют) | | Доказуемое владение репозиторием (`GET /repos` с токеном) | Зависимость от GitHub OAuth (но у нас и так всё на GitHub) | | Аватар, ник, био подтягиваются автоматически | Нет anon-публикаций (и это хорошо для open source) | -| Один токен — и публикация, и вебхуки, и профиль | Если GitHub лежит — логин не работает (редкость) | +| Один токен - и публикация, и вебхуки, и профиль | Если GitHub лежит - логин не работает (редкость) | | Нет хранения паролей, нет утечек | | -> **Вывод:** для каталога где `1 мод = 1 GitHub репо` — GitHub-only это не ограничение, а фича. Пользователи-читатели (игроки) могут смотреть каталог **без логина вообще**. Логин нужен только авторам. +> **Вывод:** для каталога где `1 мод = 1 GitHub репо` - GitHub-only это не ограничение, а фича. Пользователи-читатели (игроки) могут смотреть каталог **без логина вообще**. Логин нужен только авторам. --- @@ -26,10 +26,10 @@ ### Роли -- **Reader (anonymous)** — ищет, качает по прямым ссылкам, смотрит профили. Без аккаунта. -- **Author** — залогинен через GitHub, импортировал хотя бы один репо. Может публиковать релизы (через `git push` + webhook, без кнопки "загрузить jar"). -- **Contributor** — указан в `mod_authors` с `role=contributor`, не обязательно owner репо. Получает бейдж на карточке мода. -- **Moderator / Admin** — ручная выдача, может ставить `verified` / `suspicious`, банить. +- **Reader (anonymous)** - ищет, качает по прямым ссылкам, смотрит профили. Без аккаунта. +- **Author** - залогинен через GitHub, импортировал хотя бы один репо. Может публиковать релизы (через `git push` + webhook, без кнопки "загрузить jar"). +- **Contributor** - указан в `mod_authors` с `role=contributor`, не обязательно owner репо. Получает бейдж на карточке мода. +- **Moderator / Admin** - ручная выдача, может ставить `verified` / `suspicious`, банить. ### Что храним (минимум GDPR) @@ -56,12 +56,12 @@ CREATE TABLE mod_authors ( ); ``` -Никаких email в открытом виде (берём только для JWT, не показываем), никаких паролей. `github_id` — неизменяемый PK, `login` может смениться — обновляем по webhook `user.renamed` или при следующем логине. +Никаких email в открытом виде (берём только для JWT, не показываем), никаких паролей. `github_id` - неизменяемый PK, `login` может смениться - обновляем по webhook `user.renamed` или при следующем логине. ### Сессии - **JWT (httpOnly cookie)**: `sub: github_id`, `login`, `exp: 7d`. Подпись `HS256` с `JWT_SECRET` или `RS256` если хотим ротацию. -- **Не храним сессии в Redis на MVP** — stateless JWT достаточно. Позже — refresh token в `author_sessions`. +- **Не храним сессии в Redis на MVP** - stateless JWT достаточно. Позже - refresh token в `author_sessions`. - **CSRF**: `SameSite=Lax` + `Origin` check для `POST /mods/import`. --- @@ -78,7 +78,7 @@ CREATE TABLE mod_authors ( → 302 /me или /?welcomed=1 ``` -**Для публикации модов нужен `repo` scope** только если хотим ставить webhook автоматически. На MVP можно `read:user` + `public_repo` (только публичные). Токен GitHub не храним долго — меняем на JWT и забываем (или храним encrypted `github_access_token` для будущих API вызовов, с возможностью revoke). +**Для публикации модов нужен `repo` scope** только если хотим ставить webhook автоматически. На MVP можно `read:user` + `public_repo` (только публичные). Токен GitHub не храним долго - меняем на JWT и забываем (или храним encrypted `github_access_token` для будущих API вызовов, с возможностью revoke). **GitHub App (альтернатива OAuth):** - Плюс: `5k–12.5k RPH`, управление webhooks через App, `installation_id` per org. @@ -87,26 +87,26 @@ CREATE TABLE mod_authors ( --- -## 4. Профили — как сделать круто и по open source +## 4. Профили - как сделать круто и по open source ### URL структура -- `/u/:login` — профиль пользователя (зеркало GitHub, но с модами) -- `/org/:login` — профиль организации (если `type: Organization`) -- `/mod/:slug` — карточка мода (показывает авторов с ролями) +- `/u/:login` - профиль пользователя (зеркало GitHub, но с модами) +- `/org/:login` - профиль организации (если `type: Organization`) +- `/mod/:slug` - карточка мода (показывает авторов с ролями) Все профили **публичны и кэшируются** (ISR в SvelteKit). ### Что показываем на `/u/:login` ``` -[avatar] flashy (@flashy) — "Minecraft modder" +[avatar] flashy (@flashy) - "Minecraft modder" bio | 📍 Berlin | 🔗 flashy.dev | Joined 2024 Stats: 12 mods · 48 releases · 12k downloads (aggregated) · 342 stars (from GH) Mods: - [sodium-extra] 1.20.1 fabric — ★ 42 — MIT + [sodium-extra] 1.20.1 fabric - ★ 42 - MIT [lithium-fork] ... Contributions: контрибьютил в 5 чужих модов (через mod_authors) @@ -124,22 +124,22 @@ Links: GitHub → github.com/flashy | Indexium RSS → /u/flashy/feed.xml ### Крутые идеи (backlog, но заложим) -- **Profile README** — рендерим `https://github.com/:login/:login/blob/main/README.md` если есть (как GitHub profile README). +- **Profile README** - рендерим `https://github.com/:login/:login/blob/main/README.md` если есть (как GitHub profile README). - **Achievements:** `First Mod`, `10k Downloads`, `GPL Defender` (все моды GPL). -- **Follow:** подписка на автора (email / webhook) — `POST /u/:login/follow` → уведомляем о новых релизах (через `author_follows` таблицу). +- **Follow:** подписка на автора (email / webhook) - `POST /u/:login/follow` → уведомляем о новых релизах (через `author_follows` таблицу). - **Organizations:** группируем моды по `owner` (из `mods.owner`), страница `/org/:owner` агрегирует всех авторов организации. - **Sponsors:** кнопка `Sponsor` → ссылка на `github.com/sponsors/:login` если у автора включён Sponsors. --- -## 5. Альтернативы — когда добавлять второй провайдер +## 5. Альтернативы - когда добавлять второй провайдер | Провайдер | Когда добавлять | Как | |---|---|---| | **Discord OAuth** | Если заведём Discord сервер и хотим связать роли | `GET /auth/discord` → линк к `authors.discord_id`, не как замена GitHub, а как `linked_accounts` | | **Google / Email magic link** | Если появятся читатели-комментаторы без GitHub | Только для `Reader` роли, без права публикации. Публикация всё равно требует GitHub линк (`GET /link/github`) | | **Passkeys / WebAuthn** | Если хотим passwordless для модераторов | Избыточно на MVP | -| **Gitea / Codeberg / GitLab** | Если хотим тру-децентрализацию | Добавляем `provider: github|gitlab|codeberg` в `authors`, но каждый — отдельный OAuth. На MVP — только GitHub | +| **Gitea / Codeberg / GitLab** | Если хотим тру-децентрализацию | Добавляем `provider: github|gitlab|codeberg` в `authors`, но каждый - отдельный OAuth. На MVP - только GitHub | **Архитектура на будущее (не делаем сейчас, но не блокируем):** @@ -153,66 +153,66 @@ CREATE TABLE linked_accounts ( -- Публикация мода всё равно требует linked GitHub с доступом к репо ``` -**Рекомендация:** MVP — **только GitHub**. Второй провайдер — Discord линк **после** первых 500 пользователей, если попросят. +**Рекомендация:** MVP - **только GitHub**. Второй провайдер - Discord линк **после** первых 500 пользователей, если попросят. --- ## 6. Безопасность и приватность -- Никаких паролей — нечего утекать. +- Никаких паролей - нечего утекать. - `access_token` GitHub храним только в памяти/JWT, не в БД (или encrypted at rest). -- Rate limit на `/auth/*` — 10 req/min per IP. +- Rate limit на `/auth/*` - 10 req/min per IP. - Удаление аккаунта: `DELETE /me` → удаляем `authors` + `mod_authors`, но `mods` остаются ( orphan → показываем `by @deleted` ), т.к. код уже open source и на GitHub. -- GDPR: `GET /me/export` → JSON со всеми данными, `DELETE` — право на забвение (кроме публичных модов). +- GDPR: `GET /me/export` → JSON со всеми данными, `DELETE` - право на забвение (кроме публичных модов). --- -## 7. Расширенная авторизация — твои идеи (оценка) +## 7. Расширенная авторизация - твои идеи (оценка) -### 7.1 API Keys / PAT — **да, делаем в Phase 1** +### 7.1 API Keys / PAT - **да, делаем в Phase 1** Генерация в `/settings/tokens` с кастомными скоупами `read:mods`, `write:mods`, `webhooks:manage`. -- Хранение: `personal_access_tokens (id, github_id, token_hash, scopes[], expires_at)` — храним только `SHA256(token)` как у GitHub. +- Хранение: `personal_access_tokens (id, github_id, token_hash, scopes[], expires_at)` - храним только `SHA256(token)` как у GitHub. - Зачем: CI/CD (`github actions: indexium publish --token $INDEXIUM_TOKEN`), лаунчеры без браузера. - Риск: утечка → лимит скоупов + `expires_at` 30/90 дней + `last_used_at` + revoke. -- **Вердикт:** берём в MVP — 1 таблица + 2 эндпоинта, без OAuth сервера. +- **Вердикт:** берём в MVP - 1 таблица + 2 эндпоинта, без OAuth сервера. -### 7.2 OAuth2 Provider / Device Code Flow (RFC 8628) — **круто, но Phase 2** +### 7.2 OAuth2 Provider / Device Code Flow (RFC 8628) - **круто, но Phase 2** Ты предлагаешь сделать Indexium IdP для лаунчеров: лаунчер показывает `ABCD-1234` → юзер на `indexium.example.com/activate` подтверждает. - Плюс: идеален для Prism на Linux/TV/без браузера, как у GitHub CLI (`gh auth login --web`). - Минус: нужно реализовать полноценный Authorization Server (`/oauth/authorize`, `/oauth/token`, `/oauth/device/code`, `/oauth/device/verify`) + consent screen + refresh tokens. Это +2-3 недели. -- Альтернатива на MVP: **PAT** — лаунчер просит вставить токен вручную (как `gh` с PAT). UX хуже, но без IdP. +- Альтернатива на MVP: **PAT** - лаунчер просит вставить токен вручную (как `gh` с PAT). UX хуже, но без IdP. - **Вердикт:** проектируем сейчас (закладываем `oauth_clients`, `device_codes`), реализуем после PAT когда попросят лаунчеры. -### 7.3 Discord линк — **да, но как linked_account, не как логин** +### 7.3 Discord линк - **да, но как linked_account, не как логин** - Флоу: `GET /auth/discord` → `linked_accounts (github_id, provider='discord', provider_id)` → бот выдаёт `Verified Modder` на сервере Indexium, шлёт DM о релизах. -- Не делаем Discord как замену GitHub — публикация всё равно требует GitHub. Это синк ролей, не вход. +- Не делаем Discord как замену GitHub - публикация всё равно требует GitHub. Это синк ролей, не вход. - **Вердикт:** делаем после MVP, когда заведём Discord сервер. --- -## 8. Фичи профиля — разбор твоих идей +## 8. Фичи профиля - разбор твоих идей ### 8.1 Для разработчиков (оценка) | Идея | Оценка | Комментарий | |---|---|---| -| **Дашборд аналитики** (скачивания по версиям/лоадерам/OS, краш-логи) | **Phase 2** | Скачивания считаем агрегатом `downloads_daily` (без IP), OS — из `User-Agent` лаунчера если пришлёт. Краш-логи — отдельный `POST /telemetry/crash` с анонимизацией, опционально. | +| **Дашборд аналитики** (скачивания по версиям/лоадерам/OS, краш-логи) | **Phase 2** | Скачивания считаем агрегатом `downloads_daily` (без IP), OS - из `User-Agent` лаунчера если пришлёт. Краш-логи - отдельный `POST /telemetry/crash` с анонимизацией, опционально. | | **Организации/команды** (Team CoFH) | **MVP-лайт** | Уже есть `mod_authors` + `mods.owner` (org). Делаем `/org/:login` как агрегатор, `role=maintainer` для команды. Без отдельного `teams` на старте. | | **Спонсорство** (GitHub Sponsors, Patreon, Ko-fi) | **MVP** | Поле `authors.sponsors: JSONB { github, patreon, kofi, bmc }` + кнопки в шапке профиля/мода. Парсим из GitHub `sponsors` API или ручной ввод. | -| **Verified + PGP/GPG подпись** | **MVP / Phase 2** | `verified` уже в `mods` — ставим если репо через GitHub App и `license` ok. PGP — показываем `gpg_keys` из GitHub API (`GET /users/:login/gpg_keys`), проверка `.asc` рядом с `.jar` — Phase 2. | +| **Verified + PGP/GPG подпись** | **MVP / Phase 2** | `verified` уже в `mods` - ставим если репо через GitHub App и `license` ok. PGP - показываем `gpg_keys` из GitHub API (`GET /users/:login/gpg_keys`), проверка `.asc` рядом с `.jar` - Phase 2. | ### 8.2 Для игроков | Идея | Оценка | |---|---| -| **Коллекции / Модпаки** (`My OptiFine Alternatives`) с экспортом в Prism/CurseForge | **Phase 2, хит** | `collections (id, author_id, slug, title, mods[] JSONB, visibility)` + `collection_stars`. Экспорт — `GET /collections/:slug/export?format=prism|packwiz`. Виральная фича. | -| **Star / Follow + подписки** (уведомления о релизе под `1.20.1+fabric`) | **MVP-лайт** | `stars (github_id, mod_id)`, `follows (github_id, author_id)` + фильтр `notify_game_version/loader`. Уведомления — сначала in-app + Discord DM, email позже. | +| **Коллекции / Модпаки** (`My OptiFine Alternatives`) с экспортом в Prism/CurseForge | **Phase 2, хит** | `collections (id, author_id, slug, title, mods[] JSONB, visibility)` + `collection_stars`. Экспорт - `GET /collections/:slug/export?format=prism|packwiz`. Виральная фича. | +| **Star / Follow + подписки** (уведомления о релизе под `1.20.1+fabric`) | **MVP-лайт** | `stars (github_id, mod_id)`, `follows (github_id, author_id)` + фильтр `notify_game_version/loader`. Уведомления - сначала in-app + Discord DM, email позже. | | **Activity Feed** | **Phase 2** | Лента из `webhook_deliveries` + `collections` + `stars` по подпискам. | ### 8.3 Геймификация и виджет -- **Бейджи:** `Early Adopter` (id <1000), `Bug Hunter` (репорты), `Top Contributor` (N релизов/мес), `Open Source Veteran` (GitHub age >5 лет через `created_at` из API). Храним `badges (github_id, badge_id)` — выдаём воркером раз в день. Показываем на `/u/:login`. -- **Showcase Widget SVG:** `GET /v1/badges/:slug/downloads.svg` и `GET /v1/badges/:slug/version.svg` — генерируем SVG на лету (как `shields.io`), кэш 1h, без JS. Пример: `![Indexium](https://api.indexium.example.com/v1/badges/sodium-extra/downloads.svg)` — **делаем в MVP**, это маркетинг. +- **Бейджи:** `Early Adopter` (id <1000), `Bug Hunter` (репорты), `Top Contributor` (N релизов/мес), `Open Source Veteran` (GitHub age >5 лет через `created_at` из API). Храним `badges (github_id, badge_id)` - выдаём воркером раз в день. Показываем на `/u/:login`. +- **Showcase Widget SVG:** `GET /v1/badges/:slug/downloads.svg` и `GET /v1/badges/:slug/version.svg` - генерируем SVG на лету (как `shields.io`), кэш 1h, без JS. Пример: `![Indexium](https://api.indexium.example.com/v1/badges/sodium-extra/downloads.svg)` - **делаем в MVP**, это маркетинг. --- @@ -226,7 +226,7 @@ CREATE TABLE linked_accounts ( ## 10. Что решить сейчас -1. Подтверди: **PAT в MVP — да?** (я заложил, это быстро). -2. Device Flow — **проектируем сейчас, код позже** — ок? -3. Коллекции — делать сразу после MVP или откладываем до 500 юзеров? +1. Подтверди: **PAT в MVP - да?** (я заложил, это быстро). +2. Device Flow - **проектируем сейчас, код позже** - ок? +3. Коллекции - делать сразу после MVP или откладываем до 500 юзеров? diff --git a/docs/catalog-philosophy.md b/docs/catalog-philosophy.md index f0ebd35..93f6533 100644 --- a/docs/catalog-philosophy.md +++ b/docs/catalog-philosophy.md @@ -1,6 +1,6 @@ -# Философия каталога Indexium — Open Source Only, Zero Storage +# Философия каталога Indexium - Open Source Only, Zero Storage -> **Тезис:** Indexium — не хостинг файлов. Ты даёшь свой GitHub, мы даём индексацию, поиск и доверие. Все моды в каталоге обязаны быть open source. +> **Тезис:** Indexium - не хостинг файлов. Ты даёшь свой GitHub, мы даём индексацию, поиск и доверие. Все моды в каталоге обязаны быть open source. --- @@ -19,22 +19,22 @@ **Почему это круто:** - Дешёво: VPS $5 + managed Postgres, без S3. -- Честно: автор контролирует файлы, может удалить релиз — он пропадёт и у нас (через webhook `release.deleted`). -- Устойчиво к DMCA: мы — индексатор, а не дистрибьютор (как `crates.io` vs `GitHub`). +- Честно: автор контролирует файлы, может удалить релиз - он пропадёт и у нас (через webhook `release.deleted`). +- Устойчиво к DMCA: мы - индексатор, а не дистрибьютор (как `crates.io` vs `GitHub`). --- -## 2. Open Source Only — честь и правило +## 2. Open Source Only - честь и правило ### Что значит "обязан быть open source" Мод принимается в каталог только если: 1. **Репозиторий публичный** (`private: false` через GitHub API). -2. **Есть файл лицензии** в корне: `LICENSE` / `COPYING` / `LICENSE.md`. Проверяем через `GET /repos/{owner}/{repo}/license` — поле `license.spdx_id != null` и `license.spdx_id != "NOASSERTION"`. +2. **Есть файл лицензии** в корне: `LICENSE` / `COPYING` / `LICENSE.md`. Проверяем через `GET /repos/{owner}/{repo}/license` - поле `license.spdx_id != null` и `license.spdx_id != "NOASSERTION"`. 3. **Лицензия из allow-list OSI:** `MIT`, `Apache-2.0`, `GPL-2.0`, `GPL-3.0`, `LGPL-2.1`, `LGPL-3.0`, `MPL-2.0`, `BSD-2/3-Clause`, `CC0-1.0`, `Unlicense`, `EUPL-1.2`, `AGPL-3.0`. Список расширяется через ADR. -4. **Исходники соответствуют артефакту** (best-effort): проверяем что в репо есть `fabric.mod.json` / `gradle.properties` с тем же `mod_id`/`version` что и в `.jar`. Полная reproducible-build проверка — в backlog. -5. **Нет обфускации/шифрования** в релизе без исходников: если воркер находит `Runtime.exec` без открытого кода — флаг `suspicious`. +4. **Исходники соответствуют артефакту** (best-effort): проверяем что в репо есть `fabric.mod.json` / `gradle.properties` с тем же `mod_id`/`version` что и в `.jar`. Полная reproducible-build проверка - в backlog. +5. **Нет обфускации/шифрования** в релизе без исходников: если воркер находит `Runtime.exec` без открытого кода - флаг `suspicious`. > **На MVP** достаточно п.1 + п.2 (любая распознанная лицензия GitHub). Строгий OSI allow-list включаем после первых 100 модов. @@ -43,23 +43,23 @@ ``` POST /mods/import { repo: "owner/repo" } → GitHub API: GET /repos/{repo} → private? reject 422 - → GET /repos/{repo}/license → null? reject 422 "LICENSE required — open source only" + → GET /repos/{repo}/license → null? reject 422 "LICENSE required - open source only" → GET /repos/{repo}/contents/fabric.mod.json?ref=main → not found? reject → Создаём mods + ставим webhook ``` -При каждом `release.published` повторно проверяем лицензию — если автор сменил на `NOASSERTION`/сделал приватным → мод помечается `deprecated`, скрывается из поиска, но старые версии доступны (кэш). +При каждом `release.published` повторно проверяем лицензию - если автор сменил на `NOASSERTION`/сделал приватным → мод помечается `deprecated`, скрывается из поиска, но старые версии доступны (кэш). ### Что показываем пользователю - Бейдж `OSI: MIT` на карточке мода, ссылка на `LICENSE` на GitHub. - Фильтр `license:MIT` в поиске. -- Страница `/manifesto` — манифест: "Почему только open source" (прозрачность, безопасность, форки, обучение). +- Страница `/manifesto` - манифест: "Почему только open source" (прозрачность, безопасность, форки, обучение). ### Edge cases - **Форки:** разрешены, но `slug` уникален, показываем `fork_of: owner/repo`. Оригинал помечается `upstream`. -- **Мульти-мод репо (монорепо):** на MVP 1 репо = 1 мод. Позже — поддержка `mods.toml` с несколькими `modId`. +- **Мульти-мод репо (монорепо):** на MVP 1 репо = 1 мод. Позже - поддержка `mods.toml` с несколькими `modId`. - **Организация vs личный акк:** оба ок, если репо публичное и лицензия есть. - **Что если автор закрыл репо?** Webhook `repository.privatized` → скрываем мод, чистим кэш, храним метаданные 30 дней для восстановления. @@ -67,7 +67,7 @@ POST /mods/import { repo: "owner/repo" } ## 3. Что это даёт экосистеме -- **Доверие:** любой может `git clone`, проверить код, собрать самому — нет "левый jar с майнером". +- **Доверие:** любой может `git clone`, проверить код, собрать самому - нет "левый jar с майнером". - **Долговечность:** даже если Indexium умрёт, моды живут на GitHub. - **Культура:** стимулируем PR'ы, а не "скачал и забыл". Профили показывают контрибьюторов, а не только owner. @@ -77,6 +77,6 @@ POST /mods/import { repo: "owner/repo" } - Не принимаем бинарники без исходников (даже если автор "обещает" открыть позже). - Не зеркалируем закрытые репозитории, даже с токеном. -- Не храним `.jar` у себя даже кэшем (кроме 64KB хвоста для парсинга — эфемерно). +- Не храним `.jar` у себя даже кэшем (кроме 64KB хвоста для парсинга - эфемерно). -См. также: `docs/auth-profiles.md` — как профили усиливают open source (контрибьюторы, верификация), `docs/adr/004-open-source-only.md`. +См. также: `docs/auth-profiles.md` - как профили усиливают open source (контрибьюторы, верификация), `docs/adr/004-open-source-only.md`. diff --git a/docs/database-schema.md b/docs/database-schema.md index 940a6e6..697080e 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -14,7 +14,7 @@ CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- fuzzy search ## 2. Таблицы -### `authors` — авторы (зеркало GitHub users) +### `authors` - авторы (зеркало GitHub users) ```sql CREATE TABLE authors ( @@ -25,7 +25,7 @@ CREATE TABLE authors ( ); ``` -### `mods` — моды (один репозиторий = один мод на MVP) +### `mods` - моды (один репозиторий = один мод на MVP) ```sql CREATE TABLE mods ( @@ -68,7 +68,7 @@ BEFORE INSERT OR UPDATE OF name, summary, description ON mods FOR EACH ROW EXECUTE FUNCTION mods_search_vector_update(); ``` -### `mod_versions` — версии / релизы +### `mod_versions` - версии / релизы ```sql CREATE TABLE mod_versions ( @@ -91,7 +91,7 @@ CREATE INDEX idx_versions_lookup ON mod_versions USING GIN (game_versions, loade CREATE INDEX idx_versions_sha ON mod_versions (file_sha256); ``` -### `webhook_deliveries` — идемпотентность webhook'ов (<50ms ответ) +### `webhook_deliveries` - идемпотентность webhook'ов (<50ms ответ) ```sql CREATE TABLE webhook_deliveries ( @@ -107,11 +107,11 @@ CREATE TABLE webhook_deliveries ( -- В handler: если affected_rows == 0 → 409 Already Processed, иначе push в Redis Streams. ``` -> **Почему так:** Ingestion API должен ответить `202` за <50ms. Сначала `INSERT ... ON CONFLICT DO NOTHING` в `webhook_deliveries`, только потом `XADD` в Redis. Если `delivery_id` уже есть — сразу `409` без очереди. +> **Почему так:** Ingestion API должен ответить `202` за <50ms. Сначала `INSERT ... ON CONFLICT DO NOTHING` в `webhook_deliveries`, только потом `XADD` в Redis. Если `delivery_id` уже есть - сразу `409` без очереди. -### `mod_authors` — M2M авторы/контрибьюторы +### `mod_authors` - M2M авторы/контрибьюторы -На MVP `mods.author_github_id` достаточно (1 репо = 1 owner). Для организаций и соавторов — нормализуем сразу, чтобы не мигрировать болезненно: +На MVP `mods.author_github_id` достаточно (1 репо = 1 owner). Для организаций и соавторов - нормализуем сразу, чтобы не мигрировать болезненно: ```sql CREATE TABLE mod_authors ( @@ -129,20 +129,20 @@ CREATE INDEX idx_mod_authors_github ON mod_authors (github_id); > **MVP стратегия:** оставляем `mods.author_github_id` (как сейчас в `migrations/20260906000000_init_schema.sql`) для простых запросов, но добавляем `mod_authors` когда появится первый кейс организации. В `GET /mods/:slug` отдаём `authors: [{login, role}]` вместо одиночного `author`. -### `mod_versions.file_size` — откуда берётся +### `mod_versions.file_size` - откуда берётся В `api-spec.md` поле `file_size` возвращается клиентам. Заполняется воркером из HTTP-заголовка: ```sql --- уже в mod_versions: file_size BIGINT — bytes из Content-Length +-- уже в mod_versions: file_size BIGINT - bytes из Content-Length ``` Алгоритм воркера (`jar_parser.rs`): 1. `HEAD download_url` → `Content-Length` + `Accept-Ranges: bytes`. -2. Если `Content-Length` отсутствует — fallback на `GET` с `Range: bytes=0-0` и парсинг `Content-Range`. +2. Если `Content-Length` отсутствует - fallback на `GET` с `Range: bytes=0-0` и парсинг `Content-Range`. 3. Значение пишется в `mod_versions.file_size` при `INSERT`. -> GitHub CDN (`objects.githubusercontent.com`) всегда отдаёт `Content-Length` и поддерживает `Range` для release assets — проверено для `.jar` до 50MB. +> GitHub CDN (`objects.githubusercontent.com`) всегда отдаёт `Content-Length` и поддерживает `Range` для release assets - проверено для `.jar` до 50MB. ### `dependencies` (опционально, нормализованная) @@ -158,7 +158,7 @@ CREATE TABLE mod_dependencies ( ); ``` -## 3. Телеметрия — аналог bStats (см. docs/analytics.md) +## 3. Телеметрия - аналог bStats (см. docs/analytics.md) ```sql -- Полуагрегат: один пинг = одна строка, TTL 30 дней (DELETE via cron) @@ -176,7 +176,7 @@ CREATE TABLE mod_telemetry_pings ( CREATE INDEX idx_telemetry_lookup ON mod_telemetry_pings (mod_id, pinged_at DESC); CREATE INDEX idx_telemetry_hash ON mod_telemetry_pings (server_hash, pinged_at); --- Суточный агрегат — хранится навсегда +-- Суточный агрегат - хранится навсегда CREATE TABLE mod_daily_stats ( mod_id UUID NOT NULL REFERENCES mods(id) ON DELETE CASCADE, date DATE NOT NULL, @@ -191,13 +191,13 @@ CREATE TABLE analytics_salts ( date DATE PRIMARY KEY, salt CHAR(64) NOT NULL ); --- Хеш: server_hash = sha256(server_uuid || salt_for_today) — позволяет считать уникальные за день, но не трекать сквозь дни. +-- Хеш: server_hash = sha256(server_uuid || salt_for_today) - позволяет считать уникальные за день, но не трекать сквозь дни. -- Rate limit: Redis SET server_hash:mod_id NX EX 900 (1 пинг / 15 мин) -- TTL: DELETE FROM mod_telemetry_pings WHERE pinged_at < NOW() - INTERVAL '30 days' (cron hourly) -- Агрегация: кроном раз в час INSERT INTO mod_daily_stats ... ON CONFLICT DO UPDATE COUNT(DISTINCT server_hash) ``` -> Postgres хватает до ~10M пингов/мес. При росте — `SELECT create_hypertable('mod_telemetry_pings','pinged_at')` (TimescaleDB) или ClickHouse без смены схемы. +> Postgres хватает до ~10M пингов/мес. При росте - `SELECT create_hypertable('mod_telemetry_pings','pinged_at')` (TimescaleDB) или ClickHouse без смены схемы. ## 4. Пример запросов @@ -241,7 +241,7 @@ migrations/ ## 6. Сиды -Для дев-окружения: `migrations/seeds/dev.sql` — 5 фейковых модов + версии, чтобы фронт сразу имел данные. +Для дев-окружения: `migrations/seeds/dev.sql` - 5 фейковых модов + версии, чтобы фронт сразу имел данные. ## 7. Будущие расширения diff --git a/docs/git-strategy.md b/docs/git-strategy.md index 1d68130..7c85d9a 100644 --- a/docs/git-strategy.md +++ b/docs/git-strategy.md @@ -1,10 +1,10 @@ -# Git-стратегия — почему монорепо +# Git-стратегия - почему монорепо ## Решение (ADR-001) **Выбрано: монорепо в корне `/` с двумя пакетами `indexium-backend/` и `indexium-frontend/`.** -Альтернатива — полирепо (два отдельных git) — отклонена на старте. +Альтернатива - полирепо (два отдельных git) - отклонена на старте. ## Почему монорепо @@ -16,11 +16,11 @@ | Версионирование контрактов | Фронт всегда соответствует бэку в `main` | Нужен отдельный версионинг | | Стоимость поддержки | Минимальна для 1-3 человек | Оверхед: 2 набора настроек, 2 issue-треккера | -Монорепо оправдан пока команда <10 человек и релизный цикл единый. Если в будущем бэкенд и фронт разойдутся по командам/каденсу — легко разрезать через `git filter-repo` или `git subtree`. +Монорепо оправдан пока команда <10 человек и релизный цикл единый. Если в будущем бэкенд и фронт разойдутся по командам/каденсу - легко разрезать через `git filter-repo` или `git subtree`. ## Что было сделано -1. Удалён пустой `.git` из `indexium-backend/` (коммитов не было — безопасно). +1. Удалён пустой `.git` из `indexium-backend/` (коммитов не было - безопасно). 2. `git init --initial-branch=main` в корне `Indexium/`. 3. Корневой `.gitignore` + локальные. 4. Весь код теперь трекается как: @@ -38,8 +38,8 @@ ## Workflow ### Ветки -- `main` — защищённая, только через PR. -- `feat/-` — фичи, напр. `feat/webhook-hmac`. +- `main` - защищённая, только через PR. +- `feat/-` - фичи, напр. `feat/webhook-hmac`. - `fix/-`. ### Коммиты (Conventional Commits) @@ -52,7 +52,7 @@ chore(frontend): bump svelte 5.56 → 5.57 ### PR - Один PR = одна фича/фикс. -- Если меняется API — в том же PR обновляется `docs/api-spec.md` и фронт-клиент. +- Если меняется API - в том же PR обновляется `docs/api-spec.md` и фронт-клиент. - CI должен пройти: `cargo fmt --check`, `cargo clippy`, `cargo test`, `svelte-check`. ### Локально @@ -70,17 +70,17 @@ git push -u origin feat/my-feature Сигналы что пора: - >10 активных контрибьюторов, частые конфликты в `main`. -- Фронт деплоится 10× в день, бэк — 1× в неделю (разный каденс). +- Фронт деплоится 10× в день, бэк - 1× в неделю (разный каденс). - Нужны разные права доступа (внешние контрибьюторы только к фронту). Как резать: `git subtree split -P indexium-backend -b backend-only` и аналогично для фронта, либо `git filter-repo --path`. ## Альтернативы (для справки) -- **Git submodules** — не рекомендуется: сложны, легко сломать, плохой DX. -- **Polyrepo + shared package** — имеет смысл если выносить `openapi`/`types` в отдельный npm/crate. +- **Git submodules** - не рекомендуется: сложны, легко сломать, плохой DX. +- **Polyrepo + shared package** - имеет смысл если выносить `openapi`/`types` в отдельный npm/crate. ## ADR -- ADR-001: Монорепо vs полирепо — принято монорепо (этот документ). +- ADR-001: Монорепо vs полирепо - принято монорепо (этот документ). - Следующие ADR складывать в `docs/adr/NNN-title.md`. diff --git a/indexium-backend/.env.example b/indexium-backend/.env.example index acc630e..3f10800 100644 --- a/indexium-backend/.env.example +++ b/indexium-backend/.env.example @@ -5,3 +5,6 @@ RUST_LOG=info # GITHUB_APP_ID= # GITHUB_APP_PRIVATE_KEY= # REDIS_URL=redis://localhost:6379 +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret +JWT_SECRET=change_me_jwt_secret_at_least_32_chars diff --git a/indexium-backend/Cargo.toml b/indexium-backend/Cargo.toml index 5aa83ab..980b67a 100644 --- a/indexium-backend/Cargo.toml +++ b/indexium-backend/Cargo.toml @@ -8,7 +8,7 @@ async-trait = "0.1.92" axum = "0.8.9" chrono = { version = "0.4.45", features = ["serde"] } dotenvy = "0.15.7" -reqwest = { version = "0.13.4", features = ["json", "stream"] } +reqwest = { version = "0.13.4", features = ["json", "stream", "form"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "tls-rustls-aws-lc-rs", "uuid", "chrono", "json"] } @@ -24,3 +24,5 @@ hmac = "0.12" sha2 = "0.10" hex = "0.4" subtle = "2.6" +jsonwebtoken = "9" +cookie = "0.18" diff --git a/indexium-backend/migrations/20260909000000_stars.sql b/indexium-backend/migrations/20260909000000_stars.sql new file mode 100644 index 0000000..9ac44a5 --- /dev/null +++ b/indexium-backend/migrations/20260909000000_stars.sql @@ -0,0 +1,6 @@ +CREATE TABLE stars ( + github_id BIGINT, + mod_id UUID REFERENCES mods(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (github_id, mod_id) +); diff --git a/indexium-backend/migrations/20260909000001_authors.sql b/indexium-backend/migrations/20260909000001_authors.sql new file mode 100644 index 0000000..0c1ebee --- /dev/null +++ b/indexium-backend/migrations/20260909000001_authors.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS authors ( + github_id BIGINT PRIMARY KEY, + login VARCHAR(39) NOT NULL UNIQUE, + avatar_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/indexium-backend/src/api/analytics.rs b/indexium-backend/src/api/analytics.rs index a767554..1757250 100644 --- a/indexium-backend/src/api/analytics.rs +++ b/indexium-backend/src/api/analytics.rs @@ -1,13 +1,16 @@ -use axum::{extract::State, Json}; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use chrono::Utc; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::HashMap; use crate::AppState; -// --------------------------------------------------------------------------- -// DTOs -// --------------------------------------------------------------------------- - #[derive(Debug, Deserialize)] pub struct AnalyticsSubmitRequest { pub mod_slug: String, @@ -46,7 +49,7 @@ pub struct DailyPoint { pub active_players: i32, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Default)] pub struct Breakdown { pub mc_versions: HashMap, pub loaders: HashMap, @@ -55,45 +58,167 @@ pub struct Breakdown { pub custom: HashMap>, } -// --------------------------------------------------------------------------- -// Handlers — skeleton (без Redis/bcrypt на MVP, логика в services/analytics.rs) -// --------------------------------------------------------------------------- +#[derive(Debug, Deserialize)] +pub struct AnalyticsQuery { + pub range: Option, +} + +fn daily_salt_today() -> String { + Utc::now().format("%Y-%m-%d").to_string() +} + +fn hash_server_uuid(uuid: &str, salt: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(uuid.as_bytes()); + hasher.update(salt.as_bytes()); + hex::encode(hasher.finalize()) +} /// POST /api/v1/analytics/submit -/// Валидация allow-list, хеш server_uuid + daily_salt, Redis 1/15м, INSERT pings. -/// Сейчас — заглушка, возвращает 200 без БД, чтобы SDK мог теститься. pub async fn submit( - State(_state): State, - Json(_req): Json, -) -> Json { - // TODO: - // 1. lookup mods.id by slug (404 if not found) - // 2. validate mc_version/loader/os/java_version allow-list, custom_charts ≤5 - // 3. fetch daily_salt from analytics_salts (or generate sha256(today)) - // 4. server_hash = sha256(server_uuid + salt) - // 5. Redis SET NX EX 900 server_hash:mod_id → 429 if exists - // 6. INSERT mod_telemetry_pings - Json(AnalyticsSubmitResponse { - status: "ok".into(), - }) + State(state): State, + Json(req): Json, +) -> impl IntoResponse { + // 1. validate custom_charts + if req.metrics.custom_charts.len() > 5 { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error":"too many custom_charts"}))).into_response(); + } + // allow-list (MVP lenient) + if req.metrics.loader.is_empty() || req.metrics.mc_version.is_empty() { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error":"invalid metrics"}))).into_response(); + } + + // 2. lookup mod_id + let mod_id: Option = + sqlx::query_scalar("SELECT id FROM mods WHERE slug = $1") + .bind(&req.mod_slug) + .fetch_optional(&state.db) + .await + .unwrap_or(None); + + let mod_id = match mod_id { + Some(id) => id, + None => return (StatusCode::NOT_FOUND, Json(serde_json::json!({"error":"mod_not_found"}))).into_response(), + }; + + // 3. daily salt (fetch or create) + let today = Utc::now().date_naive(); + let salt: String = match sqlx::query_scalar("SELECT salt FROM analytics_salts WHERE date = $1") + .bind(today) + .fetch_optional(&state.db) + .await + .unwrap_or(None) + { + Some(s) => s, + None => { + let new_salt = hex::encode(Sha256::digest(daily_salt_today().as_bytes())); + let _ = sqlx::query("INSERT INTO analytics_salts (date, salt) VALUES ($1, $2) ON CONFLICT DO NOTHING") + .bind(today) + .bind(&new_salt) + .execute(&state.db) + .await; + new_salt + } + }; + + let server_hash = hash_server_uuid(&req.server_uuid, &salt); + + // 4. rate limit via DB: 1 per 15 min per server_hash+mod + let recent: Option = sqlx::query_scalar( + "SELECT COUNT(*) FROM mod_telemetry_pings WHERE server_hash = $1 AND mod_id = $2 AND pinged_at > NOW() - INTERVAL '15 minutes'", + ) + .bind(&server_hash) + .bind(mod_id) + .fetch_one(&state.db) + .await + .unwrap_or(Some(0)); + + if recent.unwrap_or(0) > 0 { + return ( + StatusCode::TOO_MANY_REQUESTS, + Json(serde_json::json!({"error":"rate_limited"})), + ) + .into_response(); + } + + // 5. insert ping + let _ = sqlx::query( + "INSERT INTO mod_telemetry_pings (mod_id, server_hash, mc_version, loader, os, java_version, player_count) VALUES ($1,$2,$3,$4,$5,$6,$7)", + ) + .bind(mod_id) + .bind(&server_hash) + .bind(&req.metrics.mc_version) + .bind(&req.metrics.loader) + .bind(&req.metrics.os) + .bind(&req.metrics.java_version) + .bind(req.metrics.player_count) + .execute(&state.db) + .await; + + (StatusCode::OK, Json(serde_json::json!({"status":"ok"}))).into_response() } -/// GET /api/v1/mods/:slug/analytics?range=30d +/// GET /api/v1/mods/{slug}/analytics?range=7d|30d|90d pub async fn get_analytics( - State(_state): State, - // TODO: extract slug + query range -) -> Json { - // TODO: SELECT * FROM mod_daily_stats WHERE mod_id = ? AND date >= NOW() - range - Json(AnalyticsGetResponse { - mod_slug: "sodium-extra".into(), - range: "30d".into(), - daily: vec![], - breakdown: Breakdown { - mc_versions: HashMap::new(), - loaders: HashMap::new(), - os: HashMap::new(), - java: HashMap::new(), - custom: HashMap::new(), - }, - }) + State(state): State, + Path(slug): Path, + Query(q): Query, +) -> impl IntoResponse { + let range = q.range.unwrap_or_else(|| "30d".to_string()); + let days: i32 = match range.as_str() { + "7d" => 7, + "90d" => 90, + _ => 30, + }; + + let mod_id: Option = sqlx::query_scalar("SELECT id FROM mods WHERE slug = $1") + .bind(&slug) + .fetch_optional(&state.db) + .await + .unwrap_or(None); + + let mod_id = match mod_id { + Some(id) => id, + None => return (StatusCode::NOT_FOUND, Json(serde_json::json!({"error":"mod_not_found"}))).into_response(), + }; + + let rows = sqlx::query_as::<_, (chrono::NaiveDate, i32, i32, serde_json::Value)>( + "SELECT date, active_servers, active_players, breakdown_json FROM mod_daily_stats WHERE mod_id = $1 AND date >= CURRENT_DATE - $2::int ORDER BY date ASC", + ) + .bind(mod_id) + .bind(days) + .fetch_all(&state.db) + .await + .unwrap_or_default(); + + let mut daily = Vec::new(); + let mut breakdown = Breakdown::default(); + for (date, servers, players, json) in rows { + daily.push(DailyPoint { + date: date.to_string(), + active_servers: servers, + active_players: players, + }); + // merge last breakdown + if let Some(obj) = json.as_object() { + if let Some(v) = obj.get("mc_versions").and_then(|x| x.as_object()) { + for (k, val) in v { + if let Some(n) = val.as_i64() { + *breakdown.mc_versions.entry(k.clone()).or_insert(0) += n as i32; + } + } + } + } + } + + ( + StatusCode::OK, + Json(serde_json::json!({ + "mod_slug": slug, + "range": range, + "daily": daily, + "breakdown": breakdown + })), + ) + .into_response() } diff --git a/indexium-backend/src/api/auth.rs b/indexium-backend/src/api/auth.rs new file mode 100644 index 0000000..7854564 --- /dev/null +++ b/indexium-backend/src/api/auth.rs @@ -0,0 +1,200 @@ +use axum::{ + extract::{Query, State}, + http::{header, HeaderMap, StatusCode}, + response::IntoResponse, + Json, +}; +use cookie::{Cookie, SameSite}; +use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; + +use crate::AppState; + +#[derive(Debug, Deserialize)] +pub struct CallbackQuery { + pub code: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct Claims { + sub: i64, + login: String, + exp: usize, +} + +#[derive(Debug, Deserialize)] +struct TokenResp { + access_token: Option, +} + +#[derive(Debug, Deserialize)] +struct GhUser { + id: i64, + login: String, + avatar_url: Option, +} + +fn jwt_secret() -> String { + std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev_jwt_secret_change_me".to_string()) +} + +fn parse_jwt_from_headers(headers: &HeaderMap) -> Option { + let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?; + for part in cookie_header.split(';') { + let trimmed = part.trim(); + if let Ok(c) = Cookie::parse(trimmed.to_string()) { + if c.name() == "jwt" { + return Some(c.value().to_string()); + } + } + if let Some(v) = trimmed.strip_prefix("jwt=") { + return Some(v.to_string()); + } + } + None +} + +fn build_jwt_cookie(token: &str) -> String { + Cookie::build(("jwt", token.to_string())) + .path("/") + .http_only(true) + .same_site(SameSite::Lax) + .max_age(cookie::time::Duration::days(7)) + .build() + .to_string() +} + +fn clear_jwt_cookie() -> String { + Cookie::build(("jwt", "")) + .path("/") + .http_only(true) + .same_site(SameSite::Lax) + .max_age(cookie::time::Duration::seconds(0)) + .build() + .to_string() +} + +/// GET /api/v1/auth/github -> 302 GitHub authorize +pub async fn github_login() -> impl IntoResponse { + let client_id = std::env::var("GITHUB_CLIENT_ID").unwrap_or_default(); + if client_id.is_empty() { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"oauth_not_configured"}))).into_response(); + } + let url = format!( + "https://github.com/login/oauth/authorize?client_id={}&scope=read:user", + client_id + ); + (StatusCode::FOUND, [(header::LOCATION, url)]).into_response() +} + +/// GET /api/v1/auth/github/callback?code=... +pub async fn github_callback( + State(state): State, + Query(q): Query, +) -> impl IntoResponse { + let code = match q.code { + Some(c) if !c.is_empty() => c, + _ => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error":"missing_code"}))).into_response(), + }; + let client_id = std::env::var("GITHUB_CLIENT_ID").unwrap_or_default(); + let client_secret = std::env::var("GITHUB_CLIENT_SECRET").unwrap_or_default(); + if client_id.is_empty() || client_secret.is_empty() { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"oauth_not_configured"}))).into_response(); + } + + let client = reqwest::Client::new(); + let token_resp = client + .post("https://github.com/login/oauth/access_token") + .header("Accept", "application/json") + .form(&[("client_id", &client_id), ("client_secret", &client_secret), ("code", &code)]) + .send() + .await; + + let token_resp = match token_resp { + Ok(r) => r, + Err(e) => { + tracing::error!(error=%e, "oauth token exchange failed"); + return (StatusCode::BAD_GATEWAY, Json(serde_json::json!({"error":"oauth_exchange_failed"}))).into_response(); + } + }; + let token_data: TokenResp = match token_resp.json().await { + Ok(v) => v, + Err(e) => { + tracing::error!(error=%e, "oauth token parse failed"); + return (StatusCode::BAD_GATEWAY, Json(serde_json::json!({"error":"oauth_parse_failed"}))).into_response(); + } + }; + let access_token = match token_data.access_token { + Some(t) if !t.is_empty() => t, + _ => return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error":"oauth_no_token"}))).into_response(), + }; + + let user_resp = client + .get("https://api.github.com/user") + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "Indexium") + .bearer_auth(&access_token) + .send() + .await; + + let user_resp = match user_resp { + Ok(r) => r, + Err(e) => { + tracing::error!(error=%e, "github user fetch failed"); + return (StatusCode::BAD_GATEWAY, Json(serde_json::json!({"error":"github_fetch_failed"}))).into_response(); + } + }; + let gh_user: GhUser = match user_resp.json().await { + Ok(v) => v, + Err(e) => { + tracing::error!(error=%e, "github user parse failed"); + return (StatusCode::BAD_GATEWAY, Json(serde_json::json!({"error":"github_parse_failed"}))).into_response(); + } + }; + + let _ = sqlx::query( + "INSERT INTO authors (github_id, login, avatar_url) VALUES ($1,$2,$3) \ + ON CONFLICT (github_id) DO UPDATE SET login=EXCLUDED.login, avatar_url=EXCLUDED.avatar_url", + ) + .bind(gh_user.id) + .bind(&gh_user.login) + .bind(&gh_user.avatar_url) + .execute(&state.db) + .await; + + let exp = (chrono::Utc::now().timestamp() as usize) + 7 * 24 * 3600; + let claims = Claims { sub: gh_user.id, login: gh_user.login, exp }; + let token = match encode(&Header::default(), &claims, &EncodingKey::from_secret(jwt_secret().as_bytes())) { + Ok(t) => t, + Err(e) => { + tracing::error!(error=%e, "jwt encode failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"jwt_failed"}))).into_response(); + } + }; + let cookie_str = build_jwt_cookie(&token); + let headers = [(header::LOCATION, "/".to_string()), (header::SET_COOKIE, cookie_str)]; + (StatusCode::FOUND, headers).into_response() +} + +/// GET /api/v1/auth/me +pub async fn me(headers: HeaderMap) -> impl IntoResponse { + let token = match parse_jwt_from_headers(&headers) { + Some(t) => t, + None => return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error":"unauthorized"}))).into_response(), + }; + let mut validation = Validation::new(Algorithm::HS256); + validation.validate_exp = true; + let data = decode::(&token, &DecodingKey::from_secret(jwt_secret().as_bytes()), &validation); + match data { + Ok(d) => (StatusCode::OK, Json(serde_json::json!({"login": d.claims.login, "github_id": d.claims.sub}))).into_response(), + Err(_) => (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error":"unauthorized"}))).into_response(), + } +} + +/// POST /api/v1/auth/logout +pub async fn logout() -> impl IntoResponse { + let cookie_str = clear_jwt_cookie(); + let mut headers = HeaderMap::new(); + headers.insert(header::SET_COOKIE, cookie_str.parse().unwrap()); + (StatusCode::OK, headers, Json(serde_json::json!({"status":"ok"}))).into_response() +} diff --git a/indexium-backend/src/api/badges.rs b/indexium-backend/src/api/badges.rs index 4958b59..d666e09 100644 --- a/indexium-backend/src/api/badges.rs +++ b/indexium-backend/src/api/badges.rs @@ -53,7 +53,7 @@ fn badge_svg(label: &str, value: &str) -> String { fn simple_badge_svg(label: &str, value: &str) -> String { // fallback simple spec: label: value - // we embed both formats — simple text ensures spec match + // we embed both formats - simple text ensures spec match let combined = format!("{}: {}", label, value); // keep width 200 height 20 as required format!( @@ -124,7 +124,7 @@ pub async fn get_downloads_badge( State(state): State, Path(slug): Path, ) -> impl IntoResponse { - // downloads tends to be larger — random 500..50000 if no DB row + // downloads tends to be larger - random 500..50000 if no DB row let raw = resolve_value(&state, &slug, 500, 50000, "downloads").await; // scale small version-count to look like downloads: * 1000 if <1000 let scaled = if raw < 100 { raw * 1200 } else { raw }; diff --git a/indexium-backend/src/api/collections.rs b/indexium-backend/src/api/collections.rs index e9b6b76..5c9b6e3 100644 --- a/indexium-backend/src/api/collections.rs +++ b/indexium-backend/src/api/collections.rs @@ -119,7 +119,7 @@ pub async fn list_collections(State(state): State) -> impl IntoRespons } } -/// POST /api/v1/collections — auth stub: без проверки токена, просто 201 +/// POST /api/v1/collections - auth stub: без проверки токена, просто 201 pub async fn create_collection( State(state): State, Json(req): Json, diff --git a/indexium-backend/src/api/mod.rs b/indexium-backend/src/api/mod.rs index 56e8208..dfeacf5 100644 --- a/indexium-backend/src/api/mod.rs +++ b/indexium-backend/src/api/mod.rs @@ -1,5 +1,7 @@ pub mod analytics; +pub mod auth; pub mod badges; pub mod collections; pub mod mods; +pub mod stars; pub mod webhooks; diff --git a/indexium-backend/src/api/mods.rs b/indexium-backend/src/api/mods.rs index bbfc51f..08d63fc 100644 --- a/indexium-backend/src/api/mods.rs +++ b/indexium-backend/src/api/mods.rs @@ -37,6 +37,7 @@ pub struct ModListItem { pub game_versions: Vec, pub loaders: Vec, pub latest_version: Option, pub download_url: Option, pub updated_at: DateTime, + pub stars_count: i64, } #[derive(Debug, Serialize, Deserialize)] pub struct Pagination { pub page: i64, pub limit: i64, pub total: i64, pub pages: i64 } @@ -57,6 +58,7 @@ pub struct ModDetailResponse { pub description: Option, pub github_repo: String, pub author: AuthorDto, pub icon_url: Option, pub verified: bool, pub versions: Vec, pub updated_at: DateTime, + pub stars_count: i64, } pub async fn list_mods(State(state): State, Query(p): Query) -> impl IntoResponse { @@ -92,6 +94,7 @@ pub async fn list_mods(State(state): State, Query(p): Query>(); let pages = if total == 0 { 0 } else { (total + limit - 1) / limit }; let body = ModListResponse { data, pagination: Pagination { page, limit, total, pages } }; @@ -127,6 +130,7 @@ pub async fn get_mod(State(state): State, Path(slug): Path) -> github_repo: format!("{}/{}", m.owner, m.repo), author: AuthorDto { login: m.owner, avatar_url: None }, icon_url: m.icon_url, verified: false, versions: versions_dto, updated_at: m.updated_at, + stars_count: m.stars_count, }; let mut res = (StatusCode::OK, Json(resp)).into_response(); res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=60")); @@ -145,7 +149,7 @@ mod tests { summary: Some("Extra".into()), author: "flashy".into(), icon_url: None, game_versions: vec!["1.20.1".into()], loaders: vec!["fabric".into()], latest_version: Some("1.2.3".into()), download_url: Some("https://example.com/mod.jar".into()), - updated_at: Utc::now(), + updated_at: Utc::now(), stars_count: 5, }], pagination: Pagination { page: 1, limit: 20, total: 1, pages: 1 }, }; @@ -161,7 +165,7 @@ mod tests { version_number: "1.2.3".into(), game_versions: vec!["1.20.1".into()], loaders: vec!["fabric".into()], download_url: "https://example.com/mod.jar".into(), file_sha256: "abc".into(), file_size: Some(123), published_at: Utc::now(), }], - updated_at: Utc::now(), + updated_at: Utc::now(), stars_count: 5, }; let jd = serde_json::to_value(&detail).unwrap(); assert_eq!(jd["versions"][0]["version_number"], "1.2.3"); diff --git a/indexium-backend/src/api/stars.rs b/indexium-backend/src/api/stars.rs new file mode 100644 index 0000000..4945257 --- /dev/null +++ b/indexium-backend/src/api/stars.rs @@ -0,0 +1,111 @@ +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + Json, +}; +use serde::Serialize; +use serde_json::json; + +use crate::{db, AppState}; + +#[derive(Serialize)] +struct StarsCount { count: i64 } + +fn github_id_from_headers(headers: &HeaderMap) -> i64 { + headers + .get("x-github-id") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(1) +} + +pub async fn get_stars(State(state): State, Path(slug): Path) -> impl IntoResponse { + let m = match db::fetch_mod_by_slug(&state.db, &slug).await { + Ok(v) => v, + Err(e) => { + tracing::error!(error=%e, slug=%slug, "get_stars fetch_mod failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"internal_error"}))).into_response(); + } + }; + let Some(m) = m else { + return (StatusCode::NOT_FOUND, Json(json!({"error":"mod_not_found"}))).into_response(); + }; + let row: Result<(i64,), _> = sqlx::query_as("SELECT COUNT(*) FROM stars WHERE mod_id=$1") + .bind(m.id) + .fetch_one(&state.db) + .await; + match row { + Ok((c,)) => (StatusCode::OK, Json(json!(StarsCount { count: c }))).into_response(), + Err(e) => { + tracing::error!(error=%e, "get_stars count failed"); + (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"internal_error"}))).into_response() + } + } +} + +pub async fn star_mod( + State(state): State, + Path(slug): Path, + headers: HeaderMap, +) -> impl IntoResponse { + let github_id = github_id_from_headers(&headers); + let m = match db::fetch_mod_by_slug(&state.db, &slug).await { + Ok(v) => v, + Err(e) => { + tracing::error!(error=%e, slug=%slug, "star_mod fetch_mod failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"internal_error"}))).into_response(); + } + }; + let Some(m) = m else { + return (StatusCode::NOT_FOUND, Json(json!({"error":"mod_not_found"}))).into_response(); + }; + if let Err(e) = sqlx::query("INSERT INTO stars (github_id, mod_id) VALUES ($1,$2) ON CONFLICT DO NOTHING") + .bind(github_id) + .bind(m.id) + .execute(&state.db) + .await + { + tracing::error!(error=%e, "star_mod insert failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"internal_error"}))).into_response(); + } + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stars WHERE mod_id=$1") + .bind(m.id) + .fetch_one(&state.db) + .await + .unwrap_or(0); + (StatusCode::OK, Json(json!({"count": count}))).into_response() +} + +pub async fn unstar_mod( + State(state): State, + Path(slug): Path, + headers: HeaderMap, +) -> impl IntoResponse { + let github_id = github_id_from_headers(&headers); + let m = match db::fetch_mod_by_slug(&state.db, &slug).await { + Ok(v) => v, + Err(e) => { + tracing::error!(error=%e, slug=%slug, "unstar_mod fetch_mod failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"internal_error"}))).into_response(); + } + }; + let Some(m) = m else { + return (StatusCode::NOT_FOUND, Json(json!({"error":"mod_not_found"}))).into_response(); + }; + if let Err(e) = sqlx::query("DELETE FROM stars WHERE github_id=$1 AND mod_id=$2") + .bind(github_id) + .bind(m.id) + .execute(&state.db) + .await + { + tracing::error!(error=%e, "unstar_mod delete failed"); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"internal_error"}))).into_response(); + } + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stars WHERE mod_id=$1") + .bind(m.id) + .fetch_one(&state.db) + .await + .unwrap_or(0); + (StatusCode::OK, Json(json!({"count": count}))).into_response() +} diff --git a/indexium-backend/src/api/webhooks.rs b/indexium-backend/src/api/webhooks.rs index f712b44..f42ed9c 100644 --- a/indexium-backend/src/api/webhooks.rs +++ b/indexium-backend/src/api/webhooks.rs @@ -151,7 +151,7 @@ pub async fn github_webhook( return (StatusCode::ACCEPTED, body).into_response(); } - // Stub for Redis Streams push — in future: XADD indexium:webhook ... + // Stub for Redis Streams push - in future: XADD indexium:webhook ... tracing::info!(delivery_id = %delivery_id, event = %event, "webhook accepted, push to redis (stub)"); let body = Json(json!({ "status": "accepted", "delivery_id": delivery_id })); diff --git a/indexium-backend/src/db/mod.rs b/indexium-backend/src/db/mod.rs index a0f0290..efcea2b 100644 --- a/indexium-backend/src/db/mod.rs +++ b/indexium-backend/src/db/mod.rs @@ -19,6 +19,7 @@ pub struct ModListRow { pub game_versions: Option>, pub loaders: Option>, pub download_url: Option, + pub stars_count: i64, } #[derive(Debug, sqlx::FromRow)] @@ -32,6 +33,7 @@ pub struct ModRow { pub icon_url: Option, pub updated_at: DateTime, pub created_at: DateTime, + pub stars_count: i64, } #[derive(Debug, sqlx::FromRow)] @@ -100,7 +102,8 @@ pub async fn fetch_mods_page( lv.version_number AS latest_version, lv.game_versions, lv.loaders, - lv.download_url + lv.download_url, + (SELECT COUNT(*) FROM stars s WHERE s.mod_id = mods.id) AS stars_count FROM mods LEFT JOIN LATERAL ( SELECT version_number, game_versions, loaders, download_url @@ -136,7 +139,8 @@ pub async fn fetch_mods_page( pub async fn fetch_mod_by_slug(pool: &PgPool, slug: &str) -> Result, sqlx::Error> { let row = sqlx::query_as::<_, ModRow>( - r#"SELECT id, slug, name, summary, owner, repo, icon_url, updated_at, created_at + r#"SELECT id, slug, name, summary, owner, repo, icon_url, updated_at, created_at, + (SELECT COUNT(*) FROM stars s WHERE s.mod_id = mods.id) AS stars_count FROM mods WHERE slug = $1"#, ) .bind(slug) diff --git a/indexium-backend/src/main.rs b/indexium-backend/src/main.rs index 660e40e..04f976e 100644 --- a/indexium-backend/src/main.rs +++ b/indexium-backend/src/main.rs @@ -65,30 +65,44 @@ async fn main() -> Result<(), Box> { let cors = CorsLayer::new() .allow_origin("http://localhost:5173".parse::()?) - .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) - .allow_headers([axum::http::header::CONTENT_TYPE]); + .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) + .allow_headers([axum::http::header::CONTENT_TYPE, axum::http::header::HeaderName::from_static("x-github-id")]); let state = AppState { db: pool }; let app = Router::new() .route("/health", get(health_check)) .route("/api/v1/mods", get(api::mods::list_mods)) - .route("/api/v1/mods/:slug", get(api::mods::get_mod)) + .route("/api/v1/mods/{slug}", get(api::mods::get_mod)) + .route("/api/v1/mods/{slug}/stars", get(api::stars::get_stars)) + .route("/api/v1/mods/{slug}/star", post(api::stars::star_mod).delete(api::stars::unstar_mod)) .route("/api/v1/collections", get(api::collections::list_collections).post(api::collections::create_collection)) - .route("/api/v1/collections/:slug", get(api::collections::get_collection)) - .route("/api/v1/collections/:slug/export", get(api::collections::export_collection)) + .route("/api/v1/collections/{slug}", get(api::collections::get_collection)) + .route("/api/v1/collections/{slug}/export", get(api::collections::export_collection)) .route( - "/api/v1/badges/:slug/downloads.svg", + "/api/v1/badges/{slug}/downloads.svg", get(api::badges::get_downloads_badge), ) .route( - "/api/v1/badges/:slug/servers.svg", + "/api/v1/badges/{slug}/servers.svg", get(api::badges::get_servers_badge), ) .route( "/api/v1/webhooks/github", post(api::webhooks::github_webhook), ) + .route("/api/v1/auth/github", get(api::auth::github_login)) + .route("/api/v1/auth/github/callback", get(api::auth::github_callback)) + .route("/api/v1/auth/me", get(api::auth::me)) + .route("/api/v1/auth/logout", post(api::auth::logout)) + .route( + "/api/v1/analytics/submit", + post(api::analytics::submit), + ) + .route( + "/api/v1/mods/{slug}/analytics", + get(api::analytics::get_analytics), + ) .layer(TraceLayer::new_for_http()) .layer(cors) .with_state(state); diff --git a/indexium-backend/src/worker/jar_parser.rs b/indexium-backend/src/worker/jar_parser.rs index 1ac271b..8ca91ab 100644 --- a/indexium-backend/src/worker/jar_parser.rs +++ b/indexium-backend/src/worker/jar_parser.rs @@ -34,7 +34,7 @@ pub struct FabricModJson { } // --------------------------------------------------------------------------- -// Async I/O — HTTP Range Requests +// Async I/O - HTTP Range Requests // --------------------------------------------------------------------------- /// HEAD → (content_length, supports_range) diff --git a/indexium-frontend/src/app.css b/indexium-frontend/src/app.css new file mode 100644 index 0000000..86a3ea9 --- /dev/null +++ b/indexium-frontend/src/app.css @@ -0,0 +1,123 @@ +/* Indexium Moss Stone - fork of Nocturne. Accent moss #4ADE80 on deep forest #0D1410 */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap'); + +:root { + --color-bg: #0D1410; + --color-surface: #1A2420; + --color-text: #E8E4D9; + --color-accent: #4ADE80; + --color-accent-2: #86EFAC; + --color-divider: color-mix(in srgb, #E8E4D9 14%, transparent); + + --color-neutral-100: #F2F0E8; + --color-neutral-200: #E8E4D9; + --color-neutral-300: #D5D0C2; + --color-neutral-400: #B8B3A3; + --color-neutral-500: #9A9590; + --color-neutral-600: #7A7572; + --color-neutral-700: #5C5A58; + --color-neutral-800: #2A2E2B; + --color-neutral-900: #1A2420; + + --color-accent-100: #F0FDF4; + --color-accent-200: #DCFCE7; + --color-accent-300: #BBF7D0; + --color-accent-400: #86EFAC; + --color-accent-500: #4ADE80; + --color-accent-600: #22C55E; + --color-accent-700: #15803D; + --color-accent-800: #14532D; + --color-accent-900: #0D1F15; + + --color-accent-2-100: #FEF3C7; + --color-accent-2-200: #FDE68A; + --color-accent-2-300: #FCD34D; + --color-accent-2-400: #F59E0B; + --color-accent-2-500: #D97706; + --color-accent-2-600: #92400E; + --color-accent-2-700: #78350F; + --color-accent-2-800: #451A03; + --color-accent-2-900: #1C0A00; + + --color-section: #14261C; + --color-section-glow: #1B3A28; + --color-section-ghost: #2A4A36; + + --font-heading: "Inter", system-ui, sans-serif; + --font-heading-weight: 500; + --font-body: "Inter", system-ui, sans-serif; + + --space-1: 2.8px; + --space-2: 5.6px; + --space-3: 8.4px; + --space-4: 11.2px; + --space-6: 16.8px; + --space-8: 22.4px; + + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 14px; + + --shadow-sm: 0 0 0 1px #2A2E2B; + --shadow-md: 0 0 0 1px #3A3F3B, 0 6px 18px rgba(0,0,0,0.6); + --shadow-lg: 0 0 0 1px #5C6140, 0 16px 40px rgba(0,0,0,0.7); +} + +body { background: var(--color-bg); color: var(--color-text); font-family: var(--font-body); } +h1,h2,h3,h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); } +.lighten{mix-blend-mode:lighten;background-color:transparent} +*, *::before, *::after { box-sizing: border-box; } +body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; } +h1 { font-size: 42px; } h2 { font-size: 32px; } h3 { font-size: 25px; } +p { margin: 0 0 var(--space-3); } +a { color: var(--color-accent); text-underline-offset: 3px; } +.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); } +:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } +::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); } + +.hr { height: 1px; border: 0; margin: var(--space-4) 0; background: linear-gradient(to right, transparent, var(--color-divider) 48px, var(--color-divider) calc(100% - 48px), transparent); } + +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; cursor: pointer; text-decoration: none; font-family: var(--font-heading); font-weight: 500; font-size: 14px; line-height: 1.2; color: var(--color-text); background: transparent; border: 1px solid transparent; padding: var(--space-2) calc(var(--space-3) * 1.2); border-radius: var(--radius-md); } +.btn-primary { color: var(--color-bg); background: var(--color-accent); border-color: var(--color-accent); font-weight: 600; } +.btn-primary:hover { background: color-mix(in srgb, var(--color-accent) 88%, white); } +.btn-secondary { border-color: var(--color-divider); } +.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); } +.btn-ghost { color: var(--color-accent); } +.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); } + +.input { width: 100%; min-height: 36px; padding: 6px 10px; font: inherit; font-size: 14px; color: var(--color-text); caret-color: var(--color-accent); background: var(--color-surface); border: 1px solid var(--color-divider); border-radius: var(--radius-md); } +.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); } +.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; } +.seg { display: inline-flex; overflow: hidden; border: 1px solid var(--color-divider); border-radius: var(--radius-md); } +.seg-opt { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; font-size: 13px; cursor: pointer; } +.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); } +.seg-opt:has(input:checked) { color: var(--color-accent); box-shadow: inset 0 0 0 1px var(--color-accent); } +.seg-opt input { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; } +.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; } +.radio input { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; } +.radio .dot { width: 16px; height: 16px; flex: none; border-radius: 50%; border: 1.5px solid color-mix(in srgb, var(--color-accent) 30%, var(--color-divider)); background: var(--color-surface); transition: border-color .15s, background .15s; } +.radio:hover .dot { border-color: var(--color-accent); } +.radio input:checked + .dot { border-color: var(--color-accent); background: var(--color-accent); box-shadow: inset 0 0 0 4px var(--color-bg); } +.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; } + +.card { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface); } +.card-title { font-family: var(--font-heading); font-weight: 500; font-size: 17px; line-height: 1.2; } +.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; } +.card-meta { display: flex; align-items: center; gap: 6px; font-size: 11px; color: color-mix(in srgb, var(--color-text) 50%, transparent); } +.elev-sm { box-shadow: var(--shadow-sm); } .elev-md { box-shadow: var(--shadow-md); } .elev-lg { box-shadow: var(--shadow-lg); } + +.tag { display: inline-flex; align-items: center; font-size: 11px; letter-spacing: 0.02em; padding: 3px 10px; border-radius: calc(var(--radius-md) * 0.75); } +.tag-accent { background: var(--color-accent-800); color: var(--color-accent-100); } +.tag-neutral { background: var(--color-neutral-800); color: var(--color-neutral-100); } +.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); } + +.nav { display: flex; align-items: center; gap: var(--space-4); padding: var(--space-3) var(--space-4); } +.nav-brand { font-family: var(--font-heading); font-weight: 600; font-size: 18px; margin-right: auto; letter-spacing: -0.02em; } +.nav a { color: inherit; text-decoration: none; font-size: 14px; opacity: 0.85; } +.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); opacity: 1; } + +.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; } +@keyframes shimmer{0%{background-position:-300px 0;}100%{background-position:300px 0;}} +@keyframes blink{0%,50%{opacity:1;}50.01%,100%{opacity:0;}} +@keyframes pingDot{75%,100%{transform:scale(2.2);opacity:0;}} diff --git a/indexium-frontend/src/lib/api.ts b/indexium-frontend/src/lib/api.ts index 2a569b1..af8e4e4 100644 --- a/indexium-frontend/src/lib/api.ts +++ b/indexium-frontend/src/lib/api.ts @@ -97,6 +97,33 @@ export interface AnalyticsResponse { breakdown: Breakdown; } +// --------------------------------------------------------------------------- +// Collections +// --------------------------------------------------------------------------- + +export interface CollectionMod { + slug: string; + version?: string | null; +} + +export interface Collection { + id: string; + slug: string; + title: string; + description: string | null; + mods: CollectionMod[]; + author_id: number | null; + created_at: string; +} + +export interface CreateCollectionPayload { + slug: string; + title: string; + description?: string | null; + mods?: CollectionMod[]; + author_id?: number | null; +} + // --------------------------------------------------------------------------- // Params // --------------------------------------------------------------------------- @@ -174,6 +201,33 @@ export async function fetchAnalytics( return request(`/api/v1/mods/${encodeURIComponent(slug)}/analytics${qs}`); } +export async function fetchCollections(): Promise { + return request('/api/v1/collections'); +} + +export async function fetchCollection(slug: string): Promise { + return request(`/api/v1/collections/${encodeURIComponent(slug)}`); +} + +export async function createCollection(payload: CreateCollectionPayload): Promise { + return request('/api/v1/collections', { + method: 'POST', + body: JSON.stringify(payload) + }); +} + +export async function fetchCollectionExport( + slug: string, + format: 'prism' | string = 'prism' +): Promise { + const qs = buildQuery({ format }); + return request(`/api/v1/collections/${encodeURIComponent(slug)}/export${qs}`); +} + +export function collectionExportUrl(slug: string, format: 'prism' | string = 'prism'): string { + return `${BASE}/api/v1/collections/${encodeURIComponent(slug)}/export?format=${encodeURIComponent(format)}`; +} + /** Stub for analytics ingestion (mod SDK → POST /analytics/submit). */ export async function submitAnalytics(payload: { mod_slug: string; diff --git a/indexium-frontend/src/routes/+layout.svelte b/indexium-frontend/src/routes/+layout.svelte index 9cebde5..96f5db4 100644 --- a/indexium-frontend/src/routes/+layout.svelte +++ b/indexium-frontend/src/routes/+layout.svelte @@ -1,5 +1,6 @@ diff --git a/indexium-frontend/src/routes/+page.svelte b/indexium-frontend/src/routes/+page.svelte index dfe95e5..e2dfe1f 100644 --- a/indexium-frontend/src/routes/+page.svelte +++ b/indexium-frontend/src/routes/+page.svelte @@ -1,122 +1,206 @@ - - Indexium — Mods - +Indexium - {t.title} -
-

Indexium — Minecraft Mods

-

Лёгкий индекс модов поверх GitHub Releases CDN

+
+ -
- {#each loaders as l} - - {/each} +
+
+
+ {t.badge} +

{t.title}

+

{t.heroDesc}

+ +

GitHub Releases - SHA-256 - loader-aware

+
+ +
+
+ {#each [['curl','cURL'],['rust','Rust'],['gradle','Gradle'],['cli','CLI']] as [k,n]} + + {/each} +
+
+ {#if heroTab === 'curl'} +
curl https://api.indexium.dev/v1/mods/sodium \
+  -H "Authorization: Bearer $TOKEN"
+ {:else if heroTab === 'rust'} +
let m = indexium::Client::new().mods().get("sodium").await?;
+ {:else if heroTab === 'gradle'} +
// build.gradle.kts
+implementation("dev.indexium:client:1.4.0")
+ {:else} +
$ indexium query sodium --loader fabric
+→ sodium@0.6.2  sha256:9f2a...c31  fabric
+ {/if} +
+
+ response +
{'{'}"mod":"sodium","sha256":"9f2a...c31"{'}'}
+
+
+
+ +
+
+

{t.trending}

+ {t.viewAll} → +
+ {#if loading} +
{#each Array(6) as _}
{/each}
+ {:else} + + {/if} +
+ +
+
+ {t.forDev} +

{t.queryTitle}

+

{t.queryDesc}

+
+ {#each [['curl','cURL'],['node','Node.js'],['cli','CLI']] as [k,n]}{/each} +
+
+ {#if activeTab === 'curl'} +
curl https://api.indexium.dev/v1/mods/sodium \
+  -H "Authorization: Bearer $TOKEN"
+ {:else if activeTab === 'node'} +
const res = await indexium.mods.get('sodium');
+console.log(res.sha256);
+ {:else} +
$ indexium query sodium --loader fabric
+ {/if} +
+
+
+
+ + + + indexium --async +
+
$ indexium index ./mods --async
→ discovered 214 jar files
→ hashing sha-256... done
→ indexed 214/214 mods in 1.8s
✓ job complete #a13f9c
+
+
+ +
+

{t.builtTitle}

+
+ {#each ['Paper','Purpur','Folia','Spigot','Bukkit','Velocity','Waterfall','Fabric','Forge','NeoForge','Quilt','Leaves','Pufferfish'] as core} + {core} + {/each} + Pumpkin Rust - NEW +
+

Fabric / Forge / NeoForge / Quilt - автоопределение по jar. Плагины - по ядру сервера. Pumpkin - Rust, 5ms старт, 100MB RAM.

+
+ +
+

{t.ctaTitle}

+

{t.ctaDesc}

+ {t.ctaBtn} — GitHub +
- - {#if loading} -

Loading mods…

- {:else if error} -
- Ошибка: - {error} - -
- {:else if mods.length === 0} -

Модов не найдено.

- {:else} - {#if pagination} -

- Найдено {pagination.total} · страница {pagination.page} из {pagination.pages} -

- {/if} -
- {#each mods as mod (mod.slug)} -
-

{mod.name}

-

- {mod.summary ?? 'Без описания'} -

-
- {#each mod.loaders as loader} - {loader} - {/each} - {#each mod.game_versions as gv} - {gv} - {/each} -
-
- {mod.slug} - Подробнее → -
-
- {/each} -
- {/if} -
+
+
© 2026 Indexium - Open-source mods only.GPL-3.0 · GitHub
+
+ diff --git a/indexium-frontend/src/routes/collections/+page.svelte b/indexium-frontend/src/routes/collections/+page.svelte new file mode 100644 index 0000000..2db0348 --- /dev/null +++ b/indexium-frontend/src/routes/collections/+page.svelte @@ -0,0 +1,294 @@ + + +Collections - Indexium + +
+ +
+ Mods + Plugins + Collections + Moss Stone +
+ +
+

Collections

+ {collections.length} всего +
+ + +
+ +
+ + {#if showCreate} +
+
+

Новая коллекция

+ +
+
+ + +
+ + + {#if createError}
{createError}
{/if} + +
+ {/if} + + {#if loading} +
+ {#each Array(6) as _}
{/each} +
+ {:else if error} +
+ Ошибка загрузки: + {error} + +
+ {:else if filtered.length === 0} +
+ {#if collections.length === 0} + Коллекций пока нет — {#if isAuthed}{:else}войди и создай{/if} + {:else} + Ничего не найдено для "{query}" — попробуй другой запрос. + {/if} +
+ {:else} + + {/if} + +

+ API: GET {BASE}/api/v1/collections · экспорт: ?format=prism — Prism Launcher JSON +

+
diff --git a/indexium-frontend/src/routes/collections/[slug]/+page.svelte b/indexium-frontend/src/routes/collections/[slug]/+page.svelte new file mode 100644 index 0000000..3d2183d --- /dev/null +++ b/indexium-frontend/src/routes/collections/[slug]/+page.svelte @@ -0,0 +1,156 @@ + + +{collection?.title ?? slug} - Collections - Indexium + +
+ + + {#if loading} +

Загрузка…

+ {:else if error} +
+ {error} + {#if error.toLowerCase().includes('not found') || error.includes('404')} + — коллекция {slug} не найдена. + {/if} +
+ {:else if collection} +
+
+
+
+ {collection.title[0]?.toUpperCase() ?? '?'} +
+
+

{collection.title}

+

/{collection.slug} · {new Date(collection.created_at).toLocaleString()}

+

+ {collection.description ?? 'Без описания'} +

+
+
+ +
+
+ Mods · {collection.mods.length} + {collection.slug} +
+ {#if collection.mods.length === 0} +

В коллекции пока нет модов.

+ {:else} + {#each collection.mods as m} +
+ {m.slug} + {#if m.version}{m.version}{/if} + Открыть +
+ {/each} + {/if} +
+ +
+

Raw JSON

+
{JSON.stringify(
+							collection,
+							null,
+							2
+						)}
+
+
+ + +
+ {/if} +
diff --git a/indexium-frontend/src/routes/mod/[slug]/+page.svelte b/indexium-frontend/src/routes/mod/[slug]/+page.svelte new file mode 100644 index 0000000..083b23c --- /dev/null +++ b/indexium-frontend/src/routes/mod/[slug]/+page.svelte @@ -0,0 +1,78 @@ + + +{mod?.name ?? slug} - Indexium + +
+ {#if loading}

Loading...

+ {:else if error}
{error}
+ {:else if mod} +
+
+
+
{mod.name[0]}
+
+

{mod.name}

+

by {mod.author.login} · {mod.github_repo} {#if mod.verified}verified{/if}

+

{mod.summary ?? ''}

+

{mod.description ?? ''}

+
+
+
+ + +
+ {#if tab==='versions'} +
+
Versions · {mod.versions.length}
+ {#each mod.versions as v} +
+ {v.version_number} + {#each v.game_versions as gv}{gv}{/each} + {#each v.loaders as l}{l}{/each} + {new Date(v.published_at).toLocaleDateString()} + Download +
+ {:else}

No versions yet.

{/each} +
+ {:else} +
+

Active servers (bStats)

+ {#if analytics?.daily?.length}
{#each analytics.daily.slice(-14) as d}
{/each}
{:else}

No analytics yet - add Indexium SDK to your mod.

{/if} +
{JSON.stringify(analytics?.breakdown ?? {}, null, 2)}
+
+ {/if} +
+ +
+ {/if} +
diff --git a/indexium-frontend/src/routes/mods/+page.svelte b/indexium-frontend/src/routes/mods/+page.svelte new file mode 100644 index 0000000..8489835 --- /dev/null +++ b/indexium-frontend/src/routes/mods/+page.svelte @@ -0,0 +1,115 @@ + + +Discover mods - Indexium + +
+ + +
+
+ + +
+ + {total} results +
+ +
+ + + + +
+ {#if loading} + {#each Array(6) as _}
{/each} + {:else if mods.length===0} +
No mods found - try different filters or publish one.
+ {:else} + {#each mods as m} + +
{m.name[0]}
+
+
+ {m.name} + by {m.author} + {new Date(m.updated_at).toLocaleDateString()} +
+

{m.summary ?? 'No description'}

+
+ {#each m.loaders as l}{l}{/each} + {#each m.game_versions.slice(0,3) as gv}{gv}{/each} + {m.latest_version ?? ''} +
+
+
+ {/each} +
+ + {page} / {pages} + +
+ {/if} +
+
+
diff --git a/indexium-frontend/src/routes/plugins/+page.svelte b/indexium-frontend/src/routes/plugins/+page.svelte new file mode 100644 index 0000000..7ef80e4 --- /dev/null +++ b/indexium-frontend/src/routes/plugins/+page.svelte @@ -0,0 +1,114 @@ + + +Discover plugins - Indexium + +
+ + +
+
+ + +
+ + {total} results +
+ +
+ + + +
+ {#if loading} + {#each Array(6) as _}
{/each} + {:else if mods.length===0} +
No plugins found - try different filters. Plugins are separate from mods (Paper/Purpur etc). Publish one.
+ {:else} + {#each mods as m} + +
{m.name[0]}
+
+
+ {m.name} + by {m.author} + {new Date(m.updated_at).toLocaleDateString()} +
+

{m.summary ?? 'No description'}

+
+ {#each m.loaders as l}{l}{/each} + {#each m.game_versions.slice(0,3) as gv}{gv}{/each} + {m.latest_version ?? ''} +
+
+
+ {/each} +
+ + {page} / {pages} + +
+ {/if} +
+
+
diff --git a/indexium-frontend/src/routes/publish/+page.svelte b/indexium-frontend/src/routes/publish/+page.svelte new file mode 100644 index 0000000..2fff925 --- /dev/null +++ b/indexium-frontend/src/routes/publish/+page.svelte @@ -0,0 +1,65 @@ + + +Publish mod - Indexium + +
+

Publish your mod

+

Open-source only. We index your GitHub Releases - no file upload.

+ + {#if !isAuthed} +
+

Требуется вход через GitHub

+

Публикация доступна только авторам. Мы проверим что репо твое, публичное и с LICENSE.

+ Войти через GitHub - Publish +
+ {:else} +
+ + + {#if msg}
{msg}
{/if} +
+ {/if} + +
+

How it works

+
    +
  1. Login with GitHub (read:user)
  2. +
  3. Paste owner/repo → we verify LICENSE + manifest
  4. +
  5. Install webhook → every release is indexed via Range-Request (no full download)
  6. +
+

See docs and GitHub.

+
+
diff --git a/indexium-frontend/src/routes/u/[login]/+page.svelte b/indexium-frontend/src/routes/u/[login]/+page.svelte new file mode 100644 index 0000000..58e4e6f --- /dev/null +++ b/indexium-frontend/src/routes/u/[login]/+page.svelte @@ -0,0 +1,28 @@ + + +{login} - Indexium + +
+
+
{login[0]?.toUpperCase()}
+
+

{login}

+

github.com/{login} · Indexium author

+
verified{mods.length} mods
+
+
+

Mods by {login}

+ {#if loading}

Loading…

+ {:else if mods.length===0}
No mods yet. Publish one.
+ {:else}
{#each mods as m}{m.name}{m.summary ?? ''}{/each}
{/if} +
diff --git a/scripts/check-no-emdash.sh b/scripts/check-no-emdash.sh new file mode 100755 index 0000000..fa0e7be --- /dev/null +++ b/scripts/check-no-emdash.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +# check for em dash - use hyphen instead +if grep -r $'\xe2\x80\x94' --exclude-dir=.git --exclude-dir=target --exclude-dir=node_modules --exclude-dir=.svelte-kit -n . 2>&1 | grep -q $'\xe2\x80\x94'; then + echo "ERROR: Found em dash - use hyphen instead" + grep -r $'\xe2\x80\x94' --exclude-dir=.git --exclude-dir=target --exclude-dir=node_modules --exclude-dir=.svelte-kit -n . 2>&1 + exit 1 +else + echo "OK: no em dash found" +fi diff --git a/todo.md b/todo.md index 0c23ba6..363d546 100644 --- a/todo.md +++ b/todo.md @@ -1,4 +1,4 @@ -# Indexium — TODO / Roadmap +# Indexium - TODO / Roadmap > Сервис: асинхронный событийный индексатор модов Minecraft поверх GitHub Releases CDN. > Бэкенд не хранит тяжёлые артефакты, только метаданные + индексация + быстрый JSON API. @@ -7,49 +7,50 @@ ## Правила проекта (обязательно к соблюдению) -> Эти правила — не чекбоксы, а инварианты. Любой PR, нарушающий их, не принимается. +> Эти правила - не чекбоксы, а инварианты. Любой PR, нарушающий их, не принимается. -### 1. KISS — Keep It Simple, Stupid +### 1. KISS - Keep It Simple, Stupid - Выбирай самое простое решение, которое закрывает задачу. Никаких абстракций «на будущее» (YAGNI). -- Один модуль — одна ответственность. Если не можешь объяснить функцию в одном предложении — дроби. +- Один модуль - одна ответственность. Если не можешь объяснить функцию в одном предложении - дроби. - Предпочитай явный код неявной магии (никаких макросов ради макросов). -### 2. DRY — Don't Repeat Yourself +### 2. DRY - Don't Repeat Yourself - Повтор >2 раз → выноси в функцию/модуль. Но не DRY ради DRY: дублирование лучше неправильной абстракции. -- Общие типы/утилиты — в `common`/`shared`, доменная логика — в своём модуле. +- Общие типы/утилиты - в `common`/`shared`, доменная логика - в своём модуле. ### 3. SOLID (применительно к Rust) -- **S** — один файл/модуль = одна причина для изменений (см. лимиты ниже). -- **O** — открыт для расширения через трейты, закрыт для модификации (feature-flag, а не `if` на типы). -- **L** — любой `impl Trait` должен заменять другой без поломки контракта. -- **I** — узкие трейты лучше жирных (`Readable`, `Validatable` вместо `GodService`). -- **D** — зависимость от абстракций (`PgPool` через `AppState`, а не глобаль). +- **S** - один файл/модуль = одна причина для изменений (см. лимиты ниже). +- **O** - открыт для расширения через трейты, закрыт для модификации (feature-flag, а не `if` на типы). +- **L** - любой `impl Trait` должен заменять другой без поломки контракта. +- **I** - узкие трейты лучше жирных (`Readable`, `Validatable` вместо `GodService`). +- **D** - зависимость от абстракций (`PgPool` через `AppState`, а не глобаль). ### 4. Лимиты структуры (жёстко) -- **Макс 250 строк на файл** — если больше, дроби файл на подмодули. -- **Макс 4 файла на папку** — если больше, вводи подпапки по домену (`api/mods/`, `worker/parsers/`). +- **Макс 250 строк на файл** - если больше, дроби файл на подмодули. +- **Макс 4 файла на папку** - если больше, вводи подпапки по домену (`api/mods/`, `worker/parsers/`). - Исключение: `mod.rs`/`lib.rs` не считаются, но должны быть тонкими реэкспортами. - CI будет ругаться (`cargo clippy` + кастомный скрипт `scripts/check-limits.sh`). ### 5. Дополнительные инварианты - **Чистота ошибок**: никаких `unwrap()`/`expect()` вне `main.rs` и тестов. Везде `Result` + `thiserror`/`anyhow`. -- **Типы вместо строк**: `Slug`, `GameVersion`, `Loader` — newtype, а не `String`. -- **Миграции только вперёд**: никаких `DROP` без ADR и бэкапа. Каждая миграция — идемпотентна (`IF NOT EXISTS`). -- **Логика без сайд-эффектов**: парсеры/валидаторы — чистые функции, I/O только на границах (handler/worker). -- **Документация рядом с кодом**: публичная функция без `///` — не готова к мерджу. +- **Типы вместо строк**: `Slug`, `GameVersion`, `Loader` - newtype, а не `String`. +- **Миграции только вперёд**: никаких `DROP` без ADR и бэкапа. Каждая миграция - идемпотентна (`IF NOT EXISTS`). +- **Логика без сайд-эффектов**: парсеры/валидаторы - чистые функции, I/O только на границах (handler/worker). +- **Документация рядом с кодом**: публичная функция без `///` - не готова к мерджу. - **Тест на каждый баг**: регрессия покрывается тестом до фикса. +- **Типографика**: запрещено использование em dash (U+2014). Везде используй `-` (дефис). Проверка: `scripts/check-no-emdash.sh` должен быть OK. --- ## Легенда статусов -- `[ ]` — не начато -- `[~]` — в процессе -- `[x]` — готово -- `[!]` — заблокировано / требует решения +- `[ ]` - не начато +- `[~]` - в процессе +- `[x]` - готово +- `[!]` - заблокировано / требует решения --- -## Phase 0 — Фундамент монорепо (Текущий приоритет) +## Phase 0 - Фундамент монорепо (Текущий приоритет) - [x] Объединить `indexium-backend` + `indexium-frontend` в один git-монорепо (корень `/`) - [x] Настроить корневой `.gitignore` + локальные `.gitignore` @@ -61,31 +62,31 @@ - [ ] Добавить `docker-compose.yml` (Postgres + Redis/Valkey) для локальной разработки - [ ] Добавить `Makefile` / `justfile` с командами `dev`, `migrate`, `lint`, `test` - [ ] Настроить CI (GitHub Actions): `cargo clippy + test`, `svelte-check`, `sqlx migrate check` -- [ ] Скрипт `scripts/check-limits.sh` — проверка 250 строк / 4 файла на папку +- [ ] Скрипт `scripts/check-limits.sh` - проверка 250 строк / 4 файла на папку -## Phase 1 — Backend Core (Rust / Axum) +## Phase 1 - Backend Core (Rust / Axum) ### 1.1 Инфраструктура -- [x] `config` — загрузка `.env` (DATABASE_URL, REDIS_URL, GITHUB_APP_ID, WEBHOOK_SECRET) — база в `main.rs` через `dotenvy` -- [x] `db` — пул `sqlx::PgPool`, миграции (`sqlx::migrate!`), health-check `/health` -- [x] `tracing` — структурированные логи (EnvFilter + fmt layer) +- [x] `config` - загрузка `.env` (DATABASE_URL, REDIS_URL, GITHUB_APP_ID, WEBHOOK_SECRET) - база в `main.rs` через `dotenvy` +- [x] `db` - пул `sqlx::PgPool`, миграции (`sqlx::migrate!`), health-check `/health` +- [x] `tracing` - структурированные логи (EnvFilter + fmt layer) - [x] Axum роутер: `GET /health`, CORS (5173), TraceLayer ### 1.2 Схема БД (PostgreSQL + FTS + pg_trgm) -- [x] Миграция `20260906000000_init_schema.sql` — таблицы `mods`, `mod_versions` + `GIN (game_versions, loaders)` -- [ ] Миграция `002_fts` — `search_vector`, `pg_trgm`, триггер (см. `docs/database-schema.md`) +- [x] Миграция `20260906000000_init_schema.sql` - таблицы `mods`, `mod_versions` + `GIN (game_versions, loaders)` +- [ ] Миграция `002_fts` - `search_vector`, `pg_trgm`, триггер (см. `docs/database-schema.md`) - [ ] Таблица `authors` + `webhook_deliveries` (идемпотентность) - [ ] Сиды / фикстуры для локального дев-окружения ### 1.3 Webhook Ingestion API -- [ ] `POST /api/v1/webhooks/github` — проверка `X-Hub-Signature-256` (HMAC SHA-256) +- [ ] `POST /api/v1/webhooks/github` - проверка `X-Hub-Signature-256` (HMAC SHA-256) - [ ] Валидация эвента `release.published` / `release.released`, идемпотентность по `delivery_id` - [ ] Пуш задачи в очередь (Redis Streams) + ответ `202 Accepted` < 50ms - [ ] Тест на replay-атаку и неверную подпись ### 1.4 Async Worker / Indexer - [ ] Консьюмер очереди (tokio task) -- [ ] Скачивание через HTTP Range Request — чтение только ZIP central directory `.jar` +- [ ] Скачивание через HTTP Range Request - чтение только ZIP central directory `.jar` - [ ] Парсинг `fabric.mod.json` / `quilt.mod.json` / `neoforge.mods.toml` / `mcmod.info` - [ ] Валидация: `mod_id`, `version`, `game_versions`, `loaders`, иконка - [ ] SHA-256 сверка (если приложен `.sha256`), отбраковка битого артефакта @@ -93,36 +94,36 @@ - [ ] Сохранение в `mod_versions`, инвалидация Redis-кэша ### 1.5 Public REST API (Read-Heavy, Cache-First) -- [ ] `GET /api/v1/mods?query=&gameVersion=&loader=&page=&limit=` — FTS + фильтры, кэш Redis 60s -- [ ] `GET /api/v1/mods/:slug` — карточка мода + список версий -- [ ] `GET /api/v1/mods/:slug/versions/:version` — детали версии + `download_url` (прямая CDN ссылка GitHub) +- [ ] `GET /api/v1/mods?query=&gameVersion=&loader=&page=&limit=` - FTS + фильтры, кэш Redis 60s +- [ ] `GET /api/v1/mods/:slug` - карточка мода + список версий +- [ ] `GET /api/v1/mods/:slug/versions/:version` - детали версии + `download_url` (прямая CDN ссылка GitHub) - [ ] Пагинация cursor/offset, ETag, `Cache-Control` - [ ] Rate limiting (tower_governor / redis-cell) ### 1.6 Auth & Profiles (см. docs/auth-profiles.md, adr/004) -- [ ] GitHub OAuth 2.0 (read:user, user:email) + JWT httpOnly — вход для авторов (MVP) -- [ ] PAT `personal_access_tokens` (hash, scopes, expires) — `POST /auth/tokens` для CLI/лаунчеров (MVP) -- [ ] `POST /api/v1/mods/import` — импорт репозитория (проверка LICENSE + public + манифест) -- [ ] Профили `/u/:login`, `/org/:login` — кэш ISR, sponsors, verified badge, SVG `/v1/badges/:slug/*.svg` (MVP) -- [ ] Star/Follow `stars`, `follows` (с фильтром game_version/loader) — in-app уведомления (MVP-лайт) -- [ ] Device Flow RFC8628 (`/oauth/device/code` → `/activate`) — спроектировать, реализация Phase 2 -- [ ] Discord linked_accounts + бот роли Verified Modder — Phase 2 -- [ ] Установка Webhook'а через GitHub App API (автоматически) — миграция с OAuth на App в Phase 2 +- [ ] GitHub OAuth 2.0 (read:user, user:email) + JWT httpOnly - вход для авторов (MVP) +- [ ] PAT `personal_access_tokens` (hash, scopes, expires) - `POST /auth/tokens` для CLI/лаунчеров (MVP) +- [ ] `POST /api/v1/mods/import` - импорт репозитория (проверка LICENSE + public + манифест) +- [ ] Профили `/u/:login`, `/org/:login` - кэш ISR, sponsors, verified badge, SVG `/v1/badges/:slug/*.svg` (MVP) +- [ ] Star/Follow `stars`, `follows` (с фильтром game_version/loader) - in-app уведомления (MVP-лайт) +- [ ] Device Flow RFC8628 (`/oauth/device/code` → `/activate`) - спроектировать, реализация Phase 2 +- [ ] Discord linked_accounts + бот роли Verified Modder - Phase 2 +- [ ] Установка Webhook'а через GitHub App API (автоматически) - миграция с OAuth на App в Phase 2 -## Phase 2 — Frontend (SvelteKit) + Social +## Phase 2 - Frontend (SvelteKit) + Social - [ ] Дизайн-система: Tailwind / UnoCSS + токены - [ ] Страницы: `/` (поиск + фильтры), `/mod/[slug]`, `/mods/import`, `/u/[login]`, `/org/[login]`, `/activate` (device flow) - [ ] Компоненты: `ModCard`, `VersionTable`, `SearchBar`, `LoaderBadge`, `ProfileHeader`, `SponsorsBar` -- [ ] Клиент API (`src/lib/api.ts`) — типизированные fetch-обёртки +- [ ] Клиент API (`src/lib/api.ts`) - типизированные fetch-обёртки - [ ] SSR + кэширование, skeletons, error boundaries - [ ] SEO / OpenGraph для карточек модов - [ ] **Collections / Modlists** `collections`, `collection_stars` + экспорт `?format=prism|packwiz` (Phase 2 хит) -- [ ] **Activity Feed** — лента по подпискам (releases + collections + stars) -- [ ] **Org/Teams** — `/org/:login` агрегатор, `role=maintainer` -- [ ] **Геймификация** — `badges` (Early Adopter, Bug Hunter, Veteran) + Showcase SVG +- [ ] **Activity Feed** - лента по подпискам (releases + collections + stars) +- [ ] **Org/Teams** - `/org/:login` агрегатор, `role=maintainer` +- [ ] **Геймификация** - `badges` (Early Adopter, Bug Hunter, Veteran) + Showcase SVG -## Phase 3 — Поиск, качество данных и аналитика (см. docs/analytics.md, adr/005) +## Phase 3 - Поиск, качество данных и аналитика (см. docs/analytics.md, adr/005) - [ ] PostgreSQL FTS (`to_tsvector` + `ts_rank`) по `name`, `summary`, `README` - [ ] `pg_trgm` для неточных совпадений / опечаток @@ -135,14 +136,14 @@ - [ ] `GET /mods/:slug/analytics?range=30d` + `GET /badges/:slug/servers.svg` + `sort=active_servers` - [ ] Легковесный Java/Kotlin SDK `dev.indexium:analytics` (MIT, SimplePie, opt-out флаг) -## Phase 4 — Надёжность и ограничения GitHub +## Phase 4 - Надёжность и ограничения GitHub -- [ ] GitHub App Install token — 5k-12.5k RPH вместо 60 RPH анонимных -- [ ] Прямые редиректы на `objects.githubusercontent.com` — не проксировать трафик +- [ ] GitHub App Install token - 5k-12.5k RPH вместо 60 RPH анонимных +- [ ] Прямые редиректы на `objects.githubusercontent.com` - не проксировать трафик - [ ] Retry + exponential backoff, DLQ для воркера - [ ] Метрики: Prometheus / `tracing` + Grafana, алерты на lag очереди -## Phase 5 — Деплой и эксплуатации +## Phase 5 - Деплой и эксплуатации - [ ] Dockerfile multi-stage для backend (distroless / alpine) - [ ] Dockerfile для frontend (adapter-node / adapter-static) @@ -150,12 +151,12 @@ - [ ] Бэкапы Postgres (PITR), миграции в CI - [ ] Документация деплоя (`docs/deployment.md`) -## Phase 6 — Расширения (Backlog) +## Phase 6 - Расширения (Backlog) - [ ] Поддержка CurseForge / Modrinth как доп. источников (опционально) - [ ] Webhooks для лаунчеров (подписка на обновления мода) - [ ] CLI для авторов (`indexium publish`) -- [x] Аналитика рантайма — аналог bStats (спроектирована, см. docs/analytics.md) → реализация в Phase 3 +- [x] Аналитика рантайма - аналог bStats (спроектирована, см. docs/analytics.md) → реализация в Phase 3 - [ ] Аналитика скачиваний (агрегация без хранения персоналки) --- @@ -164,17 +165,17 @@ 1. `docker-compose.yml` + первая миграция SQL 2. `POST /webhooks/github` с HMAC-проверкой и заглушкой очереди (in-memory channel) -3. `GET /mods` — мок-данные из БД + подключение фронта +3. `GET /mods` - мок-данные из БД + подключение фронта --- ## Как отмечать прогресс - При завершении задачи ставь `[x]` и добавляй ссылку на PR/коммит: `[x] Задача (#12)` -- Если задача блочится — ставь `[!]` и опиши блокер в комментарии ниже. +- Если задача блочится - ставь `[!]` и опиши блокер в комментарии ниже. ## Блокеры / Вопросы - [ ] Выбрать окончательно очередь: `Redis Streams` vs `NATS JetStream` vs `pg-queue` на старте? - Рекомендация: стартовать с `Redis` (уже нужен как кэш) → мигрировать на NATS если нужен strict ordering. -- [ ] Где хостить Postgres на старте — Supabase / Neon / self-hosted? +- [ ] Где хостить Postgres на старте - Supabase / Neon / self-hosted?