1use std::collections::HashMap;
8use std::sync::Arc;
9
10use axum::Router;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13use chrono::{DateTime, Utc};
14
15use backbone_core::http::{ApiResponse, BackboneCrudHandler};
17
18#[cfg(feature = "auth")]
20use backbone_auth::middleware::AuthContext;
21#[cfg(feature = "auth")]
22use backbone_auth::AuthMiddleware;
23
24use crate::domain::entity::*;
26use crate::application::service::{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_role {
254 if !has_update_perm {
255 return (StatusCode::FORBIDDEN, Json(ApiResponse::<FileLockResponseDto>::error("Insufficient permissions for expire transition")));
256 }
257 tracing::warn!(target: "backbone::write_guard", entity = "FileLock", verb = "expire", code = "transition_by_update_permission", "a transition granted only by file_lock:update, running in shadow; it needs file_lock:transition:expire or a role of ["system"]");
258 }
259 }
260
261 let current_state: FileLockState = match entity.status.to_string().parse() {
263 Ok(s) => s,
264 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<FileLockResponseDto>::error("The stored state is not a state of this machine"))),
265 };
266 let sm = FileLockStateMachine::from_state(current_state);
267 if !sm.can_transition(FileLockTransition::Expire) {
268 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<FileLockResponseDto>::error("Transition not allowed from current state")));
269 }
270
271 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
274 fields.insert("status".to_string(), serde_json::Value::String("expired".to_string()));
275
276 match backbone_core::write_guard::as_transition("expire", service.partial_update(&id, fields)).await {
277 Ok(Some(updated)) => {
278 let response: FileLockResponseDto = updated.into();
279 (StatusCode::OK, Json(ApiResponse::ok(response)))
280 }
281 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
282 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
283 }
284}
285
286pub async fn release_transition(
290 axum::extract::State(service): axum::extract::State<Arc<FileLockService>>,
291 axum::extract::Path(id): axum::extract::Path<String>,
292 #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
293) -> impl axum::response::IntoResponse {
294 use axum::{http::StatusCode, Json};
295
296 let entity = match service.get_by_id(&id).await {
298 Ok(Some(e)) => e,
299 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
300 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
301 };
302
303 #[cfg(feature = "auth")]
305 {
306 let allowed_roles = FileLockTransition::Release.allowed_roles();
307 let has_specific_perm = auth.permissions.iter().any(|p| p == "file_lock:transition:release");
308 let has_update_perm = auth.permissions.iter().any(|p| p == "file_lock:update");
309 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
310 if !has_specific_perm && !has_role {
311 if !has_update_perm {
312 return (StatusCode::FORBIDDEN, Json(ApiResponse::<FileLockResponseDto>::error("Insufficient permissions for release transition")));
313 }
314 tracing::warn!(target: "backbone::write_guard", entity = "FileLock", verb = "release", code = "transition_by_update_permission", "a transition granted only by file_lock:update, running in shadow; it needs file_lock:transition:release or a role of ["owner", "admin"]");
315 }
316 }
317
318 let current_state: FileLockState = match entity.status.to_string().parse() {
320 Ok(s) => s,
321 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<FileLockResponseDto>::error("The stored state is not a state of this machine"))),
322 };
323 let sm = FileLockStateMachine::from_state(current_state);
324 if !sm.can_transition(FileLockTransition::Release) {
325 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<FileLockResponseDto>::error("Transition not allowed from current state")));
326 }
327
328 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
331 fields.insert("status".to_string(), serde_json::Value::String("released".to_string()));
332
333 match backbone_core::write_guard::as_transition("release", service.partial_update(&id, fields)).await {
334 Ok(Some(updated)) => {
335 let response: FileLockResponseDto = updated.into();
336 (StatusCode::OK, Json(ApiResponse::ok(response)))
337 }
338 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
339 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
340 }
341}
342
343pub async fn refresh_transition(
347 axum::extract::State(service): axum::extract::State<Arc<FileLockService>>,
348 axum::extract::Path(id): axum::extract::Path<String>,
349 #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
350) -> impl axum::response::IntoResponse {
351 use axum::{http::StatusCode, Json};
352
353 let entity = match service.get_by_id(&id).await {
355 Ok(Some(e)) => e,
356 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
357 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
358 };
359
360 #[cfg(feature = "auth")]
362 {
363 let allowed_roles = FileLockTransition::Refresh.allowed_roles();
364 let has_specific_perm = auth.permissions.iter().any(|p| p == "file_lock:transition:refresh");
365 let has_update_perm = auth.permissions.iter().any(|p| p == "file_lock:update");
366 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
367 if !has_specific_perm && !has_role {
368 if !has_update_perm {
369 return (StatusCode::FORBIDDEN, Json(ApiResponse::<FileLockResponseDto>::error("Insufficient permissions for refresh transition")));
370 }
371 tracing::warn!(target: "backbone::write_guard", entity = "FileLock", verb = "refresh", code = "transition_by_update_permission", "a transition granted only by file_lock:update, running in shadow; it needs file_lock:transition:refresh or a role of ["owner"]");
372 }
373 }
374
375 let current_state: FileLockState = match entity.status.to_string().parse() {
377 Ok(s) => s,
378 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<FileLockResponseDto>::error("The stored state is not a state of this machine"))),
379 };
380 let sm = FileLockStateMachine::from_state(current_state);
381 if !sm.can_transition(FileLockTransition::Refresh) {
382 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<FileLockResponseDto>::error("Transition not allowed from current state")));
383 }
384
385 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
388 fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
389
390 match backbone_core::write_guard::as_transition("refresh", service.partial_update(&id, fields)).await {
391 Ok(Some(updated)) => {
392 let response: FileLockResponseDto = updated.into();
393 (StatusCode::OK, Json(ApiResponse::ok(response)))
394 }
395 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
396 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
397 }
398}
399
400pub fn create_file_lock_transition_routes(service: Arc<FileLockService>) -> Router {
402 use axum::routing::post;
403
404 Router::new()
405 .route("/file_locks/:id/transitions/expire", post(expire_transition))
406 .route("/file_locks/:id/transitions/release", post(release_transition))
407 .route("/file_locks/:id/transitions/refresh", post(refresh_transition))
408 .with_state(service)
409}
410