//! Share management (milestone 6). //! //! Session-authenticated (management; a share token is never enough — see //! [`SessionUser`]): //! - `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 api_types::{CreateShare, Mode, OkResp, ShareInfo}; use axum::Json; use axum::extract::{Path as AxumPath, State}; use axum::http::StatusCode; use crate::api::common::{SessionUser, display_name}; use crate::auth; use crate::db::ShareRow; use crate::error::{ApiError, AppState}; use crate::fs; /// Shared JSON shape for a share (list / create / public resolve). fn share_info(row: &ShareRow, server_root: &Path) -> ShareInfo { ShareInfo { id: row.id, token: row.token.clone(), name: display_name(server_root, &row.target), is_file: row.is_file, writable: row.mode.is_writable(), target: row.target.clone(), created_at: row.created_at.clone(), expires_at: row.expires_at.clone(), // The synthetic root id to use in file API calls. root_id: row.id, } } /// GET /api/shares — list the current user's shares. pub async fn list( State(state): State>, auth: SessionUser, ) -> Result>, ApiError> { let rows = state.db.user_shares(auth.user.id).await; Ok(Json( rows.iter().map(|r| share_info(r, &state.root)).collect(), )) } /// POST /api/shares — create a share. pub async fn create( State(state): State>, auth: SessionUser, 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"))?; // A share must never grant more than the source root does, otherwise a // read-only root could be escalated to a writable share of itself. if body.writable && !root.mode.is_writable() { return Err(ApiError::new( StatusCode::FORBIDDEN, "this folder is read-only for you, so it cannot be shared writably", )); } // 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 { Mode::Rw } else { Mode::Ro }; let row = state .db .create_share( auth.user.id, &token, &target, is_file, mode, body.expires_at.as_deref(), ) .await?; Ok(Json(share_info(&row, &state.root))) } /// DELETE /api/shares/{id} — delete one of the current user's shares. pub async fn delete( State(state): State>, auth: SessionUser, 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(OkResp { 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_info(&row, &state.root))) }