shares.rs
⎇
Raw
1//! Share management (milestone 6).
2//!
3//! Authenticated (management):
4//! - `GET /api/shares` — list the current user's shares
5//! - `POST /api/shares` — create a share
6//! - `DELETE /api/shares/{id}` — delete one of the current user's shares
7//!
8//! Public (no login; resolved by token):
9//! - `GET /api/share/{token}` — resolve a share for the share page
10
11use std::path::Path;
12use std::sync::Arc;
13
14use axum::extract::{Path as AxumPath, State};
15use axum::http::StatusCode;
16use axum::Json;
17use serde::Deserialize;
18
19use crate::api::common::AuthUser;
20use crate::auth;
21use crate::db::ShareRow;
22use crate::error::{ApiError, AppState};
23use crate::fs;
24
25/// `POST /api/shares` body.
26#[derive(Deserialize)]
27pub struct CreateBody {
28 root_id: i64,
29 /// Item path relative to the root ("" or "." for the root itself).
30 path: String,
31 #[serde(default)]
32 writable: bool,
33 /// Absolute expiry as RFC 3339, or null for "never".
34 #[serde(default)]
35 expires_at: Option<String>,
36}
37
38/// Shared JSON shape for a share (list / create / public resolve).
39fn share_json(row: &ShareRow, server_root: &Path) -> serde_json::Value {
40 serde_json::json!({
41 "id": row.id,
42 "token": row.token,
43 "name": share_name(server_root, &row.target),
44 "is_file": row.is_file,
45 "writable": row.mode == "rw",
46 "target": row.target,
47 "created_at": row.created_at,
48 "expires_at": row.expires_at,
49 // The synthetic root id to use in file API calls.
50 "root_id": row.id,
51 })
52}
53
54/// Display name for a share target: the folder/file name, or the server root's
55/// own name when the target is the whole root (".").
56fn share_name(server_root: &Path, target: &str) -> String {
57 let name = if target == "." {
58 server_root.file_name()
59 } else {
60 Path::new(target)
61 .file_name()
62 .filter(|_| !Path::new(target).as_os_str().is_empty())
63 };
64 name.map(|s| s.to_string_lossy().into_owned())
65 .unwrap_or_else(|| target.to_string())
66}
67
68/// GET /api/shares — list the current user's shares.
69pub async fn list(
70 State(state): State<Arc<AppState>>,
71 auth: AuthUser,
72) -> Result<Json<serde_json::Value>, ApiError> {
73 let rows = state.db.user_shares(auth.user.id).await;
74 let values: Vec<serde_json::Value> = rows
75 .iter()
76 .map(|r| share_json(r, &state.root))
77 .collect();
78 Ok(Json(serde_json::json!(values)))
79}
80
81/// POST /api/shares — create a share.
82pub async fn create(
83 State(state): State<Arc<AppState>>,
84 auth: AuthUser,
85 Json(body): Json<CreateBody>,
86) -> Result<Json<serde_json::Value>, ApiError> {
87 if body.writable && !state.db.allow_writable_shares().await {
88 return Err(ApiError::new(
89 StatusCode::FORBIDDEN,
90 "writable shares are disabled",
91 ));
92 }
93
94 let root = auth
95 .roots
96 .iter()
97 .find(|r| r.id == body.root_id)
98 .ok_or_else(|| ApiError::new(StatusCode::FORBIDDEN, "no such folder"))?;
99
100 // Resolve the target to a safe absolute path, then re-express it relative
101 // to the server root (the stored `target`).
102 let server_root = state.root.clone();
103 let root_path = root.path.clone();
104 let req = body.path.trim().to_string();
105 let req = if req.is_empty() { ".".to_string() } else { req };
106 let abs = tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_path, &req))
107 .await
108 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
109
110 let target = abs
111 .strip_prefix(&state.root)
112 .map(|p| p.to_string_lossy().into_owned())
113 .unwrap_or_else(|_| ".".to_string());
114 let is_file = abs.is_file();
115
116 let token = auth::share_token();
117 let mode = if body.writable { "rw" } else { "ro" };
118 let row = state
119 .db
120 .create_share(
121 auth.user.id,
122 &token,
123 &target,
124 is_file,
125 mode,
126 body.expires_at.as_deref(),
127 )
128 .await?;
129
130 Ok(Json(share_json(&row, &state.root)))
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: AuthUser,
137 AxumPath(id): AxumPath<i64>,
138) -> Result<Json<serde_json::Value>, ApiError> {
139 if !state.db.delete_share(id, auth.user.id).await {
140 return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
141 }
142 Ok(Json(serde_json::json!({ "ok": true })))
143}
144
145/// GET /api/share/{token} — public resolve for the share page.
146pub async fn resolve(
147 State(state): State<Arc<AppState>>,
148 AxumPath(token): AxumPath<String>,
149) -> Result<Json<serde_json::Value>, ApiError> {
150 let Some(row) = state.db.share_by_token(&token).await else {
151 return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
152 };
153 if row.is_expired() {
154 return Err(ApiError::new(
155 StatusCode::GONE,
156 "this share has expired",
157 ));
158 }
159 Ok(Json(share_json(&row, &state.root)))
160}
161