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