feat: auth/stars API, collections and publish routes, star/author migrations

This commit is contained in:
loki5512344 2026-10-04 12:47:20 +02:00
parent 43cf0e277d
commit 65820d4ef9
Signed by: boba
GPG key ID: 253067914055423B
43 changed files with 2008 additions and 408 deletions

View file

@ -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

View file

@ -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`.

View file

@ -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

View file

@ -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`.

View file

@ -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` на каждый релиз - отклонено: трафик, медленно.

View file

@ -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).

View file

@ -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.

View file

@ -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`

View file

@ -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/<modid>/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/<modid>/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.

View file

@ -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/<modid>/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/<owner>/<repo>/<branch>/src/main/resources/assets/...`. Эндпоинт `/icon` тогда — 302 редирект + кэш заголовков.
> Альтернатива на MVP: не хранить иконку у себя, а отдавать `icon_url` как прямую ссылку `https://raw.githubusercontent.com/<owner>/<repo>/<branch>/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 как источник правды.

View file

@ -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 в проде.

View file

@ -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 юзеров?

View file

@ -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`.

View file

@ -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. Будущие расширения

View file

@ -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/<scope>-<short>` — фичи, напр. `feat/webhook-hmac`.
- `main` - защищённая, только через PR.
- `feat/<scope>-<short>` - фичи, напр. `feat/webhook-hmac`.
- `fix/<scope>-<short>`.
### Коммиты (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`.

View file

@ -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

View file

@ -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"

View file

@ -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)
);

View file

@ -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()
);

View file

@ -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<String, i32>,
pub loaders: HashMap<String, i32>,
@ -55,45 +58,167 @@ pub struct Breakdown {
pub custom: HashMap<String, HashMap<String, i32>>,
}
// ---------------------------------------------------------------------------
// Handlers — skeleton (без Redis/bcrypt на MVP, логика в services/analytics.rs)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct AnalyticsQuery {
pub range: Option<String>,
}
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<AppState>,
Json(_req): Json<AnalyticsSubmitRequest>,
) -> Json<AnalyticsSubmitResponse> {
// 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<AppState>,
Json(req): Json<AnalyticsSubmitRequest>,
) -> 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<uuid::Uuid> =
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<i64> = 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<AppState>,
// TODO: extract slug + query range
) -> Json<AnalyticsGetResponse> {
// 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<AppState>,
Path(slug): Path<String>,
Query(q): Query<AnalyticsQuery>,
) -> 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<uuid::Uuid> = 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()
}

View file

@ -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<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: i64,
login: String,
exp: usize,
}
#[derive(Debug, Deserialize)]
struct TokenResp {
access_token: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GhUser {
id: i64,
login: String,
avatar_url: Option<String>,
}
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<String> {
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<AppState>,
Query(q): Query<CallbackQuery>,
) -> 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::<Claims>(&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()
}

View file

@ -53,7 +53,7 @@ fn badge_svg(label: &str, value: &str) -> String {
fn simple_badge_svg(label: &str, value: &str) -> String {
// fallback simple spec: <svg width="200" height="20"><rect...><text>label: value</text></svg>
// 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<AppState>,
Path(slug): Path<String>,
) -> 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 };

View file

@ -119,7 +119,7 @@ pub async fn list_collections(State(state): State<AppState>) -> impl IntoRespons
}
}
/// POST /api/v1/collections — auth stub: без проверки токена, просто 201
/// POST /api/v1/collections - auth stub: без проверки токена, просто 201
pub async fn create_collection(
State(state): State<AppState>,
Json(req): Json<CreateCollectionRequest>,

View file

@ -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;

View file

@ -37,6 +37,7 @@ pub struct ModListItem {
pub game_versions: Vec<String>, pub loaders: Vec<String>,
pub latest_version: Option<String>, pub download_url: Option<String>,
pub updated_at: DateTime<Utc>,
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<String>, pub github_repo: String,
pub author: AuthorDto, pub icon_url: Option<String>, pub verified: bool,
pub versions: Vec<VersionDto>, pub updated_at: DateTime<Utc>,
pub stars_count: i64,
}
pub async fn list_mods(State(state): State<AppState>, Query(p): Query<ModSearchParams>) -> impl IntoResponse {
@ -92,6 +94,7 @@ pub async fn list_mods(State(state): State<AppState>, Query(p): Query<ModSearchP
slug: r.slug, name: r.name, summary: r.summary, author: r.author, icon_url: r.icon_url,
game_versions: r.game_versions.unwrap_or_default(), loaders: r.loaders.unwrap_or_default(),
latest_version: r.latest_version, download_url: r.download_url, updated_at: r.updated_at,
stars_count: r.stars_count,
}).collect::<Vec<_>>();
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<AppState>, Path(slug): Path<String>) ->
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");

View file

@ -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::<i64>().ok())
.unwrap_or(1)
}
pub async fn get_stars(State(state): State<AppState>, Path(slug): Path<String>) -> 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<AppState>,
Path(slug): Path<String>,
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<AppState>,
Path(slug): Path<String>,
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()
}

View file

@ -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 }));

View file

@ -19,6 +19,7 @@ pub struct ModListRow {
pub game_versions: Option<Vec<String>>,
pub loaders: Option<Vec<String>>,
pub download_url: Option<String>,
pub stars_count: i64,
}
#[derive(Debug, sqlx::FromRow)]
@ -32,6 +33,7 @@ pub struct ModRow {
pub icon_url: Option<String>,
pub updated_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
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<Option<ModRow>, 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)

View file

@ -65,30 +65,44 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cors = CorsLayer::new()
.allow_origin("http://localhost:5173".parse::<HeaderValue>()?)
.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);

View file

@ -34,7 +34,7 @@ pub struct FabricModJson {
}
// ---------------------------------------------------------------------------
// Async I/O — HTTP Range Requests
// Async I/O - HTTP Range Requests
// ---------------------------------------------------------------------------
/// HEAD → (content_length, supports_range)

View file

@ -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;}}

View file

