shares.rs
⎇
Raw
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
12use std::sync::Arc;
13
14use api_types::{CreateShare, Mode, OkResp, ShareInfo, UnlockShare};
15use axum::Json;
16use axum::extract::{Path as AxumPath, State};
17use axum::http::StatusCode;
18use axum::http::header::{HeaderMap, SET_COOKIE};
19use axum::response::{IntoResponse, Response};
20
21use crate::api::common::{
22 SessionUser, blocking, display_name, hash_password, share_is_locked, target_rel,
23 validate_password,
24};
25use crate::auth;
26use crate::db::ShareRow;
27use crate::error::{ApiError, AppState};
28use crate::fs;
29
30/// Shared JSON shape for a share (list / create / public resolve).
31fn 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.
49pub 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.
58pub 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.
145pub 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.
161pub 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.
196async 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.
214pub(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.
227pub 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