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::{StoredFileService, ServiceError};
26
27use crate::presentation::dto::{CreateStoredFileDto, UpdateStoredFileDto, PatchStoredFileDto, StoredFileResponseDto};
29
30use crate::domain::state_machine::{StoredFileState, StoredFileStateMachine, StoredFileTransition};
31
32#[derive(Debug, thiserror::Error)]
34pub enum StoredFileError {
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("File path too long (max 1024 characters): {0}")]
45 PathTooLong(String),
46 #[error("Path traversal not allowed: {0}")]
47 PathTraversal(String),
48 #[error("File name too long (max 255 characters): {0}")]
49 FilenameTooLong(String),
50 #[error("Invalid MIME type format: {0}")]
51 InvalidMimeType(String),
52 #[error("Cannot update purged files: {0}")]
53 FilePurged(String),
54 #[error("Only admins can modify quarantined files: {0}")]
55 QuarantineRestricted(String),
56}
57
58impl From<ServiceError> for StoredFileError {
59 fn from(err: ServiceError) -> Self {
60 match err {
61 ServiceError::NotFound => Self::NotFound(err.to_string()),
62 ServiceError::Validation(ref msg) => Self::Validation(msg.clone()),
63 ServiceError::AlreadyExists(ref msg) => Self::Validation(msg.clone()),
64 ServiceError::Repository(ref e) => Self::Database(e.to_string()),
65 ServiceError::Internal(ref msg) => Self::Internal(msg.clone()),
66 ServiceError::Violations(_) => Self::Validation(err.to_string()),
67 }
68 }
69}
70
71impl axum::response::IntoResponse for StoredFileError {
72 fn into_response(self) -> axum::response::Response {
73 use axum::http::StatusCode;
74 use axum::Json;
75
76 let (status, code) = match &self {
77 Self::NotFound(_) => (StatusCode::NOT_FOUND, "STOREDFILE_NOT_FOUND"),
78 Self::Validation(_) => (StatusCode::BAD_REQUEST, "STOREDFILE_VALIDATION_ERROR"),
79 Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "STOREDFILE_DATABASE_ERROR"),
80 Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "STOREDFILE_INTERNAL_ERROR"),
81 Self::PathTooLong(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_PATH_TOO_LONG"),
82 Self::PathTraversal(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_PATH_TRAVERSAL"),
83 Self::FilenameTooLong(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_FILENAME_TOO_LONG"),
84 Self::InvalidMimeType(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_INVALID_MIME_TYPE"),
85 Self::FilePurged(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_FILE_PURGED"),
86 Self::QuarantineRestricted(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_QUARANTINE_RESTRICTED"),
87 };
88
89 let body = serde_json::json!({
90 "success": false,
91 "error": code,
92 "message": self.to_string(),
93 });
94
95 (status, Json(body)).into_response()
96 }
97}
98
99pub mod stored_file_errors {
101 pub const PATH_TOO_LONG: &str = "STOREDFILE_PATH_TOO_LONG";
102 pub const PATH_TRAVERSAL: &str = "STOREDFILE_PATH_TRAVERSAL";
103 pub const FILENAME_TOO_LONG: &str = "STOREDFILE_FILENAME_TOO_LONG";
104 pub const INVALID_MIME_TYPE: &str = "STOREDFILE_INVALID_MIME_TYPE";
105 pub const FILE_PURGED: &str = "STOREDFILE_FILE_PURGED";
106 pub const QUARANTINE_RESTRICTED: &str = "STOREDFILE_QUARANTINE_RESTRICTED";
107}
108
109pub fn create_stored_file_routes(service: Arc<StoredFileService>) -> Router {
142 BackboneCrudHandler::<StoredFileService, StoredFile, CreateStoredFileDto, UpdateStoredFileDto, StoredFileResponseDto>::routes(
143 service,
144 "/stored_files",
145 )
146}
147
148pub fn create_stored_file_read_routes(service: Arc<StoredFileService>) -> Router {
154 BackboneCrudHandler::<StoredFileService, StoredFile, CreateStoredFileDto, UpdateStoredFileDto, StoredFileResponseDto>::read_routes(
155 service,
156 "/stored_files",
157 )
158}
159
160pub fn create_stored_file_write_routes(service: Arc<StoredFileService>) -> Router {
172 BackboneCrudHandler::<StoredFileService, StoredFile, CreateStoredFileDto, UpdateStoredFileDto, StoredFileResponseDto>::write_routes(
173 service,
174 "/stored_files",
175 )
176}
177
178#[cfg(feature = "auth")]
184pub fn create_protected_stored_file_routes<A: AuthMiddleware + Send + Sync + 'static>(
185 service: Arc<StoredFileService>,
186 auth: Arc<A>,
187) -> Router {
188 use axum::middleware;
189 use axum::response::IntoResponse;
190
191 let auth_layer = auth.clone();
192 create_stored_file_routes(service)
193 .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
194 let auth = auth_layer.clone();
195 async move {
196 let token = req.headers()
197 .get(axum::http::header::AUTHORIZATION)
198 .and_then(|h| h.to_str().ok())
199 .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
200 .unwrap_or("");
201 match auth.authenticate(token).await {
202 Ok(ctx) => {
203 req.extensions_mut().insert(ctx);
204 next.run(req).await
205 }
206 Err(_) => {
207 (axum::http::StatusCode::UNAUTHORIZED,
208 axum::Json(serde_json::json!({
209 "success": false,
210 "error": "unauthorized",
211 "message": "Authentication required"
212 }))
213 ).into_response()
214 }
215 }
216 }
217 }))
218}
219
220pub async fn complete_upload_transition(
228 axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
229 axum::extract::Path(id): axum::extract::Path<String>,
230 auth: Option<axum::Extension<AuthContext>>,
231) -> impl axum::response::IntoResponse {
232 use axum::{http::StatusCode, Json};
233
234 let entity = match service.get_by_id(&id).await {
236 Ok(Some(e)) => e,
237 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
238 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
239 };
240
241 let Some(axum::Extension(auth)) = auth else {
243 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<StoredFileResponseDto>::error("A transition needs an authenticated caller")));
244 };
245 {
246 let allowed_roles = StoredFileTransition::CompleteUpload.allowed_roles();
247 let granted = |required: &str| auth.permissions.iter().any(|p| {
248 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
249 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
250 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
251 });
252 let has_specific_perm = granted("stored_file:transition:complete_upload");
253 let has_update_perm = granted("stored_file:update");
254 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
255 if !has_specific_perm && !has_role {
256 if !has_update_perm {
257 return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for complete_upload transition")));
258 }
259 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 one of the roles system");
260 }
261 }
262
263 let current_state: StoredFileState = match entity.status.to_string().parse() {
265 Ok(s) => s,
266 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
267 };
268 let sm = StoredFileStateMachine::from_state(current_state);
269 if !sm.can_transition(StoredFileTransition::CompleteUpload) {
270 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
271 }
272
273 {
274 use backbone_core::row_rule as r;
275 let row = &serde_json::to_value(&entity).unwrap_or_default();
276 let state = row.get("status").and_then(|v| v.as_str()).unwrap_or_default();
277 if ["uploading"].contains(&state) && !r::holds(&r::gt(r::field(row, "size_bytes"), r::Val::Num(0.0_f64))) {
279 return (StatusCode::UNPROCESSABLE_ENTITY, Json(ApiResponse::<StoredFileResponseDto>::error("The complete_upload transition's guard does not hold: size_bytes > 0")));
280 }
281 }
282
283 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
285 fields.insert("status".to_string(), serde_json::Value::String("processing".to_string()));
286
287 match backbone_core::write_guard::as_transition("complete_upload", service.partial_update(&id, fields)).await {
288 Ok(Some(updated)) => {
289 let response: StoredFileResponseDto = updated.into();
290 (StatusCode::OK, Json(ApiResponse::ok(response)))
291 }
292 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
293 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
294 }
295}
296
297pub async fn mark_safe_transition(
301 axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
302 axum::extract::Path(id): axum::extract::Path<String>,
303 auth: Option<axum::Extension<AuthContext>>,
304) -> impl axum::response::IntoResponse {
305 use axum::{http::StatusCode, Json};
306
307 let entity = match service.get_by_id(&id).await {
309 Ok(Some(e)) => e,
310 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
311 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
312 };
313
314 let Some(axum::Extension(auth)) = auth else {
316 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<StoredFileResponseDto>::error("A transition needs an authenticated caller")));
317 };
318 {
319 let allowed_roles = StoredFileTransition::MarkSafe.allowed_roles();
320 let granted = |required: &str| auth.permissions.iter().any(|p| {
321 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
322 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
323 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
324 });
325 let has_specific_perm = granted("stored_file:transition:mark_safe");
326 let has_update_perm = granted("stored_file:update");
327 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
328 if !has_specific_perm && !has_role {
329 if !has_update_perm {
330 return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for mark_safe transition")));
331 }
332 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 one of the roles system");
333 }
334 }
335
336 let current_state: StoredFileState = match entity.status.to_string().parse() {
338 Ok(s) => s,
339 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
340 };
341 let sm = StoredFileStateMachine::from_state(current_state);
342 if !sm.can_transition(StoredFileTransition::MarkSafe) {
343 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
344 }
345
346 {
347 use backbone_core::row_rule as r;
348 let row = &serde_json::to_value(&entity).unwrap_or_default();
349 let state = row.get("status").and_then(|v| v.as_str()).unwrap_or_default();
350 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"))))) {
352 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')")));
353 }
354 }
355
356 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
358 fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
359
360 match backbone_core::write_guard::as_transition("mark_safe", service.partial_update(&id, fields)).await {
361 Ok(Some(updated)) => {
362 let response: StoredFileResponseDto = updated.into();
363 (StatusCode::OK, Json(ApiResponse::ok(response)))
364 }
365 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
366 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
367 }
368}
369
370pub async fn quarantine_transition(
374 axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
375 axum::extract::Path(id): axum::extract::Path<String>,
376 auth: Option<axum::Extension<AuthContext>>,
377) -> impl axum::response::IntoResponse {
378 use axum::{http::StatusCode, Json};
379
380 let entity = match service.get_by_id(&id).await {
382 Ok(Some(e)) => e,
383 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
384 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
385 };
386
387 let Some(axum::Extension(auth)) = auth else {
389 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<StoredFileResponseDto>::error("A transition needs an authenticated caller")));
390 };
391 {
392 let allowed_roles = StoredFileTransition::Quarantine.allowed_roles();
393 let granted = |required: &str| auth.permissions.iter().any(|p| {
394 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
395 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
396 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
397 });
398 let has_specific_perm = granted("stored_file:transition:quarantine");
399 let has_update_perm = granted("stored_file:update");
400 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
401 if !has_specific_perm && !has_role {
402 if !has_update_perm {
403 return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for quarantine transition")));
404 }
405 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 one of the roles system");
406 }
407 }
408
409 let current_state: StoredFileState = match entity.status.to_string().parse() {
411 Ok(s) => s,
412 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
413 };
414 let sm = StoredFileStateMachine::from_state(current_state);
415 if !sm.can_transition(StoredFileTransition::Quarantine) {
416 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
417 }
418
419 {
420 use backbone_core::row_rule as r;
421 let row = &serde_json::to_value(&entity).unwrap_or_default();
422 let state = row.get("status").and_then(|v| v.as_str()).unwrap_or_default();
423 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")))) {
425 return (StatusCode::UNPROCESSABLE_ENTITY, Json(ApiResponse::<StoredFileResponseDto>::error("The quarantine transition's guard does not hold: threat_level != null && threat_level != 'safe'")));
426 }
427 }
428
429 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
431 fields.insert("status".to_string(), serde_json::Value::String("quarantined".to_string()));
432
433 match backbone_core::write_guard::as_transition("quarantine", service.partial_update(&id, fields)).await {
434 Ok(Some(updated)) => {
435 let response: StoredFileResponseDto = updated.into();
436 (StatusCode::OK, Json(ApiResponse::ok(response)))
437 }
438 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
439 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
440 }
441}
442
443pub async fn mark_safe_by_admin_transition(
447 axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
448 axum::extract::Path(id): axum::extract::Path<String>,
449 auth: Option<axum::Extension<AuthContext>>,
450) -> impl axum::response::IntoResponse {
451 use axum::{http::StatusCode, Json};
452
453 let entity = match service.get_by_id(&id).await {
455 Ok(Some(e)) => e,
456 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
457 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
458 };
459
460 let Some(axum::Extension(auth)) = auth else {
462 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<StoredFileResponseDto>::error("A transition needs an authenticated caller")));
463 };
464 {
465 let allowed_roles = StoredFileTransition::MarkSafeByAdmin.allowed_roles();
466 let granted = |required: &str| auth.permissions.iter().any(|p| {
467 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
468 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
469 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
470 });
471 let has_specific_perm = granted("stored_file:transition:mark_safe_by_admin");
472 let has_update_perm = granted("stored_file:update");
473 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
474 if !has_specific_perm && !has_role {
475 if !has_update_perm {
476 return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for mark_safe_by_admin transition")));
477 }
478 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 one of the roles admin");
479 }
480 }
481
482 let current_state: StoredFileState = match entity.status.to_string().parse() {
484 Ok(s) => s,
485 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
486 };
487 let sm = StoredFileStateMachine::from_state(current_state);
488 if !sm.can_transition(StoredFileTransition::MarkSafeByAdmin) {
489 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
490 }
491
492 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
494 fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
495
496 match backbone_core::write_guard::as_transition("mark_safe_by_admin", service.partial_update(&id, fields)).await {
497 Ok(Some(updated)) => {
498 let response: StoredFileResponseDto = updated.into();
499 (StatusCode::OK, Json(ApiResponse::ok(response)))
500 }
501 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
502 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
503 }
504}
505
506pub async fn soft_delete_transition(
510 axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
511 axum::extract::Path(id): axum::extract::Path<String>,
512 auth: Option<axum::Extension<AuthContext>>,
513) -> impl axum::response::IntoResponse {
514 use axum::{http::StatusCode, Json};
515
516 let entity = match service.get_by_id(&id).await {
518 Ok(Some(e)) => e,
519 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
520 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
521 };
522
523 let Some(axum::Extension(auth)) = auth else {
525 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<StoredFileResponseDto>::error("A transition needs an authenticated caller")));
526 };
527 {
528 let allowed_roles = StoredFileTransition::SoftDelete.allowed_roles();
529 let granted = |required: &str| auth.permissions.iter().any(|p| {
530 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
531 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
532 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
533 });
534 let has_specific_perm = granted("stored_file:transition:soft_delete");
535 let has_update_perm = granted("stored_file:update");
536 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
537 if !has_specific_perm && !has_role {
538 if !has_update_perm {
539 return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for soft_delete transition")));
540 }
541 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 one of the roles admin, owner");
542 }
543 }
544
545 let current_state: StoredFileState = match entity.status.to_string().parse() {
547 Ok(s) => s,
548 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
549 };
550 let sm = StoredFileStateMachine::from_state(current_state);
551 if !sm.can_transition(StoredFileTransition::SoftDelete) {
552 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
553 }
554
555 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
558 fields.insert("status".to_string(), serde_json::Value::String("deleted".to_string()));
559
560 match backbone_core::write_guard::as_transition("soft_delete", service.partial_update(&id, fields)).await {
561 Ok(Some(updated)) => {
562 let response: StoredFileResponseDto = updated.into();
563 (StatusCode::OK, Json(ApiResponse::ok(response)))
564 }
565 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
566 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
567 }
568}
569
570pub async fn restore_transition(
574 axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
575 axum::extract::Path(id): axum::extract::Path<String>,
576 auth: Option<axum::Extension<AuthContext>>,
577) -> impl axum::response::IntoResponse {
578 use axum::{http::StatusCode, Json};
579
580 let entity = match service.get_by_id(&id).await {
582 Ok(Some(e)) => e,
583 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
584 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
585 };
586
587 let Some(axum::Extension(auth)) = auth else {
589 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<StoredFileResponseDto>::error("A transition needs an authenticated caller")));
590 };
591 {
592 let allowed_roles = StoredFileTransition::Restore.allowed_roles();
593 let granted = |required: &str| auth.permissions.iter().any(|p| {
594 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
595 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
596 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
597 });
598 let has_specific_perm = granted("stored_file:transition:restore");
599 let has_update_perm = granted("stored_file:update");
600 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
601 if !has_specific_perm && !has_role {
602 if !has_update_perm {
603 return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for restore transition")));
604 }
605 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 one of the roles admin, owner");
606 }
607 }
608
609 let current_state: StoredFileState = match entity.status.to_string().parse() {
611 Ok(s) => s,
612 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
613 };
614 let sm = StoredFileStateMachine::from_state(current_state);
615 if !sm.can_transition(StoredFileTransition::Restore) {
616 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
617 }
618
619 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
622 fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
623
624 match backbone_core::write_guard::as_transition("restore", service.partial_update(&id, fields)).await {
625 Ok(Some(updated)) => {
626 let response: StoredFileResponseDto = updated.into();
627 (StatusCode::OK, Json(ApiResponse::ok(response)))
628 }
629 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
630 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
631 }
632}
633
634pub async fn purge_transition(
638 axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
639 axum::extract::Path(id): axum::extract::Path<String>,
640 auth: Option<axum::Extension<AuthContext>>,
641) -> impl axum::response::IntoResponse {
642 use axum::{http::StatusCode, Json};
643
644 let entity = match service.get_by_id(&id).await {
646 Ok(Some(e)) => e,
647 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
648 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
649 };
650
651 let Some(axum::Extension(auth)) = auth else {
653 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<StoredFileResponseDto>::error("A transition needs an authenticated caller")));
654 };
655 {
656 let allowed_roles = StoredFileTransition::Purge.allowed_roles();
657 let granted = |required: &str| auth.permissions.iter().any(|p| {
658 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
659 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
660 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
661 });
662 let has_specific_perm = granted("stored_file:transition:purge");
663 let has_update_perm = granted("stored_file:update");
664 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
665 if !has_specific_perm && !has_role {
666 if !has_update_perm {
667 return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for purge transition")));
668 }
669 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 one of the roles admin, system");
670 }
671 }
672
673 let current_state: StoredFileState = match entity.status.to_string().parse() {
675 Ok(s) => s,
676 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
677 };
678 let sm = StoredFileStateMachine::from_state(current_state);
679 if !sm.can_transition(StoredFileTransition::Purge) {
680 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
681 }
682
683 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
686 fields.insert("status".to_string(), serde_json::Value::String("purged".to_string()));
687
688 match backbone_core::write_guard::as_transition("purge", service.partial_update(&id, fields)).await {
689 Ok(Some(updated)) => {
690 let response: StoredFileResponseDto = updated.into();
691 (StatusCode::OK, Json(ApiResponse::ok(response)))
692 }
693 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
694 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
695 }
696}
697
698pub fn create_stored_file_transition_routes(service: Arc<StoredFileService>) -> Router {
700 use axum::routing::post;
701
702 Router::new()
703 .route("/stored_files/:id/transitions/complete_upload", post(complete_upload_transition))
704 .route("/stored_files/:id/transitions/mark_safe", post(mark_safe_transition))
705 .route("/stored_files/:id/transitions/quarantine", post(quarantine_transition))
706 .route("/stored_files/:id/transitions/mark_safe_by_admin", post(mark_safe_by_admin_transition))
707 .route("/stored_files/:id/transitions/soft_delete", post(soft_delete_transition))
708 .route("/stored_files/:id/transitions/restore", post(restore_transition))
709 .route("/stored_files/:id/transitions/purge", post(purge_transition))
710 .with_state(service)
711}
712