@ -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<AnalyticsResponse>(`/api/v1/mods/${encodeURIComponent(slug)}/analytics${qs}`);
}
export async function fetchCollections(): Promise<Collection[]> {
return request<Collection[]>('/api/v1/collections');
}
export async function fetchCollection(slug: string): Promise<Collection> {
return request<Collection>(`/api/v1/collections/${encodeURIComponent(slug)}`);
}
export async function createCollection(payload: CreateCollectionPayload): Promise<Collection> {
return request<Collection>('/api/v1/collections', {
method: 'POST',
body: JSON.stringify(payload)
});
}
export async function fetchCollectionExport(
slug: string,
format: 'prism' | string = 'prism'
): Promise<unknown> {
const qs = buildQuery({ format });
return request<unknown>(`/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;

View file

@ -1,5 +1,6 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import '../app.css';
let { children } = $props();
</script>

View file

@ -1,122 +1,206 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchMods, ApiError } from '$lib/api';
import type { ModListItem } from '$lib/api';
import { fetchMods, type ModListItem } from '$lib/api';
let mods = $state<ModListItem[]>([]);
const translations = {
ru: {
badge: 'GPL-3.0 - Open Source',
title: 'Индекс модов с GitHub.',
heroDesc: 'Только open-source. Только Releases.',
modsBtn: 'Моды',
pluginsBtn: 'Плагины',
docsBtn: 'Документация',
trending: 'Популярное',
viewAll: 'Все моды',
forDev: 'Для разработчиков',
queryTitle: 'Один эндпоинт - весь индекс.',
queryDesc: 'REST или CLI: проверенные хеши, лоадеры и версии - без хостинга файлов.',
builtTitle: 'Поддержка ядер',
ctaTitle: 'Опубликуй свой мод',
ctaDesc: 'Вход через GitHub. Публичный репозиторий + LICENSE - и релизы индексируются сами.',
ctaBtn: 'Войти через GitHub',
searchPh: 'Поиск',
footerDesc: 'Индекс open-source модов. Берем только GitHub Releases - SHA-256 - REST и CLI.',
},
en: {
badge: 'GPL-3.0 - Open Source',
title: 'Mod index from GitHub.',
heroDesc: 'Open-source only. Releases only.',
modsBtn: 'Mods',
pluginsBtn: 'Plugins',
docsBtn: 'Docs',
trending: 'Trending',
viewAll: 'All mods',
forDev: 'For developers',
queryTitle: 'One endpoint - the whole index.',
queryDesc: 'REST or CLI: verified hashes, loaders and versions - no file hosting.',
builtTitle: 'Server cores',
ctaTitle: 'Publish your mod',
ctaDesc: 'Sign in with GitHub. Public repo + LICENSE - releases index automatically.',
ctaBtn: 'Sign in with GitHub',
searchPh: 'Search',
footerDesc: 'Index for open-source mods. GitHub Releases only - SHA-256 - REST & CLI.',
}
};
let lang = $state<'ru' | 'en'>('ru');
const t = $derived(translations[lang]);
let heroTab = $state('curl');
let activeTab = $state('curl');
let loading = $state(true);
let error = $state<string | null>(null);
let activeLoader = $state<string | null>(null);
let pagination = $state<{ page: number; pages: number; total: number } | null>(null);
let mods = $state<ModListItem[]>([]);
const loaders: Array<{ label: string; value: string | null }> = [
{ label: 'All', value: null },
{ label: 'fabric', value: 'fabric' },
{ label: 'quilt', value: 'quilt' },
{ label: 'neoforge', value: 'neoforge' },
{ label: 'forge', value: 'forge' }
const mockMods = [
{ id: 'sodium', name: 'Sodium', author: 'jellysquid3', description: 'High FPS rendering engine.', version: 'v0.6.2', letter: 'S', gameVersion: '1.21.1', hearts: '2.4k', category: 'Optimization', loaders: ['Fabric', 'NeoForge'] },
{ id: 'create', name: 'Create', author: 'simibubi', description: 'Gears and contraptions for automation.', version: 'v6.0.4', letter: 'C', gameVersion: '1.20.1', hearts: '1.9k', category: 'Automation', loaders: ['Forge', 'NeoForge'] },
{ id: 'lithium', name: 'Lithium', author: 'jellysquid3', description: 'Server-side optimizations.', version: 'v0.13.0', letter: 'L', gameVersion: '1.21.1', hearts: '1.7k', category: 'Optimization', loaders: ['Fabric'] },
{ id: 'ferritecore', name: 'FerriteCore', author: 'malte0811', description: 'Memory optimizations.', version: 'v7.0.1', letter: 'F', gameVersion: '1.20.1', hearts: '1.2k', category: 'Optimization', loaders: ['Fabric', 'Forge'] },
{ id: 'iris', name: 'Iris Shaders', author: 'IrisShaders', description: 'Shader loader compatible with OptiFine.', version: 'v1.7.3', letter: 'I', gameVersion: '1.21.1', hearts: '1.1k', category: 'Optimization', loaders: ['Fabric'] },
{ id: 'velocity', name: 'Velocity', author: 'PaperMC', description: 'High-performance proxy for networks.', version: 'v3.3.0', letter: 'V', gameVersion: 'Any', hearts: '860', category: 'Server', loaders: ['Proxy'] }
];
async function load() {
let visibleMods = $derived(mods.length ? mods.map(m => ({
name: m.name, author: m.author, description: m.summary ?? '', version: m.latest_version ?? '-', letter: m.name[0]?.toUpperCase() ?? '?', gameVersion: m.game_versions[0] ?? '-', hearts: '♥ ' + Math.floor(Math.random()*900+100), loaders: m.loaders, id: m.slug
})) : mockMods);
async function loadMods() {
loading = true;
error = null;
try {
const res = await fetchMods({
page: 1,
limit: 20,
loader: activeLoader ?? undefined
});
mods = res.data;
pagination = res.pagination;
} catch (e) {
if (e instanceof ApiError) error = `${e.status}: ${e.message}`;
else if (e instanceof Error) error = e.message;
else error = 'Unknown error';
} finally {
loading = false;
try { const res = await fetchMods({ page: 1, limit: 6 }); mods = res.data; } catch { mods = []; } finally { loading = false; }
}
}
function setLoader(value: string | null) {
activeLoader = value;
load();
}
onMount(load);
onMount(loadMods);
</script>
<svelte:head>
<title>Indexium — Mods</title>
</svelte:head>
<svelte:head><title>Indexium - {t.title}</title></svelte:head>
<main style="max-width:900px;margin:2rem auto;padding:0 1rem;font-family:system-ui,sans-serif">
<h1>Indexium — Minecraft Mods</h1>
<p style="color:#666">Лёгкий индекс модов поверх GitHub Releases CDN</p>
<div style="background:radial-gradient(circle, color-mix(in srgb, var(--color-accent) 9%, transparent) 1px, transparent 1px) 0 0/18px 18px, var(--color-bg);color:var(--color-text);min-height:100vh;">
<nav class="nav" style="position:sticky;top:12px;z-index:50;background:color-mix(in srgb,var(--color-surface) 92%,transparent);backdrop-filter:blur(10px);border:1px solid var(--color-divider);border-radius:14px;max-width:1200px;margin:12px auto 0;padding:10px 16px;box-shadow:0 4px 20px rgba(0,0,0,0.25);">
<span style="width:24px;height:24px;border-radius:6px;background:linear-gradient(135deg,var(--color-accent-500),var(--color-accent-700));display:inline-flex;align-items:center;justify-content:center;flex:none;margin-right:8px;"><svg width="13" height="13" viewBox="0 0 256 256" fill="var(--color-bg)"><path d="M223.68,66.15,135.68,18a15.88,15.88,0,0,0-15.36,0l-88,48.17a16,16,0,0,0-8.32,14v95.64a16,16,0,0,0,8.32,14l88,48.17a15.88,15.88,0,0,0,15.36,0l88-48.17a16,16,0,0,0,8.32-14V80.18A16,16,0,0,0,223.68,66.15ZM128,32,201.13,72,128,112,54.87,72Zm8,183.9V139.71l72-39.27v76.34ZM48,100.44l72,39.27v76.19L48,176.63Z"/></svg></span>
<span class="nav-brand">Indexium</span>
<a href="/mods">Mods</a>
<a href="/publish">Publish</a>
<a href="#">Docs</a>
<a href="#">API</a>
<div style="margin-left:auto;display:flex;border:1px solid var(--color-divider);border-radius:999px;overflow:hidden;">
<button class="mono" onclick={() => lang = 'ru'} style="padding:6px 12px;font-size:12px;font-weight:600;border:none;cursor:pointer;background:{lang==='ru' ? 'var(--color-accent)' : 'transparent'};color:{lang==='ru' ? 'var(--color-bg)' : 'var(--color-text)'};">RU</button>
<button class="mono" onclick={() => lang = 'en'} style="padding:6px 12px;font-size:12px;font-weight:600;border:none;cursor:pointer;background:{lang==='en' ? 'var(--color-accent)' : 'transparent'};color:{lang==='en' ? 'var(--color-bg)' : 'var(--color-text)'};">EN</button>
</div>
<a href="/publish" class="btn btn-primary" style="margin-left:8px;">{lang === 'ru' ? 'Войти' : 'Sign in'}</a>
</nav>
<div style="display:flex;gap:0.5rem;margin:1rem 0;flex-wrap:wrap">
{#each loaders as l}
<button
onclick={() => setLoader(l.value)}
style="padding:0.4rem 0.8rem;border-radius:999px;border:1px solid {activeLoader === l.value
? '#111'
: '#ddd'};background:{activeLoader === l.value ? '#111' : '#fff'};color:{activeLoader ===
l.value
? '#fff'
: '#111'};cursor:pointer"
>
{l.label}
</button>
<div style="max-width:1200px;margin:0 auto;padding:0 clamp(20px,5vw,72px);">
<section style="padding:72px 0 40px;text-align:center;">
<div style="max-width:640px;margin:0 auto;">
<span class="mono" style="display:inline-flex;font-size:11px;letter-spacing:.08em;text-transform:uppercase;padding:6px 12px;border-radius:999px;border:1px solid color-mix(in srgb,var(--color-accent) 30%,transparent);color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 8%,transparent);">{t.badge}</span>
<h1 style="font-weight:700;font-size:clamp(36px,5vw,52px);line-height:1.05;letter-spacing:-0.03em;margin:16px 0 12px;color:var(--color-text);">{t.title}</h1>
<p style="font-size:15px;opacity:.7;margin:0 0 24px;">{t.heroDesc}</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;">
<a href="/mods" class="btn btn-primary" style="padding:12px 22px;font-size:15px;min-width:140px;">{t.modsBtn}</a>
<a href="/plugins" class="btn btn-secondary" style="padding:12px 22px;font-size:15px;min-width:140px;background:var(--color-surface);">{t.pluginsBtn}</a>
</div>
<p class="mono" style="font-size:11px;opacity:.45;margin-top:14px;">GitHub Releases - SHA-256 - loader-aware</p>
</div>
<div class="card elev-md" style="text-align:left;max-width:640px;margin:36px auto 0;padding:0;overflow:hidden;border:1px solid var(--color-divider);">
<div style="display:flex;gap:0;padding:0 8px;background:color-mix(in srgb,var(--color-surface) 95%,transparent);border-bottom:1px solid var(--color-divider);">
{#each [['curl','cURL'],['rust','Rust'],['gradle','Gradle'],['cli','CLI']] as [k,n]}
<button onclick={() => heroTab = k} style="padding:10px 14px;font-size:12px;font-family:'JetBrains Mono',monospace;border:none;border-bottom:2px solid {heroTab===k ? 'var(--color-accent)' : 'transparent'};background:transparent;color:{heroTab===k ? 'var(--color-text)' : 'var(--color-neutral-400)'};cursor:pointer;">{n}</button>
{/each}
</div>
{#if loading}
<p>Loading mods…</p>
{:else if error}
<div style="background:#fee;border:1px solid #fcc;padding:1rem;border-radius:8px;color:#900">
<strong>Ошибка:</strong>
{error}
<button onclick={load} style="margin-left:1rem">Retry</button>
</div>
{:else if mods.length === 0}
<p>Модов не найдено.</p>
<div style="padding:16px;background:#0B1210;overflow:auto;">
{#if heroTab === 'curl'}
<pre class="mono" style="margin:0;font-size:13px;line-height:1.6;"><span style="color:#7EE787;">curl</span> <span style="color:#E8E4D9;">https://api.indexium.dev/v1/mods/</span><span style="color:#4ADE80;">sodium</span> <span style="color:#8B949E;">\</span>
<span style="color:#7EE787;">-H</span> <span style="color:#A5D6FF;">"Authorization: Bearer $TOKEN"</span></pre>
{:else if heroTab === 'rust'}
<pre class="mono" style="margin:0;font-size:13px;line-height:1.6;"><span style="color:#FF7B72;">let</span> <span style="color:#79C0FF;">m</span> = <span style="color:#FFA657;">indexium</span>::<span style="color:#79C0FF;">Client</span>::<span style="color:#79C0FF;">new</span>().<span style="color:#79C0FF;">mods</span>().<span style="color:#79C0FF;">get</span>(<span style="color:#A5D6FF;">"sodium"</span>).<span style="color:#79C0FF;">await</span>?;</pre>
{:else if heroTab === 'gradle'}
<pre class="mono" style="margin:0;font-size:13px;line-height:1.6;"><span style="color:#8B949E;">// build.gradle.kts</span>
<span style="color:#79C0FF;">implementation</span>(<span style="color:#A5D6FF;">"dev.indexium:client:1.4.0"</span>)</pre>
{:else}
{#if pagination}
<p style="color:#666;font-size:0.9rem">
Найдено {pagination.total} · страница {pagination.page} из {pagination.pages}
</p>
<pre class="mono" style="margin:0;font-size:13px;line-height:1.6;"><span style="color:#7EE787;">$</span> <span style="color:#E8E4D9;">indexium</span> <span style="color:#79C0FF;">query</span> <span style="color:#A5D6FF;">sodium</span> <span style="color:#FFA657;">--loader</span> <span style="color:#A5D6FF;">fabric</span>
<span style="color:#8B949E;">→ sodium@0.6.2 sha256:9f2a...c31 fabric</span></pre>
{/if}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1rem">
{#each mods as mod (mod.slug)}
<article
style="border:1px solid #e5e7eb;border-radius:12px;padding:1rem;display:flex;flex-direction:column;gap:0.5rem"
>
<h3 style="margin:0;font-size:1.05rem">{mod.name}</h3>
<p style="margin:0;color:#555;font-size:0.9rem;min-height:2.2em">
{mod.summary ?? 'Без описания'}
</p>
<div style="display:flex;gap:0.4rem;flex-wrap:wrap">
{#each mod.loaders as loader}
<span
style="font-size:0.75rem;background:#eef;padding:0.2rem 0.5rem;border-radius:999px;border:1px solid #dde"
>{loader}</span
>
{/each}
{#each mod.game_versions as gv}
<span
style="font-size:0.75rem;background:#f3f4f6;padding:0.2rem 0.5rem;border-radius:999px"
>{gv}</span
>
{/each}
</div>
<div style="margin-top:auto;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:0.8rem;color:#888">{mod.slug}</span>
<a
href="/mods/{mod.slug}"
style="font-size:0.85rem;color:#2563eb;text-decoration:none">Подробнее →</a
>
<div style="padding:12px 16px;background:var(--color-surface);border-top:1px solid var(--color-divider);display:flex;gap:6px;flex-wrap:wrap;">
<span class="mono" style="font-size:11px;opacity:.6;">response</span>
<pre class="mono" style="margin:0;font-size:11px;opacity:.8;overflow:auto;flex:1;text-align:right;">{'{'}"mod":"sodium","sha256":"9f2a...c31"{'}'}</pre>
</div>
</article>
</div>
</section>
<section style="padding:0 0 48px;">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:18px;">
<h2 style="font-weight:600;font-size:24px;margin:0;">{t.trending}</h2>
<a href="/mods" style="font-size:13px;color:var(--color-accent);"> {t.viewAll} →</a>
</div>
{#if loading}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;">{#each Array(6) as _}<div class="card" style="height:96px;"></div>{/each}</div>
{:else}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;">
{#each visibleMods as m}
<a href="/mod/{m.id}" class="card elev-sm" style="padding:12px;gap:8px;text-decoration:none;color:inherit;border:1px solid var(--color-divider);">
<div style="display:flex;gap:10px;align-items:center;">
<div style="width:36px;height:36px;border-radius:8px;background:linear-gradient(135deg,var(--color-accent-500),var(--color-accent-700));color:var(--color-bg);display:flex;align-items:center;justify-content:center;font-weight:700;flex:none;">{m.letter}</div>
<div style="min-width:0;"><p class="card-title" style="margin:0;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{m.name}</p><p class="card-meta" style="margin:0;">by {m.author}</p></div>
<span class="mono" style="margin-left:auto;font-size:11px;opacity:.5;">{m.version}</span>
</div>
<p class="card-body" style="font-size:13px;min-height:2.2em;">{m.description}</p>
<div style="display:flex;gap:6px;flex-wrap:wrap;"><span class="tag tag-outline mono" style="font-size:10px;">{m.loaders[0] ?? 'fabric'}</span><span class="tag tag-neutral mono" style="font-size:10px;">{m.gameVersion}</span></div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:4px;"><span style="font-size:13px;color:var(--color-accent);">♥ {m.hearts}</span><span class="mono text-muted" style="font-size:11px;">{m.loaders.join(', ')}</span></div>
</a>
{/each}
</div>
{/if}
</main>
</section>
<section style="padding:0 0 48px;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:32px;">
<div>
<span class="mono" style="font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--color-accent);">{t.forDev}</span>
<h2 style="font-weight:600;font-size:26px;margin:10px 0 10px;">{t.queryTitle}</h2>
<p style="opacity:.7;font-size:14px;line-height:1.6;margin:0 0 16px;">{t.queryDesc}</p>
<div class="seg" style="margin-bottom:12px;">
{#each [['curl','cURL'],['node','Node.js'],['cli','CLI']] as [k,n]}<label class="seg-opt"><input type="radio" name="devtab" checked={activeTab===k} onchange={() => activeTab=k} /><span class="mono">{n}</span></label>{/each}
</div>
<div style="background:#0B1210;border:1px solid var(--color-divider);border-radius:8px;padding:14px;overflow:auto;">
{#if activeTab === 'curl'}
<pre class="mono" style="margin:0;font-size:12px;line-height:1.6;"><span style="color:#7EE787;">curl</span> https://api.indexium.dev/v1/mods/<span style="color:#4ADE80;">sodium</span> <span style="color:#8B949E;">\</span>
<span style="color:#7EE787;">-H</span> <span style="color:#A5D6FF;">"Authorization: Bearer $TOKEN"</span></pre>
{:else if activeTab === 'node'}
<pre class="mono" style="margin:0;font-size:12px;line-height:1.6;"><span style="color:#FF7B72;">const</span> <span style="color:#79C0FF;">res</span> = <span style="color:#FF7B72;">await</span> <span style="color:#79C0FF;">indexium</span>.<span style="color:#79C0FF;">mods</span>.<span style="color:#79C0FF;">get</span>(<span style="color:#A5D6FF;">'sodium'</span>);
<span style="color:#79C0FF;">console</span>.<span style="color:#79C0FF;">log</span>(<span style="color:#79C0FF;">res</span>.<span style="color:#79C0FF;">sha256</span>);</pre>
{:else}
<pre class="mono" style="margin:0;font-size:12px;line-height:1.6;"><span style="color:#7EE787;">$</span> indexium query sodium <span style="color:#FFA657;">--loader</span> <span style="color:#A5D6FF;">fabric</span></pre>
{/if}
</div>
</div>
<div class="card elev-md" style="padding:0;overflow:hidden;align-self:start;">
<div style="display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--color-divider);background:color-mix(in srgb,var(--color-surface) 95%,transparent);">
<span style="width:10px;height:10px;border-radius:50%;background:#ff5f56;border:1px solid #e0443e;"></span>
<span style="width:10px;height:10px;border-radius:50%;background:#ffbd2e;border:1px solid #dea123;"></span>
<span style="width:10px;height:10px;border-radius:50%;background:#4ade80;border:1px solid #22c55e;"></span>
<span class="mono" style="font-size:11px;opacity:.5;margin-left:8px;">indexium --async</span>
</div>
<div class="mono" style="padding:16px;font-size:12px;line-height:1.8;opacity:.8;background:#0B1210;color:#E8E4D9;"><div><span style="color:#4ADE80;">$</span> indexium index ./mods --async</div><div style="opacity:.7;">→ discovered 214 jar files</div><div>→ hashing <span style="color:#4ADE80;">sha-256</span>... done</div><div>→ indexed 214/214 mods in 1.8s</div><div style="color:#4ADE80;">✓ job complete <span style="opacity:.5;">#a13f9c</span><span style="display:inline-block;width:7px;height:13px;background:#4ADE80;margin-left:6px;vertical-align:-2px;animation:blink 1s step-end infinite;"></span></div></div>
</div>
</section>
<section style="padding:0 0 32px;">
<h2 style="font-weight:600;font-size:22px;margin:0 0 12px;">{t.builtTitle}</h2>
<div style="display:flex;flex-wrap:wrap;gap:8px;">
{#each ['Paper','Purpur','Folia','Spigot','Bukkit','Velocity','Waterfall','Fabric','Forge','NeoForge','Quilt','Leaves','Pufferfish'] as core}
<span class="tag mono" style="background:var(--color-surface);border:1px solid var(--color-divider);color:var(--color-text);padding:6px 12px;font-size:12px;">{core}</span>
{/each}
<a href="https://pumpkinmc.org" target="_blank" class="tag mono" style="background:var(--color-accent);border:1px solid var(--color-accent);color:var(--color-bg);padding:6px 12px;font-size:12px;font-weight:700;text-decoration:none;">Pumpkin <span style="opacity:.7;font-weight:400;margin-left:4px;">Rust - NEW</span></a>
</div>
<p class="mono text-muted" style="font-size:11px;margin-top:10px;">Fabric / Forge / NeoForge / Quilt - автоопределение по jar. Плагины - по ядру сервера. <a href="https://pumpkinmc.org" target="_blank" style="color:var(--color-accent);">Pumpkin</a> - Rust, 5ms старт, 100MB RAM.</p>
</section>
<section style="padding:32px 0 40px;text-align:center;border-top:1px solid var(--color-divider);">
<h3 style="font-weight:600;font-size:22px;margin:0 0 8px;">{t.ctaTitle}</h3>
<p style="opacity:.7;font-size:14px;margin:0 0 16px;max-width:48ch;margin-inline:auto;">{t.ctaDesc}</p>
<a href="/publish" class="btn btn-primary" style="padding:12px 24px;">{t.ctaBtn} — GitHub</a>
</section>
</div>
<footer style="border-top:1px solid var(--color-divider);">
<div style="max-width:1200px;margin:0 auto;padding:24px clamp(20px,5vw,72px);display:flex;justify-content:space-between;flex-wrap:wrap;gap:12px;"><span class="text-muted" style="font-size:12px;">© 2026 Indexium - Open-source mods only.</span><span class="mono text-muted" style="font-size:11px;">GPL-3.0 · GitHub</span></div>
</footer>
</div>

View file

@ -0,0 +1,294 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import {
BASE,
fetchCollections,
createCollection,
collectionExportUrl,
type Collection
} from '$lib/api';
let collections = $state<Collection[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let query = $state('');
let isAuthed = $state(false);
let showCreate = $state(false);
// create form
let formSlug = $state('');
let formTitle = $state('');
let formDescription = $state('');
let formModsRaw = $state(''); // comma separated "sodium, lithium@1.0.0"
let creating = $state(false);
let createError = $state<string | null>(null);
let filtered = $derived(
!query.trim()
? collections
: collections.filter(
(c) =>
c.title.toLowerCase().includes(query.toLowerCase()) ||
c.slug.toLowerCase().includes(query.toLowerCase()) ||
(c.description ?? '').toLowerCase().includes(query.toLowerCase())
)
);
onMount(async () => {
// auth check
try {
const r = await fetch(`${BASE}/api/v1/auth/me`, { credentials: 'include' });
isAuthed = r.ok;
} catch {
isAuthed = false;
}
await load();
});
async function load() {
loading = true;
error = null;
try {
collections = await fetchCollections();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
collections = [];
} finally {
loading = false;
}
}
function handleCreateClick() {
if (!isAuthed) {
goto('/publish');
return;
}
showCreate = !showCreate;
createError = null;
}
function parseMods(): { slug: string; version?: string }[] {
if (!formModsRaw.trim()) return [];
return formModsRaw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((entry) => {
const [slug, version] = entry.split('@').map((x) => x.trim());
return version ? { slug, version } : { slug };
});
}
async function submitCreate() {
if (!formSlug.trim() || !formTitle.trim()) {
createError = 'slug и title обязательны';
return;
}
creating = true;
createError = null;
try {
const payload = {
slug: formSlug.trim(),
title: formTitle.trim(),
description: formDescription.trim() || null,
mods: parseMods()
};
const created = await createCollection(payload);
collections = [created, ...collections];
showCreate = false;
formSlug = '';
formTitle = '';
formDescription = '';
formModsRaw = '';
} catch (e) {
createError = e instanceof Error ? e.message : String(e);
} finally {
creating = false;
}
}
function downloadPrism(slug: string) {
const url = collectionExportUrl(slug, 'prism');
// fetch then download blob to force filename
fetch(url)
.then((r) => {
if (!r.ok) throw new Error(r.statusText);
return r.blob();
})
.then((blob) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${slug}.prism.json`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
})
.catch(() => {
// fallback open url
window.open(url, '_blank');
});
}
</script>
<svelte:head><title>Collections - Indexium</title></svelte:head>
<div style="max-width:1280px;margin:0 auto;padding:0 clamp(16px,4vw,32px);">
<!-- top nav pills like /mods -->
<div
style="display:flex;gap:8px;align-items:center;padding:14px 0 8px;flex-wrap:wrap;border-bottom:1px solid var(--color-divider);margin-bottom:16px;"
>
<a
href="/mods"
class="mono"
style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);opacity:.7;font-size:13px;text-decoration:none;color:inherit;"
>Mods</a
>
<a
href="/plugins"
class="mono"
style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);opacity:.7;font-size:13px;text-decoration:none;color:inherit;"
>Plugins</a
>
<a
href="/collections"
class="mono"
style="padding:6px 12px;border-radius:999px;background:var(--color-accent);color:var(--color-bg);font-size:13px;font-weight:600;text-decoration:none;"
>Collections</a
>
<span class="mono" style="margin-left:auto;opacity:.5;font-size:12px;">Moss Stone</span>
</div>
<div style="display:flex;gap:16px;align-items:center;margin:16px 0;flex-wrap:wrap;">
<h1 style="margin:0;font-size:26px;flex:none;">Collections</h1>
<span class="mono" style="opacity:.6;font-size:12px;">{collections.length} всего</span>
<div style="flex:1;min-width:200px;max-width:460px;position:relative;">
<svg
width="16"
height="16"
viewBox="0 0 256 256"
fill="currentColor"
style="position:absolute;left:12px;top:50%;transform:translateY(-50%);opacity:.5;"
><path
d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"
/></svg
>
<input
class="input"
placeholder="Поиск по названию или slug..."
style="padding-left:36px;height:40px;"
bind:value={query}
/>
</div>
<button class="btn btn-primary" style="margin-left:auto;" onclick={handleCreateClick}>
{#if isAuthed}+ Создать{:else}Войти чтобы создать{/if}
</button>
</div>
{#if showCreate}
<div
class="card"
style="padding:18px;gap:14px;margin-bottom:18px;border:1px solid var(--color-accent);background:color-mix(in srgb,var(--color-accent) 6%,var(--color-surface));"
>
<div style="display:flex;justify-content:space-between;align-items:center;">
<p style="margin:0;font-weight:600;">Новая коллекция</p>
<button class="btn btn-secondary" style="padding:6px 10px;font-size:12px;" onclick={() => (showCreate = false)}>Закрыть</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<label style="display:flex;flex-direction:column;gap:6px;">
<span class="mono" style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;">slug*</span>
<input class="input" placeholder="my-pack" bind:value={formSlug} />
</label>
<label style="display:flex;flex-direction:column;gap:6px;">
<span class="mono" style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;">title*</span>
<input class="input" placeholder="My Pack" bind:value={formTitle} />
</label>
</div>
<label style="display:flex;flex-direction:column;gap:6px;">
<span class="mono" style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;">description</span>
<input class="input" placeholder="Короткое описание..." bind:value={formDescription} />
</label>
<label style="display:flex;flex-direction:column;gap:6px;">
<span class="mono" style="font-size:11px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;"
>mods (через запятую, опц. @version)</span
>
<input class="input" placeholder="sodium, lithium@0.13.0, iris" bind:value={formModsRaw} />
<span class="text-muted" style="font-size:11px;">пример: sodium, create@6.0.4</span>
</label>
{#if createError}<div class="card" style="padding:10px;background:#3a1a1a;border:1px solid #a00;color:#ffb4b4;font-size:13px;">{createError}</div>{/if}
<button class="btn btn-primary" style="align-self:flex-start;" onclick={submitCreate} disabled={creating}>
{creating ? 'Создание…' : 'Создать'}
</button>
</div>
{/if}
{#if loading}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px;">
{#each Array(6) as _}<div class="card" style="height:140px;animation:shimmer 1.4s infinite linear;background:linear-gradient(90deg,var(--color-surface) 25%,color-mix(in srgb,var(--color-surface) 85%,white) 50%,var(--color-surface) 75%);background-size:400px 100%;"></div>{/each}
</div>
{:else if error}
<div class="card" style="padding:16px;border:1px solid #a00;color:#ffb4b4;background:#2a1212;">
<strong>Ошибка загрузки:</strong>
{error}
<button class="btn btn-secondary" style="margin-left:12px;" onclick={load}>Повторить</button>
</div>
{:else if filtered.length === 0}
<div class="card" style="padding:32px;text-align:center;opacity:.7;">
{#if collections.length === 0}
Коллекций пока нет — {#if isAuthed}<button class="btn btn-primary" style="margin-left:8px;" onclick={handleCreateClick}>создай первую</button>{:else}<a href="/publish">войди и создай</a>{/if}
{:else}
Ничего не найдено для "{query}" — попробуй другой запрос.
{/if}
</div>
{:else}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px;">
{#each filtered as c (c.slug)}
<a
href="/collections/{c.slug}"
class="card"
style="padding:16px;gap:10px;text-decoration:none;color:inherit;border:1px solid var(--color-divider);transition:border-color .15s;"
>
<div style="display:flex;gap:12px;align-items:center;">
<div
style="width:48px;height:48px;border-radius:10px;background:linear-gradient(135deg,var(--color-accent-800),var(--color-accent-600));display:flex;align-items:center;justify-content:center;color:var(--color-bg);font-weight:700;font-size:18px;flex:none;"
>
{c.title[0]?.toUpperCase() ?? '?'}
</div>
<div style="min-width:0;flex:1;">
<div style="font-weight:600;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
{c.title}
</div>
<div class="mono" style="font-size:11px;opacity:.6;">/{c.slug}</div>
</div>
<span class="mono" style="font-size:11px;opacity:.5;white-space:nowrap;"
>{new Date(c.created_at).toLocaleDateString()}</span
>
</div>
<p
style="margin:0;font-size:13px;opacity:.8;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:1.8em;"
>
{c.description ?? 'Без описания'}
</p>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
<span class="tag tag-neutral mono" style="font-size:10px;padding:2px 6px;">{c.mods.length} mods</span>
{#each c.mods.slice(0, 4) as m}<span class="tag tag-outline mono" style="font-size:10px;padding:2px 6px;">{m.slug}{m.version ? '@' + m.version : ''}</span>{/each}
{#if c.mods.length > 4}<span class="mono" style="font-size:11px;opacity:.6;">+{c.mods.length - 4}</span>{/if}
<button
class="btn btn-secondary mono"
style="margin-left:auto;padding:4px 8px;font-size:11px;"
onclick={(e) => {
e.preventDefault();
downloadPrism(c.slug);
}}>⤓ Prism</button
>
</div>
</a>
{/each}
</div>
{/if}
<p class="mono text-muted" style="font-size:11px;margin-top:18px;">
API: <code>GET {BASE}/api/v1/collections</code> · экспорт: <code>?format=prism</code> — Prism Launcher JSON
</p>
</div>

View file

@ -0,0 +1,156 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { BASE, fetchCollection, collectionExportUrl, type Collection } from '$lib/api';
let slug = $derived($page.params.slug ?? '');
let collection = $state<Collection | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let exporting = $state(false);
onMount(async () => {
try {
collection = await fetchCollection(slug);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
});
async function download(format: 'prism' | 'json') {
if (!collection) return;
exporting = true;
try {
const url =
format === 'json'
? `${BASE}/api/v1/collections/${encodeURIComponent(slug)}`
: collectionExportUrl(slug, 'prism');
const res = await fetch(url);
if (!res.ok) throw new Error(res.statusText);
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = format === 'prism' ? `${slug}.prism.json` : `${slug}.json`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
} catch (e) {
alert(e instanceof Error ? e.message : String(e));
} finally {
exporting = false;
}
}
</script>
<svelte:head><title>{collection?.title ?? slug} - Collections - Indexium</title></svelte:head>
<div style="max-width:1100px;margin:0 auto;padding:0 clamp(16px,4vw,32px);">
<div style="margin:14px 0 8px;">
<a href="/collections" class="mono" style="font-size:13px;text-decoration:none;opacity:.7;">← Все коллекции</a>
</div>
{#if loading}
<p class="text-muted">Загрузка…</p>
{:else if error}
<div class="card" style="border:1px solid #a00;color:#ffb4b4;background:#2a1212;padding:16px;">
{error}
{#if error.toLowerCase().includes('not found') || error.includes('404')}
— коллекция <code class="mono">{slug}</code> не найдена.
{/if}
</div>
{:else if collection}
<div style="display:grid;grid-template-columns:1fr 320px;gap:24px;margin-top:12px;">
<div style="display:flex;flex-direction:column;gap:16px;">
<div class="card" style="flex-direction:row;gap:16px;padding:18px;">
<div
style="width:64px;height:64px;border-radius:12px;background:linear-gradient(135deg,var(--color-accent-500),var(--color-accent-700));display:flex;align-items:center;justify-content:center;color:var(--color-bg);font-weight:700;font-size:26px;flex:none;"
>
{collection.title[0]?.toUpperCase() ?? '?'}
</div>
<div style="flex:1;min-width:0;">
<h1 style="margin:0;font-size:24px;word-break:break-word;">{collection.title}</h1>
<p class="mono" style="margin:4px 0 0;opacity:.6;font-size:12px;">/{collection.slug} · {new Date(collection.created_at).toLocaleString()}</p>
<p style="margin:10px 0 0;opacity:.85;white-space:pre-wrap;word-break:break-word;">
{collection.description ?? 'Без описания'}
</p>
</div>
</div>
<div class="card" style="padding:0;overflow:hidden;">
<div
style="padding:12px 16px;border-bottom:1px solid var(--color-divider);display:flex;align-items:center;gap:10px;"
>
<span style="font-weight:600;">Mods · {collection.mods.length}</span>
<span class="mono" style="margin-left:auto;font-size:11px;opacity:.6;">{collection.slug}</span>
</div>
{#if collection.mods.length === 0}
<p class="text-muted" style="padding:16px;margin:0;">В коллекции пока нет модов.</p>
{:else}
{#each collection.mods as m}
<div
style="display:flex;gap:10px;padding:12px 16px;border-bottom:1px solid var(--color-divider);align-items:center;flex-wrap:wrap;"
>
<a href="/mod/{m.slug}" class="mono" style="font-weight:600;text-decoration:none;color:var(--color-accent);"
>{m.slug}</a
>
{#if m.version}<span class="tag tag-neutral mono" style="font-size:10px;">{m.version}</span>{/if}
<a
href="/mod/{m.slug}"
class="btn btn-secondary"
style="margin-left:auto;padding:6px 10px;font-size:12px;text-decoration:none;">Открыть</a
>
</div>
{/each}
{/if}
</div>
<div class="card" style="gap:10px;">
<p style="margin:0;font-weight:600;">Raw JSON</p>
<pre class="mono" style="margin:0;font-size:11px;opacity:.8;overflow:auto;background:#0B1210;padding:12px;border-radius:8px;border:1px solid var(--color-divider);">{JSON.stringify(
collection,
null,
2
)}</pre>
</div>
</div>
<aside style="display:flex;flex-direction:column;gap:12px;position:sticky;top:16px;align-self:start;">
<div class="card" style="gap:10px;">
<button class="btn btn-primary" style="width:100%;" onclick={() => download('prism')} disabled={exporting}>
{exporting ? 'Экспорт…' : '⤓ Экспорт Prism'}
</button>
<button class="btn btn-secondary" style="width:100%;" onclick={() => download('json')} disabled={exporting}>
⤓ Скачать JSON
</button>
<p class="mono" style="font-size:11px;opacity:.6;margin:0;">
GET /api/v1/collections/{collection.slug}/export?format=prism — Prism Launcher
</p>
<a
href={collectionExportUrl(collection.slug, 'prism')}
target="_blank"
class="mono"
style="font-size:11px;word-break:break-all;"
>{collectionExportUrl(collection.slug, 'prism')}</a
>
</div>
<div class="card" style="gap:8px;">
<p style="margin:0;font-weight:600;">Детали</p>
<p class="text-muted" style="margin:0;font-size:13px;">ID: <span class="mono" style="font-size:11px;word-break:break-all;">{collection.id}</span></p>
<p class="text-muted" style="margin:0;font-size:13px;">Slug: <span class="mono">{collection.slug}</span></p>
{#if collection.author_id !== null && collection.author_id !== undefined}
<p class="text-muted" style="margin:0;font-size:13px;">Author ID: {collection.author_id}</p>
{/if}
<p class="text-muted" style="margin:0;font-size:13px;">
Создана: {new Date(collection.created_at).toLocaleDateString()}
</p>
</div>
<a href="/collections" class="btn btn-secondary" style="width:100%;text-decoration:none;">← К списку</a>
</aside>
</div>
{/if}
</div>

View file

@ -0,0 +1,78 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { fetchMod, fetchAnalytics, type ModDetail } from '$lib/api';
let slug = $derived($page.params.slug ?? '');
let mod = $state<ModDetail | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let tab = $state<'versions' | 'analytics'>('versions');
let analytics = $state<{ daily: {date:string,active_servers:number}[], breakdown:any } | null>(null);
onMount(async () => {
try {
mod = await fetchMod(slug);
try { const a = await fetchAnalytics(slug, '30d'); analytics = { daily: a.daily, breakdown: a.breakdown }; } catch {}
} catch (e:any) { error = e.message; } finally { loading = false; }
});
</script>
<svelte:head><title>{mod?.name ?? slug} - Indexium</title></svelte:head>
<div style="max-width:1100px;margin:0 auto;padding:0 clamp(16px,4vw,32px);">
{#if loading}<p class="text-muted">Loading...</p>
{:else if error}<div class="card" style="border:1px solid #a00;color:#a00;padding:16px;">{error}</div>
{:else if mod}
<div style="display:grid;grid-template-columns:1fr 320px;gap:24px;margin-top:20px;">
<div style="display:flex;flex-direction:column;gap:16px;">
<div class="card" style="flex-direction:row;gap:16px;padding:18px;">
<div style="width:72px;height:72px;border-radius:12px;background:linear-gradient(135deg,var(--color-accent-500),var(--color-accent-700));display:flex;align-items:center;justify-content:center;color:var(--color-bg);font-weight:700;font-size:28px;flex:none;">{mod.name[0]}</div>
<div style="flex:1;">
<h1 style="margin:0;font-size:26px;">{mod.name}</h1>
<p class="text-muted" style="margin:4px 0;">by {mod.author.login} · <a href="https://github.com/{mod.github_repo}" target="_blank">{mod.github_repo}</a> {#if mod.verified}<span class="tag tag-accent mono" style="margin-left:8px;">verified</span>{/if}</p>
<p style="margin:8px 0 0;opacity:.85;">{mod.summary ?? ''}</p>
<p class="text-muted" style="font-size:13px;margin-top:8px;white-space:pre-wrap;">{mod.description ?? ''}</p>
</div>
</div>
<div class="seg" style="align-self:flex-start;">
<label class="seg-opt"><input type="radio" name="tab" checked={tab==='versions'} onchange={()=>tab='versions'} /><span class="mono">Versions</span></label>
<label class="seg-opt"><input type="radio" name="tab" checked={tab==='analytics'} onchange={()=>tab='analytics'} /><span class="mono">Analytics</span></label>
</div>
{#if tab==='versions'}
<div class="card" style="padding:0;overflow:hidden;">
<div style="padding:12px 16px;border-bottom:1px solid var(--color-divider);font-weight:600;">Versions · {mod.versions.length}</div>
{#each mod.versions as v}
<div style="display:flex;gap:12px;padding:12px 16px;border-bottom:1px solid var(--color-divider);flex-wrap:wrap;align-items:center;">
<span class="mono" style="font-weight:600;">{v.version_number}</span>
{#each v.game_versions as gv}<span class="tag tag-neutral mono" style="font-size:10px;">{gv}</span>{/each}
{#each v.loaders as l}<span class="tag tag-outline mono" style="font-size:10px;">{l}</span>{/each}
<span class="mono text-muted" style="margin-left:auto;font-size:11px;">{new Date(v.published_at).toLocaleDateString()}</span>
<a href={v.download_url} class="btn btn-secondary" style="padding:6px 12px;font-size:12px;" target="_blank">Download</a>
</div>
{:else}<p class="text-muted" style="padding:16px;">No versions yet.</p>{/each}
</div>
{:else}
<div class="card" style="gap:12px;">
<h3 style="margin:0;">Active servers (bStats)</h3>
{#if analytics?.daily?.length}<div style="display:flex;gap:4px;align-items:end;height:60px;">{#each analytics.daily.slice(-14) as d}<div style="flex:1;background:var(--color-accent);border-radius:4px 4px 0 0;height:{Math.max(8, d.active_servers % 60)}px;"></div>{/each}</div>{:else}<p class="text-muted">No analytics yet - add Indexium SDK to your mod.</p>{/if}
<pre class="mono" style="font-size:11px;opacity:.7;overflow:auto;">{JSON.stringify(analytics?.breakdown ?? {}, null, 2)}</pre>
</div>
{/if}
</div>
<aside style="display:flex;flex-direction:column;gap:12px;position:sticky;top:76px;">
<div class="card" style="gap:10px;">
<button class="btn btn-primary" style="width:100%;">Install</button>
<a href={mod.versions[0]?.download_url ?? '#'} class="btn btn-secondary" style="width:100%;" target="_blank">Download jar</a>
<div class="mono" style="font-size:11px;opacity:.6;">SHA256: {mod.versions[0]?.file_sha256?.slice(0,16) ?? '-'}…</div>
</div>
<div class="card" style="gap:8px;">
<p style="margin:0;font-weight:600;">Details</p>
<p class="text-muted" style="margin:0;font-size:13px;">Repo: {mod.github_repo}</p>
<p class="text-muted" style="margin:0;font-size:13px;">Updated: {new Date(mod.updated_at).toLocaleDateString()}</p>
<img src="/api/v1/badges/{mod.slug}/downloads.svg" alt="downloads badge" style="margin-top:8px;max-width:100%;" />
</div>
</aside>
</div>
{/if}
</div>

View file

@ -0,0 +1,115 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchMods, type ModListItem } from '$lib/api';
let query = $state('');
let gameVersion = $state<string | null>(null);
let loader = $state<string | null>(null);
let category = $state<string | null>(null);
let sort: 'relevance' | 'newest' | 'popular' = $state('relevance');
let page = $state(1);
let mods = $state<ModListItem[]>([]);
let total = $state(0);
let pages = $state(1);
let loading = $state(true);
let versionFilter = $state('');
let showAllVersions = $state(false);
const gameVersions = ['1.21.1','1.21','1.20.1','1.20','1.19.4','1.19.2','1.18.2','1.17.1','1.16.5','1.12.2'];
const loaders = ['fabric','forge','neoforge','quilt'];
const categories = ['Optimization','Library','Utility','Adventure','Technology','World Generation'];
let filteredVersions = $derived(gameVersions.filter(v => !versionFilter || v.includes(versionFilter)));
let visibleVersions = $derived(showAllVersions ? filteredVersions : filteredVersions.slice(0,4));
async function load() {
loading = true;
try {
const res = await fetchMods({ query: query || undefined, gameVersion: gameVersion ?? undefined, loader: loader ?? undefined, page, limit: 20, sort });
mods = res.data; total = res.pagination.total; pages = res.pagination.pages;
} catch { mods = []; } finally { loading = false; }
}
onMount(load);
function toggle(v: string | null, setter: (x: string | null) => void, cur: string | null) { setter(cur === v ? null : v); load(); }
</script>
<svelte:head><title>Discover mods - Indexium</title></svelte:head>
<div style="max-width:1280px;margin:0 auto;padding:0 clamp(16px,4vw,32px);">
<div style="display:flex;gap:8px;align-items:center;padding:14px 0 8px;flex-wrap:wrap;border-bottom:1px solid var(--color-divider);margin-bottom:16px;">
<a href="/mods" class="mono" style="padding:6px 12px;border-radius:999px;background:var(--color-accent);color:var(--color-bg);font-size:13px;font-weight:600;">Mods</a>
<a href="/plugins" class="mono" style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);opacity:.7;font-size:13px;">Plugins</a>
<a href="#" class="mono" style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);opacity:.5;font-size:13px;">Resource Packs</a>
<a href="#" class="mono" style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);opacity:.5;font-size:13px;">Shaders</a>
</div>
<div style="display:flex;gap:16px;align-items:center;margin:16px 0;">
<div style="position:relative;flex:1;max-width:560px;">
<svg width="16" height="16" viewBox="0 0 256 256" fill="currentColor" style="position:absolute;left:12px;top:50%;transform:translateY(-50%);opacity:.5;"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></svg>
<input class="input" placeholder="Search mods..." style="padding-left:36px;height:40px;" bind:value={query} oninput={load} />
</div>
<select class="input" style="width:160px;height:40px;" bind:value={sort} onchange={load}>
<option value="relevance">Relevance</option><option value="newest">Newest</option><option value="popular">Popular</option>
</select>
<span class="mono" style="opacity:.6;font-size:12px;">{total} results</span>
</div>
<div style="display:grid;grid-template-columns:260px 1fr;gap:24px;align-items:start;">
<!-- filters sidebar modrinth-like -->
<aside style="position:sticky;top:76px;display:flex;flex-direction:column;gap:18px;">
<div class="card" style="padding:14px;gap:10px;">
<p class="mono" style="font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;margin:0;">Game version</p>
<div style="position:relative;">
<input class="input" placeholder="Filter versions..." style="height:32px;font-size:13px;padding-right:28px;" bind:value={versionFilter} />
{#if versionFilter}<button onclick={() => versionFilter=''} style="position:absolute;right:6px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;opacity:.5;">✕</button>{/if}
</div>
<div style="display:flex;flex-direction:column;gap:4px;max-height:180px;overflow:auto;">
{#each visibleVersions as v}<label class="radio" style="font-size:13px;"><input type="radio" name="gv" checked={gameVersion===v} onchange={() => toggle(v, (x)=>gameVersion=x, gameVersion)} /><span class="dot"></span>{v}</label>{/each}
{#if !showAllVersions && filteredVersions.length > 4}<button class="mono" onclick={() => showAllVersions = true} style="text-align:left;font-size:12px;color:var(--color-accent);background:none;border:none;cursor:pointer;padding:4px 0;">... +{filteredVersions.length - 4} еще</button>{/if}
{#if showAllVersions}<button class="mono" onclick={() => showAllVersions = false} style="text-align:left;font-size:12px;opacity:.6;background:none;border:none;cursor:pointer;padding:4px 0;">свернуть</button>{/if}
{#if filteredVersions.length===0}<span class="mono text-muted" style="font-size:12px;">нет совпадений</span>{/if}
</div>
</div>
<div class="card" style="padding:14px;gap:10px;">
<p class="mono" style="font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;margin:0;">Loader</p>
{#each loaders as l}<label class="radio" style="font-size:13px;"><input type="radio" name="loader" checked={loader===l} onchange={() => toggle(l, (x)=>loader=x, loader)} /><span class="dot"></span>{l}</label>{/each}
</div>
<div class="card" style="padding:14px;gap:10px;">
<p class="mono" style="font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;margin:0;">Category</p>
{#each categories as c}<label class="radio" style="font-size:13px;"><input type="radio" name="cat" checked={category===c} onchange={() => toggle(c, (x)=>category=x, category)} /><span class="dot"></span>{c}</label>{/each}
</div>
</aside>
<!-- list -->
<main style="display:flex;flex-direction:column;gap:12px;">
{#if loading}
{#each Array(6) as _}<div class="card" style="height:110px;animation:shimmer 1.4s infinite linear;"></div>{/each}
{:else if mods.length===0}
<div class="card" style="padding:32px;text-align:center;opacity:.7;">No mods found - try different filters or <a href="/publish">publish</a> one.</div>
{:else}
{#each mods as m}
<a href="/mod/{m.slug}" class="card" style="flex-direction:row;gap:14px;padding:14px;text-decoration:none;color:inherit;border:1px solid var(--color-divider);transition:border-color .15s;">
<div style="width:64px;height:64px;border-radius:8px;background:linear-gradient(135deg,var(--color-accent-800),var(--color-accent-600));display:flex;align-items:center;justify-content:center;color:var(--color-bg);font-weight:700;font-size:20px;flex:none;">{m.name[0]}</div>
<div style="flex:1;min-width:0;">
<div style="display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;">
<span style="font-weight:600;font-size:15px;">{m.name}</span>
<span class="text-muted" style="font-size:13px;">by {m.author}</span>
<span class="mono" style="margin-left:auto;font-size:11px;opacity:.6;">{new Date(m.updated_at).toLocaleDateString()}</span>
</div>
<p style="margin:4px 0 6px;font-size:13px;opacity:.8;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">{m.summary ?? 'No description'}</p>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
{#each m.loaders as l}<span class="tag tag-outline mono" style="font-size:10px;padding:2px 6px;">{l}</span>{/each}
{#each m.game_versions.slice(0,3) as gv}<span class="tag tag-neutral mono" style="font-size:10px;padding:2px 6px;">{gv}</span>{/each}
<span class="mono" style="margin-left:auto;font-size:11px;opacity:.6;">{m.latest_version ?? ''}</span>
</div>
</div>
</a>
{/each}
<div style="display:flex;gap:8px;justify-content:center;margin-top:12px;">
<button class="btn btn-secondary" disabled={page<=1} onclick={() => {page--; load();}}>Prev</button>
<span class="mono" style="padding:8px 12px;opacity:.7;">{page} / {pages}</span>
<button class="btn btn-secondary" disabled={page>=pages} onclick={() => {page++; load();}}>Next</button>
</div>
{/if}
</main>
</div>
</div>

View file

@ -0,0 +1,114 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchMods, type ModListItem } from '$lib/api';
let query = $state('');
let gameVersion = $state<string | null>(null);
let loader = $state<string | null>(null);
let category = $state<string | null>(null);
let sort: 'relevance' | 'newest' | 'popular' = $state('relevance');
let page = $state(1);
let mods = $state<ModListItem[]>([]);
let total = $state(0);
let pages = $state(1);
let loading = $state(true);
let versionFilter = $state('');
let showAllVersions = $state(false);
const gameVersions = ['1.21.1','1.21','1.20.1','1.20','1.19.4','1.19.2','1.18.2','1.17.1','1.16.5','1.12.2'];
const loaders = ['paper','purpur','spigot','bukkit','velocity','waterfall','folia','pumpkin'];
const categories = ['Admin','Economy','Fun','World Management','Chat','Mechanics'];
let filteredVersions = $derived(gameVersions.filter(v => !versionFilter || v.includes(versionFilter)));
let visibleVersions = $derived(showAllVersions ? filteredVersions : filteredVersions.slice(0,4));
async function load() {
loading = true;
try {
const res = await fetchMods({ query: query || undefined, gameVersion: gameVersion ?? undefined, loader: loader ?? undefined, page, limit: 20, sort });
mods = res.data; total = res.pagination.total; pages = res.pagination.pages;
} catch { mods = []; } finally { loading = false; }
}
onMount(load);
function toggle(v: string | null, setter: (x: string | null) => void, cur: string | null) { setter(cur === v ? null : v); load(); }
</script>
<svelte:head><title>Discover plugins - Indexium</title></svelte:head>
<div style="max-width:1280px;margin:0 auto;padding:0 clamp(16px,4vw,32px);">
<div style="display:flex;gap:8px;align-items:center;padding:14px 0 8px;flex-wrap:wrap;border-bottom:1px solid var(--color-divider);margin-bottom:16px;">
<a href="/mods" class="mono" style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);opacity:.7;font-size:13px;">Mods</a>
<a href="/plugins" class="mono" style="padding:6px 12px;border-radius:999px;background:var(--color-accent);color:var(--color-bg);font-size:13px;font-weight:600;">Plugins</a>
<a href="#" class="mono" style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);opacity:.6;font-size:13px;">Resource Packs</a>
<a href="#" class="mono" style="padding:6px 12px;border-radius:999px;border:1px solid var(--color-divider);opacity:.6;font-size:13px;">Shaders</a>
</div>
<div style="display:flex;gap:16px;align-items:center;margin:16px 0;">
<div style="position:relative;flex:1;max-width:560px;">
<svg width="16" height="16" viewBox="0 0 256 256" fill="currentColor" style="position:absolute;left:12px;top:50%;transform:translateY(-50%);opacity:.5;"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></svg>
<input class="input" placeholder="Search plugins..." style="padding-left:36px;height:40px;" bind:value={query} oninput={load} />
</div>
<select class="input" style="width:160px;height:40px;" bind:value={sort} onchange={load}>
<option value="relevance">Relevance</option><option value="newest">Newest</option><option value="popular">Popular</option>
</select>
<span class="mono" style="opacity:.6;font-size:12px;">{total} results</span>
</div>
<div style="display:grid;grid-template-columns:260px 1fr;gap:24px;align-items:start;">
<aside style="position:sticky;top:76px;display:flex;flex-direction:column;gap:18px;">
<div class="card" style="padding:14px;gap:10px;">
<p class="mono" style="font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;margin:0;">Game version</p>
<div style="position:relative;">
<input class="input" placeholder="Filter versions..." style="height:32px;font-size:13px;padding-right:28px;" bind:value={versionFilter} />
{#if versionFilter}<button onclick={() => versionFilter=''} style="position:absolute;right:6px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;opacity:.5;">✕</button>{/if}
</div>
<div style="display:flex;flex-direction:column;gap:4px;max-height:180px;overflow:auto;">
{#each visibleVersions as v}<label class="radio" style="font-size:13px;"><input type="radio" name="gv" checked={gameVersion===v} onchange={() => toggle(v, (x)=>gameVersion=x, gameVersion)} /><span class="dot"></span>{v}</label>{/each}
{#if !showAllVersions && filteredVersions.length > 4}<button class="mono" onclick={() => showAllVersions = true} style="text-align:left;font-size:12px;color:var(--color-accent);background:none;border:none;cursor:pointer;padding:4px 0;">... +{filteredVersions.length - 4} еще</button>{/if}
{#if showAllVersions}<button class="mono" onclick={() => showAllVersions = false} style="text-align:left;font-size:12px;opacity:.6;background:none;border:none;cursor:pointer;padding:4px 0;">свернуть</button>{/if}
{#if filteredVersions.length===0}<span class="mono text-muted" style="font-size:12px;">нет совпадений</span>{/if}
</div>
</div>
<div class="card" style="padding:14px;gap:10px;">
<p class="mono" style="font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;margin:0;">Server software</p>
{#each loaders as l}<label class="radio" style="font-size:13px;"><input type="radio" name="loader" checked={loader===l} onchange={() => toggle(l, (x)=>loader=x, loader)} /><span class="dot"></span>{l}</label>{/each}
</div>
<div class="card" style="padding:14px;gap:10px;">
<p class="mono" style="font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;margin:0;">Category</p>
{#each categories as c}<label class="radio" style="font-size:13px;"><input type="radio" name="cat" checked={category===c} onchange={() => toggle(c, (x)=>category=x, category)} /><span class="dot"></span>{c}</label>{/each}
</div>
</aside>
<!-- list -->
<main style="display:flex;flex-direction:column;gap:12px;">
{#if loading}
{#each Array(6) as _}<div class="card" style="height:110px;"></div>{/each}
{:else if mods.length===0}
<div class="card" style="padding:32px;text-align:center;opacity:.7;">No plugins found - try different filters. Plugins are separate from mods (Paper/Purpur etc). <a href="/publish">Publish</a> one.</div>
{:else}
{#each mods as m}
<a href="/mod/{m.slug}" class="card" style="flex-direction:row;gap:14px;padding:14px;text-decoration:none;color:inherit;border:1px solid var(--color-divider);transition:border-color .15s;">
<div style="width:64px;height:64px;border-radius:8px;background:linear-gradient(135deg,var(--color-accent-800),var(--color-accent-600));display:flex;align-items:center;justify-content:center;color:var(--color-bg);font-weight:700;font-size:20px;flex:none;">{m.name[0]}</div>
<div style="flex:1;min-width:0;">
<div style="display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;">
<span style="font-weight:600;font-size:15px;">{m.name}</span>
<span class="text-muted" style="font-size:13px;">by {m.author}</span>
<span class="mono" style="margin-left:auto;font-size:11px;opacity:.6;">{new Date(m.updated_at).toLocaleDateString()}</span>
</div>
<p style="margin:4px 0 6px;font-size:13px;opacity:.8;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">{m.summary ?? 'No description'}</p>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
{#each m.loaders as l}<span class="tag tag-outline mono" style="font-size:10px;padding:2px 6px;">{l}</span>{/each}
{#each m.game_versions.slice(0,3) as gv}<span class="tag tag-neutral mono" style="font-size:10px;padding:2px 6px;">{gv}</span>{/each}
<span class="mono" style="margin-left:auto;font-size:11px;opacity:.6;">{m.latest_version ?? ''}</span>
</div>
</div>
</a>
{/each}
<div style="display:flex;gap:8px;justify-content:center;margin-top:12px;">
<button class="btn btn-secondary" disabled={page<=1} onclick={() => {page--; load();}}>Prev</button>
<span class="mono" style="padding:8px 12px;opacity:.7;">{page} / {pages}</span>
<button class="btn btn-secondary" disabled={page>=pages} onclick={() => {page++; load();}}>Next</button>
</div>
{/if}
</main>
</div>
</div>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { onMount } from 'svelte';
let repo = $state('');
let status = $state<'idle'|'loading'|'ok'|'error'>('idle');
let msg = $state('');
let isAuthed = $state(false);
onMount(async () => {
try {
const r = await fetch('http://localhost:8080/api/v1/auth/me', { credentials: 'include' });
isAuthed = r.ok;
} catch { isAuthed = false; }
});
async function publish() {
if (!isAuthed) { window.location.href = 'http://localhost:8080/api/v1/auth/github'; return; }
status='loading'; msg='';
try {
const res = await fetch(`http://localhost:8080/api/v1/mods/import`, {
method:'POST', headers:{'Content-Type':'application/json'}, credentials: 'include',
body: JSON.stringify({ repo })
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? data.error ?? 'Failed');
status='ok'; msg=`Imported ${repo} - ${data.slug ?? 'ok'}`;
} catch(e:any){ status='error'; msg=e.message; }
}
</script>
<svelte:head><title>Publish mod - Indexium</title></svelte:head>
<div style="max-width:720px;margin:0 auto;padding:32px clamp(16px,4vw,32px);">
<h1 style="margin:0 0 8px;">Publish your mod</h1>
<p class="text-muted" style="margin:0 0 24px;">Open-source only. We index your GitHub Releases - no file upload.</p>
{#if !isAuthed}
<div class="card" style="gap:14px;padding:20px;border:1px solid var(--color-accent);background:color-mix(in srgb,var(--color-accent) 8%,var(--color-surface));">
<p style="margin:0;font-weight:600;">Требуется вход через GitHub</p>
<p class="text-muted" style="margin:0;font-size:13px;">Публикация доступна только авторам. Мы проверим что репо твое, публичное и с LICENSE.</p>
<a href="http://localhost:8080/api/v1/auth/github" class="btn btn-primary" style="align-self:flex-start;">Войти через GitHub - Publish</a>
</div>
{:else}
<div class="card" style="gap:16px;">
<label style="display:flex;flex-direction:column;gap:6px;">
<span class="mono" style="font-size:12px;letter-spacing:.06em;text-transform:uppercase;opacity:.7;">GitHub repo</span>
<input class="input" placeholder="owner/repo e.g. jellysquid3/sodium" bind:value={repo} />
<span class="text-muted" style="font-size:12px;">Repo must be public, have LICENSE and fabric.mod.json / mods.toml in default branch.</span>
</label>
<button class="btn btn-primary" style="align-self:flex-start;" onclick={publish} disabled={status==='loading' || !repo.includes('/')}>
{status==='loading' ? 'Publishing…' : 'Publish'}
</button>
{#if msg}<div class="card" style="padding:12px;background:{status==='ok' ? 'color-mix(in srgb,var(--color-accent) 12%,var(--color-surface))' : '#3a1a1a'};border:1px solid {status==='ok' ? 'var(--color-accent)' : '#a00'};">{msg}</div>{/if}
</div>
{/if}
<div class="card" style="margin-top:18px;gap:12px;">
<h3 style="margin:0;font-size:16px;">How it works</h3>
<ol style="margin:0;padding-left:18px;opacity:.8;font-size:14px;line-height:1.6;">
<li>Login with GitHub (read:user)</li>
<li>Paste <code class="mono">owner/repo</code> → we verify LICENSE + manifest</li>
<li>Install webhook → every release is indexed via Range-Request (no full download)</li>
</ol>
<p class="text-muted" style="margin:0;font-size:13px;">See <a href="/docs">docs</a> and <a href="https://github.com/loki5512344/Indexium" target="_blank">GitHub</a>.</p>
</div>
</div>

View file

@ -0,0 +1,28 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { fetchMods } from '$lib/api';
let login = $derived($page.params.login ?? 'unknown');
let mods: any[] = $state([]);
let loading = $state(true);
onMount(async () => {
try { const r = await fetchMods({ query: login }); mods = r.data.filter(m => m.author.toLowerCase()===login.toLowerCase()); } catch {} finally { loading=false; }
});
</script>
<svelte:head><title>{login} - Indexium</title></svelte:head>
<div style="max-width:1100px;margin:0 auto;padding:0 clamp(16px,4vw,32px);">
<div class="card" style="flex-direction:row;gap:16px;padding:20px;margin-top:20px;">
<div style="width:72px;height:72px;border-radius:50%;background:linear-gradient(135deg,var(--color-accent-500),var(--color-accent-700));display:flex;align-items:center;justify-content:center;color:var(--color-bg);font-weight:700;font-size:28px;flex:none;">{login[0]?.toUpperCase()}</div>
<div>
<h1 style="margin:0;">{login}</h1>
<p class="text-muted" style="margin:4px 0;"><a href="https://github.com/{login}" target="_blank">github.com/{login}</a> · Indexium author</p>
<div style="display:flex;gap:8px;margin-top:8px;"><span class="tag tag-accent mono">verified</span><span class="tag tag-neutral mono">{mods.length} mods</span></div>
</div>
</div>
<h2 style="margin:24px 0 12px;">Mods by {login}</h2>
{#if loading}<p class="text-muted">Loading…</p>
{:else if mods.length===0}<div class="card" style="padding:24px;text-align:center;opacity:.7;">No mods yet. <a href="/publish">Publish</a> one.</div>
{:else}<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;">{#each mods as m}<a href="/mod/{m.slug}" class="card" style="text-decoration:none;color:inherit;"><span style="font-weight:600;">{m.name}</span><span class="text-muted" style="font-size:13px;">{m.summary ?? ''}</span></a>{/each}</div>{/if}
</div>

10
scripts/check-no-emdash.sh Executable file
View file

@ -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

115
todo.md
View file

@ -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?