Skip to main content

fraiseql_server/routes/storage/
mod.rs

1//! Storage REST API routes.
2//!
3//! Provides object storage endpoints mounted at `/storage/v1/`:
4//!
5//! | Method | Path | Operation |
6//! |--------|------|-----------|
7//! | `POST`   | `/storage/v1/object/*key` | Upload object |
8//! | `GET`    | `/storage/v1/object/*key` | Download object |
9//! | `DELETE` | `/storage/v1/object/*key` | Delete object |
10//! | `GET`    | `/storage/v1/object/sign/*key` | Generate presigned URL |
11//!
12//! Routes are only mounted when a storage backend has been attached via
13//! [`Server::with_storage`](crate::server::Server::with_storage).
14
15use std::{sync::Arc, time::Duration};
16
17use axum::{
18    Router,
19    body::Bytes,
20    extract::{Path, Query, State},
21    http::{HeaderMap, StatusCode, header},
22    response::{IntoResponse, Response},
23    routing::{get, post},
24};
25use fraiseql_error::FileError;
26use serde::{Deserialize, Serialize};
27use tracing::warn;
28
29use crate::storage::{StorageBackend, validate_key};
30
31/// Default maximum upload size: 100 `MiB`.
32pub const DEFAULT_MAX_UPLOAD_BYTES: usize = 100 * 1024 * 1024;
33
34/// Default ceiling on a presigned-URL's validity: 7 days.
35pub const DEFAULT_MAX_PRESIGN_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60;
36
37/// Shared state for all storage route handlers.
38#[derive(Clone)]
39pub struct StorageRouteState {
40    /// The configured storage backend (local, S3, GCS, Azure, …).
41    pub backend:                 Arc<dyn StorageBackend>,
42    /// Maximum allowed upload body size in bytes.
43    ///
44    /// Requests that exceed this limit are rejected with HTTP 413 before the
45    /// body is forwarded to the backend, preventing memory exhaustion on the
46    /// server when large files are sent.
47    pub max_upload_bytes:        usize,
48    /// Optional key prefix prepended to every storage key.
49    ///
50    /// Used for per-tenant isolation: set this to the tenant's ID so that
51    /// tenant A's keys (`"tenantA/file.txt"`) are disjoint from tenant B's
52    /// (`"tenantB/file.txt"`).  When `None`, keys are used as-is.
53    pub tenant_prefix:           Option<String>,
54    /// Ceiling on a presigned URL's requested validity, in seconds.
55    ///
56    /// A presigned URL grants credential-free access to an object for its
57    /// lifetime, so an unbounded expiry is a standing exposure.  Requests for a
58    /// longer validity are clamped down to this ceiling (L-presigned-expiry).
59    pub max_presign_expiry_secs: u64,
60}
61
62impl StorageRouteState {
63    /// Create state with the given backend and the default 100 `MiB` upload limit.
64    #[must_use]
65    pub fn new(backend: Arc<dyn StorageBackend>) -> Self {
66        Self {
67            backend,
68            max_upload_bytes: DEFAULT_MAX_UPLOAD_BYTES,
69            tenant_prefix: None,
70            max_presign_expiry_secs: DEFAULT_MAX_PRESIGN_EXPIRY_SECS,
71        }
72    }
73
74    /// Override the maximum upload size.
75    #[must_use]
76    pub const fn with_max_upload_bytes(mut self, bytes: usize) -> Self {
77        self.max_upload_bytes = bytes;
78        self
79    }
80
81    /// Override the ceiling on presigned-URL validity (in seconds).
82    #[must_use]
83    pub const fn with_max_presign_expiry_secs(mut self, secs: u64) -> Self {
84        self.max_presign_expiry_secs = secs;
85        self
86    }
87
88    /// Set a tenant key prefix for per-tenant object isolation.
89    ///
90    /// Every storage key is prefixed with `{prefix}/` before being forwarded
91    /// to the backend, ensuring that tenants cannot access each other's objects
92    /// even if they share the same bucket.
93    #[must_use]
94    pub fn with_tenant_prefix(mut self, prefix: impl Into<String>) -> Self {
95        self.tenant_prefix = Some(prefix.into());
96        self
97    }
98}
99
100// ── Response types ────────────────────────────────────────────────────────────
101
102/// Body returned by a successful upload.
103#[derive(Serialize)]
104struct UploadResponse {
105    /// The key under which the object was stored.
106    key: String,
107}
108
109/// Body returned by a successful presigned-URL request.
110#[derive(Serialize)]
111struct PresignedUrlResponse {
112    /// Time-limited URL that grants direct access to the object.
113    url:        String,
114    /// How long the URL remains valid, in seconds.
115    expires_in: u64,
116}
117
118/// Body returned for all error responses.
119#[derive(Serialize)]
120struct ErrorBody {
121    /// Human-readable error message.
122    error: String,
123    /// Stable machine-readable error code.
124    code:  &'static str,
125}
126
127// ── Error mapping ─────────────────────────────────────────────────────────────
128
129/// Convert a [`FileError`] into an HTTP error response.
130fn file_error_response(err: &FileError) -> Response {
131    let status = match err {
132        FileError::NotFound { .. } => StatusCode::NOT_FOUND,
133        FileError::TooLarge { .. } | FileError::QuotaExceeded => StatusCode::PAYLOAD_TOO_LARGE,
134        FileError::InvalidType { .. } | FileError::MimeMismatch { .. } => {
135            StatusCode::UNSUPPORTED_MEDIA_TYPE
136        },
137        _ => StatusCode::INTERNAL_SERVER_ERROR,
138    };
139    let body = serde_json::to_string(&ErrorBody {
140        error: err.to_string(),
141        code:  err.error_code(),
142    })
143    .unwrap_or_default();
144    (status, [(header::CONTENT_TYPE, "application/json")], body).into_response()
145}
146
147// ── Key helpers ───────────────────────────────────────────────────────────────
148
149/// Combine an optional tenant prefix with a raw key.
150///
151/// When `prefix` is `Some("tenantA")` and `key` is `"file.txt"`, the result
152/// is `"tenantA/file.txt"`.  When `prefix` is `None`, the key is returned
153/// unchanged.
154fn prefixed_key(prefix: Option<&str>, key: &str) -> String {
155    match prefix {
156        Some(p) => format!("{p}/{key}"),
157        None => key.to_owned(),
158    }
159}
160
161// ── Handlers ──────────────────────────────────────────────────────────────────
162
163/// `POST /storage/v1/object/*key` — upload an object.
164///
165/// Reads the entire request body and stores it at `key` in the configured
166/// backend. Rejects bodies larger than [`StorageRouteState::max_upload_bytes`]
167/// with HTTP 413.
168///
169/// The `Content-Type` header is forwarded to the backend and stored as the
170/// object's MIME type (falls back to `application/octet-stream` when absent).
171pub async fn upload_handler(
172    State(state): State<StorageRouteState>,
173    Path(key): Path<String>,
174    headers: HeaderMap,
175    body: Bytes,
176) -> Response {
177    if let Err(e) = validate_key(&key) {
178        return file_error_response(&e);
179    }
180
181    if body.len() > state.max_upload_bytes {
182        return file_error_response(&FileError::TooLarge {
183            size: body.len(),
184            max:  state.max_upload_bytes,
185        });
186    }
187
188    let content_type = headers
189        .get(header::CONTENT_TYPE)
190        .and_then(|v| v.to_str().ok())
191        .unwrap_or("application/octet-stream")
192        .to_string();
193
194    let full_key = prefixed_key(state.tenant_prefix.as_deref(), &key);
195
196    match state.backend.upload(&full_key, &body, &content_type).await {
197        Ok(stored_key) => {
198            (StatusCode::OK, axum::Json(UploadResponse { key: stored_key })).into_response()
199        },
200        Err(e) => file_error_response(&e),
201    }
202}
203
204/// `GET /storage/v1/object/*key` — download an object.
205///
206/// Returns the object bytes with `Content-Type: application/octet-stream`.
207pub async fn download_handler(
208    State(state): State<StorageRouteState>,
209    Path(key): Path<String>,
210) -> Response {
211    if let Err(e) = validate_key(&key) {
212        return file_error_response(&e);
213    }
214
215    let full_key = prefixed_key(state.tenant_prefix.as_deref(), &key);
216
217    match state.backend.download(&full_key).await {
218        Ok(data) => (StatusCode::OK, [(header::CONTENT_TYPE, "application/octet-stream")], data)
219            .into_response(),
220        Err(e) => file_error_response(&e),
221    }
222}
223
224/// `DELETE /storage/v1/object/*key` — delete an object.
225///
226/// Returns HTTP 204 on success.
227pub async fn delete_handler(
228    State(state): State<StorageRouteState>,
229    Path(key): Path<String>,
230) -> Response {
231    if let Err(e) = validate_key(&key) {
232        return file_error_response(&e);
233    }
234
235    let full_key = prefixed_key(state.tenant_prefix.as_deref(), &key);
236
237    match state.backend.delete(&full_key).await {
238        Ok(()) => StatusCode::NO_CONTENT.into_response(),
239        Err(e) => file_error_response(&e),
240    }
241}
242
243/// Query parameters for the presigned-URL endpoint.
244#[derive(Deserialize)]
245pub struct SignQuery {
246    /// URL expiry in seconds (default: 3 600 s = 1 hour).
247    #[serde(default = "default_expiry_secs")]
248    expiry_secs: u64,
249}
250
251const fn default_expiry_secs() -> u64 {
252    3600
253}
254
255/// Clamp a client-requested presigned-URL validity to the configured ceiling.
256///
257/// Returns `requested_secs` when it is within `ceiling_secs`, otherwise the
258/// ceiling — so a presigned URL never outlives the configured maximum
259/// (L-presigned-expiry).
260const fn clamp_presign_expiry(requested_secs: u64, ceiling_secs: u64) -> u64 {
261    if requested_secs < ceiling_secs {
262        requested_secs
263    } else {
264        ceiling_secs
265    }
266}
267
268/// `GET /storage/v1/object/sign/*key` — generate a presigned URL.
269///
270/// Returns a time-limited URL granting direct access to the object without
271/// requiring credentials.  Not all backends support presigned URLs; those that
272/// do not return HTTP 500 with `code: "file_storage_error"`.
273pub async fn presigned_url_handler(
274    State(state): State<StorageRouteState>,
275    Path(key): Path<String>,
276    Query(params): Query<SignQuery>,
277) -> Response {
278    if let Err(e) = validate_key(&key) {
279        return file_error_response(&e);
280    }
281
282    // L-presigned-expiry: a presigned URL grants credential-free access for its
283    // whole lifetime, so clamp a client-requested expiry to the configured ceiling.
284    let expiry_secs = clamp_presign_expiry(params.expiry_secs, state.max_presign_expiry_secs);
285    let expiry = Duration::from_secs(expiry_secs);
286    let full_key = prefixed_key(state.tenant_prefix.as_deref(), &key);
287
288    match state.backend.presigned_url(&full_key, expiry).await {
289        Ok(url) => (
290            StatusCode::OK,
291            axum::Json(PresignedUrlResponse {
292                url,
293                expires_in: expiry_secs,
294            }),
295        )
296            .into_response(),
297        Err(e) => {
298            warn!(key = %key, error = %e, "Presigned URL generation failed");
299            file_error_response(&e)
300        },
301    }
302}
303
304// ── Router ────────────────────────────────────────────────────────────────────
305
306/// Build the storage sub-router and attach `state` to all routes.
307///
308/// Register this router with [`Router::merge`] after the main application
309/// router is built.  The routes use the `/storage/v1/` prefix.
310///
311/// **Route registration order matters for axum wildcard matching**: the sign
312/// route (`/sign/{*key}`) is registered before the generic object route
313/// (`/{*key}`) so that axum's static-segment-wins rule resolves correctly.
314pub fn storage_router(state: StorageRouteState) -> Router {
315    Router::new()
316        // Sign route must come before the generic wildcard route.
317        .route("/storage/v1/object/sign/{*key}", get(presigned_url_handler))
318        .route(
319            "/storage/v1/object/{*key}",
320            post(upload_handler).get(download_handler).delete(delete_handler),
321        )
322        .with_state(state)
323}
324
325// ── Tests ─────────────────────────────────────────────────────────────────────
326
327#[cfg(test)]
328mod tests;