chore: init monorepo with GPL-3.0 license, docs, backend skeleton, frontend wiring
This commit is contained in:
commit
43cf0e277d
57 changed files with 5027 additions and 0 deletions
7
indexium-backend/.env.example
Normal file
7
indexium-backend/.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/indexium
|
||||
SERVER_PORT=8080
|
||||
RUST_LOG=info
|
||||
# WEBHOOK_SECRET=change_me
|
||||
# GITHUB_APP_ID=
|
||||
# GITHUB_APP_PRIVATE_KEY=
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
2
indexium-backend/.gitignore
vendored
Normal file
2
indexium-backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
.env
|
||||
26
indexium-backend/Cargo.toml
Normal file
26
indexium-backend/Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[package]
|
||||
name = "indexium-backend"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
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"] }
|
||||
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"] }
|
||||
tokio = { version = "1.53.1", features = ["full"] }
|
||||
tower-http = { version = "0.7.1", features = ["cors", "trace"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] }
|
||||
uuid = { version = "1.26.0", features = ["v4", "serde"] }
|
||||
thiserror = "2.0"
|
||||
flate2 = "1.1"
|
||||
bytes = "1.11"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
subtle = "2.6"
|
||||
32
indexium-backend/migrations/20260906000000_init_schema.sql
Normal file
32
indexium-backend/migrations/20260906000000_init_schema.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
-- Таблица модов (метаданные репозитория)
|
||||
CREATE TABLE IF NOT EXISTS mods (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
github_repo_id BIGINT UNIQUE NOT NULL,
|
||||
owner VARCHAR(255) NOT NULL,
|
||||
repo VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(64) UNIQUE NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
summary TEXT,
|
||||
icon_url TEXT,
|
||||
default_branch VARCHAR(32) NOT NULL DEFAULT 'main',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Таблица версий (релизов), полученных через Webhook/API
|
||||
CREATE TABLE IF NOT EXISTS mod_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mod_id UUID NOT NULL REFERENCES mods(id) ON DELETE CASCADE,
|
||||
version_number VARCHAR(64) NOT NULL,
|
||||
game_versions VARCHAR(32)[] NOT NULL,
|
||||
loaders VARCHAR(32)[] NOT NULL,
|
||||
download_url TEXT NOT NULL,
|
||||
file_sha256 CHAR(64) NOT NULL,
|
||||
published_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT unique_mod_version UNIQUE (mod_id, version_number)
|
||||
);
|
||||
|
||||
-- Индексы для быстрой фильтрации лаунчерами
|
||||
CREATE INDEX IF NOT EXISTS idx_mods_slug ON mods(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_versions_lookup ON mod_versions USING GIN (game_versions, loaders);
|
||||
30
indexium-backend/migrations/20260907000000_telemetry.sql
Normal file
30
indexium-backend/migrations/20260907000000_telemetry.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
-- Indexium Analytics: bStats аналог
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mod_telemetry_pings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
mod_id UUID NOT NULL REFERENCES mods(id) ON DELETE CASCADE,
|
||||
server_hash CHAR(64) NOT NULL,
|
||||
mc_version VARCHAR(16) NOT NULL,
|
||||
loader VARCHAR(16) NOT NULL,
|
||||
os VARCHAR(16) NOT NULL,
|
||||
java_version VARCHAR(16) NOT NULL,
|
||||
player_count INT NOT NULL DEFAULT 0,
|
||||
pinged_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_lookup ON mod_telemetry_pings (mod_id, pinged_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_hash ON mod_telemetry_pings (server_hash, pinged_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mod_daily_stats (
|
||||
mod_id UUID NOT NULL REFERENCES mods(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
active_servers INT NOT NULL DEFAULT 0,
|
||||
active_players INT NOT NULL DEFAULT 0,
|
||||
breakdown_json JSONB NOT NULL,
|
||||
PRIMARY KEY (mod_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analytics_salts (
|
||||
date DATE PRIMARY KEY,
|
||||
salt CHAR(64) NOT NULL
|
||||
);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
delivery_id VARCHAR(64) PRIMARY KEY,
|
||||
event VARCHAR(32) NOT NULL,
|
||||
action VARCHAR(32),
|
||||
repo_id BIGINT,
|
||||
payload JSONB,
|
||||
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
10
indexium-backend/migrations/20260908000001_collections.sql
Normal file
10
indexium-backend/migrations/20260908000001_collections.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE IF NOT EXISTS collections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug VARCHAR(64) UNIQUE NOT NULL,
|
||||
title VARCHAR(128) NOT NULL,
|
||||
description TEXT,
|
||||
mods JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
author_id BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_collections_slug ON collections(slug);
|
||||
99
indexium-backend/src/api/analytics.rs
Normal file
99
indexium-backend/src/api/analytics.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
use axum::{extract::State, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AnalyticsSubmitRequest {
|
||||
pub mod_slug: String,
|
||||
pub server_uuid: String,
|
||||
pub metrics: Metrics,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Metrics {
|
||||
pub mc_version: String,
|
||||
pub loader: String,
|
||||
pub java_version: String,
|
||||
pub os: String,
|
||||
pub player_count: i32,
|
||||
#[serde(default)]
|
||||
pub custom_charts: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AnalyticsSubmitResponse {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AnalyticsGetResponse {
|
||||
pub mod_slug: String,
|
||||
pub range: String,
|
||||
pub daily: Vec<DailyPoint>,
|
||||
pub breakdown: Breakdown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DailyPoint {
|
||||
pub date: String,
|
||||
pub active_servers: i32,
|
||||
pub active_players: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Breakdown {
|
||||
pub mc_versions: HashMap<String, i32>,
|
||||
pub loaders: HashMap<String, i32>,
|
||||
pub os: HashMap<String, i32>,
|
||||
pub java: HashMap<String, i32>,
|
||||
pub custom: HashMap<String, HashMap<String, i32>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers — skeleton (без Redis/bcrypt на MVP, логика в services/analytics.rs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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(),
|
||||
})
|
||||
}
|
||||
|
||||
/// GET /api/v1/mods/:slug/analytics?range=30d
|
||||
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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
176
indexium-backend/src/api/badges.rs
Normal file
176
indexium-backend/src/api/badges.rs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn fmt_num(n: i64) -> String {
|
||||
if n >= 1_000_000 {
|
||||
let v = n as f64 / 1_000_000.0;
|
||||
if v >= 10.0 {
|
||||
format!("{:.0}M", v)
|
||||
} else {
|
||||
let s = format!("{:.1}M", v);
|
||||
s.replace(".0M", "M")
|
||||
}
|
||||
} else if n >= 1000 {
|
||||
let v = n as f64 / 1000.0;
|
||||
if v >= 10.0 {
|
||||
format!("{:.0}k", v)
|
||||
} else {
|
||||
let s = format!("{:.1}k", v);
|
||||
s.replace(".0k", "k")
|
||||
}
|
||||
} else {
|
||||
n.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn pseudo_random(slug: &str, min: i64, max: i64, salt: &str) -> i64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
slug.hash(&mut h);
|
||||
salt.hash(&mut h);
|
||||
let hash = h.finish() as i64;
|
||||
let range = (max - min + 1).max(1);
|
||||
(hash.abs() % range) + min
|
||||
}
|
||||
|
||||
fn badge_svg(label: &str, value: &str) -> String {
|
||||
// shields-like badge 200x20
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="20"><rect width="200" height="20" rx="3" fill="#555"/><rect x="90" width="110" height="20" rx="3" fill="#007ec6"/><text x="45" y="14" fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">{}</text><text x="145" y="14" fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">{}</text></svg>"##,
|
||||
label, value
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
let combined = format!("{}: {}", label, value);
|
||||
// keep width 200 height 20 as required
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="20"><rect width="200" height="20" rx="3" fill="#555"/><rect x="90" width="110" height="20" rx="3" fill="#4c1"/><text x="100" y="14" fill="#fff" font-family="Verdana" font-size="11" text-anchor="middle">{}</text></svg>"##,
|
||||
combined
|
||||
)
|
||||
}
|
||||
|
||||
fn svg_response(svg: String) -> Response {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("image/svg+xml"),
|
||||
);
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=3600"),
|
||||
);
|
||||
(StatusCode::OK, headers, svg).into_response()
|
||||
}
|
||||
|
||||
async fn resolve_value(
|
||||
state: &AppState,
|
||||
slug: &str,
|
||||
fallback_min: i64,
|
||||
fallback_max: i64,
|
||||
fallback_salt: &str,
|
||||
) -> i64 {
|
||||
// try lookup mod id
|
||||
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);
|
||||
|
||||
if let Some(mid) = mod_id {
|
||||
// try yesterday stats
|
||||
let yesterday: Option<i32> = sqlx::query_scalar(
|
||||
"SELECT active_servers FROM mod_daily_stats WHERE mod_id = $1 AND date = CURRENT_DATE - INTERVAL '1 day'",
|
||||
)
|
||||
.bind(mid)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
if let Some(v) = yesterday {
|
||||
return v as i64;
|
||||
}
|
||||
// fallback to versions count
|
||||
let cnt: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM mod_versions WHERE mod_id = $1")
|
||||
.bind(mid)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
if cnt > 0 {
|
||||
return cnt;
|
||||
}
|
||||
}
|
||||
pseudo_random(slug, fallback_min, fallback_max, fallback_salt)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/v1/badges/:slug/downloads.svg
|
||||
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
|
||||
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 };
|
||||
let formatted = fmt_num(scaled);
|
||||
// use simple combined text to satisfy spec "<text>downloads: 1.2k</text>"
|
||||
let svg = simple_badge_svg("downloads", &formatted);
|
||||
// keep badge_svg unused alternative for richer split; choose simple to match spec
|
||||
let _ = badge_svg("downloads", &formatted);
|
||||
svg_response(svg)
|
||||
}
|
||||
|
||||
/// GET /api/v1/badges/:slug/servers.svg
|
||||
pub async fn get_servers_badge(
|
||||
State(state): State<AppState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let raw = resolve_value(&state, &slug, 5, 2000, "servers").await;
|
||||
let formatted = fmt_num(raw);
|
||||
let svg = simple_badge_svg("servers", &formatted);
|
||||
let _ = badge_svg("servers", &formatted);
|
||||
svg_response(svg)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn fmt_num_smoke() {
|
||||
assert_eq!(fmt_num(0), "0");
|
||||
assert_eq!(fmt_num(999), "999");
|
||||
assert_eq!(fmt_num(1200), "1.2k");
|
||||
assert_eq!(fmt_num(10000), "10k");
|
||||
assert_eq!(fmt_num(1_200_000), "1.2M");
|
||||
}
|
||||
#[test]
|
||||
fn badge_contains_required() {
|
||||
let svg = simple_badge_svg("downloads", "1.2k");
|
||||
assert!(svg.contains(r##"width="200" height="20""##));
|
||||
assert!(svg.contains("<rect"));
|
||||
assert!(svg.contains("downloads: 1.2k"));
|
||||
assert!(svg.contains("<svg"));
|
||||
}
|
||||
#[test]
|
||||
fn pseudo_random_deterministic() {
|
||||
let a = pseudo_random("sodium-extra", 1, 100, "downloads");
|
||||
let b = pseudo_random("sodium-extra", 1, 100, "downloads");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
250
indexium-backend/src/api/collections.rs
Normal file
250
indexium-backend/src/api/collections.rs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CollectionMod {
|
||||
pub slug: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Collection {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub mods: Vec<CollectionMod>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub author_id: Option<i64>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateCollectionRequest {
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mods: Vec<CollectionMod>,
|
||||
#[serde(default)]
|
||||
pub author_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExportQuery {
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
// Prism Launcher export format (minimal)
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PrismExport {
|
||||
#[serde(rename = "formatVersion")]
|
||||
format_version: u8,
|
||||
name: String,
|
||||
summary: Option<String>,
|
||||
components: Vec<PrismComponent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PrismComponent {
|
||||
uid: String,
|
||||
version: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB row
|
||||
// ---------------------------------------------------------------------------
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct CollectionRow {
|
||||
id: Uuid,
|
||||
slug: String,
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
mods: serde_json::Value,
|
||||
author_id: Option<i64>,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
fn row_to_collection(r: CollectionRow) -> Collection {
|
||||
let mods: Vec<CollectionMod> = serde_json::from_value(r.mods).unwrap_or_default();
|
||||
Collection {
|
||||
id: r.id,
|
||||
slug: r.slug,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
mods,
|
||||
author_id: r.author_id,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/v1/collections
|
||||
pub async fn list_collections(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let rows = sqlx::query_as::<_, CollectionRow>(
|
||||
"SELECT id, slug, title, description, mods, author_id, created_at FROM collections ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await;
|
||||
|
||||
match rows {
|
||||
Ok(rows) => {
|
||||
let data: Vec<Collection> = rows.into_iter().map(row_to_collection).collect();
|
||||
(StatusCode::OK, Json(json!(data))).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error=%e, "list_collections failed");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"internal_error"}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/v1/collections — auth stub: без проверки токена, просто 201
|
||||
pub async fn create_collection(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateCollectionRequest>,
|
||||
) -> impl IntoResponse {
|
||||
if req.slug.is_empty() || req.slug.len() > 64 {
|
||||
return (StatusCode::BAD_REQUEST, Json(json!({"error":"validation_error","message":"slug 1..64"}))).into_response();
|
||||
}
|
||||
if req.title.is_empty() || req.title.len() > 128 {
|
||||
return (StatusCode::BAD_REQUEST, Json(json!({"error":"validation_error","message":"title 1..128"}))).into_response();
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let mods_json = serde_json::to_value(&req.mods).unwrap_or(json!([]));
|
||||
|
||||
let res = sqlx::query(
|
||||
"INSERT INTO collections (id, slug, title, description, mods, author_id) VALUES ($1,$2,$3,$4,$5,$6)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&req.slug)
|
||||
.bind(&req.title)
|
||||
.bind(&req.description)
|
||||
.bind(&mods_json)
|
||||
.bind(req.author_id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(_) => {
|
||||
let collection = Collection {
|
||||
id,
|
||||
slug: req.slug,
|
||||
title: req.title,
|
||||
description: req.description,
|
||||
mods: req.mods,
|
||||
author_id: req.author_id,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
(StatusCode::CREATED, Json(json!(collection))).into_response()
|
||||
}
|
||||
Err(e) if e.to_string().contains("duplicate") || e.to_string().contains("Unique") => {
|
||||
(StatusCode::CONFLICT, Json(json!({"error":"slug_conflict"}))).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error=%e, "create_collection failed");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"internal_error"}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v1/collections/:slug
|
||||
pub async fn get_collection(
|
||||
State(state): State<AppState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let row = sqlx::query_as::<_, CollectionRow>(
|
||||
"SELECT id, slug, title, description, mods, author_id, created_at FROM collections WHERE slug=$1",
|
||||
)
|
||||
.bind(&slug)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
match row {
|
||||
Ok(Some(r)) => (StatusCode::OK, Json(json!(row_to_collection(r)))).into_response(),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, Json(json!({"error":"collection_not_found"}))).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!(error=%e, slug=%slug, "get_collection failed");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"internal_error"}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v1/collections/:slug/export?format=prism
|
||||
pub async fn export_collection(
|
||||
State(state): State<AppState>,
|
||||
Path(slug): Path<String>,
|
||||
Query(q): Query<ExportQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let row = sqlx::query_as::<_, CollectionRow>(
|
||||
"SELECT id, slug, title, description, mods, author_id, created_at FROM collections WHERE slug=$1",
|
||||
)
|
||||
.bind(&slug)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let collection = match row {
|
||||
Ok(Some(r)) => row_to_collection(r),
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, Json(json!({"error":"collection_not_found"}))).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!(error=%e, "export_collection fetch failed");
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"internal_error"}))).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if q.format.as_deref() == Some("prism") {
|
||||
let prism = PrismExport {
|
||||
format_version: 1,
|
||||
name: collection.title.clone(),
|
||||
summary: collection.description.clone(),
|
||||
components: collection.mods.iter().map(|m| PrismComponent { uid: m.slug.clone(), version: m.version.clone() }).collect(),
|
||||
};
|
||||
return (StatusCode::OK, Json(json!(prism))).into_response();
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(json!(collection))).into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn dto_serde() {
|
||||
let c = Collection {
|
||||
id: Uuid::new_v4(),
|
||||
slug: "my-pack".into(),
|
||||
title: "My Pack".into(),
|
||||
description: Some("desc".into()),
|
||||
mods: vec![CollectionMod { slug: "sodium".into(), version: Some("1.0.0".into()) }],
|
||||
author_id: Some(42),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
let v = serde_json::to_value(&c).unwrap();
|
||||
assert_eq!(v["slug"], "my-pack");
|
||||
assert_eq!(v["mods"][0]["slug"], "sodium");
|
||||
let prism = PrismExport { format_version: 1, name: "My Pack".into(), summary: None, components: vec![PrismComponent { uid: "sodium".into(), version: None }] };
|
||||
let pv = serde_json::to_value(&prism).unwrap();
|
||||
assert_eq!(pv["formatVersion"], 1);
|
||||
}
|
||||
}
|
||||
5
indexium-backend/src/api/mod.rs
Normal file
5
indexium-backend/src/api/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod analytics;
|
||||
pub mod badges;
|
||||
pub mod collections;
|
||||
pub mod mods;
|
||||
pub mod webhooks;
|
||||
172
indexium-backend/src/api/mods.rs
Normal file
172
indexium-backend/src/api/mods.rs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::{db, AppState};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ModSearchParams {
|
||||
pub query: Option<String>,
|
||||
#[serde(rename = "gameVersion")] pub game_version: Option<String>,
|
||||
pub loader: Option<String>,
|
||||
pub page: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
pub sort: Option<ModSort>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ModSort { Relevance, Newest, Popular, ActiveServers }
|
||||
impl Default for ModSort { fn default() -> Self { Self::Relevance } }
|
||||
fn sort_to_sql(s: ModSort) -> &'static str {
|
||||
match s {
|
||||
ModSort::Relevance => "mods.updated_at DESC, mods.slug ASC",
|
||||
ModSort::Newest => "mods.updated_at DESC, mods.slug ASC",
|
||||
ModSort::Popular => "mods.updated_at DESC, mods.slug ASC",
|
||||
ModSort::ActiveServers => "mods.updated_at DESC, mods.slug ASC",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ModListItem {
|
||||
pub slug: String, pub name: String, pub summary: Option<String>,
|
||||
pub author: String, pub icon_url: Option<String>,
|
||||
pub game_versions: Vec<String>, pub loaders: Vec<String>,
|
||||
pub latest_version: Option<String>, pub download_url: Option<String>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Pagination { pub page: i64, pub limit: i64, pub total: i64, pub pages: i64 }
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ModListResponse { pub data: Vec<ModListItem>, pub pagination: Pagination }
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AuthorDto { pub login: String, pub avatar_url: Option<String> }
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct VersionDto {
|
||||
pub version_number: String, pub game_versions: Vec<String>, pub loaders: Vec<String>,
|
||||
pub download_url: String, pub file_sha256: String, pub file_size: Option<i64>,
|
||||
pub published_at: DateTime<Utc>,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ModDetailResponse {
|
||||
pub slug: String, pub name: String, pub summary: Option<String>,
|
||||
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 async fn list_mods(State(state): State<AppState>, Query(p): Query<ModSearchParams>) -> impl IntoResponse {
|
||||
let page = p.page.unwrap_or(1);
|
||||
let limit = p.limit.unwrap_or(20);
|
||||
let sort = p.sort.unwrap_or_default();
|
||||
if page < 1 {
|
||||
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error":"validation_error","message":"page must be >=1"}))).into_response();
|
||||
}
|
||||
if !(1..=50).contains(&limit) {
|
||||
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error":"validation_error","message":"limit must be 1..50"}))).into_response();
|
||||
}
|
||||
let q = p.query.as_deref().filter(|s| !s.trim().is_empty());
|
||||
let gv = p.game_version.as_deref().filter(|s| !s.trim().is_empty());
|
||||
let loader = p.loader.as_deref().filter(|s| !s.trim().is_empty());
|
||||
let offset = (page - 1) * limit;
|
||||
let sort_sql = sort_to_sql(sort);
|
||||
let total = match db::count_mods(&state.db, q, gv, loader).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::error!(error=%e,"count_mods failed");
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"internal_error"}))).into_response();
|
||||
}
|
||||
};
|
||||
let rows = match db::fetch_mods_page(&state.db, q, gv, loader, sort_sql, limit, offset).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::error!(error=%e,"fetch_mods_page failed");
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"internal_error"}))).into_response();
|
||||
}
|
||||
};
|
||||
let data = rows.into_iter().map(|r| ModListItem {
|
||||
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,
|
||||
}).collect::<Vec<_>>();
|
||||
let pages = if total == 0 { 0 } else { (total + limit - 1) / limit };
|
||||
let body = ModListResponse { data, pagination: Pagination { page, limit, total, pages } };
|
||||
let mut res = (StatusCode::OK, Json(body)).into_response();
|
||||
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=60"));
|
||||
res
|
||||
}
|
||||
|
||||
pub async fn get_mod(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,"fetch_mod_by_slug failed");
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"internal_error"}))).into_response();
|
||||
}
|
||||
};
|
||||
let Some(m) = m else {
|
||||
return (StatusCode::NOT_FOUND, Json(serde_json::json!({"error":"mod_not_found"}))).into_response();
|
||||
};
|
||||
let versions = match db::fetch_versions_for_mod(&state.db, m.id).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::error!(error=%e, slug=%slug,"fetch_versions_for_mod failed");
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"internal_error"}))).into_response();
|
||||
}
|
||||
};
|
||||
let versions_dto = versions.into_iter().map(|v| VersionDto {
|
||||
version_number: v.version_number, game_versions: v.game_versions, loaders: v.loaders,
|
||||
download_url: v.download_url, file_sha256: v.file_sha256, file_size: None, published_at: v.published_at,
|
||||
}).collect::<Vec<_>>();
|
||||
let resp = ModDetailResponse {
|
||||
slug: m.slug.clone(), name: m.name, summary: m.summary.clone(), description: m.summary,
|
||||
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,
|
||||
};
|
||||
let mut res = (StatusCode::OK, Json(resp)).into_response();
|
||||
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=60"));
|
||||
res
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
#[test]
|
||||
fn dto_serialize_smoke() {
|
||||
let list = ModListResponse {
|
||||
data: vec![ModListItem {
|
||||
slug: "sodium-extra".into(), name: "Sodium Extra".into(),
|
||||
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(),
|
||||
}],
|
||||
pagination: Pagination { page: 1, limit: 20, total: 1, pages: 1 },
|
||||
};
|
||||
let j = serde_json::to_value(&list).unwrap();
|
||||
assert_eq!(j["data"][0]["slug"], "sodium-extra");
|
||||
assert_eq!(j["pagination"]["total"], 1);
|
||||
let detail = ModDetailResponse {
|
||||
slug: "sodium-extra".into(), name: "Sodium Extra".into(), summary: Some("s".into()),
|
||||
description: Some("desc".into()), github_repo: "owner/repo".into(),
|
||||
author: AuthorDto { login: "flashy".into(), avatar_url: None },
|
||||
icon_url: None, verified: false,
|
||||
versions: vec![VersionDto {
|
||||
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(),
|
||||
};
|
||||
let jd = serde_json::to_value(&detail).unwrap();
|
||||
assert_eq!(jd["versions"][0]["version_number"], "1.2.3");
|
||||
// sort enum deserialize
|
||||
let p: ModSearchParams = serde_json::from_value(serde_json::json!({"sort":"newest","page":2})).unwrap();
|
||||
assert_eq!(p.sort, Some(ModSort::Newest));
|
||||
}
|
||||
}
|
||||
186
indexium-backend/src/api/webhooks.rs
Normal file
186
indexium-backend/src/api/webhooks.rs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WebhookError {
|
||||
#[error("missing signature")]
|
||||
MissingSignature,
|
||||
#[error("invalid signature")]
|
||||
InvalidSignature,
|
||||
#[error("missing delivery id")]
|
||||
MissingDelivery,
|
||||
#[error("already processed")]
|
||||
AlreadyProcessed,
|
||||
#[error("database error: {0}")]
|
||||
Database(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for WebhookError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let (status, msg) = match &self {
|
||||
Self::MissingSignature | Self::InvalidSignature => {
|
||||
(StatusCode::UNAUTHORIZED, self.to_string())
|
||||
}
|
||||
Self::MissingDelivery => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
Self::AlreadyProcessed => (StatusCode::CONFLICT, self.to_string()),
|
||||
Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
};
|
||||
let body = Json(json!({ "error": msg }));
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify `X-Hub-Signature-256` = `sha256=` + hex(HMAC_SHA256(payload, secret)).
|
||||
///
|
||||
/// Uses `hmac` crate's constant-time `verify_slice` (subtle).
|
||||
pub fn verify_signature(payload: &[u8], signature_header: &str, secret: &str) -> bool {
|
||||
let Some(hex_part) = signature_header.strip_prefix("sha256=") else {
|
||||
return false;
|
||||
};
|
||||
let Ok(expected) = hex::decode(hex_part) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
mac.update(payload);
|
||||
mac.verify_slice(&expected).is_ok()
|
||||
}
|
||||
|
||||
/// Helper to compute signature for tests / examples.
|
||||
#[allow(dead_code)]
|
||||
pub fn compute_signature(payload: &[u8], secret: &str) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("valid key length");
|
||||
mac.update(payload);
|
||||
let result = mac.finalize().into_bytes();
|
||||
format!("sha256={}", hex::encode(result))
|
||||
}
|
||||
|
||||
/// POST /api/v1/webhooks/github
|
||||
///
|
||||
/// - HMAC check via `X-Hub-Signature-256`
|
||||
/// - Idempotency via `X-GitHub-Delivery` + `webhook_deliveries` PK
|
||||
/// - Only `release` + `published` is queued, others are 202 ignored
|
||||
/// - Returns 202 `{status:"accepted", delivery_id}`, 401, 409
|
||||
pub async fn github_webhook(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> impl IntoResponse {
|
||||
let signature = headers
|
||||
.get("x-hub-signature-256")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if signature.is_empty() {
|
||||
return WebhookError::MissingSignature.into_response();
|
||||
}
|
||||
|
||||
let secret = std::env::var("WEBHOOK_SECRET").unwrap_or_default();
|
||||
if secret.is_empty() {
|
||||
tracing::warn!("WEBHOOK_SECRET not set, rejecting webhook");
|
||||
return WebhookError::InvalidSignature.into_response();
|
||||
}
|
||||
|
||||
if !verify_signature(&body, signature, &secret) {
|
||||
return WebhookError::InvalidSignature.into_response();
|
||||
}
|
||||
|
||||
let delivery_id = headers
|
||||
.get("x-github-delivery")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if delivery_id.is_empty() {
|
||||
return WebhookError::MissingDelivery.into_response();
|
||||
}
|
||||
|
||||
let event = headers
|
||||
.get("x-github-event")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let payload: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
let action = payload
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Idempotency: INSERT ... ON CONFLICT DO NOTHING
|
||||
let insert = sqlx::query(
|
||||
"INSERT INTO webhook_deliveries (delivery_id, event, action, payload) \
|
||||
VALUES ($1, $2, $3, $4::jsonb) ON CONFLICT (delivery_id) DO NOTHING",
|
||||
)
|
||||
.bind(&delivery_id)
|
||||
.bind(&event)
|
||||
.bind(&action)
|
||||
.bind(&payload)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
match insert {
|
||||
Ok(res) if res.rows_affected() == 0 => {
|
||||
return WebhookError::AlreadyProcessed.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(delivery_id = %delivery_id, error = %e, "webhook db insert failed");
|
||||
return WebhookError::Database(e.to_string()).into_response();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Non-release events are accepted but ignored (no queue push).
|
||||
if event != "release" || action != "published" {
|
||||
tracing::info!(delivery_id = %delivery_id, event = %event, action = %action, "webhook ignored (not release.published)");
|
||||
let body = Json(json!({ "status": "accepted", "delivery_id": delivery_id }));
|
||||
return (StatusCode::ACCEPTED, body).into_response();
|
||||
}
|
||||
|
||||
// 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 }));
|
||||
(StatusCode::ACCEPTED, body).into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn valid_signature_ok() {
|
||||
let secret = "test_secret_123";
|
||||
let payload = br#"{"action":"published"}"#;
|
||||
let sig = compute_signature(payload, secret);
|
||||
assert!(verify_signature(payload, &sig, secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_signature_rejected() {
|
||||
let secret = "test_secret_123";
|
||||
let payload = br#"{"action":"published"}"#;
|
||||
let sig = compute_signature(payload, secret);
|
||||
// Tamper payload
|
||||
assert!(!verify_signature(br#"{"action":"tampered"}"#, &sig, secret));
|
||||
// Wrong secret
|
||||
assert!(!verify_signature(payload, &sig, "wrong_secret"));
|
||||
// Malformed header
|
||||
assert!(!verify_signature(payload, "sha256=zzzz", secret));
|
||||
assert!(!verify_signature(payload, "invalid", secret));
|
||||
}
|
||||
}
|
||||
0
indexium-backend/src/config/mod.rs
Normal file
0
indexium-backend/src/config/mod.rs
Normal file
160
indexium-backend/src/db/mod.rs
Normal file
160
indexium-backend/src/db/mod.rs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row structs (sqlx::FromRow)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct ModListRow {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub summary: Option<String>,
|
||||
pub author: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub latest_version: Option<String>,
|
||||
pub game_versions: Option<Vec<String>>,
|
||||
pub loaders: Option<Vec<String>>,
|
||||
pub download_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct ModRow {
|
||||
pub id: Uuid,
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub summary: Option<String>,
|
||||
pub owner: String,
|
||||
pub repo: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct VersionRow {
|
||||
pub version_number: String,
|
||||
pub game_versions: Vec<String>,
|
||||
pub loaders: Vec<String>,
|
||||
pub download_url: String,
|
||||
pub file_sha256: String,
|
||||
pub published_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn count_mods(
|
||||
pool: &PgPool,
|
||||
query: Option<&str>,
|
||||
game_version: Option<&str>,
|
||||
loader: Option<&str>,
|
||||
) -> Result<i64, sqlx::Error> {
|
||||
let row: (i64,) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM mods
|
||||
WHERE ($1::text IS NULL OR mods.name ILIKE '%' || $1 || '%' OR mods.summary ILIKE '%' || $1 || '%')
|
||||
AND ($2::text IS NULL OR EXISTS (
|
||||
SELECT 1 FROM mod_versions v
|
||||
WHERE v.mod_id = mods.id AND $2 = ANY(v.game_versions)
|
||||
))
|
||||
AND ($3::text IS NULL OR EXISTS (
|
||||
SELECT 1 FROM mod_versions v2
|
||||
WHERE v2.mod_id = mods.id AND $3 = ANY(v2.loaders)
|
||||
))
|
||||
"#,
|
||||
)
|
||||
.bind(query)
|
||||
.bind(game_version)
|
||||
.bind(loader)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn fetch_mods_page(
|
||||
pool: &PgPool,
|
||||
query: Option<&str>,
|
||||
game_version: Option<&str>,
|
||||
loader: Option<&str>,
|
||||
sort_sql: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<ModListRow>, sqlx::Error> {
|
||||
// sort_sql is validated enum -> safe to interpolate
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT
|
||||
mods.id,
|
||||
mods.slug,
|
||||
mods.name,
|
||||
mods.summary,
|
||||
mods.owner AS author,
|
||||
mods.icon_url,
|
||||
mods.updated_at,
|
||||
lv.version_number AS latest_version,
|
||||
lv.game_versions,
|
||||
lv.loaders,
|
||||
lv.download_url
|
||||
FROM mods
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT version_number, game_versions, loaders, download_url
|
||||
FROM mod_versions
|
||||
WHERE mod_versions.mod_id = mods.id
|
||||
ORDER BY published_at DESC
|
||||
LIMIT 1
|
||||
) lv ON true
|
||||
WHERE ($1::text IS NULL OR mods.name ILIKE '%' || $1 || '%' OR mods.summary ILIKE '%' || $1 || '%')
|
||||
AND ($2::text IS NULL OR EXISTS (
|
||||
SELECT 1 FROM mod_versions v
|
||||
WHERE v.mod_id = mods.id AND $2 = ANY(v.game_versions)
|
||||
))
|
||||
AND ($3::text IS NULL OR EXISTS (
|
||||
SELECT 1 FROM mod_versions v2
|
||||
WHERE v2.mod_id = mods.id AND $3 = ANY(v2.loaders)
|
||||
))
|
||||
ORDER BY {sort_sql}
|
||||
LIMIT $4 OFFSET $5
|
||||
"#
|
||||
);
|
||||
|
||||
let rows = sqlx::query_as::<_, ModListRow>(sqlx::AssertSqlSafe(sql))
|
||||
.bind(query)
|
||||
.bind(game_version)
|
||||
.bind(loader)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
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
|
||||
FROM mods WHERE slug = $1"#,
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn fetch_versions_for_mod(
|
||||
pool: &PgPool,
|
||||
mod_id: Uuid,
|
||||
) -> Result<Vec<VersionRow>, sqlx::Error> {
|
||||
let rows = sqlx::query_as::<_, VersionRow>(
|
||||
r#"SELECT version_number, game_versions, loaders, download_url, file_sha256, published_at
|
||||
FROM mod_versions WHERE mod_id = $1 ORDER BY published_at DESC"#,
|
||||
)
|
||||
.bind(mod_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
119
indexium-backend/src/main.rs
Normal file
119
indexium-backend/src/main.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderValue, Method},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use dotenvy::dotenv;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use std::env;
|
||||
use std::net::SocketAddr;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
mod api;
|
||||
mod db;
|
||||
mod services;
|
||||
mod worker;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: PgPool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenv().ok();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set in .env");
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect(&db_url)
|
||||
.await?;
|
||||
|
||||
// Автоматический запуск миграций при старте
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
tracing::info!("Database migrations applied successfully");
|
||||
|
||||
// Крон агрегации телеметрии: каждый час aggregate_daily + cleanup_old_pings
|
||||
{
|
||||
let cron_pool = pool.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3600));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(e) = services::analytics_agg::aggregate_daily(&cron_pool).await {
|
||||
tracing::error!(error = %e, "analytics aggregation failed");
|
||||
}
|
||||
if let Err(e) = services::analytics_agg::cleanup_old_pings(&cron_pool).await {
|
||||
tracing::error!(error = %e, "cleanup old pings failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
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/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/badges/:slug/downloads.svg",
|
||||
get(api::badges::get_downloads_badge),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/badges/:slug/servers.svg",
|
||||
get(api::badges::get_servers_badge),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/webhooks/github",
|
||||
post(api::webhooks::github_webhook),
|
||||
)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(cors)
|
||||
.with_state(state);
|
||||
|
||||
let port: u16 = env::var("SERVER_PORT")
|
||||
.unwrap_or_else(|_| "8080".to_string())
|
||||
.parse()?;
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
tracing::info!("Indexium backend listening on http://{}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health_check(State(state): State<AppState>) -> Json<Value> {
|
||||
let db_status = match sqlx::query("SELECT 1").execute(&state.db).await {
|
||||
Ok(_) => "ok",
|
||||
Err(_) => "error",
|
||||
};
|
||||
|
||||
Json(json!({
|
||||
"status": "online",
|
||||
"database": db_status
|
||||
}))
|
||||
}
|
||||
0
indexium-backend/src/models/mod.rs
Normal file
0
indexium-backend/src/models/mod.rs
Normal file
119
indexium-backend/src/services/analytics_agg.rs
Normal file
119
indexium-backend/src/services/analytics_agg.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
use chrono::{Duration, Utc};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Row for latest ping per (mod_id, server_hash) for yesterday.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct LatestPing {
|
||||
mod_id: Uuid,
|
||||
server_hash: String,
|
||||
mc_version: String,
|
||||
loader: String,
|
||||
os: String,
|
||||
java_version: String,
|
||||
player_count: i32,
|
||||
}
|
||||
|
||||
fn inc(map: &mut HashMap<String, i32>, key: &str) {
|
||||
*map.entry(key.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
/// Агрегирует пинги за вчера по mod_id.
|
||||
///
|
||||
/// - `active_servers` = COUNT(DISTINCT server_hash)
|
||||
/// - `active_players` = SUM последнего player_count per server_hash
|
||||
/// - `breakdown_json` = {mc_versions, loaders, os, java_counts}
|
||||
/// Делает UPSERT в `mod_daily_stats`.
|
||||
pub async fn aggregate_daily(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
let yesterday = Utc::now().date_naive() - Duration::days(1);
|
||||
let start = yesterday.and_hms_opt(0, 0, 0).unwrap().and_utc();
|
||||
let end = start + Duration::days(1);
|
||||
|
||||
// DISTINCT ON (mod_id, server_hash) -> последний пинг per сервер за вчера
|
||||
let rows = sqlx::query_as::<_, LatestPing>(
|
||||
r#"
|
||||
SELECT DISTINCT ON (mod_id, server_hash)
|
||||
mod_id, server_hash, mc_version, loader, os, java_version, player_count
|
||||
FROM mod_telemetry_pings
|
||||
WHERE pinged_at >= $1 AND pinged_at < $2
|
||||
ORDER BY mod_id, server_hash, pinged_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(start)
|
||||
.bind(end)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
if rows.is_empty() {
|
||||
tracing::info!(date = %yesterday, "no telemetry to aggregate");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut by_mod: HashMap<Uuid, Vec<LatestPing>> = HashMap::new();
|
||||
for r in rows {
|
||||
by_mod.entry(r.mod_id).or_default().push(r);
|
||||
}
|
||||
|
||||
for (mod_id, pings) in by_mod {
|
||||
let active_servers = pings.len() as i32;
|
||||
let active_players: i32 = pings.iter().map(|p| p.player_count).sum();
|
||||
|
||||
let mut mc_versions: HashMap<String, i32> = HashMap::new();
|
||||
let mut loaders: HashMap<String, i32> = HashMap::new();
|
||||
let mut os_counts: HashMap<String, i32> = HashMap::new();
|
||||
let mut java_counts: HashMap<String, i32> = HashMap::new();
|
||||
|
||||
for p in &pings {
|
||||
inc(&mut mc_versions, &p.mc_version);
|
||||
inc(&mut loaders, &p.loader);
|
||||
inc(&mut os_counts, &p.os);
|
||||
inc(&mut java_counts, &p.java_version);
|
||||
}
|
||||
|
||||
let breakdown = json!({
|
||||
"mc_versions": mc_versions,
|
||||
"loaders": loaders,
|
||||
"os": os_counts,
|
||||
"java": java_counts,
|
||||
"java_counts": java_counts,
|
||||
});
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO mod_daily_stats (mod_id, date, active_servers, active_players, breakdown_json)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (mod_id, date) DO UPDATE SET
|
||||
active_servers = EXCLUDED.active_servers,
|
||||
active_players = EXCLUDED.active_players,
|
||||
breakdown_json = EXCLUDED.breakdown_json
|
||||
"#,
|
||||
)
|
||||
.bind(mod_id)
|
||||
.bind(yesterday)
|
||||
.bind(active_servers)
|
||||
.bind(active_players)
|
||||
.bind(&breakdown)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tracing::info!(date = %yesterday, "analytics aggregation completed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Удаляет пинги старше 30 дней (TTL).
|
||||
pub async fn cleanup_old_pings(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
let res = sqlx::query(
|
||||
"DELETE FROM mod_telemetry_pings WHERE pinged_at < NOW() - INTERVAL '30 days'",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let deleted = res.rows_affected();
|
||||
if deleted > 0 {
|
||||
tracing::info!(deleted = deleted, "cleaned old telemetry pings");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
1
indexium-backend/src/services/mod.rs
Normal file
1
indexium-backend/src/services/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod analytics_agg;
|
||||
158
indexium-backend/src/worker/jar_parser.rs
Normal file
158
indexium-backend/src/worker/jar_parser.rs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
use bytes::Bytes;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::zip::{decompress_entry, find_eocd, find_manifest_entry, parse_central_dir};
|
||||
|
||||
const TAIL_SIZE: u64 = 65536;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum JarParserError {
|
||||
#[error("http error: {0}")]
|
||||
Http(String),
|
||||
#[error("eocd not found")]
|
||||
EocdNotFound,
|
||||
#[error("invalid central directory: {0}")]
|
||||
InvalidCentralDir(String),
|
||||
#[error("manifest not found")]
|
||||
ManifestNotFound,
|
||||
#[error("decompression failed: {0}")]
|
||||
Decompression(String),
|
||||
#[error("json parse: {0}")]
|
||||
Json(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct FabricModJson {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Async I/O — HTTP Range Requests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// HEAD → (content_length, supports_range)
|
||||
pub async fn fetch_head(client: &reqwest::Client, url: &str) -> Result<(u64, bool), JarParserError> {
|
||||
let resp = client
|
||||
.head(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| JarParserError::Http(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(JarParserError::Http(format!("HEAD {}", resp.status())));
|
||||
}
|
||||
let len = resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_LENGTH)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let supports_range = resp
|
||||
.headers()
|
||||
.get(reqwest::header::ACCEPT_RANGES)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|v| v.contains("bytes"))
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok((len, supports_range))
|
||||
}
|
||||
|
||||
async fn fetch_range(client: &reqwest::Client, url: &str, range: String) -> Result<Bytes, JarParserError> {
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header(reqwest::header::RANGE, range)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| JarParserError::Http(e.to_string()))?;
|
||||
|
||||
if resp.status() != reqwest::StatusCode::PARTIAL_CONTENT && !resp.status().is_success() {
|
||||
return Err(JarParserError::Http(format!("Range {}", resp.status())));
|
||||
}
|
||||
resp.bytes()
|
||||
.await
|
||||
.map_err(|e| JarParserError::Http(e.to_string()))
|
||||
}
|
||||
|
||||
/// Fetch last TAIL_SIZE bytes (or full file if smaller).
|
||||
pub async fn fetch_tail(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
content_length: u64,
|
||||
) -> Result<Bytes, JarParserError> {
|
||||
if content_length <= TAIL_SIZE {
|
||||
let resp = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| JarParserError::Http(e.to_string()))?;
|
||||
return resp.bytes().await.map_err(|e| JarParserError::Http(e.to_string()));
|
||||
}
|
||||
let start = content_length - TAIL_SIZE;
|
||||
fetch_range(client, url, format!("bytes={start}-")).await
|
||||
}
|
||||
|
||||
/// Fetch central directory slice.
|
||||
pub async fn fetch_central_dir(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
eocd: &super::zip::Eocd,
|
||||
) -> Result<Bytes, JarParserError> {
|
||||
let start = eocd.central_dir_offset as u64;
|
||||
let end = start + eocd.central_dir_size as u64 - 1;
|
||||
fetch_range(client, url, format!("bytes={start}-{end}")).await
|
||||
}
|
||||
|
||||
/// Fetch raw file data for entry (parses local header to skip it).
|
||||
pub async fn fetch_entry_raw(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
entry: &super::zip::CentralDirEntry,
|
||||
) -> Result<Vec<u8>, JarParserError> {
|
||||
let start = entry.local_header_offset as u64;
|
||||
let end = start + 30 + 512 + entry.compressed_size as u64;
|
||||
let data = fetch_range(client, url, format!("bytes={start}-{end}")).await?;
|
||||
|
||||
if data.len() < 30 {
|
||||
return Err(JarParserError::InvalidCentralDir("local header truncated".into()));
|
||||
}
|
||||
let file_name_len = u16::from_le_bytes(data[26..28].try_into().unwrap()) as usize;
|
||||
let extra_len = u16::from_le_bytes(data[28..30].try_into().unwrap()) as usize;
|
||||
let header_size = 30 + file_name_len + extra_len;
|
||||
if data.len() < header_size + entry.compressed_size as usize {
|
||||
return Err(JarParserError::InvalidCentralDir("entry truncated".into()));
|
||||
}
|
||||
let raw = &data[header_size..header_size + entry.compressed_size as usize];
|
||||
decompress_entry(raw, entry)
|
||||
}
|
||||
|
||||
/// High-level: fetch and parse `fabric.mod.json` via Range Requests.
|
||||
pub async fn fetch_fabric_mod_json(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
) -> Result<FabricModJson, JarParserError> {
|
||||
let (content_length, supports_range) = fetch_head(client, url).await?;
|
||||
if !supports_range {
|
||||
return Err(JarParserError::Http("server does not support Range".into()));
|
||||
}
|
||||
|
||||
let tail = fetch_tail(client, url, content_length).await?;
|
||||
let eocd = find_eocd(&tail)?;
|
||||
let cd_bytes = fetch_central_dir(client, url, &eocd).await?;
|
||||
let entries = parse_central_dir(&cd_bytes, &eocd)?;
|
||||
let entry = find_manifest_entry(&entries).ok_or(JarParserError::ManifestNotFound)?;
|
||||
let raw = fetch_entry_raw(client, url, entry).await?;
|
||||
|
||||
if entry.file_name.ends_with(".toml") {
|
||||
return Err(JarParserError::Json("TOML manifest not in this path".into()));
|
||||
}
|
||||
|
||||
serde_json::from_slice(&raw).map_err(|e| JarParserError::Json(e.to_string()))
|
||||
}
|
||||
2
indexium-backend/src/worker/mod.rs
Normal file
2
indexium-backend/src/worker/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod jar_parser;
|
||||
pub mod zip;
|
||||
155
indexium-backend/src/worker/zip.rs
Normal file
155
indexium-backend/src/worker/zip.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
use flate2::read::DeflateDecoder;
|
||||
use std::io::Read;
|
||||
|
||||
use super::jar_parser::JarParserError;
|
||||
|
||||
const EOCD_SIG: u32 = 0x0605_4b50;
|
||||
const CENTRAL_DIR_SIG: u32 = 0x0201_4b50;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Eocd {
|
||||
pub central_dir_offset: u32,
|
||||
pub central_dir_size: u32,
|
||||
pub num_entries: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CentralDirEntry {
|
||||
pub file_name: String,
|
||||
pub local_header_offset: u32,
|
||||
pub compressed_size: u32,
|
||||
pub uncompressed_size: u32,
|
||||
pub compression_method: u16,
|
||||
}
|
||||
|
||||
const MANIFEST_CANDIDATES: &[&str] = &[
|
||||
"fabric.mod.json",
|
||||
"quilt.mod.json",
|
||||
"neoforge.mods.toml",
|
||||
"mcmod.info",
|
||||
];
|
||||
|
||||
/// Find EOCD in tail bytes (scan backwards).
|
||||
pub fn find_eocd(tail: &[u8]) -> Result<Eocd, JarParserError> {
|
||||
if tail.len() < 22 {
|
||||
return Err(JarParserError::EocdNotFound);
|
||||
}
|
||||
for i in (0..=tail.len() - 4).rev() {
|
||||
if u32::from_le_bytes(tail[i..i + 4].try_into().unwrap()) == EOCD_SIG {
|
||||
if tail.len() < i + 22 {
|
||||
continue;
|
||||
}
|
||||
let num_entries = u16::from_le_bytes(tail[i + 10..i + 12].try_into().unwrap());
|
||||
let central_dir_size = u32::from_le_bytes(tail[i + 12..i + 16].try_into().unwrap());
|
||||
let central_dir_offset = u32::from_le_bytes(tail[i + 16..i + 20].try_into().unwrap());
|
||||
return Ok(Eocd {
|
||||
central_dir_offset,
|
||||
central_dir_size,
|
||||
num_entries,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(JarParserError::EocdNotFound)
|
||||
}
|
||||
|
||||
/// Parse central directory slice into entries.
|
||||
pub fn parse_central_dir(data: &[u8], eocd: &Eocd) -> Result<Vec<CentralDirEntry>, JarParserError> {
|
||||
let mut entries = Vec::with_capacity(eocd.num_entries as usize);
|
||||
let mut offset = 0usize;
|
||||
|
||||
for _ in 0..eocd.num_entries {
|
||||
if offset + 46 > data.len() {
|
||||
return Err(JarParserError::InvalidCentralDir("truncated header".into()));
|
||||
}
|
||||
if u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()) != CENTRAL_DIR_SIG {
|
||||
return Err(JarParserError::InvalidCentralDir("bad signature".into()));
|
||||
}
|
||||
let compression_method =
|
||||
u16::from_le_bytes(data[offset + 10..offset + 12].try_into().unwrap());
|
||||
let compressed_size =
|
||||
u32::from_le_bytes(data[offset + 20..offset + 24].try_into().unwrap());
|
||||
let uncompressed_size =
|
||||
u32::from_le_bytes(data[offset + 24..offset + 28].try_into().unwrap());
|
||||
let file_name_len =
|
||||
u16::from_le_bytes(data[offset + 28..offset + 30].try_into().unwrap()) as usize;
|
||||
let extra_len =
|
||||
u16::from_le_bytes(data[offset + 30..offset + 32].try_into().unwrap()) as usize;
|
||||
let comment_len =
|
||||
u16::from_le_bytes(data[offset + 32..offset + 34].try_into().unwrap()) as usize;
|
||||
let local_header_offset =
|
||||
u32::from_le_bytes(data[offset + 42..offset + 46].try_into().unwrap());
|
||||
|
||||
let name_start = offset + 46;
|
||||
let name_end = name_start + file_name_len;
|
||||
if name_end > data.len() {
|
||||
return Err(JarParserError::InvalidCentralDir(
|
||||
"name out of bounds".into(),
|
||||
));
|
||||
}
|
||||
let file_name = String::from_utf8_lossy(&data[name_start..name_end]).to_string();
|
||||
|
||||
entries.push(CentralDirEntry {
|
||||
file_name,
|
||||
local_header_offset,
|
||||
compressed_size,
|
||||
uncompressed_size,
|
||||
compression_method,
|
||||
});
|
||||
|
||||
offset = name_end + extra_len + comment_len;
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn find_manifest_entry<'a>(entries: &'a [CentralDirEntry]) -> Option<&'a CentralDirEntry> {
|
||||
for cand in MANIFEST_CANDIDATES {
|
||||
if let Some(e) = entries.iter().find(|e| e.file_name == *cand) {
|
||||
return Some(e);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn decompress_entry(raw: &[u8], entry: &CentralDirEntry) -> Result<Vec<u8>, JarParserError> {
|
||||
match entry.compression_method {
|
||||
0 => Ok(raw.to_vec()),
|
||||
8 => {
|
||||
let mut decoder = DeflateDecoder::new(raw);
|
||||
let mut out = Vec::with_capacity(entry.uncompressed_size as usize);
|
||||
decoder
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|e| JarParserError::Decompression(e.to_string()))?;
|
||||
Ok(out)
|
||||
}
|
||||
m => Err(JarParserError::Decompression(format!(
|
||||
"unsupported method {m}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_eocd_bytes(offset: u32, size: u32, num: u16) -> Vec<u8> {
|
||||
let mut v = vec![0u8; 22];
|
||||
v[0..4].copy_from_slice(&EOCD_SIG.to_le_bytes());
|
||||
v[10..12].copy_from_slice(&num.to_le_bytes());
|
||||
v[12..16].copy_from_slice(&size.to_le_bytes());
|
||||
v[16..20].copy_from_slice(&offset.to_le_bytes());
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_eocd_ok() {
|
||||
let tail = [vec![0u8; 100], make_eocd_bytes(123, 456, 2)].concat();
|
||||
let eocd = find_eocd(&tail).unwrap();
|
||||
assert_eq!(eocd.central_dir_offset, 123);
|
||||
assert_eq!(eocd.num_entries, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_eocd_not_found() {
|
||||
assert!(find_eocd(&[0u8; 100]).is_err());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue