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 api_types::{CreateShare, OkResp, ShareInfo};
15use axum::Json;
16use axum::extract::{Path as AxumPath, State};
17use axum::http::StatusCode;
18
19use crate::api::common::{AuthUser, 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, server_root: &Path) -> ShareInfo {
27 ShareInfo {
28 id: row.id,
29 token: row.token.clone(),
30 name: display_name(server_root, &row.target),
31 is_file: row.is_file,
32 writable: row.mode == "rw",
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 }
39}
40
41/// GET /api/shares — list the current user's shares.
42pub async fn list(
43 State(state): State<Arc<AppState>>,
44 auth: AuthUser,
45) -> Result<Json<Vec<ShareInfo>>, ApiError> {
46 let rows = state.db.user_shares(auth.user.id).await;
47 Ok(Json(
48 rows.iter().map(|r| share_info(r, &state.root)).collect(),
49 ))
50}
51
52/// POST /api/shares — create a share.
53pub async fn create(
54 State(state): State<Arc<AppState>>,
55 auth: AuthUser,
56 Json(body): Json<CreateShare>,
57) -> Result<Json<ShareInfo>, ApiError> {
58 if body.writable && !state.db.allow_writable_shares().await {
59 return Err(ApiError::new(
60 StatusCode::FORBIDDEN,
61 "writable shares are disabled",
62 ));
63 }
64
65 let root = auth
66 .roots
67 .iter()
68 .find(|r| r.id == body.root_id)
69 .ok_or_else(|| ApiError::new(StatusCode::FORBIDDEN, "no such folder"))?;
70
71 // Resolve the target to a safe absolute path, then re-express it relative
72 // to the server root (the stored `target`).
73 let server_root = state.root.clone();
74 let root_path = root.path.clone();
75 let req = body.path.trim().to_string();
76 let req = if req.is_empty() { ".".to_string() } else { req };
77 let abs = tokio::task::spawn_blocking(move || fs::resolve_path(&server_root, &root_path, &req))
78 .await
79 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error"))??;
80
81 let target = abs
82 .strip_prefix(&state.root)
83 .map(|p| p.to_string_lossy().into_owned())
84 .unwrap_or_else(|_| ".".to_string());
85 let is_file = abs.is_file();
86
87 let token = auth::share_token();
88 let mode = if body.writable { "rw" } else { "ro" };
89 let row = state
90 .db
91 .create_share(
92 auth.user.id,
93 &token,
94 &target,
95 is_file,
96 mode,
97 body.expires_at.as_deref(),
98 )
99 .await?;
100
101 Ok(Json(share_info(&row, &state.root)))
102}
103
104/// DELETE /api/shares/{id} — delete one of the current user's shares.
105pub async fn delete(
106 State(state): State<Arc<AppState>>,
107 auth: AuthUser,
108 AxumPath(id): AxumPath<i64>,
109) -> Result<Json<OkResp>, ApiError> {
110 if !state.db.delete_share(id, auth.user.id).await {
111 return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
112 }
113 Ok(Json(OkResp { ok: true }))
114}
115
116/// GET /api/share/{token} — public resolve for the share page.
117pub async fn resolve(
118 State(state): State<Arc<AppState>>,
119 AxumPath(token): AxumPath<String>,
120) -> Result<Json<ShareInfo>, ApiError> {
121 let Some(row) = state.db.share_by_token(&token).await else {
122 return Err(ApiError::new(StatusCode::NOT_FOUND, "share not found"));
123 };
124 if row.is_expired() {
125 return Err(ApiError::new(StatusCode::GONE, "this share has expired"));
126 }
127 Ok(Json(share_info(&row, &state.root)))
128}
129