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, live_share, share_is_locked, target_rel,
23 validate_password,
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 = match body.password.as_deref().map(str::trim) {
85 Some(pw) if !pw.is_empty() => {
86 validate_password(pw)?;
87 Some(hash_password(pw).await?)
88 }
89 _ => None,
90 };
91
92 let root = find_root(&auth.roots, body.root_id)?;
93
94 // A share must never grant more than the source root does, otherwise a
95 // read-only root could be escalated to a writable share of itself.
96 if body.writable && !root.mode.is_writable() {
97 return Err(ApiError::localized(
98 StatusCode::FORBIDDEN,
99 "this folder is read-only for you, so it cannot be shared writably",
100 "err_rw_ro_folder",
101 ));
102 }
103
104 // Resolve the target to a safe absolute path, then re-express it relative
105 // to the server root (the stored `target`).
106 let server_root = state.root.clone();
107 let root_path = root.path.clone();
108 let req = body.path.trim().to_string();
109 let req = if req.is_empty() { ".".to_string() } else { req };
110 let abs = blocking(move || fs::resolve_path(&server_root, &root_path, &req)).await?;
111
112 let target = target_rel(&state, &abs);
113 let is_file = abs.is_file();
114
115 let token = auth::short_token();
116 let mode = if body.writable { Mode::Rw } else { Mode::Ro };
117 let row = state
118 .db
119 .create_share(
120 auth.user.id,
121 &token,
122 &target,
123 is_file,
124 mode,
125 body.expires_at.as_deref(),
126 password_hash.as_deref(),
127 )
128 .await?;
129
130 Ok(Json(share_info(&row, &state)))
131}
132
133/// DELETE /api/shares/{id} — delete one of the current user's shares.
134pub async fn delete(
135 State(state): State<Arc<AppState>>,
136 auth: SessionUser,
137 AxumPath(id): AxumPath<i64>,
138) -> Result<Json<OkResp>, ApiError> {
139 if !state.db.delete_share(id, auth.user.id).await? {
140 return Err(ApiError::localized(
141 StatusCode::NOT_FOUND,
142 "share not found",
143 "err_share_not_found",
144 ));
145 }
146 Ok(Json(OkResp {}))
147}
148
149/// GET /api/share/{token} — public resolve for the share page.
150pub async fn resolve(
151 State(state): State<Arc<AppState>>,
152 headers: HeaderMap,
153 AxumPath(token): AxumPath<String>,
154) -> Result<Json<ShareInfo>, ApiError> {
155 let row = live_share(&state, &token).await?;
156 // Nothing is returned before the password. The shared item's name is
157 // itself information.
158 if share_is_locked(&state, &row, &headers).await? {
159 return Err(locked_error());
160 }
161 Ok(Json(share_info_sniffed(&row, &state).await))
162}
163
164/// [`share_info`] plus the file's kind for a file share.
165///
166/// A file share opens straight into the viewer, so the client needs the kind
167/// up front. It cannot list a file's "contents" to find out.
168///
169/// Both the resolve and the unlock endpoint answer with this. A visitor who
170/// unlocks a protected share never calls resolve again, so a bare
171/// `share_info` there left the viewer with nothing to open.
172async fn share_info_sniffed(row: &ShareRow, state: &AppState) -> ShareInfo {
173 let mut info = share_info(row, state);
174 if row.is_file {
175 let (server_root, target) = (state.root.clone(), row.target.clone());
176 // An unresolvable target just means no kind; the share itself is
177 // still returned.
178 info.kind = blocking(move || fs::resolve_file(&server_root, &target))
179 .await
180 .ok()
181 .map(|p| fs::detect_kind(&p, false));
182 }
183 info
184}
185
186/// The 401 that tells the client to ask for the share's password.
187///
188/// The share page branches on the code, so a locked share must stay
189/// distinguishable from a missing one.
190pub(crate) fn locked_error() -> ApiError {
191 ApiError::localized(
192 StatusCode::UNAUTHORIZED,
193 "this share is password protected",
194 "err_share_locked",
195 )
196}
197
198/// POST /api/share/{token}/unlock — submit a protected share's password.
199///
200/// On success the visitor gets a per-share session cookie. A cookie, not a
201/// header: previews and downloads are plain URLs in `src` and `href`
202/// attributes, which carry cookies and nothing else.
203pub async fn unlock(
204 State(state): State<Arc<AppState>>,
205 AxumPath(token): AxumPath<String>,
206 Json(body): Json<UnlockShare>,
207) -> Result<Response, ApiError> {
208 let row = live_share(&state, &token).await?;
209 let Some(hash) = row.password_hash.clone() else {
210 // Nothing to verify. Answering "ok" would mint a cookie that no
211 // later request ever checks.
212 return Err(ApiError::localized(
213 StatusCode::BAD_REQUEST,
214 "this share has no password",
215 "err_share_no_password",
216 ));
217 };
218
219 // Same throttle as the login route, keyed by the share token. The token
220 // is 128 bits, but the password is the weak half and the attacker
221 // already holds the token. Without this, guessing runs at full speed and
222 // a flood of attempts also drains the shared Argon2 permits that real
223 // logins need.
224 auth::throttle(&token).await;
225
226 let ok = auth::verify_password_async(&body.password, &hash).await;
227 auth::record_login(&token, ok);
228 if !ok {
229 return Err(ApiError::localized(
230 StatusCode::UNAUTHORIZED,
231 "wrong password",
232 "err_share_wrong_password",
233 ));
234 }
235
236 let unlock = state.db.create_share_unlock(row.id).await?;
237 let cookie = auth::share_cookie(row.id, &unlock, state.https());
238 Ok((
239 [(SET_COOKIE, cookie)],
240 Json(share_info_sniffed(&row, &state).await),
241 )
242 .into_response())
243}
244