1use std::collections::HashMap;
8use std::sync::Arc;
9
10use axum::Router;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13use chrono::{DateTime, Utc};
14
15use backbone_core::http::{ApiResponse, BackboneCrudHandler};
17
18use backbone_auth::middleware::AuthContext;
20#[cfg(feature = "auth")]
21use backbone_auth::AuthMiddleware;
22
23use crate::domain::entity::*;
25use crate::application::service::{FileShareService, ServiceError};
26
27use crate::presentation::dto::{CreateFileShareDto, UpdateFileShareDto, PatchFileShareDto, FileShareResponseDto};
29
30use crate::domain::state_machine::{FileShareState, FileShareStateMachine, FileShareTransition};
31
32#[derive(Debug, thiserror::Error)]
34pub enum FileShareError {
35 #[error("Not found: {0}")]
36 NotFound(String),
37 #[error("Validation error: {0}")]
38 Validation(String),
39 #[error("Database error: {0}")]
40 Database(String),
41 #[error("Internal error: {0}")]
42 Internal(String),
43 #[error("Share token must be 32-64 characters: {0}")]
45 InvalidTokenLength(String),
46 #[error("Share token must be alphanumeric: {0}")]
47 InvalidTokenChars(String),
48 #[error("Cannot share inactive or quarantined files: {0}")]
49 FileNotActive(String),
50 #[error("Only file owner can create shares: {0}")]
51 NotFileOwner(String),
52 #[error("Expiration date must be in the future: {0}")]
53 ExpiredDate(String),
54 #[error("Maximum downloads must be positive: {0}")]
55 InvalidMaxDownloads(String),
56 #[error("User shares must specify recipients: {0}")]
57 MissingRecipients(String),
58 #[error("Cannot modify inactive shares: {0}")]
59 ShareInactive(String),
60}
61
62impl From<ServiceError> for FileShareError {
63 fn from(err: ServiceError) -> Self {
64 match err {
65 ServiceError::NotFound => Self::NotFound(err.to_string()),
66 ServiceError::Validation(ref msg) => Self::Validation(msg.clone()),
67 ServiceError::AlreadyExists(ref msg) => Self::Validation(msg.clone()),
68 ServiceError::Repository(ref e) => Self::Database(e.to_string()),
69 ServiceError::Internal(ref msg) => Self::Internal(msg.clone()),
70 ServiceError::Violations(_) => Self::Validation(err.to_string()),
71 }
72 }
73}
74
75impl axum::response::IntoResponse for FileShareError {
76 fn into_response(self) -> axum::response::Response {
77 use axum::http::StatusCode;
78 use axum::Json;
79
80 let (status, code) = match &self {
81 Self::NotFound(_) => (StatusCode::NOT_FOUND, "FILESHARE_NOT_FOUND"),
82 Self::Validation(_) => (StatusCode::BAD_REQUEST, "FILESHARE_VALIDATION_ERROR"),
83 Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "FILESHARE_DATABASE_ERROR"),
84 Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "FILESHARE_INTERNAL_ERROR"),
85 Self::InvalidTokenLength(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILESHARE_INVALID_TOKEN_LENGTH"),
86 Self::InvalidTokenChars(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILESHARE_INVALID_TOKEN_CHARS"),
87 Self::FileNotActive(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILESHARE_FILE_NOT_ACTIVE"),
88 Self::NotFileOwner(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILESHARE_NOT_FILE_OWNER"),
89 Self::ExpiredDate(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILESHARE_EXPIRED_DATE"),
90 Self::InvalidMaxDownloads(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILESHARE_INVALID_MAX_DOWNLOADS"),
91 Self::MissingRecipients(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILESHARE_MISSING_RECIPIENTS"),
92 Self::ShareInactive(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILESHARE_SHARE_INACTIVE"),
93 };
94
95 let body = serde_json::json!({
96 "success": false,
97 "error": code,
98 "message": self.to_string(),
99 });
100
101 (status, Json(body)).into_response()
102 }
103}
104
105pub mod file_share_errors {
107 pub const INVALID_TOKEN_LENGTH: &str = "FILESHARE_INVALID_TOKEN_LENGTH";
108 pub const INVALID_TOKEN_CHARS: &str = "FILESHARE_INVALID_TOKEN_CHARS";
109 pub const FILE_NOT_ACTIVE: &str = "FILESHARE_FILE_NOT_ACTIVE";
110 pub const NOT_FILE_OWNER: &str = "FILESHARE_NOT_FILE_OWNER";
111 pub const EXPIRED_DATE: &str = "FILESHARE_EXPIRED_DATE";
112 pub const INVALID_MAX_DOWNLOADS: &str = "FILESHARE_INVALID_MAX_DOWNLOADS";
113 pub const MISSING_RECIPIENTS: &str = "FILESHARE_MISSING_RECIPIENTS";
114 pub const SHARE_INACTIVE: &str = "FILESHARE_SHARE_INACTIVE";
115}
116
117pub fn create_file_share_routes(service: Arc<FileShareService>) -> Router {
150 BackboneCrudHandler::<FileShareService, FileShare, CreateFileShareDto, UpdateFileShareDto, FileShareResponseDto>::routes(
151 service,
152 "/file_shares",
153 )
154}
155
156pub fn create_file_share_read_routes(service: Arc<FileShareService>) -> Router {
162 BackboneCrudHandler::<FileShareService, FileShare, CreateFileShareDto, UpdateFileShareDto, FileShareResponseDto>::read_routes(
163 service,
164 "/file_shares",
165 )
166}
167
168pub fn create_file_share_write_routes(service: Arc<FileShareService>) -> Router {
180 BackboneCrudHandler::<FileShareService, FileShare, CreateFileShareDto, UpdateFileShareDto, FileShareResponseDto>::write_routes(
181 service,
182 "/file_shares",
183 )
184}
185
186#[cfg(feature = "auth")]
192pub fn create_protected_file_share_routes<A: AuthMiddleware + Send + Sync + 'static>(
193 service: Arc<FileShareService>,
194 auth: Arc<A>,
195) -> Router {
196 use axum::middleware;
197 use axum::response::IntoResponse;
198
199 let auth_layer = auth.clone();
200 create_file_share_routes(service)
201 .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
202 let auth = auth_layer.clone();
203 async move {
204 let token = req.headers()
205 .get(axum::http::header::AUTHORIZATION)
206 .and_then(|h| h.to_str().ok())
207 .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
208 .unwrap_or("");
209 match auth.authenticate(token).await {
210 Ok(ctx) => {
211 req.extensions_mut().insert(ctx);
212 next.run(req).await
213 }
214 Err(_) => {
215 (axum::http::StatusCode::UNAUTHORIZED,
216 axum::Json(serde_json::json!({
217 "success": false,
218 "error": "unauthorized",
219 "message": "Authentication required"
220 }))
221 ).into_response()
222 }
223 }
224 }
225 }))
226}
227
228pub async fn expire_transition(
236 axum::extract::State(service): axum::extract::State<Arc<FileShareService>>,
237 axum::extract::Path(id): axum::extract::Path<String>,
238 auth: Option<axum::Extension<AuthContext>>,
239) -> impl axum::response::IntoResponse {
240 use axum::{http::StatusCode, Json};
241
242 let entity = match service.get_by_id(&id).await {
244 Ok(Some(e)) => e,
245 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found("FileShare", &id))),
246 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
247 };
248
249 let Some(axum::Extension(auth)) = auth else {
251 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<serde_json::Value>::error("A transition needs an authenticated caller")));
252 };
253 {
254 let allowed_roles = FileShareTransition::Expire.allowed_roles();
255 let granted = |required: &str| auth.permissions.iter().any(|p| {
256 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
257 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
258 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
259 });
260 let has_specific_perm = granted("file_share:transition:expire");
261 let has_update_perm = granted("file_share:update");
262 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
263 if !has_specific_perm && !has_role {
264 if !has_update_perm {
265 return (StatusCode::FORBIDDEN, Json(ApiResponse::<serde_json::Value>::error("Insufficient permissions for expire transition")));
266 }
267 tracing::warn!(target: "backbone::write_guard", entity = "FileShare", verb = "expire", code = "transition_by_update_permission", "a transition granted only by file_share:update, running in shadow; it needs file_share:transition:expire or one of the roles system");
268 }
269 }
270
271 let current_state: FileShareState = match entity.status.to_string().parse() {
273 Ok(s) => s,
274 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<serde_json::Value>::error("The stored state is not a state of this machine"))),
275 };
276 let sm = FileShareStateMachine::from_state(current_state);
277 if !sm.can_transition(FileShareTransition::Expire) {
278 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error("Transition not allowed from current state")));
279 }
280
281 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
284 fields.insert("status".to_string(), serde_json::Value::String("expired".to_string()));
285
286 match backbone_core::write_guard::as_transition("expire", service.partial_update(&id, fields)).await {
287 Ok(Some(updated)) => {
288 let response: FileShareResponseDto = updated.into();
289 (StatusCode::OK, Json(ApiResponse::ok(backbone_core::http::without_secrets::<FileShare, _>(response))))
290 }
291 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found("FileShare", &id))),
292 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
293 }
294}
295
296pub async fn exhaust_transition(
300 axum::extract::State(service): axum::extract::State<Arc<FileShareService>>,
301 axum::extract::Path(id): axum::extract::Path<String>,
302 auth: Option<axum::Extension<AuthContext>>,
303) -> impl axum::response::IntoResponse {
304 use axum::{http::StatusCode, Json};
305
306 let entity = match service.get_by_id(&id).await {
308 Ok(Some(e)) => e,
309 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found("FileShare", &id))),
310 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
311 };
312
313 let Some(axum::Extension(auth)) = auth else {
315 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<serde_json::Value>::error("A transition needs an authenticated caller")));
316 };
317 {
318 let allowed_roles = FileShareTransition::Exhaust.allowed_roles();
319 let granted = |required: &str| auth.permissions.iter().any(|p| {
320 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
321 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
322 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
323 });
324 let has_specific_perm = granted("file_share:transition:exhaust");
325 let has_update_perm = granted("file_share:update");
326 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
327 if !has_specific_perm && !has_role {
328 if !has_update_perm {
329 return (StatusCode::FORBIDDEN, Json(ApiResponse::<serde_json::Value>::error("Insufficient permissions for exhaust transition")));
330 }
331 tracing::warn!(target: "backbone::write_guard", entity = "FileShare", verb = "exhaust", code = "transition_by_update_permission", "a transition granted only by file_share:update, running in shadow; it needs file_share:transition:exhaust or one of the roles system");
332 }
333 }
334
335 let current_state: FileShareState = match entity.status.to_string().parse() {
337 Ok(s) => s,
338 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<serde_json::Value>::error("The stored state is not a state of this machine"))),
339 };
340 let sm = FileShareStateMachine::from_state(current_state);
341 if !sm.can_transition(FileShareTransition::Exhaust) {
342 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error("Transition not allowed from current state")));
343 }
344
345 {
346 use backbone_core::row_rule as r;
347 let row = &serde_json::to_value(&entity).unwrap_or_default();
348 let state = row.get("status").and_then(|v| v.as_str()).unwrap_or_default();
349 if ["active"].contains(&state) && !r::holds(&r::and(r::is_not_null(r::field(row, "max_downloads")), r::ge(r::field(row, "download_count"), r::field(row, "max_downloads")))) {
351 return (StatusCode::UNPROCESSABLE_ENTITY, Json(ApiResponse::<serde_json::Value>::error("The exhaust transition's guard does not hold: max_downloads != null && download_count >= max_downloads")));
352 }
353 }
354
355 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
357 fields.insert("status".to_string(), serde_json::Value::String("exhausted".to_string()));
358
359 match backbone_core::write_guard::as_transition("exhaust", service.partial_update(&id, fields)).await {
360 Ok(Some(updated)) => {
361 let response: FileShareResponseDto = updated.into();
362 (StatusCode::OK, Json(ApiResponse::ok(backbone_core::http::without_secrets::<FileShare, _>(response))))
363 }
364 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found("FileShare", &id))),
365 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
366 }
367}
368
369pub async fn revoke_transition(
373 axum::extract::State(service): axum::extract::State<Arc<FileShareService>>,
374 axum::extract::Path(id): axum::extract::Path<String>,
375 auth: Option<axum::Extension<AuthContext>>,
376) -> impl axum::response::IntoResponse {
377 use axum::{http::StatusCode, Json};
378
379 let entity = match service.get_by_id(&id).await {
381 Ok(Some(e)) => e,
382 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found("FileShare", &id))),
383 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
384 };
385
386 let Some(axum::Extension(auth)) = auth else {
388 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<serde_json::Value>::error("A transition needs an authenticated caller")));
389 };
390 {
391 let allowed_roles = FileShareTransition::Revoke.allowed_roles();
392 let granted = |required: &str| auth.permissions.iter().any(|p| {
393 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
394 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
395 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
396 });
397 let has_specific_perm = granted("file_share:transition:revoke");
398 let has_update_perm = granted("file_share:update");
399 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
400 if !has_specific_perm && !has_role {
401 if !has_update_perm {
402 return (StatusCode::FORBIDDEN, Json(ApiResponse::<serde_json::Value>::error("Insufficient permissions for revoke transition")));
403 }
404 tracing::warn!(target: "backbone::write_guard", entity = "FileShare", verb = "revoke", code = "transition_by_update_permission", "a transition granted only by file_share:update, running in shadow; it needs file_share:transition:revoke or one of the roles admin, owner");
405 }
406 }
407
408 let current_state: FileShareState = match entity.status.to_string().parse() {
410 Ok(s) => s,
411 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<serde_json::Value>::error("The stored state is not a state of this machine"))),
412 };
413 let sm = FileShareStateMachine::from_state(current_state);
414 if !sm.can_transition(FileShareTransition::Revoke) {
415 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error("Transition not allowed from current state")));
416 }
417
418 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
421 fields.insert("status".to_string(), serde_json::Value::String("revoked".to_string()));
422
423 match backbone_core::write_guard::as_transition("revoke", service.partial_update(&id, fields)).await {
424 Ok(Some(updated)) => {
425 let response: FileShareResponseDto = updated.into();
426 (StatusCode::OK, Json(ApiResponse::ok(backbone_core::http::without_secrets::<FileShare, _>(response))))
427 }
428 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found("FileShare", &id))),
429 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
430 }
431}
432
433pub fn create_file_share_transition_routes(service: Arc<FileShareService>) -> Router {
435 use axum::routing::post;
436
437 Router::new()
438 .route("/file_shares/:id/transitions/expire", post(expire_transition))
439 .route("/file_shares/:id/transitions/exhaust", post(exhaust_transition))
440 .route("/file_shares/:id/transitions/revoke", post(revoke_transition))
441 .with_state(service)
442}
443