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
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue