Skip to main content

ocre/
storage.rs

1//! File storage in Cloudflare R2: multipart uploads, attachments, streamed downloads.
2//!
3//! Like Active Storage without its extra tables: a file lives in the R2 bucket
4//! bound as `STORAGE` in cloudflare.config.ts, and the record that owns it keeps four
5//! columns (`<name>_key`, `<name>_filename`, `<name>_content_type`,
6//! `<name>_size`), read back as an [`Attachment`].
7//!
8//! The flow of an upload: the [`Multipart`] extractor reads the request,
9//! [`MultipartForm::file`] takes an [`Upload`], [`Validator::file`](crate::Validator::file)
10//! checks it against [`Rules`], [`store`] writes it to R2 and returns the
11//! [`Attachment`] to save with [`columns`] / [`column_changes`]. Downloads go
12//! through [`serve`] (ETag/304, `Range`, safe `Content-Disposition`); [`read`],
13//! [`delete`] and [`delete_attachments`] cover the rest. [`store_bytes`] stores
14//! app-made files and [`store_body`] streams a raw request body.
15//!
16//! Beyond the Worker: [`head`], [`exists`] and [`list`] inspect the bucket;
17//! [`presign_get`] / [`serve_redirect`] let browsers download straight from
18//! R2's S3 API; [`direct_upload`] and [`attach_direct_upload`] let them
19//! upload straight to it (no 100 MB request limit, no Worker memory), and
20//! [`purge_unattached`] deletes direct uploads no row adopted. [`analyze`]
21//! reads a file's real type and image size, [`Variant`] builds Cloudflare
22//! Image Transformations URLs, [`public_url`] links to a public bucket.
23//!
24//! Keys are random (`<prefix>/<22 characters>`, 128 bits, never derived from
25//! file names) and never reused, so a stored object never changes: replacing
26//! a file means storing a new key and deleting the old one. `ocre g scaffold
27//! Post avatar:attachment` adds the binding, `ocre dev` keeps a local copy
28//! under `.wrangler/state`, `ocre deploy` creates the bucket.
29//!
30//! # Free plan
31//!
32//! R2 (free every month): 10 GB-month stored, 1M class A operations (each
33//! upload is one), 10M class B operations (each download or 304 is one),
34//! deletes free, no egress fees. R2 has to be enabled once in the dashboard,
35//! which asks for a payment method even for the free tier.
36//! Listing is a class A operation per call (up to 1,000 keys), `head` a
37//! class B one; presigning costs no operation (the browser's `PUT` or
38//! `GET` on the URL does).
39//!
40//! CPU: downloads never pass through WebAssembly ([`serve`] hands R2's stream
41//! to [`crate::serve`]). Uploads are read into memory and split: about 1.2 ms
42//! per 10 MB in WebAssembly, plus 0.15 ms per 10 MB to copy them to R2. A
43//! Worker has 128 MB and Cloudflare refuses request bodies over 100 MB on the
44//! Free plan, so keep limits in the tens of MB.
45//!
46//! # Examples
47//!
48//! ```rust,no_run
49//! use axum::{
50//!     extract::{Path, State},
51//!     http::HeaderMap,
52//!     response::Response,
53//! };
54//! use ocre::storage::{self, Attachment, Disposition, Multipart, Rules};
55//! use ocre::{Ctx, Error, IntoParam, OptionExt, Result, Validator, params};
56//!
57//! const AVATAR: Rules = Rules { max_bytes: 5 * 1024 * 1024, content_types: &["image/png", "image/jpeg"] };
58//! const FORM_LIMIT: usize = AVATAR.max_bytes as usize + 1024 * 1024;
59//!
60//! async fn upload(
61//!     State(ctx): State<Ctx>,
62//!     Path(id): Path<i64>,
63//!     Multipart(mut form): Multipart<FORM_LIMIT>,
64//! ) -> Result<String> {
65//!     let upload = form.file("avatar").ok_or_else(|| Error::bad_request("Choose a file"))?;
66//!     Validator::new().file("avatar", &upload, &AVATAR).finish()?;
67//!     let avatar = storage::store(&ctx, "users/avatar", upload).await?;
68//!     let mut values = Vec::from(storage::columns(Some(&avatar)));
69//!     values.push(id.into_param());
70//!     let sql = "UPDATE users SET avatar_key = ?1, avatar_filename = ?2, avatar_content_type = ?3, \
71//!                avatar_size = ?4 WHERE id = ?5";
72//!     if let Err(err) = ctx.db()?.execute(sql, values).await {
73//!         storage::delete(&ctx, &avatar.key).await?; // no row points to it
74//!         return Err(err);
75//!     }
76//!     Ok(avatar.key)
77//! }
78//!
79//! async fn download(State(ctx): State<Ctx>, Path(id): Path<i64>, headers: HeaderMap) -> Result<Response> {
80//!     let sql = "SELECT avatar_key AS key, avatar_filename AS filename, avatar_content_type AS content_type, \
81//!                avatar_size AS size FROM users WHERE id = ?1 AND avatar_key IS NOT NULL";
82//!     let avatar: Attachment = ctx.db()?.first(sql, params![id]).await?.or_404()?;
83//!     storage::serve(&ctx, &avatar, &headers, Disposition::Inline).await
84//! }
85//! ```
86
87mod analyze;
88mod multipart;
89mod presign;
90mod resumable;
91mod variant;
92
93use std::fmt::Write as _;
94
95use axum::{
96    body::{Body, Bytes},
97    http::{HeaderMap, HeaderValue, StatusCode, header},
98    response::Response,
99};
100use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
101use serde::{Deserialize, Serialize};
102
103pub use crate::runtime::resumable::multipart_uploads;
104pub use crate::runtime::storage::{
105    attach_direct_upload, delete, delete_attachments, direct_upload, exists, head, list, presign_get, presign_put,
106    public_url, purge_unattached, read, read_first, serve, serve_redirect, store, store_body, store_bytes,
107};
108use crate::{Error, IntoParam, Param, Result, Validator, token::random_bytes};
109pub use analyze::{Analysis, analyze};
110pub use multipart::{Multipart, MultipartForm};
111pub use presign::{
112    DirectUpload, DirectUploadRequest, MAX_EXPIRES_IN, R2_ACCESS_KEY_ID, R2_ACCOUNT_ID, R2_BUCKET,
113    R2_SECRET_ACCESS_KEY, S3Endpoint,
114};
115pub(crate) use presign::{attachment_from_head, presign_get_url, r2_endpoint, sign_key, upload_secret, verify_key};
116pub(crate) use resumable::check_part_numbers;
117pub use resumable::{
118    CompletedPart, FinishRequest, MAX_PART, MAX_PARTS, MAX_PARTS_PER_REQUEST, MAX_WORKER_PART, MultipartUpload,
119    PART_SIZE, PartUrls, PartsRequest, check_multipart, part_layout, presign_parts,
120};
121pub use variant::{Fit, Variant};
122
123/// Name of the R2 binding holding every file: `STORAGE: bindings.r2({ name: "<app>-storage" })` in cloudflare.config.ts.
124///
125/// The bucket itself is `<app>-storage`; the first generator that needs it
126/// adds the entry, and `ocre deploy` creates the bucket. Every function of
127/// this module fails with [`Error::Internal`] naming
128/// this entry when the binding is missing.
129///
130/// # Examples
131///
132/// ```
133/// assert_eq!(ocre::storage::STORAGE_BINDING, "STORAGE");
134/// ```
135pub const STORAGE_BINDING: &str = "STORAGE";
136
137/// `Cache-Control` of files sent by [`serve`]: browsers keep them but revalidate each time.
138///
139/// While the `ETag` matches, the browser gets a body-less `304 Not Modified`
140/// (still one R2 class B operation). `private` keeps shared caches from
141/// storing files that may belong to one user. Override the header on the
142/// returned response for files that may be cached longer.
143///
144/// # Examples
145///
146/// ```no_run
147/// use axum::{extract::State, http::{HeaderMap, HeaderValue, header}, response::Response};
148/// use ocre::{Ctx, Result, storage::{self, Attachment, Disposition}};
149///
150/// async fn logo(State(ctx): State<Ctx>, headers: HeaderMap) -> Result<Response> {
151///     let logo = Attachment {
152///         key: "public/logo".into(),
153///         filename: "logo.png".into(),
154///         content_type: "image/png".into(),
155///         size: 4096,
156///     };
157///     let mut response = storage::serve(&ctx, &logo, &headers, Disposition::Inline).await?;
158///     // Keys never change, so a public file can be cached for a year.
159///     let forever = HeaderValue::from_static("public, max-age=31536000, immutable");
160///     response.headers_mut().insert(header::CACHE_CONTROL, forever);
161///     Ok(response)
162/// }
163/// # assert_eq!(storage::CACHE_CONTROL, "private, no-cache");
164/// ```
165pub const CACHE_CONTROL: &str = "private, no-cache";
166
167/// A stored file, as saved with its record in four columns.
168///
169/// The columns of an attachment named `avatar` are `avatar_key`,
170/// `avatar_filename`, `avatar_content_type` and `avatar_size` (NULL-able for
171/// an optional attachment). [`store`] returns it, [`columns`] /
172/// [`column_changes`] bind it, and it deserializes from a row whose columns
173/// are aliased to `key`, `filename`, `content_type` and `size` (generated
174/// models expose it as `photo.image()`). It also serializes to JSON as is.
175///
176/// # Examples
177///
178/// ```
179/// use ocre::storage::Attachment;
180///
181/// let avatar = Attachment {
182///     key: "posts/avatar/2u1Vd0zJ8sQqS6rJq0rVmA".into(),
183///     filename: "me.png".into(),
184///     content_type: "image/png".into(),
185///     size: 2048,
186/// };
187/// assert_eq!(avatar.human_size(), "2 KB");
188///
189/// let row = serde_json::json!({ "key": "k", "filename": "a.pdf", "content_type": "application/pdf", "size": 10 });
190/// let from_row: Attachment = serde_json::from_value(row).unwrap();
191/// assert_eq!(from_row.filename, "a.pdf");
192/// ```
193#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
194pub struct Attachment {
195    /// Object key in the R2 bucket: `<prefix>/<22 random URL-safe characters>`.
196    pub key: String,
197    /// The uploader's file name, without directories or control characters.
198    pub filename: String,
199    /// Content type, lowercase and without parameters (`image/png`).
200    pub content_type: String,
201    /// Size in bytes.
202    pub size: i64,
203}
204
205impl Attachment {
206    /// A new attachment with a fresh random key under `prefix`, a cleaned-up
207    /// filename and a normalized content type.
208    pub(crate) fn prepare(prefix: &str, filename: &str, content_type: &str, size: u64) -> Self {
209        Self {
210            key: new_key(prefix),
211            filename: sanitize_filename(filename),
212            content_type: essence(content_type),
213            size: i64::try_from(size).unwrap_or(i64::MAX),
214        }
215    }
216
217    /// Formats the size for people with [`human_size`]: `512 bytes`, `2 KB`, `1.5 MB`.
218    ///
219    /// A negative size (only possible from a hand-edited row) reads `0 bytes`.
220    ///
221    /// # Examples
222    ///
223    /// ```
224    /// let file = ocre::storage::Attachment { size: 1536 * 1024, ..Default::default() };
225    /// assert_eq!(file.human_size(), "1.5 MB");
226    /// ```
227    pub fn human_size(&self) -> String {
228        human_size(u64::try_from(self.size).unwrap_or(0))
229    }
230}
231
232/// A file received in a `multipart/form-data` request, before it is stored.
233///
234/// Get it with [`MultipartForm::file`], check it with
235/// [`Validator::file`](crate::Validator::file), store it with [`store`]. The
236/// bytes are a slice of the request body held in memory (no copy until R2
237/// gets them).
238///
239/// # Examples
240///
241/// ```
242/// use ocre::storage::Upload;
243///
244/// let upload = Upload { filename: "notes.txt".into(), content_type: "text/plain".into(), bytes: "hi".into() };
245/// assert_eq!(upload.size(), 2);
246/// ```
247#[derive(Debug, Clone, Default, PartialEq)]
248pub struct Upload {
249    /// File name sent by the browser, cleaned up.
250    ///
251    /// Directories (`C:\Users\me\` from old Windows browsers) and control
252    /// characters are removed, the name is trimmed to 200 characters with its
253    /// extension kept, and `file` stands in when nothing is left.
254    pub filename: String,
255    /// Content type sent by the browser, lowercase without parameters.
256    ///
257    /// `application/octet-stream` when none was sent. The client chooses it:
258    /// check it against an allowlist with [`Validator::file`](crate::Validator::file).
259    pub content_type: String,
260    /// The file's bytes.
261    pub bytes: axum::body::Bytes,
262}
263
264impl Upload {
265    /// An upload made by the app (Active Storage's `attach(io:, filename:, content_type:)`), cleaned up like a browser's.
266    ///
267    /// For bytes that did not come from a form: a generated PDF, a fetched
268    /// image, a test fixture. The file name loses directories and control
269    /// characters, the content type is lowercased without parameters, as
270    /// [`MultipartForm::file`] does. Hand it to a model (`NewPhoto { image:
271    /// Some(upload), .. }`) or to [`store`]; [`store_bytes`] stores bytes directly.
272    ///
273    /// # Examples
274    ///
275    /// ```
276    /// use ocre::storage::Upload;
277    ///
278    /// let upload = Upload::new("reports/2026.pdf", "Application/PDF; x=y", b"%PDF-1.7".to_vec());
279    /// assert_eq!((upload.filename.as_str(), upload.content_type.as_str()), ("2026.pdf", "application/pdf"));
280    /// assert_eq!(upload.size(), 8);
281    /// ```
282    pub fn new(filename: &str, content_type: &str, bytes: impl Into<Bytes>) -> Self {
283        Self { filename: sanitize_filename(filename), content_type: essence(content_type), bytes: bytes.into() }
284    }
285
286    /// Returns the size of the file in bytes.
287    ///
288    /// # Examples
289    ///
290    /// ```
291    /// use ocre::storage::{Upload, human_size};
292    ///
293    /// let upload = Upload { bytes: vec![0; 1536].into(), ..Default::default() };
294    /// assert_eq!(upload.size(), 1536);
295    /// assert_eq!(human_size(upload.size()), "1.5 KB");
296    /// ```
297    pub fn size(&self) -> u64 {
298        self.bytes.len() as u64
299    }
300}
301
302/// An object in the bucket, as [`head`] and [`list`] describe it (without its bytes).
303///
304/// # Examples
305///
306/// ```
307/// use ocre::storage::StoredObject;
308///
309/// let object = StoredObject {
310///     key: "uploads/2u1Vd0zJ8sQqS6rJq0rVmA".into(),
311///     size: 2048,
312///     content_type: "image/png".into(),
313///     etag: "b6ab5f279cbcf9a1b96b3ab5b207cf94".into(),
314///     uploaded_at: 1_790_000_000,
315///     filename: None,
316/// };
317/// let attachment = object.attachment("C:\\me.png");
318/// assert_eq!((attachment.filename.as_str(), attachment.size), ("me.png", 2048));
319/// ```
320#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
321pub struct StoredObject {
322    /// Object key.
323    pub key: String,
324    /// Size in bytes.
325    pub size: u64,
326    /// Content type recorded with the object (`application/octet-stream` when none was).
327    pub content_type: String,
328    /// R2's entity tag, unquoted (the MD5 of the content for single-part uploads).
329    pub etag: String,
330    /// Upload time, in Unix seconds (compare with [`crate::now`]).
331    pub uploaded_at: i64,
332    /// File name recorded by [`store`] and friends; `None` for objects uploaded directly.
333    pub filename: Option<String>,
334}
335
336impl StoredObject {
337    /// The [`Attachment`] of this object under `filename` (cleaned up), with its recorded size and type.
338    ///
339    /// Check the object against [`Rules`] first ([`attach_direct_upload`] does both).
340    ///
341    /// # Examples
342    ///
343    /// ```
344    /// let object = ocre::storage::StoredObject { key: "k".into(), size: 3, content_type: "text/plain".into(), ..Default::default() };
345    /// assert_eq!(object.attachment("a.txt").content_type, "text/plain");
346    /// ```
347    pub fn attachment(&self, filename: &str) -> Attachment {
348        Attachment {
349            key: self.key.clone(),
350            filename: sanitize_filename(filename),
351            content_type: essence(&self.content_type),
352            size: i64::try_from(self.size).unwrap_or(i64::MAX),
353        }
354    }
355}
356
357/// One page of [`list`]: the objects, and the cursor of the next page.
358///
359/// # Examples
360///
361/// ```
362/// use ocre::storage::Listing;
363///
364/// let last_page = Listing { objects: vec![], cursor: None };
365/// assert!(last_page.cursor.is_none());
366/// ```
367#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
368pub struct Listing {
369    /// Objects in key order.
370    pub objects: Vec<StoredObject>,
371    /// Pass it to the next [`list`] call; `None` on the last page.
372    pub cursor: Option<String>,
373}
374
375/// What one [`purge_unattached`] call did: the keys it deleted, and where the next call resumes.
376///
377/// # Examples
378///
379/// ```
380/// let purged = ocre::storage::Purged { deleted: vec!["uploads/a".into()], cursor: None };
381/// assert_eq!(purged.deleted.len(), 1);
382/// ```
383#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
384pub struct Purged {
385    /// Keys deleted by this call.
386    pub deleted: Vec<String>,
387    /// Cursor of the next page of the listing; `None` once the prefix was listed to the end.
388    pub cursor: Option<String>,
389}
390
391/// Name of the Worker variable holding the base URL of a public bucket, for [`public_url`].
392///
393/// An `r2.dev` URL or a custom domain connected to the bucket (dashboard:
394/// R2 > bucket > Settings > Public access), as
395/// `STORAGE_PUBLIC_URL: bindings.text("https://files.example.com"),` in `worker.env`.
396///
397/// # Examples
398///
399/// ```
400/// assert_eq!(ocre::storage::STORAGE_PUBLIC_URL, "STORAGE_PUBLIC_URL");
401/// ```
402pub const STORAGE_PUBLIC_URL: &str = "STORAGE_PUBLIC_URL";
403
404/// `<base>/<key>`, with each key segment percent-encoded.
405pub(crate) fn join_public_url(base: Option<String>, key: &str) -> Result<String> {
406    let base = base.filter(|base| !base.trim().is_empty()).ok_or_else(|| {
407        Error::internal(format!(
408            "public file URLs need the {STORAGE_PUBLIC_URL} variable. Fix: allow public access to the bucket (dashboard: \
409             R2 > bucket > Settings > Public access: an r2.dev URL or a custom domain), then add \
410             `{STORAGE_PUBLIC_URL}: bindings.text(\"https://files.example.com\"),` to worker.env in cloudflare.config.ts"
411        ))
412    })?;
413    let mut url = base.trim().trim_end_matches('/').to_owned();
414    for segment in key.split('/') {
415        url.push('/');
416        for byte in segment.bytes() {
417            if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
418                url.push(byte as char);
419            } else {
420                write!(url, "%{byte:02X}").expect("writing to a String");
421            }
422        }
423    }
424    Ok(url)
425}
426
427/// `302 Found` to a presigned URL; browsers and shared caches may reuse it for half its lifetime.
428pub(crate) fn redirect_response(url: &str, expires_in: u64) -> Response {
429    let mut response = Response::new(Body::empty());
430    *response.status_mut() = StatusCode::FOUND;
431    response.headers_mut().insert(header::LOCATION, header_value(url));
432    let cache = format!("private, max-age={}", expires_in / 2);
433    response.headers_mut().insert(header::CACHE_CONTROL, header_value(&cache));
434    response
435}
436
437/// Largest page [`list`] asks R2 for.
438pub(crate) const MAX_LIST: u32 = 1000;
439
440/// Largest number of keys looked up per D1 query (D1 binds at most 100 parameters).
441pub(crate) const KEYS_PER_QUERY: usize = 100;
442
443/// Keys of the objects uploaded before `cutoff` (Unix seconds).
444pub(crate) fn stale_keys(objects: &[StoredObject], cutoff: i64) -> Vec<String> {
445    objects.iter().filter(|object| object.uploaded_at < cutoff).map(|object| object.key.clone()).collect()
446}
447
448/// `SELECT <column> AS key FROM <table> WHERE <column> IN (?1, ...)` for `count` keys.
449///
450/// `table` and `column` come from app code, never from requests; anything
451/// but ASCII letters, digits and `_` is refused.
452pub(crate) fn referenced_keys_sql(table: &str, column: &str, count: usize) -> Result<String> {
453    let identifier = |name: &str| {
454        !name.is_empty()
455            && !name.starts_with(|c: char| c.is_ascii_digit())
456            && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
457    };
458    if !identifier(table) || !identifier(column) {
459        return Err(Error::internal(format!(
460            "purge_unattached takes a table and a column name (letters, digits, `_`), not `{table}` / `{column}`"
461        )));
462    }
463    let placeholders: Vec<String> = (1..=count).map(|n| format!("?{n}")).collect();
464    Ok(format!("SELECT {column} AS key FROM {table} WHERE {column} IN ({})", placeholders.join(", ")))
465}
466
467/// Describes what a file field accepts: a size limit and a content-type allowlist.
468///
469/// Checked by [`Validator::file`](crate::Validator::file) before anything is
470/// stored. A `const`, so the [`Multipart`] request limit can be computed from
471/// it (generated forms use the sum of their files' limits plus 1 MB). The
472/// content type comes from the browser: the allowlist limits it, and
473/// [`Validator::file_content`](crate::Validator::file_content) checks the bytes match it.
474///
475/// # Examples
476///
477/// ```
478/// use ocre::storage::Rules;
479///
480/// const AVATAR: Rules = Rules { max_bytes: 5 * 1024 * 1024, content_types: &["image/png", "image/jpeg"] };
481/// const FORM_LIMIT: usize = AVATAR.max_bytes as usize + 1024 * 1024;
482/// assert_eq!(FORM_LIMIT, 6 * 1024 * 1024);
483/// assert!(AVATAR.allows("image/PNG"));
484/// assert!(!AVATAR.allows("image/svg+xml"));
485/// ```
486#[derive(Debug, Clone, Copy, PartialEq, Eq)]
487pub struct Rules {
488    /// Largest accepted file, in bytes (`u64`: multipart uploads take files
489    /// over 4 GB, more than a `usize` holds in WebAssembly). Request limits
490    /// are `usize`: `RULES.max_bytes as usize`.
491    pub max_bytes: u64,
492    /// Accepted content types, exact and lowercase (`image/png`).
493    ///
494    /// There is no wildcard: `image/*` would admit SVG, which can carry scripts.
495    pub content_types: &'static [&'static str],
496}
497
498impl Rules {
499    /// Returns whether `content_type` is in the allowlist, ignoring case and parameters.
500    ///
501    /// `Image/PNG; charset=binary` is compared as `image/png`; an empty type
502    /// counts as `application/octet-stream`. There is no wildcard matching.
503    ///
504    /// # Examples
505    ///
506    /// ```
507    /// use ocre::storage::Rules;
508    ///
509    /// const DOC: Rules = Rules { max_bytes: 1024, content_types: &["application/pdf", "text/plain"] };
510    /// assert!(DOC.allows("text/plain; charset=utf-8"));
511    /// assert!(DOC.allows("Application/PDF"));
512    /// assert!(!DOC.allows("text/html"));
513    /// assert!(!DOC.allows(""));
514    /// ```
515    pub fn allows(&self, content_type: &str) -> bool {
516        let essence = essence(content_type);
517        self.content_types.contains(&essence.as_str())
518    }
519}
520
521impl Validator {
522    /// Checks an uploaded file against `rules`: its size and its content type.
523    ///
524    /// Adds "is too large (maximum is 5 MB)" when the file is over
525    /// [`Rules::max_bytes`] and "has an unsupported type (allowed: image/png,
526    /// image/jpeg)" when [`Rules::allows`] refuses its type; both can be
527    /// reported at once. Run it before [`store`], so a refused file costs no
528    /// R2 operation. Returns `self` for chaining; [`finish`](crate::Validator::finish)
529    /// turns the collected messages into a 422.
530    ///
531    /// # Examples
532    ///
533    /// ```
534    /// use ocre::{Validator, storage::{Rules, Upload}};
535    ///
536    /// const DOC: Rules = Rules { max_bytes: 4, content_types: &["text/plain"] };
537    /// let upload = Upload { filename: "a.html".into(), content_type: "text/html".into(), bytes: "<p>hello</p>".into() };
538    /// let err = Validator::new().file("doc", &upload, &DOC).finish().unwrap_err();
539    /// assert_eq!(
540    ///     err.to_string(),
541    ///     "invalid: Doc is too large (maximum is 4 bytes), Doc has an unsupported type (allowed: text/plain)"
542    /// );
543    ///
544    /// let note = Upload { filename: "a.txt".into(), content_type: "text/plain".into(), bytes: "ok".into() };
545    /// assert!(Validator::new().file("doc", &note, &DOC).finish().is_ok());
546    /// ```
547    pub fn file(&mut self, field: &str, upload: &Upload, rules: &Rules) -> &mut Self {
548        self.file_size_and_type(field, upload.size(), &upload.content_type, rules)
549    }
550
551    /// [`Validator::file`]'s checks on a size and a content type (declared for a direct upload, or read by `head`).
552    pub(crate) fn file_size_and_type(
553        &mut self,
554        field: &str,
555        size: u64,
556        content_type: &str,
557        rules: &Rules,
558    ) -> &mut Self {
559        let too_large = size > rules.max_bytes;
560        self.check(field, too_large, format!("is too large (maximum is {})", human_size(rules.max_bytes)));
561        let allowed = rules.content_types.join(", ");
562        self.check(field, !rules.allows(content_type), format!("has an unsupported type (allowed: {allowed})"))
563    }
564}
565
566/// Tells [`serve`] whether the browser shows a file or downloads it.
567///
568/// Either way `Content-Disposition` carries the original file name (ASCII in
569/// `filename=`, UTF-8 in `filename*=` when needed, RFC 6266).
570///
571/// # Examples
572///
573/// ```
574/// use ocre::storage::Disposition;
575///
576/// // A download link: `?download=1` forces the "Save as" dialog.
577/// let download = true;
578/// let disposition = if download { Disposition::Download } else { Disposition::Inline };
579/// assert_eq!(disposition, Disposition::Download);
580/// ```
581#[derive(Debug, Clone, Copy, PartialEq, Eq)]
582pub enum Disposition {
583    /// Shows the file in the page or tab when its type is safe to display, and downloads anything else.
584    ///
585    /// Safe types: raster images (PNG, JPEG, GIF, WebP, AVIF, BMP, TIFF,
586    /// icons), PDF, plain text, audio and video (MPEG, Ogg, WAV, WebM, MP4).
587    /// HTML, SVG, XML and JavaScript are sent as `application/octet-stream`
588    /// downloads, so an uploaded file never runs as part of the app (Rails'
589    /// `content_types_allowed_inline` / `content_types_to_serve_as_binary`).
590    Inline,
591    /// Always downloads the file, with its original name.
592    Download,
593}
594
595/// Builds the query parameters for the four columns of one attachment, in column order.
596///
597/// The order is `<name>_key, <name>_filename, <name>_content_type,
598/// <name>_size`; `None` binds four NULLs (an optional attachment left empty).
599/// Generated `create` functions extend their `params!` with it. Reading the
600/// columns back needs no helper: `Attachment` deserializes from a row with
601/// `key`, `filename`, `content_type` and `size` columns.
602///
603/// # Examples
604///
605/// ```
606/// use ocre::{params, storage::{self, Attachment}};
607///
608/// let avatar = Attachment { key: "k".into(), filename: "a.png".into(), content_type: "image/png".into(), size: 3 };
609/// let mut values = params!["Ada"];
610/// values.extend(storage::columns(Some(&avatar)));
611/// assert_eq!(values, params!["Ada", "k", "a.png", "image/png", 3_i64]);
612///
613/// let empty = storage::columns(None);
614/// assert_eq!(empty[..], params![None::<i64>, None::<i64>, None::<i64>, None::<i64>]);
615/// ```
616pub fn columns(attachment: Option<&Attachment>) -> [Param; 4] {
617    match attachment {
618        Some(file) => [
619            file.key.as_str().into_param(),
620            file.filename.as_str().into_param(),
621            file.content_type.as_str().into_param(),
622            file.size.into_param(),
623        ],
624        None => {
625            [None::<i64>.into_param(), None::<i64>.into_param(), None::<i64>.into_param(), None::<i64>.into_param()]
626        }
627    }
628}
629
630/// Builds the parameters of an `UPDATE` of one attachment's columns: a change flag, then the four [`columns`].
631///
632/// `None` keeps the stored file (flag `false`, four NULLs that the SQL
633/// ignores), `Some(None)` clears the columns (flag `true`, four NULLs),
634/// `Some(Some(file))` points them to `file`. The generated SQL reads
635/// `avatar_key = CASE WHEN ?1 THEN ?2 ELSE avatar_key END, ...`, so one
636/// statement handles "unchanged", "removed" and "replaced" without building
637/// SQL at runtime. Deleting the replaced object is up to the caller
638/// ([`delete_attachments`], after the write succeeds).
639///
640/// # Examples
641///
642/// ```
643/// use ocre::{params, storage::{self, Attachment}};
644///
645/// let photo = Attachment { key: "k".into(), filename: "a.png".into(), content_type: "image/png".into(), size: 3 };
646/// assert_eq!(storage::column_changes(Some(Some(&photo))), params![true, "k", "a.png", "image/png", 3_i64][..]);
647/// let null = None::<i64>;
648/// assert_eq!(storage::column_changes(Some(None))[..], params![true, null, null, null, null]);
649/// assert_eq!(storage::column_changes(None)[0], params![false][0]);
650/// ```
651pub fn column_changes(change: Option<Option<&Attachment>>) -> [Param; 5] {
652    let [key, filename, content_type, size] = columns(change.flatten());
653    [change.is_some().into_param(), key, filename, content_type, size]
654}
655
656/// Formats a byte count for people, in powers of 1024 like Rails' `number_to_human_size`.
657///
658/// Below 1024 the count is exact (`1 byte`, `512 bytes`); above, it is
659/// rounded to one decimal, dropped when it is zero (`2 KB`, `1.5 MB`,
660/// `10 GB`). Units stop at TB. Used in the "is too large (maximum is 5 MB)"
661/// validation message and the 413 of [`Multipart`].
662///
663/// # Examples
664///
665/// ```
666/// use ocre::storage::human_size;
667///
668/// assert_eq!(human_size(1), "1 byte");
669/// assert_eq!(human_size(512), "512 bytes");
670/// assert_eq!(human_size(2048), "2 KB");
671/// assert_eq!(human_size(1536 * 1024), "1.5 MB");
672/// assert_eq!(human_size(10 * 1024 * 1024), "10 MB");
673/// assert_eq!(human_size(3 * 1024_u64.pow(5)), "3072 TB");
674/// ```
675pub fn human_size(bytes: u64) -> String {
676    const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"];
677    if bytes < 1024 {
678        return format!("{bytes} {}", if bytes == 1 { "byte" } else { "bytes" });
679    }
680    let mut value = bytes as f64 / 1024.0;
681    let mut unit = 0;
682    while value >= 1024.0 && unit < UNITS.len() - 1 {
683        value /= 1024.0;
684        unit += 1;
685    }
686    let rounded = (value * 10.0).round() / 10.0;
687    if rounded.fract() == 0.0 {
688        format!("{rounded:.0} {}", UNITS[unit])
689    } else {
690        format!("{rounded:.1} {}", UNITS[unit])
691    }
692}
693
694/// Sends bytes built by the handler as a file (Rails' `send_data`): a CSV export, a generated image, an `.ics` file.
695///
696/// Sets `Content-Type`, `Content-Length` and `Content-Disposition` with
697/// `filename` (sanitized; non-ASCII names kept in `filename*`). As with
698/// [`serve`], [`Disposition::Inline`] shows only types that are safe to
699/// display (images, PDF, plain text, audio, video) and downloads the rest,
700/// and types a browser could run (HTML, SVG, XML, JavaScript) are sent as
701/// `application/octet-stream`. Files already in R2 go through [`serve`]
702/// instead: it streams them without copying through WebAssembly, and
703/// answers `Range` and `If-None-Match` (Rails' `send_file`). The whole body
704/// is in memory, and the Worker's 128 MB memory limit applies; building it
705/// counts toward the 10 ms CPU budget.
706///
707/// # Examples
708///
709/// ```
710/// use ocre::storage::{Disposition, send_data};
711///
712/// let csv = "id,title\n1,Hello\n";
713/// let response = send_data(csv, "posts.csv", "text/csv", Disposition::Download);
714/// assert_eq!(response.headers()["content-type"], "text/csv");
715/// assert_eq!(response.headers()["content-disposition"], "attachment; filename=\"posts.csv\"");
716/// ```
717pub fn send_data(data: impl Into<Bytes>, filename: &str, content_type: &str, disposition: Disposition) -> Response {
718    data_response(data.into(), filename, content_type, disposition)
719}
720
721/// The browser side of direct uploads (Active Storage's `activestorage.js`):
722/// a script that sends the files of `<input type="file"
723/// data-direct-upload-url="...">` to R2 before its form is submitted.
724///
725/// Each file is signed by a POST to the input's URL (a handler calling
726/// [`direct_upload`]), then `PUT` to R2 with progress events
727/// (`direct-upload:start|progress|error|end`); the form then submits
728/// `<name>_key` and `<name>_filename` for [`attach_direct_upload`] instead
729/// of the file. It also uploads the files dropped into a Trix editor with
730/// `data-embeds-url` (Action Text attachments). Serve it with [`direct_upload_script`].
731pub const DIRECT_UPLOAD_JS: &str = include_str!("storage/direct_upload.js");
732
733/// Serves [`DIRECT_UPLOAD_JS`] at `GET /ocre/direct-upload.js`: merge it into
734/// the routes, then load it in the layout with
735/// `<script src="/ocre/direct-upload.js" defer></script>`.
736///
737/// # Examples
738///
739/// ```
740/// use axum::Router;
741/// use ocre::Ctx;
742///
743/// fn routes() -> Router<Ctx> {
744///     Router::new().merge(ocre::storage::direct_upload_script())
745/// }
746/// # let _ = routes;
747/// ```
748pub fn direct_upload_script<S: Clone + Send + Sync + 'static>() -> axum::Router<S> {
749    let script = || async {
750        let headers =
751            [(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, "public, max-age=3600")];
752        (headers, DIRECT_UPLOAD_JS)
753    };
754    axum::Router::new().route("/ocre/direct-upload.js", axum::routing::get(script))
755}
756
757fn data_response(data: Bytes, filename: &str, content_type: &str, disposition: Disposition) -> Response {
758    let filename = sanitize_filename(filename);
759    let content_type = essence(content_type);
760    let binary = BINARY_TYPES.contains(&content_type.as_str());
761    let sent_type = if binary { "application/octet-stream" } else { &content_type };
762    let disposition = content_disposition(disposition, &filename, &content_type);
763    let length = HeaderValue::from(data.len());
764    let mut response = Response::new(Body::from(data));
765    let headers = response.headers_mut();
766    headers.insert(header::CONTENT_TYPE, header_value(sent_type));
767    headers.insert(header::CONTENT_DISPOSITION, header_value(&disposition));
768    headers.insert(header::CONTENT_LENGTH, length);
769    response
770}
771
772/// `<prefix>/<22 random URL-safe characters>` (128 random bits).
773pub(crate) fn new_key(prefix: &str) -> String {
774    let id = URL_SAFE_NO_PAD.encode(random_bytes::<16>());
775    match prefix.trim_matches('/') {
776        "" => id,
777        prefix => format!("{prefix}/{id}"),
778    }
779}
780
781/// Longest kept file name, in characters.
782const MAX_FILENAME: usize = 200;
783
784/// The last path segment (browsers on Windows used to send `C:\...`),
785/// without control characters, trimmed, at most 200 characters with the
786/// extension kept; `file` when nothing is left.
787pub(crate) fn sanitize_filename(raw: &str) -> String {
788    let base = raw.rsplit(['/', '\\']).next().unwrap_or_default();
789    let clean: String = base.chars().filter(|c| !c.is_control()).collect();
790    let clean = clean.trim();
791    if clean.is_empty() || clean == "." || clean == ".." {
792        return "file".to_owned();
793    }
794    if clean.chars().count() <= MAX_FILENAME {
795        return clean.to_owned();
796    }
797    let extension = clean.rsplit_once('.').map(|(_, ext)| ext).filter(|ext| ext.chars().count() <= 16);
798    match extension {
799        Some(ext) => {
800            let stem: String = clean.chars().take(MAX_FILENAME - ext.chars().count() - 1).collect();
801            format!("{stem}.{ext}")
802        }
803        None => clean.chars().take(MAX_FILENAME).collect(),
804    }
805}
806
807/// `Image/PNG; charset=x` -> `image/png`; empty -> `application/octet-stream`.
808pub(crate) fn essence(content_type: &str) -> String {
809    let essence = content_type.split(';').next().unwrap_or_default().trim().to_ascii_lowercase();
810    if essence.is_empty() { "application/octet-stream".to_owned() } else { essence }
811}
812
813/// Types a browser displays without running scripts in the app's origin
814/// (Rails' `content_types_allowed_inline`, plus common media).
815const INLINE_TYPES: &[&str] = &[
816    "image/png",
817    "image/jpeg",
818    "image/gif",
819    "image/webp",
820    "image/avif",
821    "image/bmp",
822    "image/tiff",
823    "image/vnd.microsoft.icon",
824    "image/x-icon",
825    "application/pdf",
826    "text/plain",
827    "audio/mpeg",
828    "audio/ogg",
829    "audio/wav",
830    "audio/webm",
831    "video/mp4",
832    "video/ogg",
833    "video/webm",
834];
835
836/// Types a browser could run as a page or script: served as
837/// `application/octet-stream`, like Rails' `content_types_to_serve_as_binary`.
838const BINARY_TYPES: &[&str] = &[
839    "text/html",
840    "text/javascript",
841    "text/xml",
842    "application/xml",
843    "application/xhtml+xml",
844    "application/javascript",
845    "application/mathml+xml",
846    "image/svg+xml",
847    "text/cache-manifest",
848];
849
850/// `inline` or `attachment`, with the file name as ASCII (`filename=`) and,
851/// when that loses characters, UTF-8 (`filename*=`, RFC 6266).
852pub(crate) fn content_disposition(disposition: Disposition, filename: &str, content_type: &str) -> String {
853    let kind = match disposition {
854        Disposition::Inline if INLINE_TYPES.contains(&content_type) => "inline",
855        _ => "attachment",
856    };
857    let ascii: String = filename
858        .chars()
859        .map(|c| if c.is_ascii() && !c.is_ascii_control() && c != '"' && c != '\\' { c } else { '_' })
860        .collect();
861    let mut value = format!("{kind}; filename=\"{ascii}\"");
862    if ascii != filename {
863        value.push_str("; filename*=UTF-8''");
864        for byte in filename.bytes() {
865            if byte.is_ascii_alphanumeric() || b"!#$&+-.^_`|~".contains(&byte) {
866                value.push(byte as char);
867            } else {
868                write!(value, "%{byte:02X}").expect("writing to a String");
869            }
870        }
871    }
872    value
873}
874
875/// Which bytes a `Range` header asks for, checked against the file size.
876#[derive(Debug, Clone, Copy, PartialEq, Eq)]
877pub(crate) enum ByteRange {
878    /// No (usable) range: the whole file, status 200.
879    Full,
880    /// `length` bytes from `offset`, status 206.
881    Partial { offset: u64, length: u64 },
882    /// Starts past the end: status 416.
883    Unsatisfiable,
884}
885
886/// One `bytes=a-b`, `bytes=a-` or `bytes=-n` range (RFC 9110). Several
887/// ranges, other units and malformed values are ignored: the whole file is
888/// sent, which the RFC allows.
889pub(crate) fn byte_range(header: Option<&str>, size: u64) -> ByteRange {
890    let Some(spec) = header.and_then(|value| value.trim().strip_prefix("bytes=")) else {
891        return ByteRange::Full;
892    };
893    let Some((start, end)) = spec.split_once('-').filter(|_| !spec.contains(',')) else {
894        return ByteRange::Full;
895    };
896    let (start, end) = (start.trim(), end.trim());
897    let parse = |text: &str| text.parse::<u64>().ok();
898    if start.is_empty() {
899        return match parse(end) {
900            Some(0) => ByteRange::Unsatisfiable,
901            Some(_) if size == 0 => ByteRange::Unsatisfiable,
902            Some(suffix) => ByteRange::Partial { offset: size - suffix.min(size), length: suffix.min(size) },
903            None => ByteRange::Full,
904        };
905    }
906    let Some(offset) = parse(start) else { return ByteRange::Full };
907    let last = if end.is_empty() {
908        size.saturating_sub(1)
909    } else {
910        match parse(end) {
911            Some(last) if last >= offset => last.min(size.saturating_sub(1)),
912            _ => return ByteRange::Full,
913        }
914    };
915    if offset >= size { ByteRange::Unsatisfiable } else { ByteRange::Partial { offset, length: last - offset + 1 } }
916}
917
918/// The entity tag of `If-None-Match`, unquoted, for R2's `etagDoesNotMatch`.
919/// Only the first tag of a list is used; `*` is ignored.
920pub(crate) fn if_none_match(header: Option<&str>) -> Option<String> {
921    let first = header?.split(',').next()?.trim();
922    let tag = first.strip_prefix("W/").unwrap_or(first).trim_matches('"');
923    (!tag.is_empty() && tag != "*").then(|| tag.to_owned())
924}
925
926/// What [`serve`] asks R2 for, read from the request headers.
927#[derive(Debug, Clone, PartialEq, Eq)]
928pub(crate) struct Fetch {
929    pub range: ByteRange,
930    pub if_none_match: Option<String>,
931}
932
933impl Fetch {
934    /// `If-Range` is not compared: when present, the whole file is sent.
935    pub(crate) fn from_headers(headers: &HeaderMap, size: i64) -> Self {
936        let text = |name| headers.get(name).and_then(|value: &HeaderValue| value.to_str().ok());
937        let size = u64::try_from(size).unwrap_or(0);
938        let range = if headers.contains_key(header::IF_RANGE) {
939            ByteRange::Full
940        } else {
941            byte_range(text(header::RANGE), size)
942        };
943        Self { range, if_none_match: if_none_match(text(header::IF_NONE_MATCH)) }
944    }
945}
946
947fn header_value(text: &str) -> HeaderValue {
948    HeaderValue::from_str(text).unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"))
949}
950
951/// A file response: 200 with the whole body, or 206 with `range`.
952pub(crate) fn file_response(
953    attachment: &Attachment,
954    disposition: Disposition,
955    etag: &str,
956    range: ByteRange,
957    body: Body,
958) -> Response {
959    let size = u64::try_from(attachment.size).unwrap_or(0);
960    let content_type = if BINARY_TYPES.contains(&attachment.content_type.as_str()) {
961        "application/octet-stream"
962    } else {
963        &attachment.content_type
964    };
965    let mut response = Response::new(body);
966    let headers = response.headers_mut();
967    headers.insert(header::CONTENT_TYPE, header_value(content_type));
968    headers.insert(
969        header::CONTENT_DISPOSITION,
970        header_value(&content_disposition(disposition, &attachment.filename, &attachment.content_type)),
971    );
972    headers.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes"));
973    headers.insert(header::CACHE_CONTROL, HeaderValue::from_static(CACHE_CONTROL));
974    headers.insert(header::ETAG, header_value(etag));
975    let length = match range {
976        ByteRange::Partial { offset, length } => {
977            *response.status_mut() = StatusCode::PARTIAL_CONTENT;
978            let last = offset + length - 1;
979            response
980                .headers_mut()
981                .insert(header::CONTENT_RANGE, header_value(&format!("bytes {offset}-{last}/{size}")));
982            length
983        }
984        _ => size,
985    };
986    response.headers_mut().insert(header::CONTENT_LENGTH, HeaderValue::from(length));
987    response
988}
989
990/// `304 Not Modified`: the browser's copy is current.
991pub(crate) fn not_modified(etag: &str) -> Response {
992    let mut response = Response::new(Body::empty());
993    *response.status_mut() = StatusCode::NOT_MODIFIED;
994    response.headers_mut().insert(header::ETAG, header_value(etag));
995    response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(CACHE_CONTROL));
996    response
997}
998
999/// `416 Range Not Satisfiable`, with the file size in `Content-Range`.
1000pub(crate) fn unsatisfiable(size: i64) -> Response {
1001    let mut response = Response::new(Body::empty());
1002    *response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
1003    response.headers_mut().insert(header::CONTENT_RANGE, header_value(&format!("bytes */{size}")));
1004    response
1005}
1006
1007/// Error for a missing `STORAGE` binding, naming the cloudflare.config.ts entry.
1008pub(crate) fn missing_binding(detail: &str) -> crate::Error {
1009    crate::Error::internal(format!(
1010        "R2 binding `{STORAGE_BINDING}` is missing ({detail}). Fix: add `{STORAGE_BINDING}: bindings.r2({{ name: \"<app>-storage\" }}),` to worker.env in cloudflare.config.ts\n(`ocre g scaffold <Model> <name>:attachment` adds it; `ocre deploy` creates the bucket)"
1011    ))
1012}
1013
1014#[cfg(test)]
1015#[path = "../tests/storage.rs"]
1016mod tests;