//! 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, UnlockShare}; use axum::Json; use axum::extract::{Path as AxumPath, State}; use axum::http::StatusCode; use axum::http::header::{HeaderMap, SET_COOKIE}; use axum::response::{IntoResponse, Response}; use crate::api::common::{ SessionUser, blocking, display_name, hash_password, share_is_locked, target_rel, validate_password, }; 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, has_password: row.password_hash.is_some(), } } /// 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", )); } // Validated and hashed before the row is written, so a rejected // password cannot leave a half-made share behind. let password_hash = match body.password.as_deref().map(str::trim) { Some(pw) if !pw.is_empty() => { validate_password(pw)?; Some(hash_password(pw).await?) } _ => None, }; 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(), password_hash.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>, headers: HeaderMap, 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", )); } // Nothing is returned before the password. The shared item's name is // itself information. if share_is_locked(&state, &row, &headers).await? { return Err(locked_error()); } Ok(Json(share_info_sniffed(&row, &state).await)) } /// [`share_info`] plus the file's kind for a file share. /// /// A file share opens straight into the viewer, so the client needs the kind /// up front. It cannot list a file's "contents" to find out. /// /// Both the resolve and the unlock endpoint answer with this. A visitor who /// unlocks a protected share never calls resolve again, so a bare /// `share_info` there left the viewer with nothing to open. async fn share_info_sniffed(row: &ShareRow, state: &AppState) -> ShareInfo { let mut info = share_info(row, state); 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)); } info } /// The 401 that tells the client to ask for the share's password. /// /// The share page branches on the code, so a locked share must stay /// distinguishable from a missing one. pub(crate) fn locked_error() -> ApiError { ApiError::localized( StatusCode::UNAUTHORIZED, "this share is password protected", "err_share_locked", ) } /// POST /api/share/{token}/unlock — submit a protected share's password. /// /// On success the visitor gets a per-share session cookie. A cookie, not a /// header: previews and downloads are plain URLs in `src` and `href` /// attributes, which carry cookies and nothing else. pub async fn unlock( State(state): State>, AxumPath(token): AxumPath, Json(body): Json, ) -> Result { 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 Some(hash) = row.password_hash.clone() else { // Nothing to verify. Answering "ok" would mint a cookie that no // later request ever checks. return Err(ApiError::localized( StatusCode::BAD_REQUEST, "this share has no password", "err_share_no_password", )); }; // Same throttle as the login route, keyed by the share token. The token // is 128 bits, but the password is the weak half and the attacker // already holds the token. Without this, guessing runs at full speed and // a flood of attempts also drains the shared Argon2 permits that real // logins need. let delay = auth::login_delay(&token); if !delay.is_zero() { tokio::time::sleep(delay).await; } let pw = body.password; let _slot = auth::ARGON2_SLOTS.acquire().await; let ok = tokio::task::spawn_blocking(move || auth::verify_password(&pw, &hash)) .await .map_err(|_| crate::api::common::internal_error())?; auth::record_login(&token, ok); if !ok { return Err(ApiError::localized( StatusCode::UNAUTHORIZED, "wrong password", "err_share_wrong_password", )); } let unlock = state.db.create_share_unlock(row.id).await?; let cookie = auth::share_cookie(row.id, &unlock, state.https); Ok(( [(SET_COOKIE, cookie)], Json(share_info_sniffed(&row, &state).await), ) .into_response()) }