backbone_bucket/presentation/http/
file_lock_handler.rs1use std::collections::HashMap;
8use std::sync::Arc;
9
10use axum::Router;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13use chrono::{DateTime, Utc};
14
15use backbone_core::http::{ApiResponse, BackboneCrudHandler};
17
18#[cfg(feature = "auth")]
20use backbone_auth::middleware::AuthContext;
21#[cfg(feature = "auth")]
22use backbone_auth::AuthMiddleware;
23
24use crate::domain::entity::*;
26use crate::application::service::{FileLockService, ServiceError};
27
28use crate::presentation::dto::{CreateFileLockDto, UpdateFileLockDto, PatchFileLockDto, FileLockResponseDto};
30
31use crate::domain::state_machine::{FileLockState, FileLockStateMachine, FileLockTransition};
32
33#[derive(Debug, thiserror::Error)]
35pub enum FileLockError {
36 #[error("Not found: {0}")]
37 NotFound(String),
38 #[error("Validation error: {0}")]
39 Validation(String),
40 #[error("Database error: {0}")]
41 Database(String),
42 #[error("Internal error: {0}")]
43 Internal(String),
44 #[error("File must exist to be locked: {0}")]
46 FileNotFound(String),
47 #[error("Only active files can be locked: {0}")]
48 FileNotActive(String),
49 #[error("File is already locked by another user: {0}")]
50 FileAlreadyLocked(String),
51 #[error("Lock duration must be between 5 minutes and 24 hours: {0}")]
52 InvalidDuration(String),
53 #[error("Only lock owner can modify: {0}")]
54 NotLockOwner(String),
55 #[error("Cannot modify expired lock: {0}")]
56 LockExpired(String),
57 #[error("Can only refresh within 5 minutes of expiration: {0}")]
58 RefreshTooEarly(String),
59}
60
61impl From<ServiceError> for FileLockError {
62 fn from(err: ServiceError) -> Self {
63 match err {
64 ServiceError::NotFound => Self::NotFound(err.to_string()),
65 ServiceError::Validation(ref msg) => Self::Validation(msg.clone()),
66 ServiceError::AlreadyExists(ref msg) => Self::Validation(msg.clone()),
67 ServiceError::Repository(ref e) => Self::Database(e.to_string()),
68 ServiceError::Internal(ref msg) => Self::Internal(msg.clone()),
69 ServiceError::Violations(_) => Self::Validation(err.to_string()),
70 }
71 }
72}
73
74impl axum::response::IntoResponse for FileLockError {
75 fn into_response(self) -> axum::response::Response {
76 use axum::http::StatusCode;
77 use axum::Json;
78
79 let (status, code) = match &self {
80 Self::NotFound(_) => (StatusCode::NOT_FOUND, "FILELOCK_NOT_FOUND"),
81 Self::Validation(_) => (StatusCode::BAD_REQUEST, "FILELOCK_VALIDATION_ERROR"),
82 Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "FILELOCK_DATABASE_ERROR"),
83 Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "FILELOCK_INTERNAL_ERROR"),
84 Self::FileNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILELOCK_FILE_NOT_FOUND"),
85 Self::FileNotActive(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILELOCK_FILE_NOT_ACTIVE"),
86 Self::FileAlreadyLocked(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILELOCK_FILE_ALREADY_LOCKED"),
87 Self::InvalidDuration(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILELOCK_INVALID_DURATION"),
88 Self::NotLockOwner(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILELOCK_NOT_LOCK_OWNER"),
89 Self::LockExpired(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILELOCK_LOCK_EXPIRED"),
90 Self::RefreshTooEarly(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILELOCK_REFRESH_TOO_EARLY"),
91 };
92
93 let body = serde_json::json!({
94 "success": false,
95 "error": code,
96 "message": self.to_string(),
97 });
98
99 (status, Json(body)).into_response()
100 }
101}
102
103pub mod file_lock_errors {
105 pub const FILE_NOT_FOUND: &str = "FILELOCK_FILE_NOT_FOUND";
106 pub const FILE_NOT_ACTIVE: &str = "FILELOCK_FILE_NOT_ACTIVE";
107 pub const FILE_ALREADY_LOCKED: &str = "FILELOCK_FILE_ALREADY_LOCKED";
108 pub const INVALID_DURATION: &str = "FILELOCK_INVALID_DURATION";
109 pub const NOT_LOCK_OWNER: &str = "FILELOCK_NOT_LOCK_OWNER";
110 pub const LOCK_EXPIRED: &str = "FILELOCK_LOCK_EXPIRED";
111 pub const REFRESH_TOO_EARLY: &str = "FILELOCK_REFRESH_TOO_EARLY";
112}
113
114pub fn create_file_lock_routes(service: Arc<FileLockService>) -> Router {
147 BackboneCrudHandler::<FileLockService, FileLock, CreateFileLockDto, UpdateFileLockDto, FileLockResponseDto>::routes(
148 service,
149 "/file_locks",
150 )
151}
152
153pub fn create_file_lock_read_routes(service: Arc<FileLockService>) -> Router {
159 BackboneCrudHandler::<FileLockService, FileLock, CreateFileLockDto, UpdateFileLockDto, FileLockResponseDto>::read_routes(
160 service,
161 "/file_locks",
162 )
163}
164
165pub fn create_file_lock_write_routes(service: Arc<FileLockService>) -> Router {
177 BackboneCrudHandler::<FileLockService, FileLock, CreateFileLockDto, UpdateFileLockDto, FileLockResponseDto>::write_routes(
178 service,
179 "/file_locks",
180 )
181}
182
183#[cfg(feature = "auth")]
189pub fn create_protected_file_lock_routes<A: AuthMiddleware + Send + Sync + 'static>(
190 service: Arc<FileLockService>,
191 auth: Arc<A>,
192) -> Router {
193 use axum::middleware;
194 use axum::response::IntoResponse;
195
196 let auth_layer = auth.clone();
197 create_file_lock_routes(service)
198 .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
199 let auth = auth_layer.clone();
200 async move {
201 let token = req.headers()
202 .get(axum::http::header::AUTHORIZATION)
203 .and_then(|h| h.to_str().ok())
204 .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
205 .unwrap_or("");
206 match auth.authenticate(token).await {
207 Ok(ctx) => {
208 req.extensions_mut().insert(ctx);
209 next.run(req).await
210 }
211 Err(_) => {
212 (axum::http::StatusCode::UNAUTHORIZED,
213 axum::Json(serde_json::json!({
214 "success": false,
215 "error": "unauthorized",
216 "message": "Authentication required"
217 }))
218 ).into_response()
219 }
220 }
221 }
222 }))
223}
224
225pub async fn expire_transition(
233 axum::extract::State(service): axum::extract::State<Arc<FileLockService>>,
234 axum::extract::Path(id): axum::extract::Path<String>,
235 #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
236) -> impl axum::response::IntoResponse {
237 use axum::{http::StatusCode, Json};
238
239 let entity = match service.get_by_id(&id).await {
241 Ok(Some(e)) => e,
242 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
243 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
244 };
245
246 #[cfg(feature = "auth")]
248 {
249 let allowed_roles = FileLockTransition::Expire.allowed_roles();
250 let has_specific_perm = auth.permissions.iter().any(|p| p == "file_lock:transition:expire");
251 let has_update_perm = auth.permissions.iter().any(|p| p == "file_lock:update");
252 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
253 if !has_specific_perm && !has_update_perm && !has_role {
254 return (StatusCode::FORBIDDEN, Json(ApiResponse::<FileLockResponseDto>::error("Insufficient permissions for expire transition")));
255 }
256 }
257
258 let current_state: FileLockState = entity.status.to_string().parse()
260 .unwrap_or(FileLockState::default());
261 let sm = FileLockStateMachine::from_state(current_state);
262 if !sm.can_transition(FileLockTransition::Expire) {
263 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<FileLockResponseDto>::error("Transition not allowed from current state")));
264 }
265
266 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
268 fields.insert("status".to_string(), serde_json::Value::String("Expired".to_string()));
269
270 match service.partial_update(&id, fields).await {
271 Ok(Some(updated)) => {
272 let response: FileLockResponseDto = updated.into();
273 (StatusCode::OK, Json(ApiResponse::ok(response)))
274 }
275 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
276 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
277 }
278}
279
280pub async fn release_transition(
284 axum::extract::State(service): axum::extract::State<Arc<FileLockService>>,
285 axum::extract::Path(id): axum::extract::Path<String>,
286 #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
287) -> impl axum::response::IntoResponse {
288 use axum::{http::StatusCode, Json};
289
290 let entity = match service.get_by_id(&id).await {
292 Ok(Some(e)) => e,
293 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
294 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
295 };
296
297 #[cfg(feature = "auth")]
299 {
300 let allowed_roles = FileLockTransition::Release.allowed_roles();
301 let has_specific_perm = auth.permissions.iter().any(|p| p == "file_lock:transition:release");
302 let has_update_perm = auth.permissions.iter().any(|p| p == "file_lock:update");
303 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
304 if !has_specific_perm && !has_update_perm && !has_role {
305 return (StatusCode::FORBIDDEN, Json(ApiResponse::<FileLockResponseDto>::error("Insufficient permissions for release transition")));
306 }
307 }
308
309 let current_state: FileLockState = entity.status.to_string().parse()
311 .unwrap_or(FileLockState::default());
312 let sm = FileLockStateMachine::from_state(current_state);
313 if !sm.can_transition(FileLockTransition::Release) {
314 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<FileLockResponseDto>::error("Transition not allowed from current state")));
315 }
316
317 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
319 fields.insert("status".to_string(), serde_json::Value::String("Released".to_string()));
320
321 match service.partial_update(&id, fields).await {
322 Ok(Some(updated)) => {
323 let response: FileLockResponseDto = updated.into();
324 (StatusCode::OK, Json(ApiResponse::ok(response)))
325 }
326 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
327 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
328 }
329}
330
331pub async fn refresh_transition(
335 axum::extract::State(service): axum::extract::State<Arc<FileLockService>>,
336 axum::extract::Path(id): axum::extract::Path<String>,
337 #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
338) -> impl axum::response::IntoResponse {
339 use axum::{http::StatusCode, Json};
340
341 let entity = match service.get_by_id(&id).await {
343 Ok(Some(e)) => e,
344 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
345 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
346 };
347
348 #[cfg(feature = "auth")]
350 {
351 let allowed_roles = FileLockTransition::Refresh.allowed_roles();
352 let has_specific_perm = auth.permissions.iter().any(|p| p == "file_lock:transition:refresh");
353 let has_update_perm = auth.permissions.iter().any(|p| p == "file_lock:update");
354 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
355 if !has_specific_perm && !has_update_perm && !has_role {
356 return (StatusCode::FORBIDDEN, Json(ApiResponse::<FileLockResponseDto>::error("Insufficient permissions for refresh transition")));
357 }
358 }
359
360 let current_state: FileLockState = entity.status.to_string().parse()
362 .unwrap_or(FileLockState::default());
363 let sm = FileLockStateMachine::from_state(current_state);
364 if !sm.can_transition(FileLockTransition::Refresh) {
365 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<FileLockResponseDto>::error("Transition not allowed from current state")));
366 }
367
368 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
370 fields.insert("status".to_string(), serde_json::Value::String("Active".to_string()));
371
372 match service.partial_update(&id, fields).await {
373 Ok(Some(updated)) => {
374 let response: FileLockResponseDto = updated.into();
375 (StatusCode::OK, Json(ApiResponse::ok(response)))
376 }
377 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
378 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
379 }
380}
381
382pub fn create_file_lock_transition_routes(service: Arc<FileLockService>) -> Router {
384 use axum::routing::post;
385
386 Router::new()
387 .route("/file_locks/:id/transitions/expire", post(expire_transition))
388 .route("/file_locks/:id/transitions/release", post(release_transition))
389 .route("/file_locks/:id/transitions/refresh", post(refresh_transition))
390 .with_state(service)
391}