//! 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::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, blocking, display_name, target_rel}; 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, state: &AppState) -> ShareInfo { ShareInfo { id: row.id, token: row.token.clone(), name: display_name(state, &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, kind: None, } } /// 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)).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::localized( StatusCode::FORBIDDEN, "writable shares are disabled", "err_rw_shares_disabled", )); } // Validated at the trust boundary: `is_expired` treats an unparseable // value as "never expires", so garbage here would make a permanent share. if let Some(e) = &body.expires_at && chrono::DateTime::parse_from_rfc3339(e).is_err() { return Err(ApiError::localized( StatusCode::BAD_REQUEST, "expires_at must be an RFC 3339 timestamp", "err_bad_expires_at", )); } let root = auth .roots .iter() .find(|r| r.id == body.root_id) .ok_or_else(|| { ApiError::localized( StatusCode::FORBIDDEN, "no such folder", "err_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::localized( StatusCode::FORBIDDEN, "this folder is read-only for you, so it cannot be shared writably", "err_rw_ro_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 = blocking(move || fs::resolve_path(&server_root, &root_path, &req)).await?; let target = target_rel(&state, &abs); 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))) } /// 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::localized( StatusCode::NOT_FOUND, "share not found", "err_share_not_found", )); } Ok(Json(OkResp {})) } /// 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::localized( StatusCode::NOT_FOUND, "share not found", "err_share_not_found", )); }; if row.is_expired() { return Err(ApiError::localized( StatusCode::GONE, "this share has expired", "err_share_expired", )); } let mut info = share_info(&row, &state); // A file share opens straight into the viewer, so the client needs the // file's kind up front (it cannot list a file's "contents"). if row.is_file { let (server_root, target) = (state.root.clone(), row.target.clone()); // An unresolvable target just means no kind; the share itself is // still returned. info.kind = blocking(move || fs::resolve_file(&server_root, &target)) .await .ok() .map(|p| fs::detect_kind(&p, false)); } Ok(Json(info)) }