Skip to main content

backbone_bucket/presentation/http/
file_share_handler.rs

1//! FileShare 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::{FileShareService, ServiceError};
26
27// DTO imports
28use crate::presentation::dto::{CreateFileShareDto, UpdateFileShareDto, PatchFileShareDto, FileShareResponseDto};
29
30use crate::domain::state_machine::{FileShareState, FileShareStateMachine, FileShareTransition};
31
32/// Application error type
33#[derive(Debug, thiserror::Error)]
34pub enum FileShareError {
35    #[error("Not found: {0}")]
36    NotFound(String),
37    #[error("Validation error: {0}")]
38    Validation(String),
39    #[error("Database error: {0}")]
40    Database(String),
41    #[error("Internal error: {0}")]
42    Internal(String),
43    // Domain-specific errors from hook rules
44    #[error("Share token must be 32-64 characters: {0}")]
45    InvalidTokenLength(String),
46    #[error("Share token must be alphanumeric: {0}")]
47    InvalidTokenChars(String),
48    #[error("Cannot share inactive or quarantined files: {0}")]
49    FileNotActive(String),
50    #[error("Only file owner can create shares: {0}")]
51    NotFileOwner(String),
52    #[error("Expiration date must be in the future: {0}")]
53    ExpiredDate(String),
54    #[error("Maximum downloads must be positive: {0}")]
55    InvalidMaxDownloads(String),
56    #[error("User shares must specify recipients: {0}")]
57    MissingRecipients(String),
58    #[error("Cannot modify inactive shares: {0}")]
59    ShareInactive(String),
60}
61
62impl From<ServiceError> for FileShareError {
63    fn from(err: ServiceError) -> Self {
64        match err {
65            ServiceError::NotFound => Self::NotFound(err.to_string()),
66            ServiceError::Validation(ref msg) => Self::Validation(msg.clone()),
67            ServiceError::AlreadyExists(ref msg) => Self::Validation(msg.clone()),
68            ServiceError::Repository(ref e) => Self::Database(e.to_string()),
69            ServiceError::Internal(ref msg) => Self::Internal(msg.clone()),
70            ServiceError::Violations(_) => Self::Validation(err.to_string()),
71        }
72    }
73}
74
75impl axum::response::IntoResponse for FileShareError {
76    fn into_response(self) -> axum::response::Response {
77        use axum::http::StatusCode;
78        use axum::Json;
79
80        let (status, code) = match &self {
81            Self::NotFound(_) => (StatusCode::NOT_FOUND, "FILESHARE_NOT_FOUND"),
82            Self::Validation(_) => (StatusCode::BAD_REQUEST, "FILESHARE_VALIDATION_ERROR"),
83            Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "FILESHARE_DATABASE_ERROR"),
84            Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "FILESHARE_INTERNAL_ERROR"),
85            Self::InvalidTokenLength(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILESHARE_INVALID_TOKEN_LENGTH"),
86            Self::InvalidTokenChars(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILESHARE_INVALID_TOKEN_CHARS"),
87            Self::FileNotActive(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILESHARE_FILE_NOT_ACTIVE"),
88            Self::NotFileOwner(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILESHARE_NOT_FILE_OWNER"),
89            Self::ExpiredDate(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILESHARE_EXPIRED_DATE"),
90            Self::InvalidMaxDownloads(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILESHARE_INVALID_MAX_DOWNLOADS"),
91            Self::MissingRecipients(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILESHARE_MISSING_RECIPIENTS"),
92            Self::ShareInactive(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILESHARE_SHARE_INACTIVE"),
93        };
94
95        let body = serde_json::json!({
96            "success": false,
97            "error": code,
98            "message": self.to_string(),
99        });
100
101        (status, Json(body)).into_response()
102    }
103}
104
105/// Domain-specific error codes for FileShare
106pub mod file_share_errors {
107    pub const INVALID_TOKEN_LENGTH: &str = "FILESHARE_INVALID_TOKEN_LENGTH";
108    pub const INVALID_TOKEN_CHARS: &str = "FILESHARE_INVALID_TOKEN_CHARS";
109    pub const FILE_NOT_ACTIVE: &str = "FILESHARE_FILE_NOT_ACTIVE";
110    pub const NOT_FILE_OWNER: &str = "FILESHARE_NOT_FILE_OWNER";
111    pub const EXPIRED_DATE: &str = "FILESHARE_EXPIRED_DATE";
112    pub const INVALID_MAX_DOWNLOADS: &str = "FILESHARE_INVALID_MAX_DOWNLOADS";
113    pub const MISSING_RECIPIENTS: &str = "FILESHARE_MISSING_RECIPIENTS";
114    pub const SHARE_INACTIVE: &str = "FILESHARE_SHARE_INACTIVE";
115}
116
117// =============================================================================
118// Route Configuration
119// =============================================================================
120
121/// Create Axum router with all 16 Backbone endpoints for FileShare.
122///
123/// # Routes
124///
125/// | Method | Path | Description |
126/// |--------|------|-------------|
127/// | GET | /file_shares | List with pagination |
128/// | POST | /file_shares | Create new |
129/// | GET | /file_shares/:id | Get by ID |
130/// | PUT | /file_shares/:id | Full update |
131/// | PATCH | /file_shares/:id | Partial update |
132/// | DELETE | /file_shares/:id | Soft delete |
133/// | POST | /file_shares/bulk | Bulk create |
134/// | POST | /file_shares/upsert | Upsert |
135/// | GET | /file_shares/trash | List deleted |
136/// | POST | /file_shares/:id/restore | Restore |
137/// | DELETE | /file_shares/empty | Empty trash |
138/// | GET | /file_shares/:id/deleted | Get deleted by ID |
139/// | DELETE | /file_shares/trash/:id | Permanent delete from trash |
140/// | GET | /file_shares/count | Count active entities |
141/// | GET | /file_shares/trash/count | Count deleted entities |
142///
143/// # Example
144///
145/// ```text
146/// let service = Arc::new(FileShareService::with_repository(repository));
147/// let router = create_file_share_routes(service);
148/// ```
149pub fn create_file_share_routes(service: Arc<FileShareService>) -> Router {
150    BackboneCrudHandler::<FileShareService, FileShare, CreateFileShareDto, UpdateFileShareDto, FileShareResponseDto>::routes(
151        service,
152        "/file_shares",
153    )
154}
155
156/// Create Axum router with only the read (GET) endpoints for FileShare.
157///
158/// Safe for public, unauthenticated exposure (e.g., reference data).
159/// Mutations must be served separately via `create_file_share_write_routes`,
160/// typically wrapped in an auth middleware layer.
161pub fn create_file_share_read_routes(service: Arc<FileShareService>) -> Router {
162    BackboneCrudHandler::<FileShareService, FileShare, CreateFileShareDto, UpdateFileShareDto, FileShareResponseDto>::read_routes(
163        service,
164        "/file_shares",
165    )
166}
167
168/// Create Axum router with only the write (mutation) endpoints for FileShare.
169///
170/// These routes must NOT be publicly exposed. Wrap them with an auth
171/// middleware before nesting into the application router.
172///
173/// # This is unguarded generic CRUD, not a validated write path
174///
175/// These are plain create/update/patch/delete mutations over the entity row —
176/// they bypass all business invariants. If the module exposes a validated write
177/// service (e.g. a command router over its domain engine), serve THAT instead
178/// for any mutation that must respect domain rules.
179pub fn create_file_share_write_routes(service: Arc<FileShareService>) -> Router {
180    BackboneCrudHandler::<FileShareService, FileShare, CreateFileShareDto, UpdateFileShareDto, FileShareResponseDto>::write_routes(
181        service,
182        "/file_shares",
183    )
184}
185
186/// Create authenticated routes with auth middleware.
187///
188/// Requires the `auth` feature flag. The `AuthMiddleware` implementation
189/// is responsible for extracting and validating tokens, then providing
190/// an `AuthContext` via request extensions.
191#[cfg(feature = "auth")]
192pub fn create_protected_file_share_routes<A: AuthMiddleware + Send + Sync + 'static>(
193    service: Arc<FileShareService>,
194    auth: Arc<A>,
195) -> Router {
196    use axum::middleware;
197    use axum::response::IntoResponse;
198
199    let auth_layer = auth.clone();
200    create_file_share_routes(service)
201        .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
202            let auth = auth_layer.clone();
203            async move {
204                let token = req.headers()
205                    .get(axum::http::header::AUTHORIZATION)
206                    .and_then(|h| h.to_str().ok())
207                    .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
208                    .unwrap_or("");
209                match auth.authenticate(token).await {
210                    Ok(ctx) => {
211                        req.extensions_mut().insert(ctx);
212                        next.run(req).await
213                    }
214                    Err(_) => {
215                        (axum::http::StatusCode::UNAUTHORIZED,
216                         axum::Json(serde_json::json!({
217                             "success": false,
218                             "error": "unauthorized",
219                             "message": "Authentication required"
220                         }))
221                        ).into_response()
222                    }
223                }
224            }
225        }))
226}
227
228// =============================================================================
229// State Transition Handlers
230// =============================================================================
231
232/// Execute expire transition on a FileShare.
233///
234/// POST /file_shares/:id/transitions/expire
235pub async fn expire_transition(
236    axum::extract::State(service): axum::extract::State<Arc<FileShareService>>,
237    axum::extract::Path(id): axum::extract::Path<String>,
238    auth: Option<axum::Extension<AuthContext>>,
239) -> impl axum::response::IntoResponse {
240    use axum::{http::StatusCode, Json};
241
242    // Get current entity
243    let entity = match service.get_by_id(&id).await {
244        Ok(Some(e)) => e,
245        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found("FileShare", &id))),
246        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
247    };
248
249    // Check permission
250    let Some(axum::Extension(auth)) = auth else {
251        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<serde_json::Value>::error("A transition needs an authenticated caller")));
252    };
253    {
254        let allowed_roles = FileShareTransition::Expire.allowed_roles();
255        let granted = |required: &str| auth.permissions.iter().any(|p| {
256            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
257            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
258                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
259        });
260        let has_specific_perm = granted("file_share:transition:expire");
261        let has_update_perm = granted("file_share:update");
262        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
263        if !has_specific_perm && !has_role {
264            if !has_update_perm {
265                return (StatusCode::FORBIDDEN, Json(ApiResponse::<serde_json::Value>::error("Insufficient permissions for expire transition")));
266            }
267            tracing::warn!(target: "backbone::write_guard", entity = "FileShare", verb = "expire", code = "transition_by_update_permission", "a transition granted only by file_share:update, running in shadow; it needs file_share:transition:expire or one of the roles system");
268        }
269    }
270
271    // Create state machine from entity's actual status and validate transition
272    let current_state: FileShareState = match entity.status.to_string().parse() {
273        Ok(s) => s,
274        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<serde_json::Value>::error("The stored state is not a state of this machine"))),
275    };
276    let sm = FileShareStateMachine::from_state(current_state);
277    if !sm.can_transition(FileShareTransition::Expire) {
278        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error("Transition not allowed from current state")));
279    }
280
281    // Guard `expires_at != null && expires_at <= now()` not compiled (reads the writer, the time or the row before the write); the transition is not checked against it.
282    // Apply transition via partial update
283    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
284    fields.insert("status".to_string(), serde_json::Value::String("expired".to_string()));
285
286    match backbone_core::write_guard::as_transition("expire", service.partial_update(&id, fields)).await {
287        Ok(Some(updated)) => {
288            let response: FileShareResponseDto = updated.into();
289            (StatusCode::OK, Json(ApiResponse::ok(backbone_core::http::without_secrets::<FileShare, _>(response))))
290        }
291        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found("FileShare", &id))),
292        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
293    }
294}
295
296/// Execute exhaust transition on a FileShare.
297///
298/// POST /file_shares/:id/transitions/exhaust
299pub async fn exhaust_transition(
300    axum::extract::State(service): axum::extract::State<Arc<FileShareService>>,
301    axum::extract::Path(id): axum::extract::Path<String>,
302    auth: Option<axum::Extension<AuthContext>>,
303) -> impl axum::response::IntoResponse {
304    use axum::{http::StatusCode, Json};
305
306    // Get current entity
307    let entity = match service.get_by_id(&id).await {
308        Ok(Some(e)) => e,
309        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found("FileShare", &id))),
310        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
311    };
312
313    // Check permission
314    let Some(axum::Extension(auth)) = auth else {
315        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<serde_json::Value>::error("A transition needs an authenticated caller")));
316    };
317    {
318        let allowed_roles = FileShareTransition::Exhaust.allowed_roles();
319        let granted = |required: &str| auth.permissions.iter().any(|p| {
320            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
321            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
322                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
323        });
324        let has_specific_perm = granted("file_share:transition:exhaust");
325        let has_update_perm = granted("file_share:update");
326        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
327        if !has_specific_perm && !has_role {
328            if !has_update_perm {
329                return (StatusCode::FORBIDDEN, Json(ApiResponse::<serde_json::Value>::error("Insufficient permissions for exhaust transition")));
330            }
331            tracing::warn!(target: "backbone::write_guard", entity = "FileShare", verb = "exhaust", code = "transition_by_update_permission", "a transition granted only by file_share:update, running in shadow; it needs file_share:transition:exhaust or one of the roles system");
332        }
333    }
334
335    // Create state machine from entity's actual status and validate transition
336    let current_state: FileShareState = match entity.status.to_string().parse() {
337        Ok(s) => s,
338        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<serde_json::Value>::error("The stored state is not a state of this machine"))),
339    };
340    let sm = FileShareStateMachine::from_state(current_state);
341    if !sm.can_transition(FileShareTransition::Exhaust) {
342        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error("Transition not allowed from current state")));
343    }
344
345    {
346        use backbone_core::row_rule as r;
347        let row = &serde_json::to_value(&entity).unwrap_or_default();
348        let state = row.get("status").and_then(|v| v.as_str()).unwrap_or_default();
349        // Guard: max_downloads != null && download_count >= max_downloads
350        if ["active"].contains(&state) && !r::holds(&r::and(r::is_not_null(r::field(row, "max_downloads")), r::ge(r::field(row, "download_count"), r::field(row, "max_downloads")))) {
351            return (StatusCode::UNPROCESSABLE_ENTITY, Json(ApiResponse::<serde_json::Value>::error("The exhaust transition's guard does not hold: max_downloads != null && download_count >= max_downloads")));
352        }
353    }
354
355    // Apply transition via partial update
356    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
357    fields.insert("status".to_string(), serde_json::Value::String("exhausted".to_string()));
358
359    match backbone_core::write_guard::as_transition("exhaust", service.partial_update(&id, fields)).await {
360        Ok(Some(updated)) => {
361            let response: FileShareResponseDto = updated.into();
362            (StatusCode::OK, Json(ApiResponse::ok(backbone_core::http::without_secrets::<FileShare, _>(response))))
363        }
364        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found("FileShare", &id))),
365        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
366    }
367}
368
369/// Execute revoke transition on a FileShare.
370///
371/// POST /file_shares/:id/transitions/revoke
372pub async fn revoke_transition(
373    axum::extract::State(service): axum::extract::State<Arc<FileShareService>>,
374    axum::extract::Path(id): axum::extract::Path<String>,
375    auth: Option<axum::Extension<AuthContext>>,
376) -> impl axum::response::IntoResponse {
377    use axum::{http::StatusCode, Json};
378
379    // Get current entity
380    let entity = match service.get_by_id(&id).await {
381        Ok(Some(e)) => e,
382        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found("FileShare", &id))),
383        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
384    };
385
386    // Check permission
387    let Some(axum::Extension(auth)) = auth else {
388        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<serde_json::Value>::error("A transition needs an authenticated caller")));
389    };
390    {
391        let allowed_roles = FileShareTransition::Revoke.allowed_roles();
392        let granted = |required: &str| auth.permissions.iter().any(|p| {
393            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
394            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
395                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
396        });
397        let has_specific_perm = granted("file_share:transition:revoke");
398        let has_update_perm = granted("file_share:update");
399        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
400        if !has_specific_perm && !has_role {
401            if !has_update_perm {
402                return (StatusCode::FORBIDDEN, Json(ApiResponse::<serde_json::Value>::error("Insufficient permissions for revoke transition")));
403            }
404            tracing::warn!(target: "backbone::write_guard", entity = "FileShare", verb = "revoke", code = "transition_by_update_permission", "a transition granted only by file_share:update, running in shadow; it needs file_share:transition:revoke or one of the roles admin, owner");
405        }
406    }
407
408    // Create state machine from entity's actual status and validate transition
409    let current_state: FileShareState = match entity.status.to_string().parse() {
410        Ok(s) => s,
411        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<serde_json::Value>::error("The stored state is not a state of this machine"))),
412    };
413    let sm = FileShareStateMachine::from_state(current_state);
414    if !sm.can_transition(FileShareTransition::Revoke) {
415        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<serde_json::Value>::error("Transition not allowed from current state")));
416    }
417
418    // 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.
419    // Apply transition via partial update
420    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
421    fields.insert("status".to_string(), serde_json::Value::String("revoked".to_string()));
422
423    match backbone_core::write_guard::as_transition("revoke", service.partial_update(&id, fields)).await {
424        Ok(Some(updated)) => {
425            let response: FileShareResponseDto = updated.into();
426            (StatusCode::OK, Json(ApiResponse::ok(backbone_core::http::without_secrets::<FileShare, _>(response))))
427        }
428        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<serde_json::Value>::not_found("FileShare", &id))),
429        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<serde_json::Value>::error(e.to_string()))),
430    }
431}
432
433/// Create routes for state transitions.
434pub fn create_file_share_transition_routes(service: Arc<FileShareService>) -> Router {
435    use axum::routing::post;
436
437    Router::new()
438        .route("/file_shares/:id/transitions/expire", post(expire_transition))
439        .route("/file_shares/:id/transitions/exhaust", post(exhaust_transition))
440        .route("/file_shares/:id/transitions/revoke", post(revoke_transition))
441        .with_state(service)
442}
443