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
18#[cfg(feature = "auth")]
20use backbone_auth::middleware::AuthContext;
21#[cfg(feature = "auth")]
22use backbone_auth::AuthMiddleware;
23
24use crate::domain::entity::*;
26use crate::application::service::{StoredFileService, ServiceError};
27
28use crate::presentation::dto::{CreateStoredFileDto, UpdateStoredFileDto, PatchStoredFileDto, StoredFileResponseDto};
30
31use crate::domain::state_machine::{StoredFileState, StoredFileStateMachine, StoredFileTransition};
32
33#[derive(Debug, thiserror::Error)]
35pub enum StoredFileError {
36 #[error("Not found: {0}")]
37 NotFound(String),
38 #[error("Validation error: {0}")]
39 Validation(String),
40 #[error("Database error: {0}")]
41 Database(String),
42 #[error("Internal error: {0}")]
43 Internal(String),
44 #[error("File path too long (max 1024 characters): {0}")]
46 PathTooLong(String),
47 #[error("Path traversal not allowed: {0}")]
48 PathTraversal(String),
49 #[error("File name too long (max 255 characters): {0}")]
50 FilenameTooLong(String),
51 #[error("Invalid MIME type format: {0}")]
52 InvalidMimeType(String),
53 #[error("Cannot update purged files: {0}")]
54 FilePurged(String),
55 #[error("Only admins can modify quarantined files: {0}")]
56 QuarantineRestricted(String),
57}
58
59impl From<ServiceError> for StoredFileError {
60 fn from(err: ServiceError) -> Self {
61 match err {
62 ServiceError::NotFound => Self::NotFound(err.to_string()),
63 ServiceError::Validation(ref msg) => Self::Validation(msg.clone()),
64 ServiceError::AlreadyExists(ref msg) => Self::Validation(msg.clone()),
65 ServiceError::Repository(ref e) => Self::Database(e.to_string()),
66 ServiceError::Internal(ref msg) => Self::Internal(msg.clone()),
67 ServiceError::Violations(_) => Self::Validation(err.to_string()),
68 }
69 }
70}
71
72impl axum::response::IntoResponse for StoredFileError {
73 fn into_response(self) -> axum::response::Response {
74 use axum::http::StatusCode;
75 use axum::Json;
76
77 let (status, code) = match &self {
78 Self::NotFound(_) => (StatusCode::NOT_FOUND, "STOREDFILE_NOT_FOUND"),
79 Self::Validation(_) => (StatusCode::BAD_REQUEST, "STOREDFILE_VALIDATION_ERROR"),
80 Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "STOREDFILE_DATABASE_ERROR"),
81 Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "STOREDFILE_INTERNAL_ERROR"),
82 Self::PathTooLong(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_PATH_TOO_LONG"),
83 Self::PathTraversal(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_PATH_TRAVERSAL"),
84 Self::FilenameTooLong(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_FILENAME_TOO_LONG"),
85 Self::InvalidMimeType(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_INVALID_MIME_TYPE"),
86 Self::FilePurged(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_FILE_PURGED"),
87 Self::QuarantineRestricted(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_QUARANTINE_RESTRICTED"),
88 };
89
90 let body = serde_json::json!({
91 "success": false,
92 "error": code,
93 "message": self.to_string(),
94 });
95
96 (status, Json(body)).into_response()
97 }
98}
99
100pub mod stored_file_errors {
102 pub const PATH_TOO_LONG: &str = "STOREDFILE_PATH_TOO_LONG";
103 pub const PATH_TRAVERSAL: &str = "STOREDFILE_PATH_TRAVERSAL";
104 pub const FILENAME_TOO_LONG: &str = "STOREDFILE_FILENAME_TOO_LONG";
105 pub const INVALID_MIME_TYPE: &str = "STOREDFILE_INVALID_MIME_TYPE";
106 pub const FILE_PURGED: &str = "STOREDFILE_FILE_PURGED";
107 pub const QUARANTINE_RESTRICTED: &str = "STOREDFILE_QUARANTINE_RESTRICTED";
108}
109
110pub fn create_stored_file_routes(service: Arc<StoredFileService>) -> Router {
143 BackboneCrudHandler::<StoredFileService, StoredFile, CreateStoredFileDto, UpdateStoredFileDto, StoredFileResponseDto>::routes(
144 service,
145 "/stored_files",
146 )
147}
148
149pub fn create_stored_file_read_routes(service: Arc<StoredFileService>) -> Router {
155 BackboneCrudHandler::<StoredFileService, StoredFile, CreateStoredFileDto, UpdateStoredFileDto, StoredFileResponseDto>::read_routes(
156 service,
157 "/stored_files",
158 )
159}
160
161pub fn create_stored_file_write_routes(service: Arc<StoredFileService>) -> Router {
173 BackboneCrudHandler::<StoredFileService, StoredFile, CreateStoredFileDto, UpdateStoredFileDto, StoredFileResponseDto>::write_routes(
174 service,
175 "/stored_files",
176 )
177}
178
179#[cfg(feature = "auth")]
185pub fn create_protected_stored_file_routes<A: AuthMiddleware + Send + Sync + 'static>(
186 service: Arc<StoredFileService>,
187 auth: Arc<A>,
188) -> Router {
189 use axum::middleware;
190 use axum::response::IntoResponse;
191
192 let auth_layer = auth.clone();
193 create_stored_file_routes(service)
194 .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
195 let auth = auth_layer.clone();
196 async move {
197 let token = req.headers()
198 .get(axum::http::header::AUTHORIZATION)
199 .and_then(|h| h.to_str().ok())
200 .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
201 .unwrap_or("");
202 match auth.authenticate(token).await {
203 Ok(ctx) => {
204 req.extensions_mut().insert(ctx);
205 next.run(req).await
206 }
207 Err(_) => {
208 (axum::http::StatusCode::UNAUTHORIZED,
209 axum::Json(serde_json::json!({
210 "success": false,
211 "error": "unauthorized",
212 "message": "Authentication required"
213 }))
214 ).into_response()
215 }
216 }
217 }
218 }))
219}
220
221pub async fn complete_upload_transition(
229 axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
230 axum::extract::Path(id): axum::extract::Path<String>,
231 #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
232) -> impl axum::response::IntoResponse {
233 use axum::{http::StatusCode, Json};
234
235 let entity = match service.get_by_id(&id).await {
237 Ok(Some(e)) => e,
238 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
239 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
240 };
241
242 #[cfg(feature = "auth")]
244 {
245 let allowed_roles = StoredFileTransition::CompleteUpload.allowed_roles();
246 let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:complete_upload");
247 let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
248 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
249 if !has_specific_perm && !has_role {
250 if !has_update_perm {
251 return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for complete_upload transition")));
252 }
253 tracing::warn!(target: "backbone::write_guard", entity = "StoredFile", verb = "complete_upload", code = "transition_by_update_permission", "a transition granted only by stored_file:update, running in shadow; it needs stored_file:transition:complete_upload or a role of ["system"]");
254 }
255 }
256
257 let current_state: StoredFileState = match entity.status.to_string().parse() {
259 Ok(s) => s,
260 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
261 };
262 let sm = StoredFileStateMachine::from_state(current_state);
263 if !sm.can_transition(StoredFileTransition::CompleteUpload) {
264 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
265 }
266
267 {
268 use backbone_core::row_rule as r;
269 let row = &serde_json::to_value(&entity).unwrap_or_default();
270 let state = row.get("status").and_then(|v| v.as_str()).unwrap_or_default();
271 if ["uploading"].contains(&state) && !r::holds(&r::gt(r::field(row, "size_bytes"), r::Val::Num(0.0_f64))) {
273 return (StatusCode::UNPROCESSABLE_ENTITY, Json(ApiResponse::<StoredFileResponseDto>::error("The complete_upload transition's guard does not hold: size_bytes > 0")));
274 }
275 }
276
277 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
279 fields.insert("status".to_string(), serde_json::Value::String("processing".to_string()));
280
281 match backbone_core::write_guard::as_transition("complete_upload", service.partial_update(&id, fields)).await {
282 Ok(Some(updated)) => {
283 let response: StoredFileResponseDto = updated.into();
284 (StatusCode::OK, Json(ApiResponse::ok(response)))
285 }
286 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
287 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
288 }
289}
290
291pub async fn mark_safe_transition(
295 axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
296 axum::extract::Path(id): axum::extract::Path<String>,
297 #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
298) -> impl axum::response::IntoResponse {
299 use axum::{http::StatusCode, Json};
300
301 let entity = match service.get_by_id(&id).await {
303 Ok(Some(e)) => e,
304 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
305 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
306 };
307
308 #[cfg(feature = "auth")]
310 {
311 let allowed_roles = StoredFileTransition::MarkSafe.allowed_roles();
312 let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:mark_safe");
313 let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
314 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
315 if !has_specific_perm && !has_role {
316 if !has_update_perm {
317 return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for mark_safe transition")));
318 }
319 tracing::warn!(target: "backbone::write_guard", entity = "StoredFile", verb = "mark_safe", code = "transition_by_update_permission", "a transition granted only by stored_file:update, running in shadow; it needs stored_file:transition:mark_safe or a role of ["system"]");
320 }
321 }
322
323 let current_state: StoredFileState = match entity.status.to_string().parse() {
325 Ok(s) => s,
326 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
327 };
328 let sm = StoredFileStateMachine::from_state(current_state);
329 if !sm.can_transition(StoredFileTransition::MarkSafe) {
330 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
331 }
332
333 {
334 use backbone_core::row_rule as r;
335 let row = &serde_json::to_value(&entity).unwrap_or_default();
336 let state = row.get("status").and_then(|v| v.as_str()).unwrap_or_default();
337 if ["processing"].contains(&state) && !r::holds(&r::and(r::eq(r::field(row, "is_scanned"), r::Val::Bool(true)), r::or(r::is_null(r::field(row, "threat_level")), r::eq(r::field(row, "threat_level"), r::text("safe"))))) {
339 return (StatusCode::UNPROCESSABLE_ENTITY, Json(ApiResponse::<StoredFileResponseDto>::error("The mark_safe transition's guard does not hold: is_scanned == true && (threat_level == null || threat_level == 'safe')")));
340 }
341 }
342
343 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
345 fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
346
347 match backbone_core::write_guard::as_transition("mark_safe", service.partial_update(&id, fields)).await {
348 Ok(Some(updated)) => {
349 let response: StoredFileResponseDto = updated.into();
350 (StatusCode::OK, Json(ApiResponse::ok(response)))
351 }
352 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
353 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
354 }
355}
356
357pub async fn quarantine_transition(
361 axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
362 axum::extract::Path(id): axum::extract::Path<String>,
363 #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
364) -> impl axum::response::IntoResponse {
365 use axum::{http::StatusCode, Json};
366
367 let entity = match service.get_by_id(&id).await {
369 Ok(Some(e)) => e,
370 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
371 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
372 };
373
374 #[cfg(feature = "auth")]
376 {
377 let allowed_roles = StoredFileTransition::Quarantine.allowed_roles();
378 let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:quarantine");
379 let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
380 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
381 if !has_specific_perm && !has_role {
382 if !has_update_perm {
383 return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for quarantine transition")));
384 }
385 tracing::warn!(target: "backbone::write_guard", entity = "StoredFile", verb = "quarantine", code = "transition_by_update_permission", "a transition granted only by stored_file:update, running in shadow; it needs stored_file:transition:quarantine or a role of ["system"]");
386 }
387 }
388
389 let current_state: StoredFileState = match entity.status.to_string().parse() {
391 Ok(s) => s,
392 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
393 };
394 let sm = StoredFileStateMachine::from_state(current_state);
395 if !sm.can_transition(StoredFileTransition::Quarantine) {
396 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
397 }
398
399 {
400 use backbone_core::row_rule as r;
401 let row = &serde_json::to_value(&entity).unwrap_or_default();
402 let state = row.get("status").and_then(|v| v.as_str()).unwrap_or_default();
403 if ["processing"].contains(&state) && !r::holds(&r::and(r::is_not_null(r::field(row, "threat_level")), r::ne(r::field(row, "threat_level"), r::text("safe")))) {
405 return (StatusCode::UNPROCESSABLE_ENTITY, Json(ApiResponse::<StoredFileResponseDto>::error("The quarantine transition's guard does not hold: threat_level != null && threat_level != 'safe'")));
406 }
407 }
408
409 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
411 fields.insert("status".to_string(), serde_json::Value::String("quarantined".to_string()));
412
413 match backbone_core::write_guard::as_transition("quarantine", service.partial_update(&id, fields)).await {
414 Ok(Some(updated)) => {
415 let response: StoredFileResponseDto = updated.into();
416 (StatusCode::OK, Json(ApiResponse::ok(response)))
417 }
418 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
419 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
420 }
421}
422
423pub async fn mark_safe_by_admin_transition(
427 axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
428 axum::extract::Path(id): axum::extract::Path<String>,
429 #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
430) -> impl axum::response::IntoResponse {
431 use axum::{http::StatusCode, Json};
432
433 let entity = match service.get_by_id(&id).await {
435 Ok(Some(e)) => e,
436 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
437 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
438 };
439
440 #[cfg(feature = "auth")]
442 {
443 let allowed_roles = StoredFileTransition::MarkSafeByAdmin.allowed_roles();
444 let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:mark_safe_by_admin");
445 let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
446 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
447 if !has_specific_perm && !has_role {
448 if !has_update_perm {
449 return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for mark_safe_by_admin transition")));
450 }
451 tracing::warn!(target: "backbone::write_guard", entity = "StoredFile", verb = "mark_safe_by_admin", code = "transition_by_update_permission", "a transition granted only by stored_file:update, running in shadow; it needs stored_file:transition:mark_safe_by_admin or a role of ["admin"]");
452 }
453 }
454
455 let current_state: StoredFileState = match entity.status.to_string().parse() {
457 Ok(s) => s,
458 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
459 };
460 let sm = StoredFileStateMachine::from_state(current_state);
461 if !sm.can_transition(StoredFileTransition::MarkSafeByAdmin) {
462 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
463 }
464
465 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
467 fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
468
469 match backbone_core::write_guard::as_transition("mark_safe_by_admin", service.partial_update(&id, fields)).await {
470 Ok(Some(updated)) => {
471 let response: StoredFileResponseDto = updated.into();
472 (StatusCode::OK, Json(ApiResponse::ok(response)))
473 }
474 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
475 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
476 }
477}
478
479pub async fn soft_delete_transition(
483 axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
484 axum::extract::Path(id): axum::extract::Path<String>,
485 #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
486) -> impl axum::response::IntoResponse {
487 use axum::{http::StatusCode, Json};
488
489 let entity = match service.get_by_id(&id).await {
491 Ok(Some(e)) => e,
492 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
493 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
494 };
495
496 #[cfg(feature = "auth")]
498 {
499 let allowed_roles = StoredFileTransition::SoftDelete.allowed_roles();
500 let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:soft_delete");
501 let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
502 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
503 if !has_specific_perm && !has_role {
504 if !has_update_perm {
505 return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for soft_delete transition")));
506 }
507 tracing::warn!(target: "backbone::write_guard", entity = "StoredFile", verb = "soft_delete", code = "transition_by_update_permission", "a transition granted only by stored_file:update, running in shadow; it needs stored_file:transition:soft_delete or a role of ["admin", "owner"]");
508 }
509 }
510
511 let current_state: StoredFileState = match entity.status.to_string().parse() {
513 Ok(s) => s,
514 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
515 };
516 let sm = StoredFileStateMachine::from_state(current_state);
517 if !sm.can_transition(StoredFileTransition::SoftDelete) {
518 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
519 }
520
521 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
524 fields.insert("status".to_string(), serde_json::Value::String("deleted".to_string()));
525
526 match backbone_core::write_guard::as_transition("soft_delete", service.partial_update(&id, fields)).await {
527 Ok(Some(updated)) => {
528 let response: StoredFileResponseDto = updated.into();
529 (StatusCode::OK, Json(ApiResponse::ok(response)))
530 }
531 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
532 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
533 }
534}
535
536pub async fn restore_transition(
540 axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
541 axum::extract::Path(id): axum::extract::Path<String>,
542 #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
543) -> impl axum::response::IntoResponse {
544 use axum::{http::StatusCode, Json};
545
546 let entity = match service.get_by_id(&id).await {
548 Ok(Some(e)) => e,
549 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
550 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
551 };
552
553 #[cfg(feature = "auth")]
555 {
556 let allowed_roles = StoredFileTransition::Restore.allowed_roles();
557 let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:restore");
558 let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
559 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
560 if !has_specific_perm && !has_role {
561 if !has_update_perm {
562 return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for restore transition")));
563 }
564 tracing::warn!(target: "backbone::write_guard", entity = "StoredFile", verb = "restore", code = "transition_by_update_permission", "a transition granted only by stored_file:update, running in shadow; it needs stored_file:transition:restore or a role of ["admin", "owner"]");
565 }
566 }
567
568 let current_state: StoredFileState = match entity.status.to_string().parse() {
570 Ok(s) => s,
571 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
572 };
573 let sm = StoredFileStateMachine::from_state(current_state);
574 if !sm.can_transition(StoredFileTransition::Restore) {
575 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
576 }
577
578 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
581 fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
582
583 match backbone_core::write_guard::as_transition("restore", service.partial_update(&id, fields)).await {
584 Ok(Some(updated)) => {
585 let response: StoredFileResponseDto = updated.into();
586 (StatusCode::OK, Json(ApiResponse::ok(response)))
587 }
588 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
589 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
590 }
591}
592
593pub async fn purge_transition(
597 axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
598 axum::extract::Path(id): axum::extract::Path<String>,
599 #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
600) -> impl axum::response::IntoResponse {
601 use axum::{http::StatusCode, Json};
602
603 let entity = match service.get_by_id(&id).await {
605 Ok(Some(e)) => e,
606 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
607 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
608 };
609
610 #[cfg(feature = "auth")]
612 {
613 let allowed_roles = StoredFileTransition::Purge.allowed_roles();
614 let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:purge");
615 let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
616 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
617 if !has_specific_perm && !has_role {
618 if !has_update_perm {
619 return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for purge transition")));
620 }
621 tracing::warn!(target: "backbone::write_guard", entity = "StoredFile", verb = "purge", code = "transition_by_update_permission", "a transition granted only by stored_file:update, running in shadow; it needs stored_file:transition:purge or a role of ["admin", "system"]");
622 }
623 }
624
625 let current_state: StoredFileState = match entity.status.to_string().parse() {
627 Ok(s) => s,
628 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
629 };
630 let sm = StoredFileStateMachine::from_state(current_state);
631 if !sm.can_transition(StoredFileTransition::Purge) {
632 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
633 }
634
635 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
638 fields.insert("status".to_string(), serde_json::Value::String("purged".to_string()));
639
640 match backbone_core::write_guard::as_transition("purge", service.partial_update(&id, fields)).await {
641 Ok(Some(updated)) => {
642 let response: StoredFileResponseDto = updated.into();
643 (StatusCode::OK, Json(ApiResponse::ok(response)))
644 }
645 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
646 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
647 }
648}
649
650pub fn create_stored_file_transition_routes(service: Arc<StoredFileService>) -> Router {
652 use axum::routing::post;
653
654 Router::new()
655 .route("/stored_files/:id/transitions/complete_upload", post(complete_upload_transition))
656 .route("/stored_files/:id/transitions/mark_safe", post(mark_safe_transition))
657 .route("/stored_files/:id/transitions/quarantine", post(quarantine_transition))
658 .route("/stored_files/:id/transitions/mark_safe_by_admin", post(mark_safe_by_admin_transition))
659 .route("/stored_files/:id/transitions/soft_delete", post(soft_delete_transition))
660 .route("/stored_files/:id/transitions/restore", post(restore_transition))
661 .route("/stored_files/:id/transitions/purge", post(purge_transition))
662 .with_state(service)
663}
664