//! Share management (milestone 6). //! //! Authenticated (management): //! - `GET /api/shares` — list the current user's shares //! - `POST /api/shares` — create a share //! - `DELETE /api/shares/{id}` — delete one of the current user's shares //! //! Public (no login; resolved by token): //! - `GET /api/share/{token}` — resolve a share for the share page use std::path::Path; use std::sync::Arc; use axum::extract::{Path as AxumPath, State}; use axum::http::StatusCode; use axum::Json; use serde::Deserialize; use crate::api::common::AuthUser; use crate::auth; use crate::db::ShareRow; use crate::error::{ApiError, AppState}; use crate::fs; /// `POST /api/shares` body. #[derive(Deserialize)] pub struct CreateBody { root_id: i64, /// Item path relative to the root ("" or "." for the root itself). path: String, #[serde(default)] writable: bool, /// Absolute expiry as RFC 3339, or null for "never". #[serde(default)] expires_at: Option, } /// Shared JSON shape for a share (list / create / public resolve). fn share_json(row: &ShareRow, server_root: &Path) -> serde_json::Value { serde_json::json!({ "id": row.id, "token": row.token, "name": share_name(server_root, &row.target), "is_file": row.is_file, "writable": row.mode == "rw", "target": row.target, "created_at": row.created_at, "expires_at": row.expires_at, // The synthetic root id to use in file API calls. "root_id": row.id, }) } /// Display name for a share target: the folder/file name, or the server root's /// own name when the target is the whole root ("."). fn share_name(server_root: &Path, target: &str) -> String { let name = if target == "." { server_root.file_name() } else { Path::new(target) .file_name() .filter(|_| !Path::new(target).as_os_str().is_empty()) }; name.map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| target.to_string()) } /// GET /api/shares — list the current user's shares. pub async fn list( State(state): State>, auth: AuthUser, ) -> Result, ApiError> { let rows = state.db.user_shares(auth.user.id).await; let values: Vec = rows.iter().map(|r| share_json(r, &state.root)).collect(); Ok(Json(serde_json::json!(values))) } /// POST /api/shares — create a share. pub async fn create( State(state): State>, auth: AuthUser, Json(body): Json, ) -> Result, ApiError> { if body.writable && !state.db.allow_writable_shares().await { return Err(ApiError::new( StatusCode::FORBIDDEN, "writable shares are disabled", )); } let root = auth .roots .iter() .find(|r| r.id == body.root_id) .ok_or_else(|| ApiError::new(StatusCode::FORBIDDEN, "no such folder"))?; // Resolve the target to a safe absolute path, then re-express it relative // to the server root (the stored `target`). let server_root = state.root.clone(); let root_path = root.path.clone(); let req = body.path.trim().to_string(); let req = if req.is_empty() { ".".to_string() } else { req }; let abs = tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_path, &req)) .await .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??; let target = abs .strip_prefix(&state.root) .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|_| ".".to_string()); let is_file = abs.is_file(); let token = auth::share_token(); let mode = if body.writable { "rw" } else { "ro" }; let row = state .db .create_share( auth.user.id, &token, &target, is_file, mode, body.expires_at.as_deref(), ) .await?; Ok(Json(share_json(&row, &state.root))) } /// DELETE /api/shares/{id} — delete one of the current user's shares. pub async fn delete( State(state): State>, auth: AuthUser, AxumPath(id): AxumPath, ) -> Result, ApiError> { if !state.db.delete_share(id, auth.user.id).await { return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found")); } Ok(Json(serde_json::json!({ "ok": true }))) } /// GET /api/share/{token} — public resolve for the share page. pub async fn resolve( State(state): State>, AxumPath(token): AxumPath, ) -> Result, ApiError> { let Some(row) = state.db.share_by_token(&token).await else { return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found")); }; if row.is_expired() { return Err(ApiError::new(StatusCode::GONE, "this share has expired")); } Ok(Json(share_json(&row, &state.root))) }