Skip to main content

backbone_bucket/presentation/http/
stored_file_handler.rs

1//! StoredFile REST handlers
2//!
3//! Generated by metaphor-schema. Do not edit manually.
4//!
5//! Uses Axum and backbone-core's BackboneCrudHandler for all 12 CRUD endpoints.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use axum::Router;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13use chrono::{DateTime, Utc};
14
15// Backbone framework imports
16use backbone_core::http::{ApiResponse, BackboneCrudHandler};
17
18// Auth integration (optional)
19use backbone_auth::middleware::AuthContext;
20#[cfg(feature = "auth")]
21use backbone_auth::AuthMiddleware;
22
23// Domain imports
24use crate::domain::entity::*;
25use crate::application::service::{StoredFileService, ServiceError};
26
27// DTO imports
28use crate::presentation::dto::{CreateStoredFileDto, UpdateStoredFileDto, PatchStoredFileDto, StoredFileResponseDto};
29
30use crate::domain::state_machine::{StoredFileState, StoredFileStateMachine, StoredFileTransition};
31
32/// Application error type
33#[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    // Domain-specific errors from hook rules
44    #[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
99/// Domain-specific error codes for StoredFile
100pub 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
109// =============================================================================
110// Route Configuration
111// =============================================================================
112
113/// Create Axum router with all 16 Backbone endpoints for StoredFile.
114///
115/// # Routes
116///
117/// | Method | Path | Description |
118/// |--------|------|-------------|
119/// | GET | /stored_files | List with pagination |
120/// | POST | /stored_files | Create new |
121/// | GET | /stored_files/:id | Get by ID |
122/// | PUT | /stored_files/:id | Full update |
123/// | PATCH | /stored_files/:id | Partial update |
124/// | DELETE | /stored_files/:id | Soft delete |
125/// | POST | /stored_files/bulk | Bulk create |
126/// | POST | /stored_files/upsert | Upsert |
127/// | GET | /stored_files/trash | List deleted |
128/// | POST | /stored_files/:id/restore | Restore |
129/// | DELETE | /stored_files/empty | Empty trash |
130/// | GET | /stored_files/:id/deleted | Get deleted by ID |
131/// | DELETE | /stored_files/trash/:id | Permanent delete from trash |
132/// | GET | /stored_files/count | Count active entities |
133/// | GET | /stored_files/trash/count | Count deleted entities |
134///
135/// # Example
136///
137/// ```text
138/// let service = Arc::new(StoredFileService::with_repository(repository));
139/// let router = create_stored_file_routes(service);
140/// ```
141pub 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
148/// Create Axum router with only the read (GET) endpoints for StoredFile.
149///
150/// Safe for public, unauthenticated exposure (e.g., reference data).
151/// Mutations must be served separately via `create_stored_file_write_routes`,
152/// typically wrapped in an auth middleware layer.
153pub 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
160/// Create Axum router with only the write (mutation) endpoints for StoredFile.
161///
162/// These routes must NOT be publicly exposed. Wrap them with an auth
163/// middleware before nesting into the application router.
164///
165/// # This is unguarded generic CRUD, not a validated write path
166///
167/// These are plain create/update/patch/delete mutations over the entity row —
168/// they bypass all business invariants. If the module exposes a validated write
169/// service (e.g. a command router over its domain engine), serve THAT instead
170/// for any mutation that must respect domain rules.
171pub 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/// Create authenticated routes with auth middleware.
179///
180/// Requires the `auth` feature flag. The `AuthMiddleware` implementation
181/// is responsible for extracting and validating tokens, then providing
182/// an `AuthContext` via request extensions.
183#[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
220// =============================================================================
221// State Transition Handlers
222// =============================================================================
223
224/// Execute complete_upload transition on a StoredFile.
225///
226/// POST /stored_files/:id/transitions/complete_upload
227pub 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    // Get current entity
235    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    // Check permission
242    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    // Create state machine from entity's actual status and validate transition
264    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        // Guard: size_bytes > 0
278        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    // Apply transition via partial update
284    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
297/// Execute mark_safe transition on a StoredFile.
298///
299/// POST /stored_files/:id/transitions/mark_safe
300pub 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    // Get current entity
308    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    // Check permission
315    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    // Create state machine from entity's actual status and validate transition
337    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        // Guard: is_scanned == true && (threat_level == null || threat_level == 'safe')
351        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    // Apply transition via partial update
357    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
370/// Execute quarantine transition on a StoredFile.
371///
372/// POST /stored_files/:id/transitions/quarantine
373pub 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    // Get current entity
381    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    // Check permission
388    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    // Create state machine from entity's actual status and validate transition
410    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        // Guard: threat_level != null && threat_level != 'safe'
424        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    // Apply transition via partial update
430    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
443/// Execute mark_safe_by_admin transition on a StoredFile.
444///
445/// POST /stored_files/:id/transitions/mark_safe_by_admin
446pub 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    // Get current entity
454    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    // Check permission
461    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    // Create state machine from entity's actual status and validate transition
483    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    // Apply transition via partial update
493    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
506/// Execute soft_delete transition on a StoredFile.
507///
508/// POST /stored_files/:id/transitions/soft_delete
509pub 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    // Get current entity
517    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    // Check permission
524    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    // Create state machine from entity's actual status and validate transition
546    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    // Guard `$actor.id == owner_id || $actor.has_role('admin')` not compiled (reads the writer, the time or the row before the write); the transition is not checked against it.
556    // Apply transition via partial update
557    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
570/// Execute restore transition on a StoredFile.
571///
572/// POST /stored_files/:id/transitions/restore
573pub 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    // Get current entity
581    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    // Check permission
588    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    // Create state machine from entity's actual status and validate transition
610    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    // Guard `metadata.deleted_at != null && $actor.id == owner_id || $actor.has_role('admin')` not compiled (reads the writer, the time or the row before the write); the transition is not checked against it.
620    // Apply transition via partial update
621    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
634/// Execute purge transition on a StoredFile.
635///
636/// POST /stored_files/:id/transitions/purge
637pub 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    // Get current entity
645    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    // Check permission
652    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    // Create state machine from entity's actual status and validate transition
674    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    // Guard `metadata.deleted_at != null && metadata.deleted_at < now() - 30.days` not compiled (`.days` reads a relation or a duration); the transition is not checked against it.
684    // Apply transition via partial update
685    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
698/// Create routes for state transitions.
699pub 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