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