shares.rs
| 1 | //! Share management (milestone 6). |
| 2 | //! |
| 3 | //! Session-authenticated (management; a share token is never enough — see |
| 4 | //! [`SessionUser`]): |
| 5 | //! - `GET /api/shares` — list the current user's shares |
| 6 | //! - `POST /api/shares` — create a share |
| 7 | //! - `DELETE /api/shares/{id}` — delete one of the current user's shares |
| 8 | //! |
| 9 | //! Public (no login; resolved by token): |
| 10 | //! - `GET /api/share/{token}` — resolve a share for the share page |
| 11 | |
| 12 | use std::sync::Arc; |
| 13 | |
| 14 | use api_types::{CreateShare, Mode, OkResp, ShareInfo, UnlockShare}; |
| 15 | use axum::Json; |
| 16 | use axum::extract::{Path as AxumPath, State}; |
| 17 | use axum::http::StatusCode; |
| 18 | use axum::http::header::{HeaderMap, SET_COOKIE}; |
| 19 | use axum::response::{IntoResponse, Response}; |
| 20 | |
| 21 | use crate::api::common::{ |
| 22 | SessionUser, blocking, display_name, live_share, optional_password_hash, share_is_locked, |
| 23 | target_rel, |
| 24 | }; |
| 25 | use crate::api::files::find_root; |
| 26 | use crate::auth; |
| 27 | use crate::db::ShareRow; |
| 28 | use crate::error::{ApiError, AppState}; |
| 29 | use crate::fs; |
| 30 | |
| 31 | /// Shared JSON shape for a share (list / create / public resolve). |
| 32 | pub(crate) fn share_info(row: &ShareRow, state: &AppState) -> ShareInfo { |
| 33 | ShareInfo { |
| 34 | id: row.id, |
| 35 | token: row.token.clone(), |
| 36 | name: display_name(state, &row.target), |
| 37 | is_file: row.is_file, |
| 38 | writable: row.mode.is_writable(), |
| 39 | target: row.target.clone(), |
| 40 | created_at: row.created_at.clone(), |
| 41 | expires_at: row.expires_at.clone(), |
| 42 | kind: None, |
| 43 | has_password: row.password_hash.is_some(), |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | /// GET /api/shares — list the current user's shares. |
| 48 | pub async fn list( |
| 49 | State(state): State<Arc<AppState>>, |
| 50 | auth: SessionUser, |
| 51 | ) -> Result<Json<Vec<ShareInfo>>, ApiError> { |
| 52 | let rows = state.db.user_shares(auth.user.id).await?; |
| 53 | Ok(Json(rows.iter().map(|r| share_info(r, &state)).collect())) |
| 54 | } |
| 55 | |
| 56 | /// POST /api/shares — create a share. |
| 57 | pub async fn create( |
| 58 | State(state): State<Arc<AppState>>, |
| 59 | auth: SessionUser, |
| 60 | Json(body): Json<CreateShare>, |
| 61 | ) -> Result<Json<ShareInfo>, ApiError> { |
| 62 | if body.writable && !state.db.allow_writable_shares().await? { |
| 63 | return Err(ApiError::localized( |
| 64 | StatusCode::FORBIDDEN, |
| 65 | "writable shares are disabled", |
| 66 | "err_rw_shares_disabled", |
| 67 | )); |
| 68 | } |
| 69 | |
| 70 | // Validated at the trust boundary: `is_expired` treats an unparseable |
| 71 | // value as "never expires", so garbage here would make a permanent share. |
| 72 | if let Some(e) = &body.expires_at |
| 73 | && chrono::DateTime::parse_from_rfc3339(e).is_err() |
| 74 | { |
| 75 | return Err(ApiError::localized( |
| 76 | StatusCode::BAD_REQUEST, |
| 77 | "expires_at must be an RFC 3339 timestamp", |
| 78 | "err_bad_expires_at", |
| 79 | )); |
| 80 | } |
| 81 | |
| 82 | // Validated and hashed before the row is written, so a rejected |
| 83 | // password cannot leave a half-made share behind. |
| 84 | let password_hash = optional_password_hash(body.password.as_deref()).await?; |
| 85 | |
| 86 | let root = find_root(&auth.roots, body.root_id)?; |
| 87 | |
| 88 | // A share must never grant more than the source root does, otherwise a |
| 89 | // read-only root could be escalated to a writable share of itself. |
| 90 | if body.writable && !root.mode.is_writable() { |
| 91 | return Err(ApiError::localized( |
| 92 | StatusCode::FORBIDDEN, |
| 93 | "this folder is read-only for you, so it cannot be shared writably", |
| 94 | "err_rw_ro_folder", |
| 95 | )); |
| 96 | } |
| 97 | |
| 98 | // Resolve the target to a safe absolute path, then re-express it relative |
| 99 | // to the server root (the stored `target`). |
| 100 | let server_root = state.root.clone(); |
| 101 | let root_path = root.path.clone(); |
| 102 | let req = body.path.trim().to_string(); |
| 103 | let req = if req.is_empty() { ".".to_string() } else { req }; |
| 104 | let abs = blocking(move || fs::resolve_path(&server_root, &root_path, &req)).await?; |
| 105 | |
| 106 | let target = target_rel(&state, &abs); |
| 107 | let is_file = abs.is_file(); |
| 108 | |
| 109 | let token = auth::short_token(); |
| 110 | let mode = if body.writable { Mode::Rw } else { Mode::Ro }; |
| 111 | let row = state |
| 112 | .db |
| 113 | .create_share( |
| 114 | auth.user.id, |
| 115 | &token, |
| 116 | &target, |
| 117 | is_file, |
| 118 | mode, |
| 119 | body.expires_at.as_deref(), |
| 120 | password_hash.as_deref(), |
| 121 | ) |
| 122 | .await?; |
| 123 | |
| 124 | Ok(Json(share_info(&row, &state))) |
| 125 | } |
| 126 | |
| 127 | /// DELETE /api/shares/{id} — delete one of the current user's shares. |
| 128 | pub async fn delete( |
| 129 | State(state): State<Arc<AppState>>, |
| 130 | auth: SessionUser, |
| 131 | AxumPath(id): AxumPath<i64>, |
| 132 | ) -> Result<Json<OkResp>, ApiError> { |
| 133 | if !state.db.delete_share(id, Some(auth.user.id)).await? { |
| 134 | return Err(ApiError::localized( |
| 135 | StatusCode::NOT_FOUND, |
| 136 | "share not found", |
| 137 | "err_share_not_found", |
| 138 | )); |
| 139 | } |
| 140 | Ok(Json(OkResp {})) |
| 141 | } |
| 142 | |
| 143 | /// GET /api/share/{token} — public resolve for the share page. |
| 144 | pub async fn resolve( |
| 145 | State(state): State<Arc<AppState>>, |
| 146 | headers: HeaderMap, |
| 147 | AxumPath(token): AxumPath<String>, |
| 148 | ) -> Result<Json<ShareInfo>, ApiError> { |
| 149 | let row = live_share(&state, &token).await?; |
| 150 | // Nothing is returned before the password. The shared item's name is |
| 151 | // itself information. |
| 152 | if share_is_locked(&state, &row, &headers).await? { |
| 153 | return Err(locked_error()); |
| 154 | } |
| 155 | Ok(Json(share_info_sniffed(&row, &state).await)) |
| 156 | } |
| 157 | |
| 158 | /// [`share_info`] plus the file's kind for a file share. |
| 159 | /// |
| 160 | /// A file share opens straight into the viewer, so the client needs the kind |
| 161 | /// up front. It cannot list a file's "contents" to find out. |
| 162 | /// |
| 163 | /// Both the resolve and the unlock endpoint answer with this. A visitor who |
| 164 | /// unlocks a protected share never calls resolve again, so a bare |
| 165 | /// `share_info` there left the viewer with nothing to open. |
| 166 | async fn share_info_sniffed(row: &ShareRow, state: &AppState) -> ShareInfo { |
| 167 | let mut info = share_info(row, state); |
| 168 | if row.is_file { |
| 169 | let (server_root, target) = (state.root.clone(), row.target.clone()); |
| 170 | // An unresolvable target just means no kind; the share itself is |
| 171 | // still returned. |
| 172 | info.kind = blocking(move || fs::resolve_file(&server_root, &target)) |
| 173 | .await |
| 174 | .ok() |
| 175 | .map(|p| fs::detect_kind(&p, false)); |
| 176 | } |
| 177 | info |
| 178 | } |
| 179 | |
| 180 | /// The 401 that tells the client to ask for the share's password. |
| 181 | /// |
| 182 | /// The share page branches on the code, so a locked share must stay |
| 183 | /// distinguishable from a missing one. |
| 184 | pub(crate) fn locked_error() -> ApiError { |
| 185 | ApiError::localized( |
| 186 | StatusCode::UNAUTHORIZED, |
| 187 | "this share is password protected", |
| 188 | "err_share_locked", |
| 189 | ) |
| 190 | } |
| 191 | |
| 192 | /// POST /api/share/{token}/unlock — submit a protected share's password. |
| 193 | /// |
| 194 | /// On success the visitor gets a per-share session cookie. A cookie, not a |
| 195 | /// header: previews and downloads are plain URLs in `src` and `href` |
| 196 | /// attributes, which carry cookies and nothing else. |
| 197 | pub async fn unlock( |
| 198 | State(state): State<Arc<AppState>>, |
| 199 | AxumPath(token): AxumPath<String>, |
| 200 | Json(body): Json<UnlockShare>, |
| 201 | ) -> Result<Response, ApiError> { |
| 202 | let row = live_share(&state, &token).await?; |
| 203 | let Some(hash) = row.password_hash.clone() else { |
| 204 | // Nothing to verify. Answering "ok" would mint a cookie that no |
| 205 | // later request ever checks. |
| 206 | return Err(ApiError::localized( |
| 207 | StatusCode::BAD_REQUEST, |
| 208 | "this share has no password", |
| 209 | "err_share_no_password", |
| 210 | )); |
| 211 | }; |
| 212 | |
| 213 | // Same throttle as the login route, keyed by the share token. The token |
| 214 | // is 128 bits, but the password is the weak half and the attacker |
| 215 | // already holds the token. Without this, guessing runs at full speed and |
| 216 | // a flood of attempts also drains the shared Argon2 permits that real |
| 217 | // logins need. |
| 218 | auth::throttle(&token).await; |
| 219 | |
| 220 | let ok = auth::verify_password_async(&body.password, &hash).await; |
| 221 | auth::record_login(&token, ok); |
| 222 | if !ok { |
| 223 | return Err(ApiError::localized( |
| 224 | StatusCode::UNAUTHORIZED, |
| 225 | "wrong password", |
| 226 | "err_share_wrong_password", |
| 227 | )); |
| 228 | } |
| 229 | |
| 230 | let unlock = state.db.create_share_unlock(row.id).await?; |
| 231 | let cookie = auth::share_cookie(row.id, &unlock, state.https()); |
| 232 | Ok(( |
| 233 | [(SET_COOKIE, cookie)], |
| 234 | Json(share_info_sniffed(&row, &state).await), |
| 235 | ) |
| 236 | .into_response()) |
| 237 | } |
| 238 |