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 }
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(
49 rows.iter().map(|r| share_info(r, &state.root)).collect(),
50 ))
51}
52
53/// POST /api/shares — create a share.
54pub async fn create(
55 State(state): State<Arc<AppState>>,
56 auth: SessionUser,
57 Json(body): Json<CreateShare>,
58) -> Result<Json<ShareInfo>, ApiError> {
59 if body.writable && !state.db.allow_writable_shares().await {
60 return Err(ApiError::new(
61 StatusCode::FORBIDDEN,
62 "writable shares are disabled",
63 ));
64 }
65
66 let root = auth
67 .roots
68 .iter()
69 .find(|r| r.id == body.root_id)
70 .ok_or_else(|| ApiError::new(StatusCode::FORBIDDEN, "no such folder"))?;
71
72 // A share must never grant more than the source root does, otherwise a
73 // read-only root could be escalated to a writable share of itself.
74 if body.writable && !root.mode.is_writable() {
75 return Err(ApiError::new(
76 StatusCode::FORBIDDEN,
77 "this folder is read-only for you, so it cannot be shared writably",
78 ));
79 }
80
81 // Resolve the target to a safe absolute path, then re-express it relative
82 // to the server root (the stored `target`).
83 let server_root = state.root.clone();
84 let root_path = root.path.clone();
85 let req = body.path.trim().to_string();
86 let req = if req.is_empty() { ".".to_string() } else { req };
87 let abs = tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_path, &req))
88 .await
89 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
90
91 let target = abs
92 .strip_prefix(&state.root)
93 .map(|p| p.to_string_lossy().into_owned())
94 .unwrap_or_else(|_| ".".to_string());
95 let is_file = abs.is_file();
96
97 let token = auth::share_token();
98 let mode = if body.writable { Mode::Rw } else { Mode::Ro };
99 let row = state
100 .db
101 .create_share(
102 auth.user.id,
103 &token,
104 &target,
105 is_file,
106 mode,
107 body.expires_at.as_deref(),
108 )
109 .await?;
110
111 Ok(Json(share_info(&row, &state.root)))
112}
113
114/// DELETE /api/shares/{id} — delete one of the current user's shares.
115pub async fn delete(
116 State(state): State<Arc<AppState>>,
117 auth: SessionUser,
118 AxumPath(id): AxumPath<i64>,
119) -> Result<Json<OkResp>, ApiError> {
120 if !state.db.delete_share(id, auth.user.id).await {
121 return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
122 }
123 Ok(Json(OkResp { ok: true }))
124}
125
126/// GET /api/share/{token} — public resolve for the share page.
127pub async fn resolve(
128 State(state): State<Arc<AppState>>,
129 AxumPath(token): AxumPath<String>,
130) -> Result<Json<ShareInfo>, ApiError> {
131 let Some(row) = state.db.share_by_token(&token).await else {
132 return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
133 };
134 if row.is_expired() {
135 return Err(ApiError::new(StatusCode::GONE, "this share has expired"));
136 }
137 Ok(Json(share_info(&row, &state.root)))
138}
139