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::{FileLockService, ServiceError};
26
27use crate::presentation::dto::{CreateFileLockDto, UpdateFileLockDto, PatchFileLockDto, FileLockResponseDto};
29
30use crate::domain::state_machine::{FileLockState, FileLockStateMachine, FileLockTransition};
31
32#[derive(Debug, thiserror::Error)]
34pub enum FileLockError {
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 must exist to be locked: {0}")]
45 FileNotFound(String),
46 #[error("Only active files can be locked: {0}")]
47 FileNotActive(String),
48 #[error("File is already locked by another user: {0}")]
49 FileAlreadyLocked(String),
50 #[error("Lock duration must be between 5 minutes and 24 hours: {0}")]
51 InvalidDuration(String),
52 #[error("Only lock owner can modify: {0}")]
53 NotLockOwner(String),
54 #[error("Cannot modify expired lock: {0}")]
55 LockExpired(String),
56 #[error("Can only refresh within 5 minutes of expiration: {0}")]
57 RefreshTooEarly(String),
58}
59
60impl From<ServiceError> for FileLockError {
61 fn from(err: ServiceError) -> Self {
62 match err {
63 ServiceError::NotFound => Self::NotFound(err.to_string()),
64 ServiceError::Validation(ref msg) => Self::Validation(msg.clone()),
65 ServiceError::AlreadyExists(ref msg) => Self::Validation(msg.clone()),
66 ServiceError::Repository(ref e) => Self::Database(e.to_string()),
67 ServiceError::Internal(ref msg) => Self::Internal(msg.clone()),
68 ServiceError::Violations(_) => Self::Validation(err.to_string()),
69 }
70 }
71}
72
73impl axum::response::IntoResponse for FileLockError {
74 fn into_response(self) -> axum::response::Response {
75 use axum::http::StatusCode;
76 use axum::Json;
77
78 let (status, code) = match &self {
79 Self::NotFound(_) => (StatusCode::NOT_FOUND, "FILELOCK_NOT_FOUND"),
80 Self::Validation(_) => (StatusCode::BAD_REQUEST, "FILELOCK_VALIDATION_ERROR"),
81 Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "FILELOCK_DATABASE_ERROR"),
82 Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "FILELOCK_INTERNAL_ERROR"),
83 Self::FileNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILELOCK_FILE_NOT_FOUND"),
84 Self::FileNotActive(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILELOCK_FILE_NOT_ACTIVE"),
85 Self::FileAlreadyLocked(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILELOCK_FILE_ALREADY_LOCKED"),
86 Self::InvalidDuration(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILELOCK_INVALID_DURATION"),
87 Self::NotLockOwner(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILELOCK_NOT_LOCK_OWNER"),
88 Self::LockExpired(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILELOCK_LOCK_EXPIRED"),
89 Self::RefreshTooEarly(_) => (StatusCode::UNPROCESSABLE_ENTITY, "FILELOCK_REFRESH_TOO_EARLY"),
90 };
91
92 let body = serde_json::json!({
93 "success": false,
94 "error": code,
95 "message": self.to_string(),
96 });
97
98 (status, Json(body)).into_response()
99 }
100}
101
102pub mod file_lock_errors {
104 pub const FILE_NOT_FOUND: &str = "FILELOCK_FILE_NOT_FOUND";
105 pub const FILE_NOT_ACTIVE: &str = "FILELOCK_FILE_NOT_ACTIVE";
106 pub const FILE_ALREADY_LOCKED: &str = "FILELOCK_FILE_ALREADY_LOCKED";
107 pub const INVALID_DURATION: &str = "FILELOCK_INVALID_DURATION";
108 pub const NOT_LOCK_OWNER: &str = "FILELOCK_NOT_LOCK_OWNER";
109 pub const LOCK_EXPIRED: &str = "FILELOCK_LOCK_EXPIRED";
110 pub const REFRESH_TOO_EARLY: &str = "FILELOCK_REFRESH_TOO_EARLY";
111}
112
113pub fn create_file_lock_routes(service: Arc<FileLockService>) -> Router {
146 BackboneCrudHandler::<FileLockService, FileLock, CreateFileLockDto, UpdateFileLockDto, FileLockResponseDto>::routes(
147 service,
148 "/file_locks",
149 )
150}
151
152pub fn create_file_lock_read_routes(service: Arc<FileLockService>) -> Router {
158 BackboneCrudHandler::<FileLockService, FileLock, CreateFileLockDto, UpdateFileLockDto, FileLockResponseDto>::read_routes(
159 service,
160 "/file_locks",
161 )
162}
163
164pub fn create_file_lock_write_routes(service: Arc<FileLockService>) -> Router {
176 BackboneCrudHandler::<FileLockService, FileLock, CreateFileLockDto, UpdateFileLockDto, FileLockResponseDto>::write_routes(
177 service,
178 "/file_locks",
179 )
180}
181
182#[cfg(feature = "auth")]
188pub fn create_protected_file_lock_routes<A: AuthMiddleware + Send + Sync + 'static>(
189 service: Arc<FileLockService>,
190 auth: Arc<A>,
191) -> Router {
192 use axum::middleware;
193 use axum::response::IntoResponse;
194
195 let auth_layer = auth.clone();
196 create_file_lock_routes(service)
197 .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
198 let auth = auth_layer.clone();
199 async move {
200 let token = req.headers()
201 .get(axum::http::header::AUTHORIZATION)
202 .and_then(|h| h.to_str().ok())
203 .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
204 .unwrap_or("");
205 match auth.authenticate(token).await {
206 Ok(ctx) => {
207 req.extensions_mut().insert(ctx);
208 next.run(req).await
209 }
210 Err(_) => {
211 (axum::http::StatusCode::UNAUTHORIZED,
212 axum::Json(serde_json::json!({
213 "success": false,
214 "error": "unauthorized",
215 "message": "Authentication required"
216 }))
217 ).into_response()
218 }
219 }
220 }
221 }))
222}
223
224pub async fn expire_transition(
232 axum::extract::State(service): axum::extract::State<Arc<FileLockService>>,
233 axum::extract::Path(id): axum::extract::Path<String>,
234 auth: Option<axum::Extension<AuthContext>>,
235) -> impl axum::response::IntoResponse {
236 use axum::{http::StatusCode, Json};
237
238 let entity = match service.get_by_id(&id).await {
240 Ok(Some(e)) => e,
241 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
242 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
243 };
244
245 let Some(axum::Extension(auth)) = auth else {
247 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<FileLockResponseDto>::error("A transition needs an authenticated caller")));
248 };
249 {
250 let allowed_roles = FileLockTransition::Expire.allowed_roles();
251 let granted = |required: &str| auth.permissions.iter().any(|p| {
252 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
253 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
254 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
255 });
256 let has_specific_perm = granted("file_lock:transition:expire");
257 let has_update_perm = granted("file_lock:update");
258 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
259 if !has_specific_perm && !has_role {
260 if !has_update_perm {
261 return (StatusCode::FORBIDDEN, Json(ApiResponse::<FileLockResponseDto>::error("Insufficient permissions for expire transition")));
262 }
263 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 one of the roles system");
264 }
265 }
266
267 let current_state: FileLockState = match entity.status.to_string().parse() {
269 Ok(s) => s,
270 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<FileLockResponseDto>::error("The stored state is not a state of this machine"))),
271 };
272 let sm = FileLockStateMachine::from_state(current_state);
273 if !sm.can_transition(FileLockTransition::Expire) {
274 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<FileLockResponseDto>::error("Transition not allowed from current state")));
275 }
276
277 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
280 fields.insert("status".to_string(), serde_json::Value::String("expired".to_string()));
281
282 match backbone_core::write_guard::as_transition("expire", service.partial_update(&id, fields)).await {
283 Ok(Some(updated)) => {
284 let response: FileLockResponseDto = updated.into();
285 (StatusCode::OK, Json(ApiResponse::ok(response)))
286 }
287 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
288 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
289 }
290}
291
292pub async fn release_transition(
296 axum::extract::State(service): axum::extract::State<Arc<FileLockService>>,
297 axum::extract::Path(id): axum::extract::Path<String>,
298 auth: Option<axum::Extension<AuthContext>>,
299) -> impl axum::response::IntoResponse {
300 use axum::{http::StatusCode, Json};
301
302 let entity = match service.get_by_id(&id).await {
304 Ok(Some(e)) => e,
305 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
306 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
307 };
308
309 let Some(axum::Extension(auth)) = auth else {
311 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<FileLockResponseDto>::error("A transition needs an authenticated caller")));
312 };
313 {
314 let allowed_roles = FileLockTransition::Release.allowed_roles();
315 let granted = |required: &str| auth.permissions.iter().any(|p| {
316 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
317 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
318 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
319 });
320 let has_specific_perm = granted("file_lock:transition:release");
321 let has_update_perm = granted("file_lock:update");
322 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
323 if !has_specific_perm && !has_role {
324 if !has_update_perm {
325 return (StatusCode::FORBIDDEN, Json(ApiResponse::<FileLockResponseDto>::error("Insufficient permissions for release transition")));
326 }
327 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 one of the roles owner, admin");
328 }
329 }
330
331 let current_state: FileLockState = match entity.status.to_string().parse() {
333 Ok(s) => s,
334 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<FileLockResponseDto>::error("The stored state is not a state of this machine"))),
335 };
336 let sm = FileLockStateMachine::from_state(current_state);
337 if !sm.can_transition(FileLockTransition::Release) {
338 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<FileLockResponseDto>::error("Transition not allowed from current state")));
339 }
340
341 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
344 fields.insert("status".to_string(), serde_json::Value::String("released".to_string()));
345
346 match backbone_core::write_guard::as_transition("release", service.partial_update(&id, fields)).await {
347 Ok(Some(updated)) => {
348 let response: FileLockResponseDto = updated.into();
349 (StatusCode::OK, Json(ApiResponse::ok(response)))
350 }
351 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
352 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
353 }
354}
355
356pub async fn refresh_transition(
360 axum::extract::State(service): axum::extract::State<Arc<FileLockService>>,
361 axum::extract::Path(id): axum::extract::Path<String>,
362 auth: Option<axum::Extension<AuthContext>>,
363) -> impl axum::response::IntoResponse {
364 use axum::{http::StatusCode, Json};
365
366 let entity = match service.get_by_id(&id).await {
368 Ok(Some(e)) => e,
369 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
370 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
371 };
372
373 let Some(axum::Extension(auth)) = auth else {
375 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<FileLockResponseDto>::error("A transition needs an authenticated caller")));
376 };
377 {
378 let allowed_roles = FileLockTransition::Refresh.allowed_roles();
379 let granted = |required: &str| auth.permissions.iter().any(|p| {
380 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
381 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
382 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
383 });
384 let has_specific_perm = granted("file_lock:transition:refresh");
385 let has_update_perm = granted("file_lock:update");
386 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
387 if !has_specific_perm && !has_role {
388 if !has_update_perm {
389 return (StatusCode::FORBIDDEN, Json(ApiResponse::<FileLockResponseDto>::error("Insufficient permissions for refresh transition")));
390 }
391 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 one of the roles owner");
392 }
393 }
394
395 let current_state: FileLockState = match entity.status.to_string().parse() {
397 Ok(s) => s,
398 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<FileLockResponseDto>::error("The stored state is not a state of this machine"))),
399 };
400 let sm = FileLockStateMachine::from_state(current_state);
401 if !sm.can_transition(FileLockTransition::Refresh) {
402 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<FileLockResponseDto>::error("Transition not allowed from current state")));
403 }
404
405 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
408 fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
409
410 match backbone_core::write_guard::as_transition("refresh", service.partial_update(&id, fields)).await {
411 Ok(Some(updated)) => {
412 let response: FileLockResponseDto = updated.into();
413 (StatusCode::OK, Json(ApiResponse::ok(response)))
414 }
415 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<FileLockResponseDto>::not_found("FileLock", &id))),
416 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<FileLockResponseDto>::error(e.to_string()))),
417 }
418}
419
420pub fn create_file_lock_transition_routes(service: Arc<FileLockService>) -> Router {
422 use axum::routing::post;
423
424 Router::new()
425 .route("/file_locks/:id/transitions/expire", post(expire_transition))
426 .route("/file_locks/:id/transitions/release", post(release_transition))
427 .route("/file_locks/:id/transitions/refresh", post(refresh_transition))
428 .with_state(service)
429}
430