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

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

View file

@ -5,3 +5,6 @@ RUST_LOG=info
# GITHUB_APP_ID=
# GITHUB_APP_PRIVATE_KEY=
# REDIS_URL=redis://localhost:6379
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
JWT_SECRET=change_me_jwt_secret_at_least_32_chars

View file

@ -8,7 +8,7 @@ async-trait = "0.1.92"
axum = "0.8.9"
chrono = { version = "0.4.45", features = ["serde"] }
dotenvy = "0.15.7"
reqwest = { version = "0.13.4", features = ["json", "stream"] }
reqwest = { version = "0.13.4", features = ["json", "stream", "form"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "tls-rustls-aws-lc-rs", "uuid", "chrono", "json"] }
@ -24,3 +24,5 @@ hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
subtle = "2.6"
jsonwebtoken = "9"
cookie = "0.18"

View file

@ -0,0 +1,6 @@
CREATE TABLE stars (
github_id BIGINT,
mod_id UUID REFERENCES mods(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (github_id, mod_id)
);

View file

@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS authors (
github_id BIGINT PRIMARY KEY,
login VARCHAR(39) NOT NULL UNIQUE,
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View file

@ -1,13 +1,16 @@
use axum::{extract::State, Json};
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use crate::AppState;
// ---------------------------------------------------------------------------
// DTOs
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct AnalyticsSubmitRequest {
pub mod_slug: String,
@ -46,7 +49,7 @@ pub struct DailyPoint {
pub active_players: i32,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, Default)]
pub struct Breakdown {
pub mc_versions: HashMap<String, i32>,
pub loaders: HashMap<String, i32>,
@ -55,45 +58,167 @@ pub struct Breakdown {
pub custom: HashMap<String, HashMap<String, i32>>,
}
// ---------------------------------------------------------------------------
// Handlers — skeleton (без Redis/bcrypt на MVP, логика в services/analytics.rs)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct AnalyticsQuery {
pub range: Option<String>,
}
fn daily_salt_today() -> String {
Utc::now().format("%Y-%m-%d").to_string()
}
fn hash_server_uuid(uuid: &str, salt: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(uuid.as_bytes());
hasher.update(salt.as_bytes());
hex::encode(hasher.finalize())
}
/// POST /api/v1/analytics/submit
/// Валидация allow-list, хеш server_uuid + daily_salt, Redis 1/15м, INSERT pings.
/// Сейчас — заглушка, возвращает 200 без БД, чтобы SDK мог теститься.
pub async fn submit(
State(_state): State<AppState>,
Json(_req): Json<AnalyticsSubmitRequest>,
) -> Json<AnalyticsSubmitResponse> {
// TODO:
// 1. lookup mods.id by slug (404 if not found)
// 2. validate mc_version/loader/os/java_version allow-list, custom_charts ≤5
// 3. fetch daily_salt from analytics_salts (or generate sha256(today))
// 4. server_hash = sha256(server_uuid + salt)
// 5. Redis SET NX EX 900 server_hash:mod_id → 429 if exists
// 6. INSERT mod_telemetry_pings
Json(AnalyticsSubmitResponse {
status: "ok".into(),
})
State(state): State<AppState>,
Json(req): Json<AnalyticsSubmitRequest>,
) -> impl IntoResponse {
// 1. validate custom_charts
if req.metrics.custom_charts.len() > 5 {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error":"too many custom_charts"}))).into_response();
}
// allow-list (MVP lenient)
if req.metrics.loader.is_empty() || req.metrics.mc_version.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error":"invalid metrics"}))).into_response();
}
// 2. lookup mod_id
let mod_id: Option<uuid::Uuid> =
sqlx::query_scalar("SELECT id FROM mods WHERE slug = $1")
.bind(&req.mod_slug)
.fetch_optional(&state.db)
.await
.unwrap_or(None);
let mod_id = match mod_id {
Some(id) => id,
None => return (StatusCode::NOT_FOUND, Json(serde_json::json!({"error":"mod_not_found"}))).into_response(),
};
// 3. daily salt (fetch or create)
let today = Utc::now().date_naive();
let salt: String = match sqlx::query_scalar("SELECT salt FROM analytics_salts WHERE date = $1")
.bind(today)
.fetch_optional(&state.db)
.await
.unwrap_or(None)
{
Some(s) => s,
None => {
let new_salt = hex::encode(Sha256::digest(daily_salt_today().as_bytes()));
let _ = sqlx::query("INSERT INTO analytics_salts (date, salt) VALUES ($1, $2) ON CONFLICT DO NOTHING")
.bind(today)
.bind(&new_salt)
.execute(&state.db)
.await;
new_salt
}
};
let server_hash = hash_server_uuid(&req.server_uuid, &salt);
// 4. rate limit via DB: 1 per 15 min per server_hash+mod
let recent: Option<i64> = sqlx::query_scalar(
"SELECT COUNT(*) FROM mod_telemetry_pings WHERE server_hash = $1 AND mod_id = $2 AND pinged_at > NOW() - INTERVAL '15 minutes'",
)
.bind(&server_hash)
.bind(mod_id)
.fetch_one(&state.db)
.await
.unwrap_or(Some(0));
if recent.unwrap_or(0) > 0 {
return (
StatusCode::TOO_MANY_REQUESTS,
Json(serde_json::json!({"error":"rate_limited"})),
)
.into_response();
}
// 5. insert ping
let _ = sqlx::query(
"INSERT INTO mod_telemetry_pings (mod_id, server_hash, mc_version, loader, os, java_version, player_count) VALUES ($1,$2,$3,$4,$5,$6,$7)",
)
.bind(mod_id)
.bind(&server_hash)
.bind(&req.metrics.mc_version)
.bind(&req.metrics.loader)
.bind(&req.metrics.os)
.bind(&req.metrics.java_version)
.bind(req.metrics.player_count)
.execute(&state.db)
.await;
(StatusCode::OK, Json(serde_json::json!({"status":"ok"}))).into_response()
}
/// GET /api/v1/mods/:slug/analytics?range=30d
/// GET /api/v1/mods/{slug}/analytics?range=7d|30d|90d
pub async fn get_analytics(
State(_state): State<AppState>,
// TODO: extract slug + query range
) -> Json<AnalyticsGetResponse> {
// TODO: SELECT * FROM mod_daily_stats WHERE mod_id = ? AND date >= NOW() - range
Json(AnalyticsGetResponse {
mod_slug: "sodium-extra".into(),
range: "30d".into(),
daily: vec![],
breakdown: Breakdown {
mc_versions: HashMap::new(),
loaders: HashMap::new(),
os: HashMap::new(),
java: HashMap::new(),
custom: HashMap::new(),
},
})
State(state): State<AppState>,
Path(slug): Path<String>,
Query(q): Query<AnalyticsQuery>,
) -> impl IntoResponse {
let range = q.range.unwrap_or_else(|| "30d".to_string());
let days: i32 = match range.as_str() {
"7d" => 7,
"90d" => 90,
_ => 30,
};
let mod_id: Option<uuid::Uuid> = sqlx::query_scalar("SELECT id FROM mods WHERE slug = $1")
.bind(&slug)
.fetch_optional(&state.db)
.await
.unwrap_or(None);
let mod_id = match mod_id {
Some(id) => id,
None => return (StatusCode::NOT_FOUND, Json(serde_json::json!({"error":"mod_not_found"}))).into_response(),
};
let rows = sqlx::query_as::<_, (chrono::NaiveDate, i32, i32, serde_json::Value)>(
"SELECT date, active_servers, active_players, breakdown_json FROM mod_daily_stats WHERE mod_id = $1 AND date >= CURRENT_DATE - $2::int ORDER BY date ASC",
)
.bind(mod_id)
.bind(days)
.fetch_all(&state.db)
.await
.unwrap_or_default();
let mut daily = Vec::new();
let mut breakdown = Breakdown::default();
for (date, servers, players, json) in rows {
daily.push(DailyPoint {
date: date.to_string(),
active_servers: servers,
active_players: players,
});
// merge last breakdown
if let Some(obj) = json.as_object() {
if let Some(v) = obj.get("mc_versions").and_then(|x| x.as_object()) {
for (k, val) in v {
if let Some(n) = val.as_i64() {
*breakdown.mc_versions.entry(k.clone()).or_insert(0) += n as i32;
}
}
}
}
}
(
StatusCode::OK,
Json(serde_json::json!({
"mod_slug": slug,
"range": range,
"daily": daily,
"breakdown": breakdown
})),
)
.into_response()
}

View file

@ -0,0 +1,200 @@
use axum::{
extract::{Query, State},
http::{header, HeaderMap, StatusCode},
response::IntoResponse,
Json,
};
use cookie::{Cookie, SameSite};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use crate::AppState;
#[derive(Debug, Deserialize)]
pub struct CallbackQuery {
pub code: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: i64,
login: String,
exp: usize,
}
#[derive(Debug, Deserialize)]
struct TokenResp {
access_token: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GhUser {
id: i64,
login: String,
avatar_url: Option<String>,
}
fn jwt_secret() -> String {
std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev_jwt_secret_change_me".to_string())
}
fn parse_jwt_from_headers(headers: &HeaderMap) -> Option<String> {
let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?;
for part in cookie_header.split(';') {
let trimmed = part.trim();
if let Ok(c) = Cookie::parse(trimmed.to_string()) {
if c.name() == "jwt" {
return Some(c.value().to_string());
}
}
if let Some(v) = trimmed.strip_prefix("jwt=") {
return Some(v.to_string());
}
}
None
}
fn build_jwt_cookie(token: &str) -> String {
Cookie::build(("jwt", token.to_string()))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.max_age(cookie::time::Duration::days(7))
.build()
.to_string()
}
fn clear_jwt_cookie() -> String {
Cookie::build(("jwt", ""))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.max_age(cookie::time::Duration::seconds(0))
.build()
.to_string()
}
/// GET /api/v1/auth/github -> 302 GitHub authorize
pub async fn github_login() -> impl IntoResponse {
let client_id = std::env::var("GITHUB_CLIENT_ID").unwrap_or_default();
if client_id.is_empty() {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"oauth_not_configured"}))).into_response();
}
let url = format!(
"https://github.com/login/oauth/authorize?client_id={}&scope=read:user",
client_id
);
(StatusCode::FOUND, [(header::LOCATION, url)]).into_response()
}
/// GET /api/v1/auth/github/callback?code=...
pub async fn github_callback(
State(state): State<AppState>,
Query(q): Query<CallbackQuery>,
) -> impl IntoResponse {
let code = match q.code {
Some(c) if !c.is_empty() => c,
_ => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error":"missing_code"}))).into_response(),
};
let client_id = std::env::var("GITHUB_CLIENT_ID").unwrap_or_default();
let client_secret = std::env::var("GITHUB_CLIENT_SECRET").unwrap_or_default();
if client_id.is_empty() || client_secret.is_empty() {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"oauth_not_configured"}))).into_response();
}
let client = reqwest::Client::new();
let token_resp = client
.post("https://github.com/login/oauth/access_token")
.header("Accept", "application/json")
.form(&[("client_id", &client_id), ("client_secret", &client_secret), ("code", &code)])
.send()
.await;
let token_resp = match token_resp {
Ok(r) => r,
Err(e) => {
tracing::error!(error=%e, "oauth token exchange failed");
return (StatusCode::BAD_GATEWAY, Json(serde_json::json!({"error":"oauth_exchange_failed"}))).into_response();
}
};
let token_data: TokenResp = match token_resp.json().await {
Ok(v) => v,
Err(e) => {
tracing::error!(error=%e, "oauth token parse failed");
return (StatusCode::BAD_GATEWAY, Json(serde_json::json!({"error":"oauth_parse_failed"}))).into_response();
}
};
let access_token = match token_data.access_token {
Some(t) if !t.is_empty() => t,
_ => return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error":"oauth_no_token"}))).into_response(),
};
let user_resp = client
.get("https://api.github.com/user")
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Indexium")
.bearer_auth(&access_token)
.send()
.await;
let user_resp = match user_resp {
Ok(r) => r,
Err(e) => {
tracing::error!(error=%e, "github user fetch failed");
return (StatusCode::BAD_GATEWAY, Json(serde_json::json!({"error":"github_fetch_failed"}))).into_response();
}
};
let gh_user: GhUser = match user_resp.json().await {
Ok(v) => v,
Err(e) => {
tracing::error!(error=%e, "github user parse failed");
return (StatusCode::BAD_GATEWAY, Json(serde_json::json!({"error":"github_parse_failed"}))).into_response();
}
};
let _ = sqlx::query(
"INSERT INTO authors (github_id, login, avatar_url) VALUES ($1,$2,$3) \
ON CONFLICT (github_id) DO UPDATE SET login=EXCLUDED.login, avatar_url=EXCLUDED.avatar_url",
)
.bind(gh_user.id)
.bind(&gh_user.login)
.bind(&gh_user.avatar_url)
.execute(&state.db)
.await;
let exp = (chrono::Utc::now().timestamp() as usize) + 7 * 24 * 3600;
let claims = Claims { sub: gh_user.id, login: gh_user.login, exp };
let token = match encode(&Header::default(), &claims, &EncodingKey::from_secret(jwt_secret().as_bytes())) {
Ok(t) => t,
Err(e) => {
tracing::error!(error=%e, "jwt encode failed");
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error":"jwt_failed"}))).into_response();
}
};
let cookie_str = build_jwt_cookie(&token);
let headers = [(header::LOCATION, "/".to_string()), (header::SET_COOKIE, cookie_str)];
(StatusCode::FOUND, headers).into_response()
}
/// GET /api/v1/auth/me
pub async fn me(headers: HeaderMap) -> impl IntoResponse {
let token = match parse_jwt_from_headers(&headers) {
Some(t) => t,
None => return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error":"unauthorized"}))).into_response(),
};
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = true;
let data = decode::<Claims>(&token, &DecodingKey::from_secret(jwt_secret().as_bytes()), &validation);
match data {
Ok(d) => (StatusCode::OK, Json(serde_json::json!({"login": d.claims.login, "github_id": d.claims.sub}))).into_response(),
Err(_) => (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error":"unauthorized"}))).into_response(),
}
}
/// POST /api/v1/auth/logout
pub async fn logout() -> impl IntoResponse {
let cookie_str = clear_jwt_cookie();
let mut headers = HeaderMap::new();
headers.insert(header::SET_COOKIE, cookie_str.parse().unwrap());
(StatusCode::OK, headers, Json(serde_json::json!({"status":"ok"}))).into_response()
}

View file

@ -53,7 +53,7 @@ fn badge_svg(label: &str, value: &str) -> String {
fn simple_badge_svg(label: &str, value: &str) -> String {
// fallback simple spec: <svg width="200" height="20"><rect...><text>label: value</text></svg>
// we embed both formats — simple text ensures spec match
// we embed both formats - simple text ensures spec match
let combined = format!("{}: {}", label, value);
// keep width 200 height 20 as required
format!(
@ -124,7 +124,7 @@ pub async fn get_downloads_badge(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> impl IntoResponse {
// downloads tends to be larger — random 500..50000 if no DB row
// downloads tends to be larger - random 500..50000 if no DB row
let raw = resolve_value(&state, &slug, 500, 50000, "downloads").await;
// scale small version-count to look like downloads: * 1000 if <1000
let scaled = if raw < 100 { raw * 1200 } else { raw };

View file

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

View file

@ -1,5 +1,7 @@
pub mod analytics;
pub mod auth;
pub mod badges;
pub mod collections;
pub mod mods;
pub mod stars;
pub mod webhooks;

View file

@ -37,6 +37,7 @@ pub struct ModListItem {
pub game_versions: Vec<String>, pub loaders: Vec<String>,
pub latest_version: Option<String>, pub download_url: Option<String>,
pub updated_at: DateTime<Utc>,
pub stars_count: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Pagination { pub page: i64, pub limit: i64, pub total: i64, pub pages: i64 }
@ -57,6 +58,7 @@ pub struct ModDetailResponse {
pub description: Option<String>, pub github_repo: String,
pub author: AuthorDto, pub icon_url: Option<String>, pub verified: bool,
pub versions: Vec<VersionDto>, pub updated_at: DateTime<Utc>,
pub stars_count: i64,
}
pub async fn list_mods(State(state): State<AppState>, Query(p): Query<ModSearchParams>) -> impl IntoResponse {
@ -92,6 +94,7 @@ pub async fn list_mods(State(state): State<AppState>, Query(p): Query<ModSearchP
slug: r.slug, name: r.name, summary: r.summary, author: r.author, icon_url: r.icon_url,
game_versions: r.game_versions.unwrap_or_default(), loaders: r.loaders.unwrap_or_default(),
latest_version: r.latest_version, download_url: r.download_url, updated_at: r.updated_at,
stars_count: r.stars_count,
}).collect::<Vec<_>>();
let pages = if total == 0 { 0 } else { (total + limit - 1) / limit };
let body = ModListResponse { data, pagination: Pagination { page, limit, total, pages } };
@ -127,6 +130,7 @@ pub async fn get_mod(State(state): State<AppState>, Path(slug): Path<String>) ->
github_repo: format!("{}/{}", m.owner, m.repo),
author: AuthorDto { login: m.owner, avatar_url: None },
icon_url: m.icon_url, verified: false, versions: versions_dto, updated_at: m.updated_at,
stars_count: m.stars_count,
};
let mut res = (StatusCode::OK, Json(resp)).into_response();
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=60"));
@ -145,7 +149,7 @@ mod tests {
summary: Some("Extra".into()), author: "flashy".into(),
icon_url: None, game_versions: vec!["1.20.1".into()], loaders: vec!["fabric".into()],
latest_version: Some("1.2.3".into()), download_url: Some("https://example.com/mod.jar".into()),
updated_at: Utc::now(),
updated_at: Utc::now(), stars_count: 5,
}],
pagination: Pagination { page: 1, limit: 20, total: 1, pages: 1 },
};
@ -161,7 +165,7 @@ mod tests {
version_number: "1.2.3".into(), game_versions: vec!["1.20.1".into()], loaders: vec!["fabric".into()],
download_url: "https://example.com/mod.jar".into(), file_sha256: "abc".into(), file_size: Some(123), published_at: Utc::now(),
}],
updated_at: Utc::now(),
updated_at: Utc::now(), stars_count: 5,
};
let jd = serde_json::to_value(&detail).unwrap();
assert_eq!(jd["versions"][0]["version_number"], "1.2.3");

View file

@ -0,0 +1,111 @@
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
Json,
};
use serde::Serialize;
use serde_json::json;
use crate::{db, AppState};
#[derive(Serialize)]
struct StarsCount { count: i64 }
fn github_id_from_headers(headers: &HeaderMap) -> i64 {
headers
.get("x-github-id")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(1)
}
pub async fn get_stars(State(state): State<AppState>, Path(slug): Path<String>) -> impl IntoResponse {
let m = match db::fetch_mod_by_slug(&state.db, &slug).await {
Ok(v) => v,
Err(e) => {
tracing::error!(error=%e, slug=%slug, "get_stars fetch_mod failed");
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"internal_error"}))).into_response();
}
};
let Some(m) = m else {
return (StatusCode::NOT_FOUND, Json(json!({"error":"mod_not_found"}))).into_response();
};
let row: Result<(i64,), _> = sqlx::query_as("SELECT COUNT(*) FROM stars WHERE mod_id=$1")
.bind(m.id)
.fetch_one(&state.db)
.await;
match row {
Ok((c,)) => (StatusCode::OK, Json(json!(StarsCount { count: c }))).into_response(),
Err(e) => {
tracing::error!(error=%e, "get_stars count failed");
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"internal_error"}))).into_response()
}
}
}
pub async fn star_mod(
State(state): State<AppState>,
Path(slug): Path<String>,
headers: HeaderMap,
) -> impl IntoResponse {
let github_id = github_id_from_headers(&headers);
let m = match db::fetch_mod_by_slug(&state.db, &slug).await {
Ok(v) => v,
Err(e) => {
tracing::error!(error=%e, slug=%slug, "star_mod fetch_mod failed");
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"internal_error"}))).into_response();
}
};
let Some(m) = m else {
return (StatusCode::NOT_FOUND, Json(json!({"error":"mod_not_found"}))).into_response();
};
if let Err(e) = sqlx::query("INSERT INTO stars (github_id, mod_id) VALUES ($1,$2) ON CONFLICT DO NOTHING")
.bind(github_id)
.bind(m.id)
.execute(&state.db)
.await
{
tracing::error!(error=%e, "star_mod insert failed");
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"internal_error"}))).into_response();
}
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stars WHERE mod_id=$1")
.bind(m.id)
.fetch_one(&state.db)
.await
.unwrap_or(0);
(StatusCode::OK, Json(json!({"count": count}))).into_response()
}
pub async fn unstar_mod(
State(state): State<AppState>,
Path(slug): Path<String>,
headers: HeaderMap,
) -> impl IntoResponse {
let github_id = github_id_from_headers(&headers);
let m = match db::fetch_mod_by_slug(&state.db, &slug).await {
Ok(v) => v,
Err(e) => {
tracing::error!(error=%e, slug=%slug, "unstar_mod fetch_mod failed");
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"internal_error"}))).into_response();
}
};
let Some(m) = m else {
return (StatusCode::NOT_FOUND, Json(json!({"error":"mod_not_found"}))).into_response();
};
if let Err(e) = sqlx::query("DELETE FROM stars WHERE github_id=$1 AND mod_id=$2")
.bind(github_id)
.bind(m.id)
.execute(&state.db)
.await
{
tracing::error!(error=%e, "unstar_mod delete failed");
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error":"internal_error"}))).into_response();
}
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stars WHERE mod_id=$1")
.bind(m.id)
.fetch_one(&state.db)
.await
.unwrap_or(0);
(StatusCode::OK, Json(json!({"count": count}))).into_response()
}

