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.iter().map(|r| share_json(r, &state.root)).collect();
75 Ok(Json(serde_json::json!(values)))
76}
77
78/// POST /api/shares — create a share.
79pub async fn create(
80 State(state): State<Arc<AppState>>,
81 auth: AuthUser,
82 Json(body): Json<CreateBody>,
83) -> Result<Json<serde_json::Value>, ApiError> {
84 if body.writable && !state.db.allow_writable_shares().await {
85 return Err(ApiError::new(
86 StatusCode::FORBIDDEN,
87 "writable shares are disabled",
88 ));
89 }
90
91 let root = auth
92 .roots
93 .iter()
94 .find(|r| r.id == body.root_id)
95 .ok_or_else(|| ApiError::new(StatusCode::FORBIDDEN, "no such folder"))?;
96
97 // Resolve the target to a safe absolute path, then re-express it relative
98 // to the server root (the stored `target`).
99 let server_root = state.root.clone();
100 let root_path = root.path.clone();
101 let req = body.path.trim().to_string();
102 let req = if req.is_empty() { ".".to_string() } else { req };
103 let abs = tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_path, &req))
104 .await
105 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
106
107 let target = abs
108 .strip_prefix(&state.root)
109 .map(|p| p.to_string_lossy().into_owned())
110 .unwrap_or_else(|_| ".".to_string());
111 let is_file = abs.is_file();
112
113 let token = auth::share_token();
114 let mode = if body.writable { "rw" } else { "ro" };
115 let row = state
116 .db
117 .create_share(
118 auth.user.id,
119 &token,
120 &target,
121 is_file,
122 mode,
123 body.expires_at.as_deref(),
124 )
125 .await?;
126
127 Ok(Json(share_json(&row, &state.root)))
128}
129
130/// DELETE /api/shares/{id} — delete one of the current user's shares.
131pub async fn delete(
132 State(state): State<Arc<AppState>>,
133 auth: AuthUser,
134 AxumPath(id): AxumPath<i64>,
135) -> Result<Json<serde_json::Value>, ApiError> {
136 if !state.db.delete_share(id, auth.user.id).await {
137 return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
138 }
139 Ok(Json(serde_json::json!({ "ok": true })))
140}
141
142/// GET /api/share/{token} — public resolve for the share page.
143pub async fn resolve(
144 State(state): State<Arc<AppState>>,
145 AxumPath(token): AxumPath<String>,
146) -> Result<Json<serde_json::Value>, ApiError> {
147 let Some(row) = state.db.share_by_token(&token).await else {
148 return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
149 };
150 if row.is_expired() {
151 return Err(ApiError::new(StatusCode::GONE, "this share has expired"));
152 }
153 Ok(Json(share_json(&row, &state.root)))
154}
155