navree
Documentation

Media

Attach files to your models in collections, convert images into thumbnails and responsive variants, download remote files safely, unpack zip archives, extract text from PDF, DOCX and HTML, and process video with FFmpeg.

On this page

Introduction

Featuremedia Sooner or later a model needs files: a course has a thumbnail and attachments, a news post a gallery, a brand a logo, a knowledge base the documents it answers from. navree-media attaches files to your models and does the work around them:

Rust
  • Collections. A model declares named collections (thumbnail, attachments) that accept certain types, cap the size, and keep one file or the latest few. The type is sniffed from the content, never taken from the client.
  • Rows and files. Each file is a row of the media table (MediaRecord, a navree-orm model) and bytes on a storage disk, written in the request's unit of work and deleted with it.
  • Image conversions. Thumbnails, crops, WebP copies, greyscale and blurred versions, and responsive variants with a ready srcset, generated on a queue worker and regenerated with media:regenerate. Originals lose their EXIF, XMP and text metadata, and SVG logos are rendered with resvg.
  • Remote files. MediaSource::url downloads through the HTTP client and its SSRF guard, capped in size and time.
  • Archives. Archive opens zip files with zip-slip, symlink, encryption and size-bomb guards and unpacks them to a disk, a SCORM package to the directory it is served from.
  • Text. text::extract reads PDF, DOCX, HTML and plain text, page by page, for search and retrieval.
  • Video and audio (feature media-ffmpeg): an FFmpeg sidecar reads durations and sizes, grabs poster frames and transcodes to MP4, WebM, MP3 or M4A.
  • Tests. MediaFake runs the real pipeline on memory disks and rows, with fake downloads and a fake FFmpeg, and records what was added and deleted.

Installation

The media feature of the navree facade is opt-in: it is not part of full, because the image codecs, the SVG renderer and the document readers add to your build. Turn it on by name, and add media-ffmpeg for video and audio:

Cargo.toml

It brings navree::media (the navree-media crate) and puts MediaModule, Media, HasMedia and ModuleBuilderMediaExt in the prelude. Every codec is pure Rust: nothing to install for images, archives and text. media-ffmpeg runs the ffmpeg and ffprobe programs, which must be on the server.

Add MediaModule to your app root, after the modules it builds on:

src/app.rs

The module is global and provides Arc<Media> and MediaOptions. It needs StorageModule (the disks) and DatabaseModule (the media table, from the media migration set), unless media.store is memory. With QueueModule, conversions run on a worker; without it, while the file is added. With HttpClientModule, remote files are downloaded through the app's client and its SSRF policy; without it, through a client with the default policy. The app fails to boot when the options are invalid, media.disk names a disk storage doesn't have, or the database store has no database.

Configuration

Every key is optional:

config/app.toml

From the environment: APP_MEDIA__DISK=public, APP_MEDIA__QUEUE=media, APP_MEDIA__IMAGES__RESPONSIVE_WIDTHS=480,960,1440 (lists are comma-separated), APP_MEDIA__FFMPEG__FFMPEG=/usr/bin/ffmpeg. MediaModule::for_root(MediaOptions { .. }) takes the same options in code. An invalid value fails the boot with a message that names the key.

Models and collections

A model gets media by implementing HasMedia: a stable type name for the model_type column, the record's key, and the collections it accepts.

