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", ¬e, &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;