View file

@ -151,7 +151,7 @@ pub async fn github_webhook(
return (StatusCode::ACCEPTED, body).into_response();
}
// Stub for Redis Streams push — in future: XADD indexium:webhook ...
// Stub for Redis Streams push - in future: XADD indexium:webhook ...
tracing::info!(delivery_id = %delivery_id, event = %event, "webhook accepted, push to redis (stub)");
let body = Json(json!({ "status": "accepted", "delivery_id": delivery_id }));

View file

@ -19,6 +19,7 @@ pub struct ModListRow {
pub game_versions: Option<Vec<String>>,
pub loaders: Option<Vec<String>>,
pub download_url: Option<String>,
pub stars_count: i64,
}
#[derive(Debug, sqlx::FromRow)]
@ -32,6 +33,7 @@ pub struct ModRow {
pub icon_url: Option<String>,
pub updated_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub stars_count: i64,
}
#[derive(Debug, sqlx::FromRow)]
@ -100,7 +102,8 @@ pub async fn fetch_mods_page(
lv.version_number AS latest_version,
lv.game_versions,
lv.loaders,
lv.download_url
lv.download_url,
(SELECT COUNT(*) FROM stars s WHERE s.mod_id = mods.id) AS stars_count
FROM mods
LEFT JOIN LATERAL (
SELECT version_number, game_versions, loaders, download_url
@ -136,7 +139,8 @@ pub async fn fetch_mods_page(
pub async fn fetch_mod_by_slug(pool: &PgPool, slug: &str) -> Result<Option<ModRow>, sqlx::Error> {
let row = sqlx::query_as::<_, ModRow>(
r#"SELECT id, slug, name, summary, owner, repo, icon_url, updated_at, created_at
r#"SELECT id, slug, name, summary, owner, repo, icon_url, updated_at, created_at,
(SELECT COUNT(*) FROM stars s WHERE s.mod_id = mods.id) AS stars_count
FROM mods WHERE slug = $1"#,
)
.bind(slug)

View file

@ -65,30 +65,44 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cors = CorsLayer::new()
.allow_origin("http://localhost:5173".parse::<HeaderValue>()?)
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers([axum::http::header::CONTENT_TYPE]);
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
.allow_headers([axum::http::header::CONTENT_TYPE, axum::http::header::HeaderName::from_static("x-github-id")]);
let state = AppState { db: pool };
let app = Router::new()
.route("/health", get(health_check))
.route("/api/v1/mods", get(api::mods::list_mods))
.route("/api/v1/mods/:slug", get(api::mods::get_mod))
.route("/api/v1/mods/{slug}", get(api::mods::get_mod))
.route("/api/v1/mods/{slug}/stars", get(api::stars::get_stars))
.route("/api/v1/mods/{slug}/star", post(api::stars::star_mod).delete(api::stars::unstar_mod))
.route("/api/v1/collections", get(api::collections::list_collections).post(api::collections::create_collection))
.route("/api/v1/collections/:slug", get(api::collections::get_collection))
.route("/api/v1/collections/:slug/export", get(api::collections::export_collection))
.route("/api/v1/collections/{slug}", get(api::collections::get_collection))
.route("/api/v1/collections/{slug}/export", get(api::collections::export_collection))
.route(
"/api/v1/badges/:slug/downloads.svg",
"/api/v1/badges/{slug}/downloads.svg",
get(api::badges::get_downloads_badge),
)
.route(
"/api/v1/badges/:slug/servers.svg",
"/api/v1/badges/{slug}/servers.svg",
get(api::badges::get_servers_badge),
)
.route(
"/api/v1/webhooks/github",
post(api::webhooks::github_webhook),
)
.route("/api/v1/auth/github", get(api::auth::github_login))
.route("/api/v1/auth/github/callback", get(api::auth::github_callback))
.route("/api/v1/auth/me", get(api::auth::me))
.route("/api/v1/auth/logout", post(api::auth::logout))
.route(
"/api/v1/analytics/submit",
post(api::analytics::submit),
)
.route(
"/api/v1/mods/{slug}/analytics",
get(api::analytics::get_analytics),
)
.layer(TraceLayer::new_for_http())
.layer(cors)
.with_state(state);

View file

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