Private course videos with Bunny Stream
rullst-media is an optional v13 candidate, first published in the
13.0.0-alpha.1 prerelease. It implements a bounded
private-video lifecycle: authorized creation and resumable upload, processing
refresh, explicit publication, current-entitlement playback, withdrawal, metadata
updates, deletion and durable reconciliation. Its standalone package is in the
v13 release inventory, pending final release acceptance; it is not part of the framework facade. Existing starters are not
changed.
The crate integration guide describes installation
(rullst-media = { version = "=13.0.0-alpha.4", features = ["bunny", "sqlite"] }),
separate provider keys, required embed/CDN/direct-file
protection, HTTP security composition, browser uploads, captions/transcripts,
typed failure handling, SQLite retention and backup responsibilities.
Run cargo run -p rullst-media --example private_course_video --all-features
to exercise the executable offline journey. Empty or mock_* credentials never
become live playback authority; production store configuration rejects them.
Applications supply authenticated tenant/course membership and current entitlement.
Automated acceptance uses actual local HTTP, SQLite and Chromium with controlled media. It checks signatures, upload bytes and failure recovery, CSRF and tenant denial, media decoding, captions/keyboard interaction, withdrawal and deletion. Real Bunny account, transcoding, CDN and iframe interoperability remain unvalidated under the owner’s no-live-provider-testing instruction. Signed URLs do not imply DRM, immediate revocation of existing capabilities or prevention of all copying.
See the managed-video acceptance roadmap and the specification. Release acceptance remains separate from implementation and local test success.
S3-compatible object storage (S3, R2, MinIO)
Status: Experimental; validated against the offline mock only; provider
interoperability not yet validated. The opt-in s3 feature adds
rullst_media::s3::S3Storage, a second VideoProvider beside Bunny Stream for
AWS S3, Cloudflare R2 and MinIO (custom endpoint). It stores and serves the
uploaded original. There is no transcoding, HLS or renditions: browsers play
the file that was uploaded, so upload a format they can decode (for example
H.264/AAC MP4).
| Step | Mechanism |
|---|---|
| Create | The adapter derives the video id from the local creation marker and writes a small private metadata.json beside the object. |
| Upload | MediaService::upload_declared checks the host’s Authorization, then signs a presigned PUT for {prefix}{video}/original. The signature binds the declared Content-Type and exact Content-Length; types outside with_content_types and sizes above with_max_object_bytes are refused before signing. |
| Confirm | refresh or publish sends HEAD; a non-empty object within with_max_object_bytes whose type is accepted becomes Ready, anything else Failed (a new grant may then replace it). |
| Playback | playback(..., PlaybackKind::Original) returns a presigned GET after the usual entitlement and publication checks. Range requests go directly to the bucket. |
| Delete | delete removes the original and its metadata object. |
Object keys are always generated by the server: an optional validated prefix
plus the adapter’s UUID-shaped id; no request value becomes a path. Upload and
playback grants expire after at most 900 seconds (s3::MAX_GRANT_SECONDS) and
never after the host’s permission. Signed URLs live only in the private
UploadGrant signature and PlaybackGrant::expose_url; Debug output and
MediaError never contain them, and signing runs with tracing disabled. Never
log the grant JSON you send to the browser.
use rullst_media::{LibraryId, UploadDeclaration, s3::{S3Config, S3Credentials, S3Storage}};
// AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (+ optional AWS_SESSION_TOKEN).
// Unset keys select the offline mock, which StoreConfig::production rejects.
let credentials = S3Credentials::from_env()?;
// Cloudflare R2: https://<account>.r2.cloudflarestorage.com, region `auto`.
let config = S3Config::r2(LibraryId::new(1)?, "<account-id>", "course-videos", credentials)?
.with_key_prefix("lessons/")?
.with_content_types(["video/mp4", "video/webm"])?
.with_max_object_bytes(2 * 1024 * 1024 * 1024)?;
// AWS S3: S3Config::aws(library, "eu-west-1", "course-videos", credentials)
// MinIO: S3Config::custom(library, "https://minio.example.internal:9000", "us-east-1", "course-videos", credentials)
// (path style; HTTPS required except for a literal loopback address)
let provider = S3Storage::new(config)?;
// In the CSRF-protected upload-grant route, after validating the browser's file:
let declared = UploadDeclaration::new(file_type, file_size)?;
let grant = media.upload_declared(&policy, &actor, &scope, &lesson, 600, &declared).await?;
The browser then sends exactly one request:
// grant.protocol === "PresignedPut"
await fetch(`${grant.endpoint}?${grant.signature}`, {
method: 'PUT',
headers: { 'Content-Type': grant.content_type },
body: file, // its size must equal grant.content_length
});
Bucket CORS must allow the application origin to PUT with a Content-Type
header and to GET/HEAD with Range. Keep the bucket private: no public
access, no public r2.dev URL and no custom-domain public access. Credentials
need GetObject, PutObject and DeleteObject on the prefix and ListBucket
(so that HEAD on a missing object returns 404 instead of 403). An AWS S3 CORS
configuration (R2 accepts the same rules as JSON in its dashboard or API):
[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["PUT", "GET", "HEAD"],
"AllowedHeaders": ["content-type", "range"],
"ExposeHeaders": ["ETag", "Content-Length", "Content-Range", "Accept-Ranges"],
"MaxAgeSeconds": 600
}
]
Limits: one PUT uploads at most 5 GiB and is not resumable; there is no
multipart upload, malware scanning, transcoding or thumbnailing. Whether a
provider enforces the signed Content-Length and Content-Type is part of the
unvalidated interoperability; the HEAD check after upload is the enforced
bound. A presigned URL is a bearer capability until it expires, even after
withdrawal or deletion. Provider event notifications are not consumed;
confirmation is explicit through refresh or publish.