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};
15use axum::Json;
16use axum::extract::{Path as AxumPath, State};
17use axum::http::StatusCode;
18
19use crate::api::common::{SessionUser, display_name};
20use crate::auth;
21use crate::db::ShareRow;
22use crate::error::{ApiError, AppState};
23use crate::fs;
24
25/// Shared JSON shape for a share (list / create / public resolve).
26fn share_info(row: &ShareRow, state: &AppState) -> ShareInfo {
27 ShareInfo {
28 id: row.id,
29 token: row.token.clone(),
30 name: display_name(state, &row.target),
31 is_file: row.is_file,
32 writable: row.mode.is_writable(),
33 target: row.target.clone(),
34 created_at: row.created_at.clone(),
35 expires_at: row.expires_at.clone(),
36 // The synthetic root id to use in file API calls.
37 root_id: row.id,
38 kind: None,
39 }
40}
41
42/// GET /api/shares — list the current user's shares.
43pub async fn list(
44 State(state): State<Arc<AppState>>,
45 auth: SessionUser,
46) -> Result<Json<Vec<ShareInfo>>, ApiError> {
47 let rows = state.db.user_shares(auth.user.id).await?;
48 Ok(Json(rows.iter().map(|r| share_info(r, &state)).collect()))
49}
50
51/// POST /api/shares — create a share.
52pub async fn create(
53 State(state): State<Arc<AppState>>,
54 auth: SessionUser,
55 Json(body): Json<CreateShare>,
56) -> Result<Json<ShareInfo>, ApiError> {
57 if body.writable && !state.db.allow_writable_shares().await? {
58 return Err(ApiError::localized(
59 StatusCode::FORBIDDEN,
60 "writable shares are disabled",
61 "err_rw_shares_disabled",
62 ));
63 }
64
65 // Validated at the trust boundary: `is_expired` treats an unparseable
66 // value as "never expires", so garbage here would make a permanent share.
67 if let Some(e) = &body.expires_at
68 && chrono::DateTime::parse_from_rfc3339(e).is_err()
69 {
70 return Err(ApiError::localized(
71 StatusCode::BAD_REQUEST,
72 "expires_at must be an RFC 3339 timestamp",
73 "err_bad_expires_at",
74 ));
75 }
76
77 let root = auth
78 .roots
79 .iter()
80 .find(|r| r.id == body.root_id)
81 .ok_or_else(|| {
82 ApiError::localized(
83 StatusCode::FORBIDDEN,
84 "no such folder",
85 "err_no_such_folder",
86 )
87 })?;
88
89 // A share must never grant more than the source root does, otherwise a
90 // read-only root could be escalated to a writable share of itself.
91 if body.writable && !root.mode.is_writable() {
92 return Err(ApiError::localized(
93 StatusCode::FORBIDDEN,
94 "this folder is read-only for you, so it cannot be shared writably",
95 "err_rw_ro_folder",
96 ));
97 }
98
99 // Resolve the target to a safe absolute path, then re-express it relative
100 // to the server root (the stored `target`).
101 let server_root = state.root.clone();
102 let root_path = root.path.clone();
103 let req = body.path.trim().to_string();
104 let req = if req.is_empty() { ".".to_string() } else { req };
105 let abs = tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_path, &req))
106 .await
107 .map_err(|_| {
108 ApiError::localized(
109 StatusCode::INTERNAL_SERVER_ERROR,
110 "internal error",
111 "err_internal",
112 )
113 })??;
114
115 let target = abs
116 .strip_prefix(&state.root)
117 .map(|p| p.to_string_lossy().into_owned())
118 .unwrap_or_else(|_| ".".to_string());
119 let is_file = abs.is_file();
120
121 let token = auth::share_token();
122 let mode = if body.writable { Mode::Rw } else { Mode::Ro };
123 let row = state
124 .db
125 .create_share(
126 auth.user.id,
127 &token,
128 &target,
129 is_file,
130 mode,
131 body.expires_at.as_deref(),
132 )
133 .await?;
134
135 Ok(Json(share_info(&row, &state)))
136}
137
138/// DELETE /api/shares/{id} — delete one of the current user's shares.
139pub async fn delete(
140 State(state): State<Arc<AppState>>,
141 auth: SessionUser,
142 AxumPath(id): AxumPath<i64>,
143) -> Result<Json<OkResp>, ApiError> {
144 if !state.db.delete_share(id, auth.user.id).await? {
145 return Err(ApiError::localized(
146 StatusCode::NOT_FOUND,
147 "share not found",
148 "err_share_not_found",
149 ));
150 }
151 Ok(Json(OkResp { ok: true }))
152}
153
154/// GET /api/share/{token} — public resolve for the share page.
155pub async fn resolve(
156 State(state): State<Arc<AppState>>,
157 AxumPath(token): AxumPath<String>,
158) -> Result<Json<ShareInfo>, ApiError> {
159 let Some(row) = state.db.share_by_token(&token).await? else {
160 return Err(ApiError::localized(
161 StatusCode::NOT_FOUND,
162 "share not found",
163 "err_share_not_found",
164 ));
165 };
166 if row.is_expired() {
167 return Err(ApiError::localized(
168 StatusCode::GONE,
169 "this share has expired",
170 "err_share_expired",
171 ));
172 }
173 let mut info = share_info(&row, &state);
174 // A file share opens straight into the viewer, so the client needs the
175 // file's kind up front (it cannot list a file's "contents").
176 if row.is_file {
177 let (server_root, target) = (state.root.clone(), row.target.clone());
178 info.kind = tokio::task::spawn_blocking(move || {
179 fs::resolve_file(&server_root, &target)
180 .ok()
181 .map(|p| fs::detect_kind(&p, false))
182 })
183 .await
184 .ok()
185 .flatten();
186 }
187 Ok(Json(info))
188}
189