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, live_share, optional_password_hash, share_is_locked,
23 target_rel,
24};
25use crate::api::files::find_root;
26use crate::auth;
27use crate::db::ShareRow;
28use crate::error::{ApiError, AppState};
29use crate::fs;
30
31/// Shared JSON shape for a share (list / create / public resolve).
32pub(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.
48pub 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.
57pub 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.
128pub 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.
144pub 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.
166async 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.
184pub(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.
197pub 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