feat: auth/stars API, collections and publish routes, star/author migrations
This commit is contained in:
parent
43cf0e277d
commit
65820d4ef9
43 changed files with 2008 additions and 408 deletions
|
|
@ -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
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -1,7 +1,7 @@
|
|||
# Indexium
|
||||
|
||||
> Лёгкий, дешёвый и устойчивый к лимитам GitHub индексатор модов Minecraft.
|
||||
> Бэкенд — **асинхронный событийный индексатор**: не хранит тяжёлые `.jar`, индексирует метаданные из GitHub Releases CDN и отдаёт быстрые JSON-ответы.
|
||||
> Бэкенд - **асинхронный событийный индексатор**: не хранит тяжёлые `.jar`, индексирует метаданные из GitHub Releases CDN и отдаёт быстрые JSON-ответы.
|
||||
|
||||
## Архитектура (TL;DR)
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ GitHub --webhook release.published--> Ingestion API (HMAC check) --> Redis Queue
|
|||
└── docker-compose.yml # (WIP) Postgres + Redis
|
||||
```
|
||||
|
||||
Почему монорепо: см. [`docs/git-strategy.md`](docs/git-strategy.md) — один clone, атомарные изменения API+UI, один CI.
|
||||
Почему монорепо: см. [`docs/git-strategy.md`](docs/git-strategy.md) - один clone, атомарные изменения API+UI, один CI.
|
||||
|
||||
## Быстрый старт (локально)
|
||||
|
||||
|
|
@ -58,12 +58,12 @@ bun run dev
|
|||
|
||||
## Документация
|
||||
|
||||
- [`docs/architecture.md`](docs/architecture.md) — компоненты, lifecycle релиза, обход лимитов
|
||||
- [`docs/database-schema.md`](docs/database-schema.md) — схема БД + индексы
|
||||
- [`docs/api-spec.md`](docs/api-spec.md) — Public REST API v1
|
||||
- [`docs/deployment.md`](docs/deployment.md) — деплой, бэкапы
|
||||
- [`docs/git-strategy.md`](docs/git-strategy.md) — почему монорепо и как работать с ним
|
||||
- [`todo.md`](todo.md) — роадмап по фазам
|
||||
- [`docs/architecture.md`](docs/architecture.md) - компоненты, lifecycle релиза, обход лимитов
|
||||
- [`docs/database-schema.md`](docs/database-schema.md) - схема БД + индексы
|
||||
- [`docs/api-spec.md`](docs/api-spec.md) - Public REST API v1
|
||||
- [`docs/deployment.md`](docs/deployment.md) - деплой, бэкапы
|
||||
- [`docs/git-strategy.md`](docs/git-strategy.md) - почему монорепо и как работать с ним
|
||||
- [`todo.md`](todo.md) - роадмап по фазам
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
|
@ -71,4 +71,4 @@ TBD
|
|||
|
||||
## Контакты / Issues
|
||||
|
||||
Используй GitHub Issues для багов и фич. Перед PR — `cargo fmt && cargo clippy`.
|
||||
Используй GitHub Issues для багов и фич. Перед PR - `cargo fmt && cargo clippy`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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` на каждый релиз - отклонено: трафик, медленно.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 как источник правды.
|
||||
|
|
|
|||
|
|
@ -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 в проде.
|
||||
|
|
|
|||
|
|
@ -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. Пример: `` — **делаем в 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. Пример: `` - **делаем в 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 юзеров?
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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. Будущие расширения
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
6
indexium-backend/migrations/20260909000000_stars.sql
Normal file
6
indexium-backend/migrations/20260909000000_stars.sql
Normal 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)
|
||||
);
|
||||
6
indexium-backend/migrations/20260909000001_authors.sql
Normal file
6
indexium-backend/migrations/20260909000001_authors.sql
Normal 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()
|
||||
);
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
200
indexium-backend/src/api/auth.rs
Normal file
200
indexium-backend/src/api/auth.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
111
indexium-backend/src/api/stars.rs
Normal file
111
indexium-backend/src/api/stars.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ pub struct FabricModJson {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Async I/O — HTTP Range Requests
|
||||
// Async I/O - HTTP Range Requests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// HEAD → (content_length, supports_range)
|
||||
|
|
|
|||
123
indexium-frontend/src/app.css
Normal file
123
indexium-frontend/src/app.css
Normal 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;}}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{/each}
|
||||
<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>
|
||||
<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}
|
||||
<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>
|
||||
<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>
|
||||
</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}
|
||||
</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>
|
||||
|
||||
{#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>
|
||||
{:else}
|
||||
{#if pagination}
|
||||
<p style="color:#666;font-size:0.9rem">
|
||||
Найдено {pagination.total} · страница {pagination.page} из {pagination.pages}
|
||||
</p>
|
||||
{/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>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
<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>
|
||||
|
|
|
|||
294
indexium-frontend/src/routes/collections/+page.svelte
Normal file
294
indexium-frontend/src/routes/collections/+page.svelte
Normal 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>
|
||||
156
indexium-frontend/src/routes/collections/[slug]/+page.svelte
Normal file
156
indexium-frontend/src/routes/collections/[slug]/+page.svelte
Normal 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>
|
||||
78
indexium-frontend/src/routes/mod/[slug]/+page.svelte
Normal file
78
indexium-frontend/src/routes/mod/[slug]/+page.svelte
Normal 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>
|
||||
115
indexium-frontend/src/routes/mods/+page.svelte
Normal file
115
indexium-frontend/src/routes/mods/+page.svelte
Normal 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>
|
||||
114
indexium-frontend/src/routes/plugins/+page.svelte
Normal file
114
indexium-frontend/src/routes/plugins/+page.svelte
Normal 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>
|
||||
65
indexium-frontend/src/routes/publish/+page.svelte
Normal file
65
indexium-frontend/src/routes/publish/+page.svelte
Normal 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>
|
||||
28
indexium-frontend/src/routes/u/[login]/+page.svelte
Normal file
28
indexium-frontend/src/routes/u/[login]/+page.svelte
Normal 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
10
scripts/check-no-emdash.sh
Executable 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
115
todo.md
|
|
@ -1,4 +1,4 @@
|
|||
# Indexium — TODO / Roadmap
|
||||
# Indexium - TODO / Roadmap
|
||||
|
||||
> Сервис: асинхронный событийный индексатор модов Minecraft поверх GitHub Releases CDN.
|
||||
> Бэкенд не хранит тяжёлые артефакты, только метаданные + индексация + быстрый JSON API.
|
||||
|
|
@ -7,49 +7,50 @@
|
|||
|
||||
## Правила проекта (обязательно к соблюдению)
|
||||
|
||||
> Эти правила — не чекбоксы, а инварианты. Любой PR, нарушающий их, не принимается.
|
||||
> Эти правила - не чекбоксы, а инварианты. Любой PR, нарушающий их, не принимается.
|
||||
|
||||
### 1. KISS — Keep It Simple, Stupid
|
||||
### 1. KISS - Keep It Simple, Stupid
|
||||
- Выбирай самое простое решение, которое закрывает задачу. Никаких абстракций «на будущее» (YAGNI).
|
||||
- Один модуль — одна ответственность. Если не можешь объяснить функцию в одном предложении — дроби.
|
||||
- Один модуль - одна ответственность. Если не можешь объяснить функцию в одном предложении - дроби.
|
||||
- Предпочитай явный код неявной магии (никаких макросов ради макросов).
|
||||
|
||||
### 2. DRY — Don't Repeat Yourself
|
||||
### 2. DRY - Don't Repeat Yourself
|
||||
- Повтор >2 раз → выноси в функцию/модуль. Но не DRY ради DRY: дублирование лучше неправильной абстракции.
|
||||
- Общие типы/утилиты — в `common`/`shared`, доменная логика — в своём модуле.
|
||||
- Общие типы/утилиты - в `common`/`shared`, доменная логика - в своём модуле.
|
||||
|
||||
### 3. SOLID (применительно к Rust)
|
||||
- **S** — один файл/модуль = одна причина для изменений (см. лимиты ниже).
|
||||
- **O** — открыт для расширения через трейты, закрыт для модификации (feature-flag, а не `if` на типы).
|
||||
- **L** — любой `impl Trait` должен заменять другой без поломки контракта.
|
||||
- **I** — узкие трейты лучше жирных (`Readable`, `Validatable` вместо `GodService`).
|
||||
- **D** — зависимость от абстракций (`PgPool` через `AppState`, а не глобаль).
|
||||
- **S** - один файл/модуль = одна причина для изменений (см. лимиты ниже).
|
||||
- **O** - открыт для расширения через трейты, закрыт для модификации (feature-flag, а не `if` на типы).
|
||||
- **L** - любой `impl Trait` должен заменять другой без поломки контракта.
|
||||
- **I** - узкие трейты лучше жирных (`Readable`, `Validatable` вместо `GodService`).
|
||||
- **D** - зависимость от абстракций (`PgPool` через `AppState`, а не глобаль).
|
||||
|
||||
### 4. Лимиты структуры (жёстко)
|
||||
- **Макс 250 строк на файл** — если больше, дроби файл на подмодули.
|
||||
- **Макс 4 файла на папку** — если больше, вводи подпапки по домену (`api/mods/`, `worker/parsers/`).
|
||||
- **Макс 250 строк на файл** - если больше, дроби файл на подмодули.
|
||||
- **Макс 4 файла на папку** - если больше, вводи подпапки по домену (`api/mods/`, `worker/parsers/`).
|
||||
- Исключение: `mod.rs`/`lib.rs` не считаются, но должны быть тонкими реэкспортами.
|
||||
- CI будет ругаться (`cargo clippy` + кастомный скрипт `scripts/check-limits.sh`).
|
||||
|
||||
### 5. Дополнительные инварианты
|
||||
- **Чистота ошибок**: никаких `unwrap()`/`expect()` вне `main.rs` и тестов. Везде `Result` + `thiserror`/`anyhow`.
|
||||
- **Типы вместо строк**: `Slug`, `GameVersion`, `Loader` — newtype, а не `String`.
|
||||
- **Миграции только вперёд**: никаких `DROP` без ADR и бэкапа. Каждая миграция — идемпотентна (`IF NOT EXISTS`).
|
||||
- **Логика без сайд-эффектов**: парсеры/валидаторы — чистые функции, I/O только на границах (handler/worker).
|
||||
- **Документация рядом с кодом**: публичная функция без `///` — не готова к мерджу.
|
||||
- **Типы вместо строк**: `Slug`, `GameVersion`, `Loader` - newtype, а не `String`.
|
||||
- **Миграции только вперёд**: никаких `DROP` без ADR и бэкапа. Каждая миграция - идемпотентна (`IF NOT EXISTS`).
|
||||
- **Логика без сайд-эффектов**: парсеры/валидаторы - чистые функции, I/O только на границах (handler/worker).
|
||||
- **Документация рядом с кодом**: публичная функция без `///` - не готова к мерджу.
|
||||
- **Тест на каждый баг**: регрессия покрывается тестом до фикса.
|
||||
- **Типографика**: запрещено использование em dash (U+2014). Везде используй `-` (дефис). Проверка: `scripts/check-no-emdash.sh` должен быть OK.
|
||||
|
||||
---
|
||||
|
||||
## Легенда статусов
|
||||
- `[ ]` — не начато
|
||||
- `[~]` — в процессе
|
||||
- `[x]` — готово
|
||||
- `[!]` — заблокировано / требует решения
|
||||
- `[ ]` - не начато
|
||||
- `[~]` - в процессе
|
||||
- `[x]` - готово
|
||||
- `[!]` - заблокировано / требует решения
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Фундамент монорепо (Текущий приоритет)
|
||||
## Phase 0 - Фундамент монорепо (Текущий приоритет)
|
||||
|
||||
- [x] Объединить `indexium-backend` + `indexium-frontend` в один git-монорепо (корень `/`)
|
||||
- [x] Настроить корневой `.gitignore` + локальные `.gitignore`
|
||||
|
|
@ -61,31 +62,31 @@
|
|||
- [ ] Добавить `docker-compose.yml` (Postgres + Redis/Valkey) для локальной разработки
|
||||
- [ ] Добавить `Makefile` / `justfile` с командами `dev`, `migrate`, `lint`, `test`
|
||||
- [ ] Настроить CI (GitHub Actions): `cargo clippy + test`, `svelte-check`, `sqlx migrate check`
|
||||
- [ ] Скрипт `scripts/check-limits.sh` — проверка 250 строк / 4 файла на папку
|
||||
- [ ] Скрипт `scripts/check-limits.sh` - проверка 250 строк / 4 файла на папку
|
||||
|
||||
## Phase 1 — Backend Core (Rust / Axum)
|
||||
## Phase 1 - Backend Core (Rust / Axum)
|
||||
|
||||
### 1.1 Инфраструктура
|
||||
- [x] `config` — загрузка `.env` (DATABASE_URL, REDIS_URL, GITHUB_APP_ID, WEBHOOK_SECRET) — база в `main.rs` через `dotenvy`
|
||||
- [x] `db` — пул `sqlx::PgPool`, миграции (`sqlx::migrate!`), health-check `/health`
|
||||
- [x] `tracing` — структурированные логи (EnvFilter + fmt layer)
|
||||
- [x] `config` - загрузка `.env` (DATABASE_URL, REDIS_URL, GITHUB_APP_ID, WEBHOOK_SECRET) - база в `main.rs` через `dotenvy`
|
||||
- [x] `db` - пул `sqlx::PgPool`, миграции (`sqlx::migrate!`), health-check `/health`
|
||||
- [x] `tracing` - структурированные логи (EnvFilter + fmt layer)
|
||||
- [x] Axum роутер: `GET /health`, CORS (5173), TraceLayer
|
||||
|
||||
### 1.2 Схема БД (PostgreSQL + FTS + pg_trgm)
|
||||
- [x] Миграция `20260906000000_init_schema.sql` — таблицы `mods`, `mod_versions` + `GIN (game_versions, loaders)`
|
||||
- [ ] Миграция `002_fts` — `search_vector`, `pg_trgm`, триггер (см. `docs/database-schema.md`)
|
||||
- [x] Миграция `20260906000000_init_schema.sql` - таблицы `mods`, `mod_versions` + `GIN (game_versions, loaders)`
|
||||
- [ ] Миграция `002_fts` - `search_vector`, `pg_trgm`, триггер (см. `docs/database-schema.md`)
|
||||
- [ ] Таблица `authors` + `webhook_deliveries` (идемпотентность)
|
||||
- [ ] Сиды / фикстуры для локального дев-окружения
|
||||
|
||||
### 1.3 Webhook Ingestion API
|
||||
- [ ] `POST /api/v1/webhooks/github` — проверка `X-Hub-Signature-256` (HMAC SHA-256)
|
||||
- [ ] `POST /api/v1/webhooks/github` - проверка `X-Hub-Signature-256` (HMAC SHA-256)
|
||||
- [ ] Валидация эвента `release.published` / `release.released`, идемпотентность по `delivery_id`
|
||||
- [ ] Пуш задачи в очередь (Redis Streams) + ответ `202 Accepted` < 50ms
|
||||
- [ ] Тест на replay-атаку и неверную подпись
|
||||
|
||||
### 1.4 Async Worker / Indexer
|
||||
- [ ] Консьюмер очереди (tokio task)
|
||||
- [ ] Скачивание через HTTP Range Request — чтение только ZIP central directory `.jar`
|
||||
- [ ] Скачивание через HTTP Range Request - чтение только ZIP central directory `.jar`
|
||||
- [ ] Парсинг `fabric.mod.json` / `quilt.mod.json` / `neoforge.mods.toml` / `mcmod.info`
|
||||
- [ ] Валидация: `mod_id`, `version`, `game_versions`, `loaders`, иконка
|
||||
- [ ] SHA-256 сверка (если приложен `.sha256`), отбраковка битого артефакта
|
||||
|
|
@ -93,36 +94,36 @@
|
|||
- [ ] Сохранение в `mod_versions`, инвалидация Redis-кэша
|
||||
|
||||
### 1.5 Public REST API (Read-Heavy, Cache-First)
|
||||
- [ ] `GET /api/v1/mods?query=&gameVersion=&loader=&page=&limit=` — FTS + фильтры, кэш Redis 60s
|
||||
- [ ] `GET /api/v1/mods/:slug` — карточка мода + список версий
|
||||
- [ ] `GET /api/v1/mods/:slug/versions/:version` — детали версии + `download_url` (прямая CDN ссылка GitHub)
|
||||
- [ ] `GET /api/v1/mods?query=&gameVersion=&loader=&page=&limit=` - FTS + фильтры, кэш Redis 60s
|
||||
- [ ] `GET /api/v1/mods/:slug` - карточка мода + список версий
|
||||
- [ ] `GET /api/v1/mods/:slug/versions/:version` - детали версии + `download_url` (прямая CDN ссылка GitHub)
|
||||
- [ ] Пагинация cursor/offset, ETag, `Cache-Control`
|
||||
- [ ] Rate limiting (tower_governor / redis-cell)
|
||||
|
||||
### 1.6 Auth & Profiles (см. docs/auth-profiles.md, adr/004)
|
||||
- [ ] GitHub OAuth 2.0 (read:user, user:email) + JWT httpOnly — вход для авторов (MVP)
|
||||
- [ ] PAT `personal_access_tokens` (hash, scopes, expires) — `POST /auth/tokens` для CLI/лаунчеров (MVP)
|
||||
- [ ] `POST /api/v1/mods/import` — импорт репозитория (проверка LICENSE + public + манифест)
|
||||
- [ ] Профили `/u/:login`, `/org/:login` — кэш ISR, sponsors, verified badge, SVG `/v1/badges/:slug/*.svg` (MVP)
|
||||
- [ ] Star/Follow `stars`, `follows` (с фильтром game_version/loader) — in-app уведомления (MVP-лайт)
|
||||
- [ ] Device Flow RFC8628 (`/oauth/device/code` → `/activate`) — спроектировать, реализация Phase 2
|
||||
- [ ] Discord linked_accounts + бот роли Verified Modder — Phase 2
|
||||
- [ ] Установка Webhook'а через GitHub App API (автоматически) — миграция с OAuth на App в Phase 2
|
||||
- [ ] GitHub OAuth 2.0 (read:user, user:email) + JWT httpOnly - вход для авторов (MVP)
|
||||
- [ ] PAT `personal_access_tokens` (hash, scopes, expires) - `POST /auth/tokens` для CLI/лаунчеров (MVP)
|
||||
- [ ] `POST /api/v1/mods/import` - импорт репозитория (проверка LICENSE + public + манифест)
|
||||
- [ ] Профили `/u/:login`, `/org/:login` - кэш ISR, sponsors, verified badge, SVG `/v1/badges/:slug/*.svg` (MVP)
|
||||
- [ ] Star/Follow `stars`, `follows` (с фильтром game_version/loader) - in-app уведомления (MVP-лайт)
|
||||
- [ ] Device Flow RFC8628 (`/oauth/device/code` → `/activate`) - спроектировать, реализация Phase 2
|
||||
- [ ] Discord linked_accounts + бот роли Verified Modder - Phase 2
|
||||
- [ ] Установка Webhook'а через GitHub App API (автоматически) - миграция с OAuth на App в Phase 2
|
||||
|
||||
## Phase 2 — Frontend (SvelteKit) + Social
|
||||
## Phase 2 - Frontend (SvelteKit) + Social
|
||||
|
||||
- [ ] Дизайн-система: Tailwind / UnoCSS + токены
|
||||
- [ ] Страницы: `/` (поиск + фильтры), `/mod/[slug]`, `/mods/import`, `/u/[login]`, `/org/[login]`, `/activate` (device flow)
|
||||
- [ ] Компоненты: `ModCard`, `VersionTable`, `SearchBar`, `LoaderBadge`, `ProfileHeader`, `SponsorsBar`
|
||||
- [ ] Клиент API (`src/lib/api.ts`) — типизированные fetch-обёртки
|
||||
- [ ] Клиент API (`src/lib/api.ts`) - типизированные fetch-обёртки
|
||||
- [ ] SSR + кэширование, skeletons, error boundaries
|
||||
- [ ] SEO / OpenGraph для карточек модов
|
||||
- [ ] **Collections / Modlists** `collections`, `collection_stars` + экспорт `?format=prism|packwiz` (Phase 2 хит)
|
||||
- [ ] **Activity Feed** — лента по подпискам (releases + collections + stars)
|
||||
- [ ] **Org/Teams** — `/org/:login` агрегатор, `role=maintainer`
|
||||
- [ ] **Геймификация** — `badges` (Early Adopter, Bug Hunter, Veteran) + Showcase SVG
|
||||
- [ ] **Activity Feed** - лента по подпискам (releases + collections + stars)
|
||||
- [ ] **Org/Teams** - `/org/:login` агрегатор, `role=maintainer`
|
||||
- [ ] **Геймификация** - `badges` (Early Adopter, Bug Hunter, Veteran) + Showcase SVG
|
||||
|
||||
## Phase 3 — Поиск, качество данных и аналитика (см. docs/analytics.md, adr/005)
|
||||
## Phase 3 - Поиск, качество данных и аналитика (см. docs/analytics.md, adr/005)
|
||||
|
||||
- [ ] PostgreSQL FTS (`to_tsvector` + `ts_rank`) по `name`, `summary`, `README`
|
||||
- [ ] `pg_trgm` для неточных совпадений / опечаток
|
||||
|
|
@ -135,14 +136,14 @@
|
|||
- [ ] `GET /mods/:slug/analytics?range=30d` + `GET /badges/:slug/servers.svg` + `sort=active_servers`
|
||||
- [ ] Легковесный Java/Kotlin SDK `dev.indexium:analytics` (MIT, SimplePie, opt-out флаг)
|
||||
|
||||
## Phase 4 — Надёжность и ограничения GitHub
|
||||
## Phase 4 - Надёжность и ограничения GitHub
|
||||
|
||||
- [ ] GitHub App Install token — 5k-12.5k RPH вместо 60 RPH анонимных
|
||||
- [ ] Прямые редиректы на `objects.githubusercontent.com` — не проксировать трафик
|
||||
- [ ] GitHub App Install token - 5k-12.5k RPH вместо 60 RPH анонимных
|
||||
- [ ] Прямые редиректы на `objects.githubusercontent.com` - не проксировать трафик
|
||||
- [ ] Retry + exponential backoff, DLQ для воркера
|
||||
- [ ] Метрики: Prometheus / `tracing` + Grafana, алерты на lag очереди
|
||||
|
||||
## Phase 5 — Деплой и эксплуатации
|
||||
## Phase 5 - Деплой и эксплуатации
|
||||
|
||||
- [ ] Dockerfile multi-stage для backend (distroless / alpine)
|
||||
- [ ] Dockerfile для frontend (adapter-node / adapter-static)
|
||||
|
|
@ -150,12 +151,12 @@
|
|||
- [ ] Бэкапы Postgres (PITR), миграции в CI
|
||||
- [ ] Документация деплоя (`docs/deployment.md`)
|
||||
|
||||
## Phase 6 — Расширения (Backlog)
|
||||
## Phase 6 - Расширения (Backlog)
|
||||
|
||||
- [ ] Поддержка CurseForge / Modrinth как доп. источников (опционально)
|
||||
- [ ] Webhooks для лаунчеров (подписка на обновления мода)
|
||||
- [ ] CLI для авторов (`indexium publish`)
|
||||
- [x] Аналитика рантайма — аналог bStats (спроектирована, см. docs/analytics.md) → реализация в Phase 3
|
||||
- [x] Аналитика рантайма - аналог bStats (спроектирована, см. docs/analytics.md) → реализация в Phase 3
|
||||
- [ ] Аналитика скачиваний (агрегация без хранения персоналки)
|
||||
|
||||
---
|
||||
|
|
@ -164,17 +165,17 @@
|
|||
|
||||
1. `docker-compose.yml` + первая миграция SQL
|
||||
2. `POST /webhooks/github` с HMAC-проверкой и заглушкой очереди (in-memory channel)
|
||||
3. `GET /mods` — мок-данные из БД + подключение фронта
|
||||
3. `GET /mods` - мок-данные из БД + подключение фронта
|
||||
|
||||
---
|
||||
|
||||
## Как отмечать прогресс
|
||||
|
||||
- При завершении задачи ставь `[x]` и добавляй ссылку на PR/коммит: `[x] Задача (#12)`
|
||||
- Если задача блочится — ставь `[!]` и опиши блокер в комментарии ниже.
|
||||
- Если задача блочится - ставь `[!]` и опиши блокер в комментарии ниже.
|
||||
|
||||
## Блокеры / Вопросы
|
||||
|
||||
- [ ] Выбрать окончательно очередь: `Redis Streams` vs `NATS JetStream` vs `pg-queue` на старте?
|
||||
- Рекомендация: стартовать с `Redis` (уже нужен как кэш) → мигрировать на NATS если нужен strict ordering.
|
||||
- [ ] Где хостить Postgres на старте — Supabase / Neon / self-hosted?
|
||||
- [ ] Где хостить Postgres на старте - Supabase / Neon / self-hosted?
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue