Skip to main content

doido_storage/
serving.rs

1//! Axum handlers that serve blobs and accept direct uploads — the analogue of
2//! ActiveStorage's `blobs`/`disk`/`direct_uploads` controllers.
3//!
4//! [`routes`] returns a `Router` (with [`Storage`] as state) mounting, under the
5//! configured prefix (default `/doido/storage`):
6//!
7//! * `GET  {prefix}/blobs/redirect/{signed_id}/{filename}` — 302 to the service's
8//!   native URL (S3/Azure) or to the proxy route (disk/memory).
9//! * `GET  {prefix}/blobs/proxy/{signed_id}/{filename}` — stream the object.
10//! * `PUT  {prefix}/disk/{token}` — receive a disk/memory direct upload.
11//! * `POST {prefix}/direct_uploads` — create a blob and return an upload URL.
12
13use crate::blob::{self, Blob};
14use crate::client::{Storage, PURPOSE_DISK_UPLOAD};
15use crate::content_type;
16use crate::signing::Disposition;
17use axum::body::Bytes;
18use axum::extract::{Path, State};
19use axum::http::{header, StatusCode};
20use axum::response::{IntoResponse, Redirect, Response};
21use axum::routing::{get, post, put};
22use axum::{Json, Router};
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25
26/// Build the storage router mounted at the facade's prefix.
27pub fn routes(storage: Storage) -> Router {
28    let p = storage.prefix().to_string();
29    Router::new()
30        .route(
31            &format!("{p}/blobs/redirect/{{signed_id}}/{{filename}}"),
32            get(redirect_handler),
33        )
34        .route(
35            &format!("{p}/blobs/proxy/{{signed_id}}/{{filename}}"),
36            get(proxy_handler),
37        )
38        .route(&format!("{p}/disk/{{token}}"), put(disk_upload_handler))
39        .route(&format!("{p}/direct_uploads"), post(direct_uploads_handler))
40        .with_state(storage)
41}
42
43async fn resolve_blob(storage: &Storage, signed_id: &str) -> Option<Blob> {
44    let key = storage.verify_signed_id(signed_id).ok()?;
45    storage.find_blob(&key).await.ok().flatten()
46}
47
48async fn redirect_handler(
49    State(storage): State<Storage>,
50    Path((signed_id, _filename)): Path<(String, String)>,
51) -> Response {
52    let Some(blob) = resolve_blob(&storage, &signed_id).await else {
53        return StatusCode::NOT_FOUND.into_response();
54    };
55    match storage.url_for(&blob, Disposition::Inline).await {
56        Ok(url) => Redirect::temporary(&url).into_response(),
57        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
58    }
59}
60
61async fn proxy_handler(
62    State(storage): State<Storage>,
63    Path((signed_id, _filename)): Path<(String, String)>,
64) -> Response {
65    let Some(blob) = resolve_blob(&storage, &signed_id).await else {
66        return StatusCode::NOT_FOUND.into_response();
67    };
68    let bytes = match storage.download(&blob.key).await {
69        Ok(b) => b,
70        Err(_) => return StatusCode::NOT_FOUND.into_response(),
71    };
72    let ct = blob
73        .content_type
74        .clone()
75        .unwrap_or_else(|| content_type::DEFAULT.to_string());
76    (
77        [
78            (header::CONTENT_TYPE, ct),
79            (
80                header::CONTENT_DISPOSITION,
81                Disposition::Inline.header(&blob.filename),
82            ),
83        ],
84        bytes,
85    )
86        .into_response()
87}
88
89async fn disk_upload_handler(
90    State(storage): State<Storage>,
91    Path(token): Path<String>,
92    body: Bytes,
93) -> Response {
94    let key = match storage.signer().verify(&token, PURPOSE_DISK_UPLOAD) {
95        Ok(k) => k,
96        Err(_) => return StatusCode::FORBIDDEN.into_response(),
97    };
98    match storage.service().upload(&key, body.to_vec(), None).await {
99        Ok(()) => StatusCode::NO_CONTENT.into_response(),
100        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
101    }
102}
103
104/// Client-provided details for a direct upload.
105#[derive(Debug, Deserialize)]
106pub struct DirectUploadRequest {
107    /// Original filename.
108    pub filename: String,
109    /// Declared byte size (recorded on the blob; verified on upload later).
110    #[serde(default)]
111    pub byte_size: Option<i64>,
112    /// Declared base64 MD5 checksum.
113    #[serde(default)]
114    pub checksum: Option<String>,
115    /// Declared content type (else inferred from the filename).
116    #[serde(default)]
117    pub content_type: Option<String>,
118}
119
120/// The upload target returned to the client.
121#[derive(Debug, Serialize)]
122pub struct DirectUpload {
123    /// URL to PUT the file bytes to.
124    pub url: String,
125    /// Headers the client must send with the PUT.
126    pub headers: HashMap<String, String>,
127}
128
129/// The direct-upload response: the blob's signed id plus its upload target.
130#[derive(Debug, Serialize)]
131pub struct DirectUploadResponse {
132    /// The blob storage key.
133    pub key: String,
134    /// The blob's signed id (attach with this after uploading).
135    pub signed_id: String,
136    /// Where and how to upload the bytes.
137    pub direct_upload: DirectUpload,
138}
139
140async fn direct_uploads_handler(
141    State(storage): State<Storage>,
142    Json(req): Json<DirectUploadRequest>,
143) -> Response {
144    let content_type = req
145        .content_type
146        .or_else(|| content_type::from_filename(&req.filename).map(str::to_string));
147
148    let blob = Blob {
149        key: blob::new_key(),
150        filename: req.filename.clone(),
151        content_type,
152        metadata: None,
153        service_name: storage.service().name().to_string(),
154        byte_size: req.byte_size.unwrap_or(0),
155        checksum: req.checksum,
156        created_at: chrono::Utc::now().to_rfc3339(),
157    };
158
159    if blob::insert(storage.conn(), &blob).await.is_err() {
160        return StatusCode::INTERNAL_SERVER_ERROR.into_response();
161    }
162
163    let opts = crate::service::UrlOptions {
164        expires_in: storage.expires_in(),
165        disposition: Disposition::Inline,
166        filename: Some(blob.filename.clone()),
167        content_type: blob.content_type.clone(),
168    };
169
170    // Prefer the backend's native presigned PUT; fall back to the disk route.
171    let url = match storage.service().presigned_put(&blob.key, &opts).await {
172        Ok(Some(u)) => u,
173        _ => {
174            let token =
175                storage
176                    .signer()
177                    .sign(&blob.key, PURPOSE_DISK_UPLOAD, Some(storage.expires_in()));
178            format!("{}/disk/{}", storage.prefix(), token)
179        }
180    };
181
182    let mut headers = HashMap::new();
183    if let Some(ct) = &blob.content_type {
184        headers.insert("Content-Type".to_string(), ct.clone());
185    }
186
187    Json(DirectUploadResponse {
188        signed_id: storage.signed_id(&blob.key),
189        key: blob.key,
190        direct_upload: DirectUpload { url, headers },
191    })
192    .into_response()
193}