src/modules/courses/model.rs
Collection methodWhat it does
single_file()Keeps one file: adding a new one deletes the previous one
only_keep_latest(n)Keeps the n newest files
accepts([..])Exact types (application/pdf) or families (image/*), sniffed from the content; everything by default
max_size(bytes)The largest file, never more than media.max_file_size
disk(name), conversions_disk(name)The disks this collection's files go to
conversion(Conversion)A conversion generated for every file of the collection (conversions)

A model that declares no collections accepts any collection name with the module's limits. Collection names are 1 to 100 letters, digits, - and _; conversion names are lower-case.

Register the model in the feature module that owns it:

src/modules/courses/mod.rs

Registration checks the collection and conversion names at boot, lets media:regenerate and queue workers find the model's conversions, and, with MEDIA_TABLE set, deletes a course's media when the course is deleted (deleting). Adding a file also registers its model, but only in the process that adds it: register every model that has conversions, so a worker or a command that never added a file knows them. In a hand-written module, m.media_model::<Course>() does the same.

MEDIA_TYPE is stored with every row: keep it stable, or rows of the old name lose their model.

Adding files

Inject Arc<Media> and start with add(model, collection, source). The source is where the file comes from:

MediaSourceThe file
bytes(file_name, bytes)Bytes in memory: a multipart upload's field
url(url)A remote http or https file (remote files)
disk(disk, path)A file already on a storage disk, such as a direct upload; copied, not moved
file(path)A file on the local filesystem: imports and seeders

A multipart upload goes through the request's unit of work:

src/modules/courses/controller.rs

save does everything in order, and either all of it happens or nothing is kept:

  1. Reads the source, refusing an empty file and anything over the collection's size limit.
  2. Sniffs the type from the content and checks it against accepts. A renamed executable is not an image; a file infer doesn't recognise is text/* when it is UTF-8 text, application/octet-stream otherwise.
  3. For JPEG, PNG, GIF, WebP, BMP and SVG: reads the width and height, and removes the original's metadata (what is removed). A file that claims one of these types and doesn't decode is refused (invalid_image).
  4. Writes the original to {prefix}/{id}/{file_name} on the collection's disk. The id is a UUID v7; the file name is the uploaded one with directories, control characters and /\:*?"<>| removed, at most 200 bytes.
  5. Generates the conversions that run now (queued or not), writes the row, deletes the files a single_file or only_keep_latest collection no longer keeps (after the unit of work commits), and queues the other conversions.

PendingMedia takes name(..) (the display name, by default the file name without its extension), file_name(..) (the stored name, sanitized the same way) and any number of property(key, value) for your own data (alt, caption, credit), kept in the row's custom_properties.

With save(&uow), the row and the queued job are part of the unit of work: a rollback leaves no row and no job. The files are written at once; when the unit of work rolls back afterwards, they stay on the disk without a row. save(None) writes the row on its own, joining the current unit of work when the code runs inside one.

Remote files

MediaSource::url re-hosts a file from another server, a logo a customer pasted or an image from a feed:

Rust

The download goes through the app's HttpClient, so its SSRF guard refuses private, loopback, link-local and cloud metadata addresses, on every redirect too (remote_file_blocked). Only http and https URLs are downloaded. The size limit is checked against Content-Length and again while the body streams, so a large file stops at the limit (payload_too_large), and media.download_timeout_secs caps the whole download. A server error or a timeout is remote_file_unavailable. The file name comes from Content-Disposition, else from the URL's last segment. Error messages show the URL without its query string, so a token in a signed URL never reaches a response or a log.

Reading media

Rust

A MediaItem is one row: id, model_type, model_id, collection, name, file_name, mime_type, disk, conversions_disk, path, size, width and height (images and video), conversions (the generated ones, by name), metadata (what was read from the file, such as a video's duration), custom_properties, order, created_at and updated_at. has_conversion(name), property(key), is_image(), is_video() and is_audio() answer the usual questions.

media.list(&MediaQuery { .. }) selects rows by model type, model key, collection and ids, and pages in id order with after and limit. For anything else, query the table with navree-orm; it is yours to read:

Rust

Change media through Media, which keeps the files and the rows in step.

Presenting media

media.resource(&item) is the item as an API returns it, with a URL for the original, one for each generated conversion, and a srcset for each responsive one:

200 OK

On a disk with a public_url, the URLs are public. On a disk without one (a private bucket, a local disk served by nobody), they are temporary URLs that last media.url_ttl_secs: signed by the provider on cloud disks, served by the storage route on local and memory disks. __type lets front-end permission checks read the subject type.

The parts are there on their own too:

Media methodReturns
url(&item)The original's public URL (NoPublicUrl on a disk without one)
conversion_url(&item, name)A conversion's public URL
srcset(&item, name)url 320w, url 640w, ... for a responsive conversion, with public URLs
temporary_url(&item, expires_at)An expiring link to the original
temporary_conversion_url(&item, name, expires_at)An expiring link to a conversion
read(&item), read_conversion(&item, name)The bytes

Changing and deleting media

Rust

save writes the item's name, custom_properties and order; the files, paths and conversions are the module's and stay as they are.

Deleting media

Rust

The rows go in the unit of work; the files (the original, its conversions and responsive variants, everything under {prefix}/{id}/ on both disks) are deleted after it commits, so a rollback keeps them. Without a unit of work they go at once.

When a model registered with MediaModule::for_feature names its MEDIA_TABLE, deleting one of its rows takes its media along: a change observer deletes the rows in the same transaction and the files after the commit. It sees deletes that record change events (a navree-orm record's delete, Model::destroy, Repo::delete); bulk deletes from the query builder record none, so delete their media with delete_all first. Models with a deleted_at column keep their media when they are deleted, since a soft delete and a force delete look the same to the observer: call delete_all when you force-delete one. media.delete_with_model = false turns the observer off.

Conversions

A conversion is a file generated from the original. Declare it on a collection; every file of the collection gets it:

Rust
Conversion methodWhat it does
width(px), height(px)At most this wide or high; the other side follows the aspect ratio
fit(Fit, width, height)A box: Contain fits inside it (the default), Cover fills it and crops the overflow evenly, Fill stretches to it
upscale()Lets smaller images grow to the box (by default they keep their size; SVGs always scale)
format(OutputFormat)Jpeg, Png or WebP (lossless); by default JPEG, PNG and WebP keep theirs and other images become PNG. JPEG flattens transparency onto white
quality(1..=100)The JPEG quality (default 82)
greyscale(), blur(sigma)Filters
responsive()Also generates a copy for each width of media.images.responsive_widths below the conversion's own width, for srcset
nonqueued(), queued()Generates this one while the file is added, or on the queue, whatever media.queue_conversions says
frame_at(secs)For video: the frame the image is made from (video)
transcode(Transcode)For video and audio: re-encodes the file instead (video)

Images are decoded with a pixel limit (media.images.max_pixels) and their EXIF orientation applied, resized with SIMD (fast_image_resize, Lanczos), filtered and encoded without copying any metadata. SVGs are rendered with resvg at the conversion's size, without loading external files or URLs. Image conversions apply to JPEG, PNG, GIF (the first frame), WebP, BMP and SVG originals, and with media-ffmpeg to video frames; a PDF in a collection with a thumbnail conversion simply has no thumbnail.

Generated files live next to the original: {prefix}/{id}/conversions/{name of the file}-{conversion}.{ext}, responsive copies under {prefix}/{id}/responsive/, on the collection's conversions disk. Each is recorded on the row with its path, type, size, width, height and variants.

Queued conversions

With a queue in the app and media.queue_conversions on (the default), save stores the file and its row and dispatches one media.conversions job (GenerateConversions) on media.queue for the item's queued conversions, in the same unit of work. A worker generates them and saves them on the row; until then has_conversion is false and the resource has no URL for them. Conversions marked nonqueued() are generated before save returns, so a small preview is there at once. Without a queue, every conversion runs while the file is added.

A conversion that runs while the file is added and fails fails the add, and nothing is kept. A queued one that fails is retried (three attempts with backoff; each attempt may take 15 minutes, enough for a transcode); an item deleted before its job runs is skipped. The job writes only the conversions onto the row, so a rename made meanwhile stays.

Original images

With media.strip_metadata on (the default), originals lose what they say about the person who made them before they are stored: the EXIF and XMP segments, IPTC and comments of JPEGs, the eXIf, tEXt, zTXt, iTXt and tIME chunks of PNGs, and the EXIF and XMP chunks of WebP files. Colour profiles stay. The bytes are cut out without re-encoding, except for photos whose EXIF orientation turns them: those are decoded, turned upright and re-encoded in their own format, since the orientation is part of what is removed. width and height are the displayed size.

Regenerating

Change a conversion and generate it again for the files you already have:

Shell
Text

--queue dispatches one batch named media.regenerate, one job per item, with allow_failures so one broken file doesn't cancel the rest; the command prints the batch id. In code, media.regenerate(&Regenerate { .. }) does the work page by page and returns a RegenerateReport (items, conversions, and the items that failed with their errors), and media.queue_regenerate(&filter) dispatches the batch and returns the JobBatch, or None when nothing needs converting. media.convert(id, only) converts one item now.

Images without a model

The image functions work on bytes too, in navree::media::images:

Rust

convert returns a ConvertedImage with bytes, mime_type, extension, width, height and the responsive variants. These functions are CPU-bound: call them from tokio::task::spawn_blocking in a request, as Media does.

Archives

Archive opens a zip file and checks every entry before you read one: no absolute paths, drive letters, .. segments or control characters (zip slip), no symbolic links, no encrypted entries, no duplicates, and the declared sizes within media.archives. Reading an entry checks the bytes it actually inflates to as well, so a forged header gets nowhere.

Rust

A SCORM package uploaded as media unpacks to the directory it is served from:

Rust

media.open_archive(&item) opens an item's archive with the configured limits, and Archive::extract_to(&disk, prefix) writes every file under a prefix, checking the total again as it goes. To build one, ZipBuilder::new() takes file(path, bytes) and directory(path) and refuses unsafe names; media.zip(&items) puts the originals of several items in one archive, naming duplicates report (2).pdf.

LimitDefaultRefused with
max_entries10 000TooManyEntries
max_entry_bytes512 MiBEntryTooLarge
max_total_bytes2 GiBTooLarge
max_ratio200 to 1, for entries over 1 MiBRatio (a zip bomb)

A 0 turns a limit off.

Text extraction

text::extract(bytes, mime_type) reads the text of a document, for a search index or the passages of a knowledge base:

Rust
TypeWhat comes out
PDFThe text of each page, in content order; the title from the document information
DOCXParagraphs on their own lines, tabs and breaks kept; the title from the core properties
HTML, XHTMLThe visible text, blocks on their own lines, scripts and styles skipped; the <title>
text/*The text, byte-order mark and \r\n removed

ExtractedText has text (the whole document, whitespace collapsed, PDF pages separated by a blank line), pages, title and the mime_type it was read as. A type it doesn't read is sniffed from the content first; one that is still unsupported is unsupported_media_type (415), and a damaged document invalid_document. text::supports(mime_type) tells you in advance. Scanned PDFs have no text to extract: they need OCR, which this module doesn't do.

Video and audio

Featuremedia-ffmpeg With media-ffmpeg, video and audio files are probed when they are added, and conversions can grab frames and transcode. The work runs in ffmpeg and ffprobe, started as child processes without a shell and killed after media.ffmpeg.timeout_secs. Inputs are opened with -protocol_whitelist file, so a crafted upload (a playlist hidden in a container) cannot make FFmpeg fetch URLs. FFmpeg still parses whatever people upload: run it with the least privileges you can, in its own container or as a user that can read nothing else.

Rust
  • Probe. An added video or audio file gets width and height (turned for phone videos recorded sideways) and metadata.duration, metadata.video_codec and metadata.audio_codec. When FFmpeg is missing or fails, the file is still added, without them, and a warning is logged.
  • Poster frames. An image conversion on a video takes the frame at frame_at seconds (default 1 s, or the middle of a shorter video) and converts it like any image.
  • Transcodes. Conversion::transcode re-encodes with a preset, adjusted as you need:
TranscodeContainerVideoAudio
mp4()MP4, index at the front for streamingH.264, CRF 23, preset medium, yuv420pAAC 128k
webm()WebMVP9, CRF 32Opus 96k
mp3()MP3noneMP3 192k
m4a()M4AnoneAAC 128k

.height(720) scales (the width follows, rounded to an even number), .crf(..), .preset(..) and .audio_bitrate(..) tune the encoder, .max_duration(secs) keeps the start (previews), .without_audio() drops the sound, and .arg(..) adds FFmpeg output arguments.

Transcodes are slow: keep them queued (the default), on a queue with workers sized for them (media.queue). Ffmpeg is also there on its own, as media.ffmpeg() or Ffmpeg::new(FfmpegOptions), with probe(path), frame(input, secs, output), transcode(input, output, &transcode) and version().

Errors

Return a MediaError from a handler with ?. A refused file answers with a status and a code of its own; everything else is a 500 whose details stay in the logs:

MediaErrorStatuscodeWhen
UnknownCollection400unknown_media_collectionThe model declares collections, and not this one
TypeNotAllowed400file_type_not_allowedThe collection doesn't accept the sniffed type
TooLarge413payload_too_largeOver the collection's or media.max_file_size
Empty400empty_fileThe file has no content
Download400remote_file_unavailableA remote file failed: status, connection or timeout
Blocked400remote_file_blockedThe SSRF guard refused the address, or the scheme isn't http(s)
Image400invalid_imageThe image doesn't decode, or has too many pixels
Archive(..)400, 404 or 413invalid_archive, unsafe_archive, archive_too_large, not_foundSee ArchiveError
Text400invalid_documentA damaged PDF or DOCX
Unsupported415unsupported_media_typeText extraction of a type it doesn't read
NotFound404media_not_foundNo item has this id
MissingConversion404conversion_not_foundThe item has no generated conversion of this name
NoPublicUrl, Ffmpeg, File, Queue, Storage(..), Db(..)500internal_errorA disk, the database, the queue or FFmpeg failed

Testing

MediaFake is the real Media on a memory disk named media and memory rows, with every conversion generated at once, remote downloads answered by a fake HTTP server, FFmpeg answered by FfmpegFake, and every add and delete recorded:

tests/course_media.rs

The fake replaces Media wherever it is injected, so the app needs neither MediaModule nor StorageModule for it.

  • fake.added(), assert_added(model_type, collection), assert_added_with(predicate), assert_added_count(n) and assert_nothing_added() check adds; fake.deleted() and assert_deleted(id) check deletes.
  • fake.http() is the FakeServer remote files come from: fake.http().on(Method::GET, "https://cdn.example.com/*", FakeResponse::new(200).body(png)). An unstubbed URL fails as unreachable.
  • fake.ffmpeg() (with media-ffmpeg) answers ffprobe with a 10-second 1280x720 H.264 video by default (probe_json(..) sets another), writes frames of the probed size and placeholder transcodes, records every run (assert_ran) and can fail(..). To keep the real Media and fake only FFmpeg, override Arc<dyn FfmpegRunner> with FfmpegFake::new().runner().
  • MediaFake::with_options(MediaOptions { .. }) takes options (its disks are always the memory disk).

To test with the real store, use TestApp::builder().temp_database() and StorageModule with a memory disk: the migrations create media, and queued conversions are dispatched to whatever queue the app has (a QueueFake records them, and GenerateConversions { .. }.handle(ctx) runs one). Outside an app, Media::builder(storage).options(..).register::<Course>().build()? builds the service on your disks with memory rows.

Reference

KeyDefaultMeaning
media.storedatabasedatabase (the media table) or memory
media.diskstorage's default diskThe disk originals go to
media.conversions_diskthe original'sThe disk conversions go to
media.prefixmediaFiles live under {prefix}/{id}/
media.max_file_size26214400 (25 MiB)The largest file
media.queue_conversionstrueGenerate conversions on the queue
media.queuedefaultThe queue of media.conversions jobs and regeneration batches
media.strip_metadatatrueRemove metadata from JPEG, PNG and WebP originals
media.delete_with_modeltrueDelete a registered model's media with it
media.url_ttl_secs3600Temporary URLs in resources
media.download_timeout_secs30Remote downloads
media.images.max_pixels50000000Larger images are refused
media.images.responsive_widths[320, 640, 960, 1280, 1920, 2560]Widths of responsive variants
media.archives.max_entries10000Entries of an archive
media.archives.max_entry_bytes536870912 (512 MiB)One unpacked entry
media.archives.max_total_bytes2147483648 (2 GiB)All entries unpacked
media.archives.max_ratio200Compression ratio of an entry over 1 MiB
media.ffmpeg.ffmpeg, media.ffmpeg.ffprobeffmpeg, ffprobeThe programs
media.ffmpeg.timeout_secs600One run
media.ffmpeg.threads0 (FFmpeg chooses)Encoder threads
Media method
add(&model, collection, source), add_from_url(&model, collection, url)A PendingMedia; save(uow) stores it
get, first, all, find, find_or_fail, list(&query)Reading
resource, url, conversion_url, srcset, temporary_url, temporary_conversion_urlPresenting
read, read_conversionThe bytes
save(uow, &item), reorder(uow, &ids)Name, custom properties and order
delete(uow, id), clear(uow, &model, collection), delete_all(uow, &model)Deleting
convert(id, only), regenerate(&filter), queue_regenerate(&filter)Conversions
extract_text, open_archive, unpack, zipDocuments and archives
register::<M>(), options(), storage(), store(), ffmpeg()The service

The media table: id (UUID; BINARY(16) on MySQL), model_type, model_id, collection, name, file_name, mime_type, disk, conversions_disk, path, size, width, height, conversions, metadata and custom_properties (JSON; JSONB on PostgreSQL), order_column, created_at and updated_at, indexed by (model_type, model_id, collection). Rows are kept by a MediaStore: DatabaseStore or MemoryStore, or your own through Media::builder(..).store(..).