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::{UploadSessionService, ServiceError};
26
27use crate::presentation::dto::{CreateUploadSessionDto, UpdateUploadSessionDto, PatchUploadSessionDto, UploadSessionResponseDto};
29
30use crate::domain::state_machine::{UploadSessionState, UploadSessionStateMachine, UploadSessionTransition};
31
32#[derive(Debug, thiserror::Error)]
34pub enum UploadSessionError {
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("Target bucket must exist: {0}")]
45 BucketNotFound(String),
46 #[error("Bucket must accept uploads: {0}")]
47 BucketNotActive(String),
48 #[error("File size must be positive: {0}")]
49 InvalidFileSize(String),
50 #[error("Chunk size must be at least 5MB: {0}")]
51 ChunkTooSmall(String),
52 #[error("Chunk size must not exceed 5GB: {0}")]
53 ChunkTooLarge(String),
54 #[error("Total chunks must match file size and chunk size: {0}")]
55 InvalidChunkCount(String),
56 #[error("Path must be valid and not contain traversal: {0}")]
57 InvalidPath(String),
58 #[error("Expiration must be in future: {0}")]
59 InvalidExpiration(String),
60 #[error("Expiration must be within 7 days: {0}")]
61 ExpirationTooFar(String),
62 #[error("Session has expired: {0}")]
63 SessionExpired(String),
64}
65
66impl From<ServiceError> for UploadSessionError {
67 fn from(err: ServiceError) -> Self {
68 match err {
69 ServiceError::NotFound => Self::NotFound(err.to_string()),
70 ServiceError::Validation(ref msg) => Self::Validation(msg.clone()),
71 ServiceError::AlreadyExists(ref msg) => Self::Validation(msg.clone()),
72 ServiceError::Repository(ref e) => Self::Database(e.to_string()),
73 ServiceError::Internal(ref msg) => Self::Internal(msg.clone()),
74 ServiceError::Violations(_) => Self::Validation(err.to_string()),
75 }
76 }
77}
78
79impl axum::response::IntoResponse for UploadSessionError {
80 fn into_response(self) -> axum::response::Response {
81 use axum::http::StatusCode;
82 use axum::Json;
83
84 let (status, code) = match &self {
85 Self::NotFound(_) => (StatusCode::NOT_FOUND, "UPLOADSESSION_NOT_FOUND"),
86 Self::Validation(_) => (StatusCode::BAD_REQUEST, "UPLOADSESSION_VALIDATION_ERROR"),
87 Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "UPLOADSESSION_DATABASE_ERROR"),
88 Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "UPLOADSESSION_INTERNAL_ERROR"),
89 Self::BucketNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_BUCKET_NOT_FOUND"),
90 Self::BucketNotActive(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_BUCKET_NOT_ACTIVE"),
91 Self::InvalidFileSize(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_INVALID_FILE_SIZE"),
92 Self::ChunkTooSmall(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_CHUNK_TOO_SMALL"),
93 Self::ChunkTooLarge(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_CHUNK_TOO_LARGE"),
94 Self::InvalidChunkCount(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_INVALID_CHUNK_COUNT"),
95 Self::InvalidPath(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_INVALID_PATH"),
96 Self::InvalidExpiration(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_INVALID_EXPIRATION"),
97 Self::ExpirationTooFar(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_EXPIRATION_TOO_FAR"),
98 Self::SessionExpired(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_SESSION_EXPIRED"),
99 };
100
101 let body = serde_json::json!({
102 "success": false,
103 "error": code,
104 "message": self.to_string(),
105 });
106
107 (status, Json(body)).into_response()
108 }
109}
110
111pub mod upload_session_errors {
113 pub const BUCKET_NOT_FOUND: &str = "UPLOADSESSION_BUCKET_NOT_FOUND";
114 pub const BUCKET_NOT_ACTIVE: &str = "UPLOADSESSION_BUCKET_NOT_ACTIVE";
115 pub const INVALID_FILE_SIZE: &str = "UPLOADSESSION_INVALID_FILE_SIZE";
116 pub const CHUNK_TOO_SMALL: &str = "UPLOADSESSION_CHUNK_TOO_SMALL";
117 pub const CHUNK_TOO_LARGE: &str = "UPLOADSESSION_CHUNK_TOO_LARGE";
118 pub const INVALID_CHUNK_COUNT: &str = "UPLOADSESSION_INVALID_CHUNK_COUNT";
119 pub const INVALID_PATH: &str = "UPLOADSESSION_INVALID_PATH";
120 pub const INVALID_EXPIRATION: &str = "UPLOADSESSION_INVALID_EXPIRATION";
121 pub const EXPIRATION_TOO_FAR: &str = "UPLOADSESSION_EXPIRATION_TOO_FAR";
122 pub const SESSION_EXPIRED: &str = "UPLOADSESSION_SESSION_EXPIRED";
123}
124
125pub fn create_upload_session_routes(service: Arc<UploadSessionService>) -> Router {
158 BackboneCrudHandler::<UploadSessionService, UploadSession, CreateUploadSessionDto, UpdateUploadSessionDto, UploadSessionResponseDto>::routes(
159 service,
160 "/upload_sessions",
161 )
162}
163
164pub fn create_upload_session_read_routes(service: Arc<UploadSessionService>) -> Router {
170 BackboneCrudHandler::<UploadSessionService, UploadSession, CreateUploadSessionDto, UpdateUploadSessionDto, UploadSessionResponseDto>::read_routes(
171 service,
172 "/upload_sessions",
173 )
174}
175
176pub fn create_upload_session_write_routes(service: Arc<UploadSessionService>) -> Router {
188 BackboneCrudHandler::<UploadSessionService, UploadSession, CreateUploadSessionDto, UpdateUploadSessionDto, UploadSessionResponseDto>::write_routes(
189 service,
190 "/upload_sessions",
191 )
192}
193
194#[cfg(feature = "auth")]
200pub fn create_protected_upload_session_routes<A: AuthMiddleware + Send + Sync + 'static>(
201 service: Arc<UploadSessionService>,
202 auth: Arc<A>,
203) -> Router {
204 use axum::middleware;
205 use axum::response::IntoResponse;
206
207 let auth_layer = auth.clone();
208 create_upload_session_routes(service)
209 .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
210 let auth = auth_layer.clone();
211 async move {
212 let token = req.headers()
213 .get(axum::http::header::AUTHORIZATION)
214 .and_then(|h| h.to_str().ok())
215 .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
216 .unwrap_or("");
217 match auth.authenticate(token).await {
218 Ok(ctx) => {
219 req.extensions_mut().insert(ctx);
220 next.run(req).await
221 }
222 Err(_) => {
223 (axum::http::StatusCode::UNAUTHORIZED,
224 axum::Json(serde_json::json!({
225 "success": false,
226 "error": "unauthorized",
227 "message": "Authentication required"
228 }))
229 ).into_response()
230 }
231 }
232 }
233 }))
234}
235
236pub async fn start_upload_transition(
244 axum::extract::State(service): axum::extract::State<Arc<UploadSessionService>>,
245 axum::extract::Path(id): axum::extract::Path<String>,
246 auth: Option<axum::Extension<AuthContext>>,
247) -> impl axum::response::IntoResponse {
248 use axum::{http::StatusCode, Json};
249
250 let entity = match service.get_by_id(&id).await {
252 Ok(Some(e)) => e,
253 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
254 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
255 };
256
257 let Some(axum::Extension(auth)) = auth else {
259 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UploadSessionResponseDto>::error("A transition needs an authenticated caller")));
260 };
261 {
262 let allowed_roles = UploadSessionTransition::StartUpload.allowed_roles();
263 let granted = |required: &str| auth.permissions.iter().any(|p| {
264 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
265 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
266 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
267 });
268 let has_specific_perm = granted("upload_session:transition:start_upload");
269 let has_update_perm = granted("upload_session:update");
270 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
271 if !has_specific_perm && !has_role {
272 if !has_update_perm {
273 return (StatusCode::FORBIDDEN, Json(ApiResponse::<UploadSessionResponseDto>::error("Insufficient permissions for start_upload transition")));
274 }
275 tracing::warn!(target: "backbone::write_guard", entity = "UploadSession", verb = "start_upload", code = "transition_by_update_permission", "a transition granted only by upload_session:update, running in shadow; it needs upload_session:transition:start_upload or one of the roles system, user");
276 }
277 }
278
279 let current_state: UploadSessionState = match entity.status.to_string().parse() {
281 Ok(s) => s,
282 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UploadSessionResponseDto>::error("The stored state is not a state of this machine"))),
283 };
284 let sm = UploadSessionStateMachine::from_state(current_state);
285 if !sm.can_transition(UploadSessionTransition::StartUpload) {
286 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UploadSessionResponseDto>::error("Transition not allowed from current state")));
287 }
288
289 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
292 fields.insert("status".to_string(), serde_json::Value::String("uploading".to_string()));
293
294 match backbone_core::write_guard::as_transition("start_upload", service.partial_update(&id, fields)).await {
295 Ok(Some(updated)) => {
296 let response: UploadSessionResponseDto = updated.into();
297 (StatusCode::OK, Json(ApiResponse::ok(response)))
298 }
299 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
300 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
301 }
302}
303
304pub async fn add_part_transition(
308 axum::extract::State(service): axum::extract::State<Arc<UploadSessionService>>,
309 axum::extract::Path(id): axum::extract::Path<String>,
310 auth: Option<axum::Extension<AuthContext>>,
311) -> impl axum::response::IntoResponse {
312 use axum::{http::StatusCode, Json};
313
314 let entity = match service.get_by_id(&id).await {
316 Ok(Some(e)) => e,
317 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
318 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
319 };
320
321 let Some(axum::Extension(auth)) = auth else {
323 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UploadSessionResponseDto>::error("A transition needs an authenticated caller")));
324 };
325 {
326 let allowed_roles = UploadSessionTransition::AddPart.allowed_roles();
327 let granted = |required: &str| auth.permissions.iter().any(|p| {
328 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
329 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
330 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
331 });
332 let has_specific_perm = granted("upload_session:transition:add_part");
333 let has_update_perm = granted("upload_session:update");
334 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
335 if !has_specific_perm && !has_role {
336 if !has_update_perm {
337 return (StatusCode::FORBIDDEN, Json(ApiResponse::<UploadSessionResponseDto>::error("Insufficient permissions for add_part transition")));
338 }
339 tracing::warn!(target: "backbone::write_guard", entity = "UploadSession", verb = "add_part", code = "transition_by_update_permission", "a transition granted only by upload_session:update, running in shadow; it needs upload_session:transition:add_part or one of the roles system, user");
340 }
341 }
342
343 let current_state: UploadSessionState = match entity.status.to_string().parse() {
345 Ok(s) => s,
346 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UploadSessionResponseDto>::error("The stored state is not a state of this machine"))),
347 };
348 let sm = UploadSessionStateMachine::from_state(current_state);
349 if !sm.can_transition(UploadSessionTransition::AddPart) {
350 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UploadSessionResponseDto>::error("Transition not allowed from current state")));
351 }
352
353 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
356 fields.insert("status".to_string(), serde_json::Value::String("uploading".to_string()));
357
358 match backbone_core::write_guard::as_transition("add_part", service.partial_update(&id, fields)).await {
359 Ok(Some(updated)) => {
360 let response: UploadSessionResponseDto = updated.into();
361 (StatusCode::OK, Json(ApiResponse::ok(response)))
362 }
363 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
364 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
365 }
366}
367
368pub async fn complete_transition(
372 axum::extract::State(service): axum::extract::State<Arc<UploadSessionService>>,
373 axum::extract::Path(id): axum::extract::Path<String>,
374 auth: Option<axum::Extension<AuthContext>>,
375) -> impl axum::response::IntoResponse {
376 use axum::{http::StatusCode, Json};
377
378 let entity = match service.get_by_id(&id).await {
380 Ok(Some(e)) => e,
381 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
382 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
383 };
384
385 let Some(axum::Extension(auth)) = auth else {
387 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UploadSessionResponseDto>::error("A transition needs an authenticated caller")));
388 };
389 {
390 let allowed_roles = UploadSessionTransition::Complete.allowed_roles();
391 let granted = |required: &str| auth.permissions.iter().any(|p| {
392 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
393 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
394 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
395 });
396 let has_specific_perm = granted("upload_session:transition:complete");
397 let has_update_perm = granted("upload_session:update");
398 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
399 if !has_specific_perm && !has_role {
400 if !has_update_perm {
401 return (StatusCode::FORBIDDEN, Json(ApiResponse::<UploadSessionResponseDto>::error("Insufficient permissions for complete transition")));
402 }
403 tracing::warn!(target: "backbone::write_guard", entity = "UploadSession", verb = "complete", code = "transition_by_update_permission", "a transition granted only by upload_session:update, running in shadow; it needs upload_session:transition:complete or one of the roles system, user");
404 }
405 }
406
407 let current_state: UploadSessionState = match entity.status.to_string().parse() {
409 Ok(s) => s,
410 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UploadSessionResponseDto>::error("The stored state is not a state of this machine"))),
411 };
412 let sm = UploadSessionStateMachine::from_state(current_state);
413 if !sm.can_transition(UploadSessionTransition::Complete) {
414 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UploadSessionResponseDto>::error("Transition not allowed from current state")));
415 }
416
417 {
418 use backbone_core::row_rule as r;
419 let row = &serde_json::to_value(&entity).unwrap_or_default();
420 let state = row.get("status").and_then(|v| v.as_str()).unwrap_or_default();
421 if ["uploading"].contains(&state) && !r::holds(&r::eq(r::field(row, "uploaded_chunks"), r::field(row, "total_chunks"))) {
423 return (StatusCode::UNPROCESSABLE_ENTITY, Json(ApiResponse::<UploadSessionResponseDto>::error("The complete transition's guard does not hold: uploaded_chunks == total_chunks")));
424 }
425 }
426
427 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
429 fields.insert("status".to_string(), serde_json::Value::String("completing".to_string()));
430
431 match backbone_core::write_guard::as_transition("complete", service.partial_update(&id, fields)).await {
432 Ok(Some(updated)) => {
433 let response: UploadSessionResponseDto = updated.into();
434 (StatusCode::OK, Json(ApiResponse::ok(response)))
435 }
436 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
437 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
438 }
439}
440
441pub async fn finish_transition(
445 axum::extract::State(service): axum::extract::State<Arc<UploadSessionService>>,
446 axum::extract::Path(id): axum::extract::Path<String>,
447 auth: Option<axum::Extension<AuthContext>>,
448) -> impl axum::response::IntoResponse {
449 use axum::{http::StatusCode, Json};
450
451 let entity = match service.get_by_id(&id).await {
453 Ok(Some(e)) => e,
454 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
455 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
456 };
457
458 let Some(axum::Extension(auth)) = auth else {
460 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UploadSessionResponseDto>::error("A transition needs an authenticated caller")));
461 };
462 {
463 let allowed_roles = UploadSessionTransition::Finish.allowed_roles();
464 let granted = |required: &str| auth.permissions.iter().any(|p| {
465 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
466 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
467 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
468 });
469 let has_specific_perm = granted("upload_session:transition:finish");
470 let has_update_perm = granted("upload_session:update");
471 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
472 if !has_specific_perm && !has_role {
473 if !has_update_perm {
474 return (StatusCode::FORBIDDEN, Json(ApiResponse::<UploadSessionResponseDto>::error("Insufficient permissions for finish transition")));
475 }
476 tracing::warn!(target: "backbone::write_guard", entity = "UploadSession", verb = "finish", code = "transition_by_update_permission", "a transition granted only by upload_session:update, running in shadow; it needs upload_session:transition:finish or one of the roles system");
477 }
478 }
479
480 let current_state: UploadSessionState = match entity.status.to_string().parse() {
482 Ok(s) => s,
483 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UploadSessionResponseDto>::error("The stored state is not a state of this machine"))),
484 };
485 let sm = UploadSessionStateMachine::from_state(current_state);
486 if !sm.can_transition(UploadSessionTransition::Finish) {
487 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UploadSessionResponseDto>::error("Transition not allowed from current state")));
488 }
489
490 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
492 fields.insert("status".to_string(), serde_json::Value::String("completed".to_string()));
493
494 match backbone_core::write_guard::as_transition("finish", service.partial_update(&id, fields)).await {
495 Ok(Some(updated)) => {
496 let response: UploadSessionResponseDto = updated.into();
497 (StatusCode::OK, Json(ApiResponse::ok(response)))
498 }
499 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
500 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
501 }
502}
503
504pub async fn fail_transition(
508 axum::extract::State(service): axum::extract::State<Arc<UploadSessionService>>,
509 axum::extract::Path(id): axum::extract::Path<String>,
510 auth: Option<axum::Extension<AuthContext>>,
511) -> impl axum::response::IntoResponse {
512 use axum::{http::StatusCode, Json};
513
514 let entity = match service.get_by_id(&id).await {
516 Ok(Some(e)) => e,
517 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
518 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
519 };
520
521 let Some(axum::Extension(auth)) = auth else {
523 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UploadSessionResponseDto>::error("A transition needs an authenticated caller")));
524 };
525 {
526 let allowed_roles = UploadSessionTransition::Fail.allowed_roles();
527 let granted = |required: &str| auth.permissions.iter().any(|p| {
528 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
529 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
530 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
531 });
532 let has_specific_perm = granted("upload_session:transition:fail");
533 let has_update_perm = granted("upload_session:update");
534 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
535 if !has_specific_perm && !has_role {
536 if !has_update_perm {
537 return (StatusCode::FORBIDDEN, Json(ApiResponse::<UploadSessionResponseDto>::error("Insufficient permissions for fail transition")));
538 }
539 tracing::warn!(target: "backbone::write_guard", entity = "UploadSession", verb = "fail", code = "transition_by_update_permission", "a transition granted only by upload_session:update, running in shadow; it needs upload_session:transition:fail or one of the roles system");
540 }
541 }
542
543 let current_state: UploadSessionState = match entity.status.to_string().parse() {
545 Ok(s) => s,
546 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UploadSessionResponseDto>::error("The stored state is not a state of this machine"))),
547 };
548 let sm = UploadSessionStateMachine::from_state(current_state);
549 if !sm.can_transition(UploadSessionTransition::Fail) {
550 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UploadSessionResponseDto>::error("Transition not allowed from current state")));
551 }
552
553 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
555 fields.insert("status".to_string(), serde_json::Value::String("failed".to_string()));
556
557 match backbone_core::write_guard::as_transition("fail", service.partial_update(&id, fields)).await {
558 Ok(Some(updated)) => {
559 let response: UploadSessionResponseDto = updated.into();
560 (StatusCode::OK, Json(ApiResponse::ok(response)))
561 }
562 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
563 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
564 }
565}
566
567pub async fn abort_transition(
571 axum::extract::State(service): axum::extract::State<Arc<UploadSessionService>>,
572 axum::extract::Path(id): axum::extract::Path<String>,
573 auth: Option<axum::Extension<AuthContext>>,
574) -> impl axum::response::IntoResponse {
575 use axum::{http::StatusCode, Json};
576
577 let entity = match service.get_by_id(&id).await {
579 Ok(Some(e)) => e,
580 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
581 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
582 };
583
584 let Some(axum::Extension(auth)) = auth else {
586 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UploadSessionResponseDto>::error("A transition needs an authenticated caller")));
587 };
588 {
589 let allowed_roles = UploadSessionTransition::Abort.allowed_roles();
590 let granted = |required: &str| auth.permissions.iter().any(|p| {
591 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
592 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
593 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
594 });
595 let has_specific_perm = granted("upload_session:transition:abort");
596 let has_update_perm = granted("upload_session:update");
597 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
598 if !has_specific_perm && !has_role {
599 if !has_update_perm {
600 return (StatusCode::FORBIDDEN, Json(ApiResponse::<UploadSessionResponseDto>::error("Insufficient permissions for abort transition")));
601 }
602 tracing::warn!(target: "backbone::write_guard", entity = "UploadSession", verb = "abort", code = "transition_by_update_permission", "a transition granted only by upload_session:update, running in shadow; it needs upload_session:transition:abort or one of the roles user, admin");
603 }
604 }
605
606 let current_state: UploadSessionState = match entity.status.to_string().parse() {
608 Ok(s) => s,
609 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UploadSessionResponseDto>::error("The stored state is not a state of this machine"))),
610 };
611 let sm = UploadSessionStateMachine::from_state(current_state);
612 if !sm.can_transition(UploadSessionTransition::Abort) {
613 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UploadSessionResponseDto>::error("Transition not allowed from current state")));
614 }
615
616 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
619 fields.insert("status".to_string(), serde_json::Value::String("failed".to_string()));
620
621 match backbone_core::write_guard::as_transition("abort", service.partial_update(&id, fields)).await {
622 Ok(Some(updated)) => {
623 let response: UploadSessionResponseDto = updated.into();
624 (StatusCode::OK, Json(ApiResponse::ok(response)))
625 }
626 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
627 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
628 }
629}
630
631pub async fn expire_transition(
635 axum::extract::State(service): axum::extract::State<Arc<UploadSessionService>>,
636 axum::extract::Path(id): axum::extract::Path<String>,
637 auth: Option<axum::Extension<AuthContext>>,
638) -> impl axum::response::IntoResponse {
639 use axum::{http::StatusCode, Json};
640
641 let entity = match service.get_by_id(&id).await {
643 Ok(Some(e)) => e,
644 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
645 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
646 };
647
648 let Some(axum::Extension(auth)) = auth else {
650 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UploadSessionResponseDto>::error("A transition needs an authenticated caller")));
651 };
652 {
653 let allowed_roles = UploadSessionTransition::Expire.allowed_roles();
654 let granted = |required: &str| auth.permissions.iter().any(|p| {
655 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
656 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
657 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
658 });
659 let has_specific_perm = granted("upload_session:transition:expire");
660 let has_update_perm = granted("upload_session:update");
661 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
662 if !has_specific_perm && !has_role {
663 if !has_update_perm {
664 return (StatusCode::FORBIDDEN, Json(ApiResponse::<UploadSessionResponseDto>::error("Insufficient permissions for expire transition")));
665 }
666 tracing::warn!(target: "backbone::write_guard", entity = "UploadSession", verb = "expire", code = "transition_by_update_permission", "a transition granted only by upload_session:update, running in shadow; it needs upload_session:transition:expire or one of the roles system");
667 }
668 }
669
670 let current_state: UploadSessionState = match entity.status.to_string().parse() {
672 Ok(s) => s,
673 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UploadSessionResponseDto>::error("The stored state is not a state of this machine"))),
674 };
675 let sm = UploadSessionStateMachine::from_state(current_state);
676 if !sm.can_transition(UploadSessionTransition::Expire) {
677 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UploadSessionResponseDto>::error("Transition not allowed from current state")));
678 }
679
680 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
683 fields.insert("status".to_string(), serde_json::Value::String("expired".to_string()));
684
685 match backbone_core::write_guard::as_transition("expire", service.partial_update(&id, fields)).await {
686 Ok(Some(updated)) => {
687 let response: UploadSessionResponseDto = updated.into();
688 (StatusCode::OK, Json(ApiResponse::ok(response)))
689 }
690 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
691 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
692 }
693}
694
695pub async fn retry_transition(
699 axum::extract::State(service): axum::extract::State<Arc<UploadSessionService>>,
700 axum::extract::Path(id): axum::extract::Path<String>,
701 auth: Option<axum::Extension<AuthContext>>,
702) -> impl axum::response::IntoResponse {
703 use axum::{http::StatusCode, Json};
704
705 let entity = match service.get_by_id(&id).await {
707 Ok(Some(e)) => e,
708 Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
709 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
710 };
711
712 let Some(axum::Extension(auth)) = auth else {
714 return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UploadSessionResponseDto>::error("A transition needs an authenticated caller")));
715 };
716 {
717 let allowed_roles = UploadSessionTransition::Retry.allowed_roles();
718 let granted = |required: &str| auth.permissions.iter().any(|p| {
719 let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
720 have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
721 && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
722 });
723 let has_specific_perm = granted("upload_session:transition:retry");
724 let has_update_perm = granted("upload_session:update");
725 let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
726 if !has_specific_perm && !has_role {
727 if !has_update_perm {
728 return (StatusCode::FORBIDDEN, Json(ApiResponse::<UploadSessionResponseDto>::error("Insufficient permissions for retry transition")));
729 }
730 tracing::warn!(target: "backbone::write_guard", entity = "UploadSession", verb = "retry", code = "transition_by_update_permission", "a transition granted only by upload_session:update, running in shadow; it needs upload_session:transition:retry or one of the roles user");
731 }
732 }
733
734 let current_state: UploadSessionState = match entity.status.to_string().parse() {
736 Ok(s) => s,
737 Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UploadSessionResponseDto>::error("The stored state is not a state of this machine"))),
738 };
739 let sm = UploadSessionStateMachine::from_state(current_state);
740 if !sm.can_transition(UploadSessionTransition::Retry) {
741 return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UploadSessionResponseDto>::error("Transition not allowed from current state")));
742 }
743
744 let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
747 fields.insert("status".to_string(), serde_json::Value::String("initiated".to_string()));
748
749 match backbone_core::write_guard::as_transition("retry", service.partial_update(&id, fields)).await {
750 Ok(Some(updated)) => {
751 let response: UploadSessionResponseDto = updated.into();
752 (StatusCode::OK, Json(ApiResponse::ok(response)))
753 }
754 Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
755 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
756 }
757}
758
759pub fn create_upload_session_transition_routes(service: Arc<UploadSessionService>) -> Router {
761 use axum::routing::post;
762
763 Router::new()
764 .route("/upload_sessions/:id/transitions/start_upload", post(start_upload_transition))
765 .route("/upload_sessions/:id/transitions/add_part", post(add_part_transition))
766 .route("/upload_sessions/:id/transitions/complete", post(complete_transition))
767 .route("/upload_sessions/:id/transitions/finish", post(finish_transition))
768 .route("/upload_sessions/:id/transitions/fail", post(fail_transition))
769 .route("/upload_sessions/:id/transitions/abort", post(abort_transition))
770 .route("/upload_sessions/:id/transitions/expire", post(expire_transition))
771 .route("/upload_sessions/:id/transitions/retry", post(retry_transition))
772 .with_state(service)
773}
774