use argon2::password_hash::rand_core::OsRng; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHasher}; use axum::extract::{Path, State}; use axum::Json; use chrono::{DateTime, Utc}; use serde::Deserialize; use uuid::Uuid; use crate::auth::random_token; use crate::error::{ApiError, ApiResult}; use crate::state::AppState; #[derive(sqlx::FromRow)] struct ShareAdminRow { id: Uuid, token: String, label: String, password_hash: Option, allow_download: bool, expires_at: Option>, locked_until: Option>, created_at: DateTime, rating_count: i64, tag_count: i64, accept_count: i64, reject_count: i64, } fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value { serde_json::json!({ "id": row.id, "token": row.token, "url": format!("{}/s/{}", state.config.public_url, row.token), "label": row.label, "has_password": row.password_hash.is_some(), "allow_download": row.allow_download, "expires_at": row.expires_at, "locked": row.locked_until.map(|t| t > Utc::now()).unwrap_or(false), "created_at": row.created_at, "rating_count": row.rating_count, "tag_count": row.tag_count, "accept_count": row.accept_count, "reject_count": row.reject_count, }) } // Aggregates run as laterals so each feedback table is scanned once per // share (the verdict lateral yields both counts from a single pass). const SHARE_SELECT: &str = "select s.id, s.token, s.label, s.password_hash, s.allow_download, s.expires_at, s.locked_until, s.created_at, rc.rating_count, tc.tag_count, vc.accept_count, vc.reject_count from shares s cross join lateral (select count(*) as rating_count from ratings r where r.share_id = s.id) rc cross join lateral (select count(*) as tag_count from tags t where t.share_id = s.id) tc cross join lateral ( select count(*) filter (where v.verdict = 'accept') as accept_count, count(*) filter (where v.verdict = 'reject') as reject_count from verdicts v where v.share_id = s.id) vc"; pub async fn list( State(state): State, Path(album_id): Path, ) -> ApiResult>> { let rows: Vec = sqlx::query_as(&format!( "{SHARE_SELECT} where s.album_id = $1 order by s.created_at desc" )) .bind(album_id) .fetch_all(&state.db) .await?; Ok(Json(rows.iter().map(|r| share_json(&state, r)).collect())) } #[derive(Deserialize)] pub struct CreateShare { #[serde(default)] label: String, password: Option, #[serde(default = "default_true")] allow_download: bool, expires_at: Option>, } fn default_true() -> bool { true } pub async fn create( State(state): State, Path(album_id): Path, Json(body): Json, ) -> ApiResult> { let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1") .bind(album_id) .fetch_optional(&state.db) .await?; if album_exists.is_none() { return Err(ApiError::not_found()); } let password_hash = match body.password.as_deref().map(str::trim) { Some(pw) if !pw.is_empty() => { // Argon2 is deliberately slow; keep it off the async runtime threads. let pw = pw.to_string(); let hash = tokio::task::spawn_blocking(move || { let salt = SaltString::generate(&mut OsRng); Argon2::default() .hash_password(pw.as_bytes(), &salt) .map(|h| h.to_string()) .map_err(|e| anyhow::anyhow!("hashing password: {e}")) }) .await .map_err(|e| anyhow::anyhow!("hash task failed: {e}"))??; Some(hash) } _ => None, }; let token = random_token(24); let (share_id,): (Uuid,) = sqlx::query_as( "insert into shares (album_id, token, label, password_hash, allow_download, expires_at) values ($1, $2, $3, $4, $5, $6) returning id", ) .bind(album_id) .bind(&token) .bind(body.label.trim()) .bind(&password_hash) .bind(body.allow_download) .bind(body.expires_at) .fetch_one(&state.db) .await?; let row: ShareAdminRow = sqlx::query_as(&format!("{SHARE_SELECT} where s.id = $1")) .bind(share_id) .fetch_one(&state.db) .await?; Ok(Json(share_json(&state, &row))) } /// Distinguishes an absent JSON field (keep current value) from an explicit /// null (clear the expiry): absent → None, present → Some(inner). fn double_option<'de, D>(de: D) -> Result>>, D::Error> where D: serde::Deserializer<'de>, { serde::Deserialize::deserialize(de).map(Some) } #[derive(Deserialize)] pub struct UpdateShare { allow_download: Option, #[serde(default, deserialize_with = "double_option")] expires_at: Option>>, } pub async fn update( State(state): State, Path(share_id): Path, Json(body): Json, ) -> ApiResult> { let updated = sqlx::query( "update shares set allow_download = coalesce($2, allow_download), expires_at = case when $3 then $4 else expires_at end where id = $1", ) .bind(share_id) .bind(body.allow_download) .bind(body.expires_at.is_some()) .bind(body.expires_at.flatten()) .execute(&state.db) .await?; if updated.rows_affected() == 0 { return Err(ApiError::not_found()); } let row: ShareAdminRow = sqlx::query_as(&format!("{SHARE_SELECT} where s.id = $1")) .bind(share_id) .fetch_one(&state.db) .await?; Ok(Json(share_json(&state, &row))) } /// Clear a share's password-lockout state (e.g. after a client fat-fingered /// their way into the 15-minute lock). pub async fn reset_lock( State(state): State, Path(share_id): Path, ) -> ApiResult> { let updated = sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1") .bind(share_id) .execute(&state.db) .await?; if updated.rows_affected() == 0 { return Err(ApiError::not_found()); } Ok(Json(serde_json::json!({ "ok": true }))) } pub async fn delete( State(state): State, Path(share_id): Path, ) -> ApiResult> { let deleted = sqlx::query("delete from shares where id = $1") .bind(share_id) .execute(&state.db) .await?; if deleted.rows_affected() == 0 { return Err(ApiError::not_found()); } Ok(Json(serde_json::json!({ "ok": true }))) }