Media: storage, the library and serving
Every picture, recording, video and download a lesson, a page or a course cover shows is a file of the media library, kept once and shown by reference. This chapter follows a file from the moment it arrives to the moment a browser is handed its bytes: the models, the disks, the record of where it is used, the addresses, the access decisions, moving it between disks, and how to add something new that shows files.
The pieces live in four places. The library module owns the assets, their uses, the picker, the addresses and the relocator. blocks and catalog show assets. learning answers media.show, because only it may decide whether a lesson opens. ops owns the “Media and storage” page. The host keeps the one upload field, App\Filament\Components\MediaUpload, and the fetcher behind “Add from a web address”.
The shape in one picture
Section titled “The shape in one picture” media_assets ──< media (spatie) file: the bytes, conversions thumb 480 / content 1600 │ │ captions: a video's WebVTT tracks, one per language │ └── visibility ─> disk public ─> media-library.disk_name │ course-material ─> media-library.lessons_disk │ media_asset_uses (asset, usable morph, slot) │ ┌───────────────────┼─────────────────────┐ lesson page course (block uuid slots) (block uuid slots) (slot `cover`) takes course public only public only materialA record that shows files holds no file of its own. A block’s data names an asset id; a course names one in cover_asset_id. Where a file is shown is a row in media_asset_uses, and that row is what the renderer draws from and what the access check reads.
Assets
Section titled “Assets”Hilms\Library\Models\MediaAsset (media_assets) is one file:
| Column or trait | Is |
|---|---|
uuid |
A second identifier (HasUuids on uuid only). Blocks store the integer id |
title |
One string, the file’s name when the uploader gave none |
alt, description |
Translatable per installation language (spatie/laravel-translatable). alt is shown only for pictures |
visibility |
Hilms\Library\Enums\AssetVisibility: Public or CourseMaterial. It decides the disk and who may open the file |
kind |
Hilms\Library\Enums\AssetKind: Image, Video, Audio, Document, read from the sniffed bytes and never from a name |
uploaded_by |
The uploader, null on delete. An asset is staff content, not personal data: it is not exported with a person’s data, and a deleted account leaves the column pointing at the tombstone |
| Media collections | file (single file) holds the bytes; captions holds a video’s WebVTT tracks, one per language, the language as the custom property language |
| Conversions | thumb (480 wide) and content (1600 wide) on file, queued on the media queue |
| Tags, audit | spatie/laravel-tags; the activity log records title, alt, description and visibility |
Both enum columns carry CHECK constraints written the house way — IS NOT NULL first, the list literal — and MediaAssetConstraintTest compares the live clauses with the enums.
The kind decides what a file may be. AssetKind::types() is the list each kind accepts — JPEG, PNG, WebP; MP4, WebM, QuickTime; MP3, M4A, OGG, WAV; and the non-media entries of config('blocks.file_mime_types') for documents — and maxKilobytes() its own ceiling (8 MB, 200 MB, 100 MB, 50 MB), capped like every upload by the system’s effective limit. SVG is not a library type: the branding logo is the one place an SVG is taken, and it stays outside the library.
Words belong to the file. altIn(?language) answers what a picture shows in the given language, else in the installation’s default one, else nothing. A block may say it in other words for one place (below), but the file is where an editor writes it once.
A caption track is not an asset. It is a file of its video’s captions collection, so every lesson that shows the recording offers the same tracks.
Hilms\Library\Models\MediaAssetUse (media_asset_uses) is one place an asset is shown: the asset, a usable morph and a slot — a block’s uuid, a gallery item’s uuid, or cover. The four columns are unique together.
Hilms\Library\Actions\RecordUses is the one writer:
RecordUses::handle(Model $usable, array $slots): void // [slot => asset id, or a list of ids]RecordUses::forget(string $usableType, iterable $ids): voidhandle() replaces the record’s rows as a whole in one transaction: what the record no longer shows stops counting at once, the rows that stay are kept, and an id no asset answers to is simply not a use. forget() is for records that go — a deleted lesson or page (HasBlocks hooks deleted), the lessons of a deleted section (Section::deleting, because the database removes them with no model event), and a force-deleted course with its lessons and its cover. A soft-deleted course keeps its cover in use, since it may come back. Deleting an asset cascades to its uses.
Nobody else writes the table. SyncBlocks calls handle() once per save, and Course calls it when its cover changes.
A use is also a permission. A lesson’s course material opens only through a lesson that has a use row for it, so a file a block stopped showing stops opening through that lesson the moment the lesson is saved.
Records that show assets
Section titled “Records that show assets”A model that shows library files implements Hilms\Library\Contracts\UsesAssets:
| Method | Lesson | Page | Course (cover) |
|---|---|---|---|
takesCourseMaterial() |
true | false | false |
assetLanguage() |
its course’s language | its own | its own |
assetUseLabel() |
“Course › Lesson” | its title | its title |
assetUseEditUrl() |
the lesson editor | the page editor | the course editor |
takesCourseMaterial() is the question the whole design turns on. Only a record whose access is decided lesson by lesson may show course material; anything else is read by anyone, so a private file there would either leak or not show. The picker narrows what it offers by it, the server refuses by it, AssetUrl mints by it, and MediaAsset::publicUses() — the uses whose record could not show course material — is what keeps an asset public.
assetLanguage() is the language a picture’s words are read in where the record shows it. assetUseLabel() and assetUseEditUrl() feed the library’s “Where used” list.
Who sees what
Section titled “Who sees what”MediaAsset::visibleTo(User) is a #[Scope]: administrators and editors see the whole library; anyone else sees what they uploaded and every asset used by what they teach. The library knows no course, so “what they teach” comes through Hilms\Library\Contracts\TeachingScope, which constrains a query of uses. library binds NothingTaught as the floor, and catalog binds Hilms\Catalog\Support\CourseTeaching, which counts a lesson of a course listed for the person in course_instructor and that course’s cover.
MediaAssetPolicy declares all twelve methods: viewAny and create by permission; view by permission and scope; update and delete by permission and ownership — administrators and editors own everything, an instructor what they uploaded; deleteAny by permission; restore, force delete, replicate and reorder closed. LibraryPermissionSeeder seeds the RECORD set. Editors hold all of it, and so do instructors: the policy is what narrows them.
An empty library — or a search that found nothing, since Filament shows the same state for both — says “No files here” and where a file comes from, rather than Filament’s “No records found”.
Whether an asset is still used is asked by the delete actions, never by the policy, so the listing’s query count does not move with it: the card reads the uses_count the listing already counted, and the action asks again when it runs.
The view and edit pages resolve their record through visibleTo(), so a file outside what a person may see is a 404 rather than a 403. A file an instructor sees but did not upload opens on the read-only view page (ViewMediaAsset, drawn by MediaAssetInfolist), because the edit page needs update: the file itself — a player for a video, with its caption tracks, or for a recording — its words in every language, its facts, its visibility and tags, and “Where used”. Edit sits in its sidebar only for whoever may update the file, and it has no Delete. MediaAssetResource::openUrl() chooses between the two pages, and every way into a file goes through it — the cards, the global search, the redirect after an upload and the Video block’s link to its captions — so a card a person may not change offers View instead of Edit. “Where used” links a place only for whoever may update it.
Four disks hold media, and code names none of them outside configuration:
| Disk | Where | Visibility | Holds |
|---|---|---|---|
public |
storage/app/public, read at /storage |
public | public assets, avatars, the branding |
lessons |
storage/app/private/lessons |
private | course material |
remote-public |
a public bucket, under public/ |
public | public media once a public bucket is set |
remote-lessons |
the lesson bucket, under lessons/ |
private | course material once a bucket is chosen |
Two configuration keys choose between them: media-library.disk_name (public media, MEDIA_DISK) and media-library.lessons_disk (course material, MEDIA_LESSONS_DISK). AssetVisibility::disk() reads one or the other; nothing else in the code says a disk’s name.
The disk belongs to the owner. A model that chooses where its files live implements Hilms\Library\Contracts\ChoosesMediaDisk (mediaDisk()), which MediaAsset answers with its visibility’s disk. MediaUpload asks the record it is editing: a ChoosesMediaDisk names its own, anything else — an avatar, the logo — goes to media-library.disk_name. A lesson, a page and a course are not media owners at all.
The two bucket disks are s3 disks (league/flysystem-aws-s3-v3) that throw on failure, give up on a connection after ten seconds while a transfer has no limit, and send and check checksums only where S3 requires them (request_checksum_calculation and response_checksum_validation at when_required), which is what providers other than AWS accept. The media library sends Cache-Control: max-age=604800 with every file it writes to one.
Access lists follow the provider. Flysystem sends an access list with every file it writes or copies — public-read on remote-public, private on remote-lessons — and offers no way to send none. A provider that decides public reading for the whole bucket refuses or ignores one (an AWS bucket whose owner enforces ownership, the default since 2023, refuses public-read outright), so each bucket disk carries an object_acl flag, and Hilms\Ops\Storage\ObjectAcl::driver() — the s3 driver, registered from OpsServiceProvider — builds the disk with Laravel’s own createS3Driver() and, when the flag is false, appends an SDK middleware that takes ACL off PutObject, CreateMultipartUpload and CopyObject, and turns retain_visibility off so a copy does not read the source’s list first. R2, AWS, B2 and Hetzner send none; Spaces and Other keep per-file public-read, which is how they make a file public.
The panel chooses, configuration obeys. Hilms\Ops\Settings\MediaSettings holds the choice — a provider (Hilms\Ops\Storage\BucketProvider), its account id or location, the buckets and the key — and Hilms\Ops\Support\MediaConfiguration::apply() is the one bridge from it to configuration (Operations): Hilms\Ops\Storage\BucketConfiguration turns the settings into the two disks’ endpoint, region, addressing, object_acl and public address (a named provider builds them; Other takes them typed, a blank field keeping the environment’s MEDIA_S3_*), then apply() sets the two keys above — a bucket always takes course material, and takes public media only when a public bucket of its own and its public address are both named — then the video block’s two keys. The modules below ops read those keys and never learn the page exists.
Public media never shares the lesson bucket. Cloudflare R2 and Backblaze B2 open a whole bucket to anyone once it has a public address, so a public bucket that is also the lesson bucket would publish every lesson file. The page refuses it, and apply() refuses it again whichever of the panel and the environment named them.
Livewire’s temporary uploads stay local whatever is chosen. Every upload rule — FileContentType, PlayableVideo, PlayableAudio, WebVtt, the SVG sanitiser, AssetFile — reads a local file, and the form’s save then streams it onto the owner’s disk.
Getting a file in
Section titled “Getting a file in”There are three doors, and all of them are MediaUpload over the asset’s file collection:
- The library’s own upload (
CreateMediaAsset): one field taking every library type (AssetKind::acceptedTypes()), the largest kind’s ceiling as its limit, andHilms\Library\Rules\AssetFile. - “Upload new” beside every picker (
AssetPicker::getUploadAction()): a window over a new asset, taking only the kinds the place shows, with a title and, where pictures are allowed, the alternative text. - “Add from a web address” on either of those fields (
MediaUpload::fromUrl()).
What they share:
- The kind is read from the sniffed bytes (
FileContentType::detect()thenAssetKind::fromMime()) before the row exists, and a file whose kind nobody can tell is refused. AssetFilejudges the file by that kind: a name whose extension claims another kind among the library’s types is refused and says so (lecture.mp4holding text;.mp4claims a video and never the documentapplication/mp4would be, while a missing or unknown extension claims nothing), then the kind’s own ceiling, a video that plays (PlayableVideo), a recording that plays (PlayableAudio).- The visibility is decided before the file lands. The library’s upload defaults to course material; the picker’s window creates the asset with the visibility the record asks for — course material from a lesson, public from a page or a cover — so
MediaUploadfinds the right disk on the new asset. - The file is streamed.
MediaUploadadds media from the temporary file’s path rather than from its contents, so a video is never held in memory. - A file the disk refuses is a field error, and leaves nothing behind. The media library writes its row before the file, so
MediaUploadstores inside a transaction: a write the disk refuses — a bucket whose key was revoked, say — takes the row with it, is reported to the log, and comes back as “The file could not be stored, because the storage refused it…” on the field instead of a 500. The library’s create page saves the asset and its file in one transaction ($hasDatabaseTransactions), and the picker’s window creates the asset first so the upload finds its disk, then deletes it and rethrows the field error, so neither leaves an asset without a file.
From a web address. App\Filament\Actions\AddFromUrlAction asks for an https address and hands it to App\Support\RemoteFiles\RemoteFile, which treats this server asking an address somebody typed as the threat it is: public addresses only (PublicAddress, every A and AAAA answer checked), the connection pinned to the address that was checked, redirects followed by hand at most three times with every check made again, the size capped as it streams, forty seconds at most. The download lands in Livewire’s temporary storage exactly as an upload would, so every rule above judges it. The action is throttled by the media-fetch limiter (twenty a minute per person) and authorises like the form it sits in. Security and privacy has the whole list of what the fetcher refuses.
Showing a file
Section titled “Showing a file”In a block
Section titled “In a block”A block names a file by its id under the key asset — the image, video (an upload), audio, file and hero blocks in their own data, a gallery in each item — chosen with Hilms\Library\Filament\AssetPicker. Nothing else about a file is written into block data.
SyncBlocks writes every asset down as an integer and collects them under the uuid beside them — the block’s own, or a nested item’s when the item carries a uuid — then calls RecordUses once. That is why a gallery item has a hidden uuid of its own: it is the slot its picture is recorded in.
A view receives $assets, a Hilms\Blocks\Support\BlockAssets:
| Asks | Answers |
|---|---|
find($id) |
The asset, if the record shows it |
file($asset) |
Its file (Media) |
url($asset, ?conversion) |
The address of the file or a conversion, minted for this record |
mediaUrl($media, ?conversion) |
The same for any file of the asset — a caption track |
captions($asset) |
A video’s tracks, in the order they were added |
alt($asset, $override) |
The block’s own words for this place, else the file’s in the record’s language, else in the default language, else nothing |
@php($image = $assets->find($data['asset'] ?? null))@php($src = $assets->url($image, 'content'))@if ($src) <img src="{{ $src }}" alt="{{ ($data['decorative'] ?? false) ? '' : $assets->alt($image, $data['alt'] ?? null) }}">@endifOn the site, BlockAssets::of($blockable) holds what the record has recorded as used, so <x-blocks::content :blockable> expects blocks and assetUses.asset.media eager-loaded (PageController and LessonPlayerController do it) and a page costs the same queries however many files it shows. A file the record has not recorded is never drawn, whatever the block’s data says. In the panel, BlockAssets::preview() holds whatever the item names at that moment, so a pick shows before anything is saved, and draws course material through the panel’s own link.
As a cover
Section titled “As a cover”A course’s cover is courses.cover_asset_id (restrict on delete, so the database refuses to lose a file in use as the library does), chosen with the picker (public pictures only). Course records the cover use whenever the column changes, whoever changed it. Course::coverUrl($conversion) mints the address through AssetUrl — thumb on a card and in the courses table, content on the course page — and the listings eager-load coverAsset.media. TranslateCourse copies the id, not the file.
Copying
Section titled “Copying”BlockCopy mints every uuid in a block again and keeps the asset ids, so a copy shows the same file by reference and no byte is copied. The editor’s duplicate, CopyBlocks and the two translate actions all go through it, and SyncBlocks records the new record’s uses when the copy is written.
Addresses
Section titled “Addresses”Hilms\Library\Support\AssetUrl mints every address of an asset’s files:
AssetUrl::in(MediaAsset $asset, Model $usable, ?string $conversion = null): ?stringAssetUrl::of(Media $media, Model $usable, ?string $conversion = null): ?stringAssetUrl::forPanel(Media $media, ?string $conversion = null, ?DateTimeInterface $expiration = null): string- A file on a public disk gets its plain public address:
/storage/…, or the public bucket’s address. - A file on a private disk gets a 60-minute signed
media.showroute carryinglesson— the record’s key, inside the signature — and only for a record that takes course material. Any other record gets null, and the view draws nothing. forPanel()signs the same route with no lesson, for the panel’s thumbnails and upload previews.- A conversion that has not been generated yet falls back to the original.
Hilms\Library\Media\PrivateUrlGenerator is the media library’s url_generator. Media on a disk whose visibility is not public gets forPanel() from getUrl() and getTemporaryUrl() wherever the file lives, and never the bucket’s own presigned address, which would skip the access check. So a theme view that calls $media->getUrl() on course material hands a student the panel’s link, which does not open for them: what a student is given is always minted by AssetUrl for the lesson that shows it.
Serving course material
Section titled “Serving course material”GET /media/{media}/{conversion?} (media.show, throttle:media, signed) is Hilms\Learning\Http\Controllers\MediaController. It answers media owned by a MediaAsset and nothing else (404), and a public asset never (404: it has an address of its own). Then:
| The link names | Who is let in |
|---|---|
a lesson |
The key must be a lesson (404); the lesson must have a use row for the asset (403); ResolveLessonAccess must open it to the visitor (403); and a recording must be asked for by the element that plays it (403) |
| no lesson | Only somebody who may view the asset in the library. A student never |
A changed lesson breaks the signature, so a link cannot be pointed at another lesson.
A recording answers only its player. For video/* and audio/*, a request whose Sec-Fetch-Dest is present and is not video, audio, track or image is refused before a bucket is ever named — the address bar (document), a frame, a script’s fetch(), or a download manager the browser hands the link to (empty). The header is the browser’s own and no page can set it; a client that sends none is not a browser and is let through. Whoever may update the lesson is exempt, because the panel’s upload field fetches the file to show it. Pictures and attachments are not asked: a gallery opens its pictures in a tab, and a file is there to be downloaded.
Once the visitor is let in, the answer depends on the disk:
- A local disk: a
BinaryFileResponsewith byte ranges (which is what scrubbing a video needs),Cache-Control: private, max-age=3600andVary: Sec-Fetch-Dest, so the copy a player was given is not handed to an address bar from the browser’s cache. WithMEDIA_SENDFILE=nginxand only for thelessonsdisk, anX-Accel-Redirectto the internal/_lessons/location instead. - A bucket: a
302to the bucket’s presigned address, living five minutes and carrying the type and the disposition (ResponseContentType,ResponseContentDisposition), so the bucket serves the bytes and the byte ranges. The bucket is not asked first whether the object exists: that would be a round trip on every seek.
Content-Disposition is inline for images, video and audio and attachment for anything else, built by HeaderUtils::makeDisposition() with the real name percent-encoded beside an ASCII fallback.
The media rate limiter allows 600 requests a minute per person (or address): a lesson opens every file it shows at once and a player asks again with a byte range on every seek, so ten a second sustained for a minute is beyond anybody watching and not beyond a script.
A bucket and the browser
Section titled “A bucket and the browser”Two things change once files live in a bucket.
- The content security policy names the bucket.
Hilms\Access\Csp\MediaOriginsworks the origins out from configuration the way the S3 client addresses a bucket — a sub-domain of the endpoint unless path-style addressing is on or the name cannot be one label — plus the public disk’surl, and both presets put them inimg-srcandmedia-src(the panel’s inconnect-srcas well, because its upload fieldfetch()es a stored file to preview it). Both bucket disks are always asked, because a file stays in a bucket until it is moved back. - The player asks in CORS mode.
Hilms\Blocks\Support\VideoPlayer::crossOrigin()answers true when the video or any caption track sits on a disk that is not local, and the view then writescrossorigin="anonymous": a caption track that arrives from another origin without CORS is thrown away. CORS mode still sends the session cookie tomedia.show, which is on this origin; the bucket’s answer then needs a CORS rule forAPP_URL, which the guide on the settings page hands out and its check proves. - The player renews its address. After the redirect the browser keeps asking the bucket’s own address for the rest of the file, and that address dies in five minutes. Measured on Cloudflare R2: a pause of more than five minutes followed by a seek past the buffer — or a lesson read for a while before its video is first played — sends the browser to the dead address, and because R2’s refusal carries no CORS header, Chrome sees a network failure and retries it for about thirty seconds before raising
MEDIA_ERR_NETWORK.resources/js/media.jsremembers when each player last loaded its source (loadstart, orperformance.timeOriginfor a player in the page’s markup, which starts loading before the module runs) and, when the player has to wait for data (waiting) on an address older than four minutes, loads the source again at once; the network error is the fallback. Either way it keeps the position, the playback rate and whether the viewer last asked it to play, andload()goes throughmedia.showagain, so access is checked and a new address signed. At most three times a minute, and only for a source on this origin with known metadata, so a file that is really gone fails as it always did. The five-minute lifetime stays: a longer one would let a copied address work longer.
Moving files
Section titled “Moving files”A file moves when its asset changes visibility and when an administrator moves the files after a change of storage. Moving is never required: every row names its own disk, so a file is served from wherever it lies, and a deployment only counts what waits. One class moves.
Hilms\Library\Media\MediaRelocator:
targetFor($media)— the owner’smediaDisk()for aChoosesMediaDisk,media-library.disk_namefor any other owner that holds files, and null for a row whose owner is gone or holds no files, so a row nothing claims is never moved where a stranger could read it.misplaced()— the items that are not where their owner names, as one query: a library asset by its visibility, every other model in the morph map that holds files on the public media disk (a second model choosing a disk of its own is refused there rather than misjudged).locations()answers files and bytes per disk in one grouped query.move($media, $target)— one move of an item at a time: it holdsCache::lock("media:move:{id}")(longer than any move may take), reads the record again inside it, and answers false without touching anything while another process holds it. Two overlapping moves once let the loser’s clean-up delete the copies the winner had just pointed the record at; a test with a disk that starts a rival move mid-copy keeps that from coming back. Then every file the media library’s own path generator names (the original, its conversions, its responsive images,MEDIA_PREFIXincluded) is streamed across and checked on the destination, there and at its full size; a file already there whole is not written twice. Only when every file has arrived is the row repointed, and only then are the originals and their empty directories removed. A move that fails half way removes what it wrote and leaves the item where it was, asMediaNotRelocated.
Hilms\Library\Jobs\RelocateMedium moves one item on the media-moves connection and queue (RelocateMedium::QUEUE): one Horizon process, an hour per file with retry_after above it, three tries, released for a minute when the lock is held, and run in-process where QUEUE_CONNECTION is sync. It asks for the target again when it runs, so a job queued before the storage changed once more still takes the file where it belongs now. Hilms\Library\Media\MediaMoves is the move the settings page starts: one job batch (allowFailures(), the jobs added in chunks of 500, the batch id remembered in the cache, a second start refused while one runs), whose failures RelocateMedium::failed() records with the file name and a reason stripped of secrets and signatures. A batch that allows failures never gets finished_at once a job failed, so “has run” is pendingJobs <= failedJobs.
hilms:media:relocate [--dry-run] [--queue] is the same walk from the console, where an Artisan run has no time limit: idempotent, it names each failure, skips an item another process is moving, and exits FAILURE when any file stayed. hilms:upgrade does not move: its RelocateMedia step says how many files wait, because moving inside a starting container would keep the site down until every byte had crossed.
A font’s faces are media too — of a Hilms\Theme\Models\Font, on media-library.disk_name like any owner that does not choose its disk — and they move with the rest. A style’s sheet holds each face’s address, and library may not know about styles, so the theme watches the media rows of its own fonts instead: Fonts\FaceObserver compiles every style using a font again whenever one of its rows is saved, which move() does once the files have arrived (Styles). The cached branding holds its logo’s and favicon’s addresses the same way, so Hilms\Theme\Support\BrandingMediaObserver forgets Branding::current() when one of the branding’s media rows is saved or deleted.
Changing an asset’s visibility goes through Hilms\Library\Actions\ChangeVisibility, called after the form saves. Public to course material is refused while a record that cannot show course material shows the asset (publicUses()), naming those records; course material to public needs a confirmation ticked in the form. ChangeVisibility::refusal() is also the field’s own rule, so the form and the action cannot disagree. Either change queues a RelocateMedium for every file of the asset, on the moves queue, — the original with its conversions and each caption track — after the commit.
Adding a block that shows a file
Section titled “Adding a block that shows a file”-
Scaffold it (
hilms:make-block), then put anAssetPickerinsettings()under the keyasset, naming the kinds the block shows:AssetPicker::make('asset')->label(__('blocks::fields.file'))->kinds([AssetKind::Document])->required(),The key is not a convention you may change:
SyncBlocksandBlockAssets::ids()look forassetwherever it sits in the data. -
Inside a repeater, give every item
Hidden::make('uuid')->default(fn (): string => (string) Str::uuid())next to its picker, so each item is a slot of its own, as the gallery does. -
A picture carries a live
decorativetoggle andHilms\Blocks\Filament\AltText::make(), which asks for the block’s own words only when the file says nothing in the record’s language nor the default one. -
In the view, draw only through
$assets:find(),url()ormediaUrl(),alt(),captions(). Never$media->getUrl()— on course material that is the panel’s link. -
Give the design catalogue something to draw:
blocks’ContractViews::withSampleAssets()fillsassetfor each type that shows a file with an unsaved sample asset. -
Nothing else: the picker narrows by the record,
SyncBlocksrecords the use,BlockCopykeeps the id on a copy, andmedia.showchecks the lesson.
Adding a model that shows assets
Section titled “Adding a model that shows assets”A model that is not a blockable — the way a course shows its cover:
- A nullable foreign key to
media_assetswithrestrictOnDelete(), in the model’s own module. - Implement
UsesAssets. AnswertakesCourseMaterial()with false unless access to the model is decided lesson by lesson throughResolveLessonAccess:MediaControlleronly knows lessons, so anything else answering true would mint links that never open. - Record the use under a slot of its own whenever the column changes (
RecordUses::handle($this, ['cover' => $this->cover_asset_id])from asavedhook), and forget it when the record goes for good. - Add the model to the morph map in
AppServiceProvider:usable_typeis a morph alias. - Put
AssetPicker::make('…')->kinds([...])in its form; it readstakesCourseMaterial()from the record, or from a fresh instance of the model on a create page. - Mint addresses with
AssetUrl::in($asset, $this, $conversion), and eager-load the asset with itsmediawherever a listing draws it, with a query-count test to keep it so. - If instructors should find those files in the library, widen
CourseTeaching(or bind aTeachingScopeof your own that covers the new uses).
What this achieves, and what it does not
Section titled “What this achieves, and what it does not”Achieves: a file is stored once and shown by reference; a lesson file cannot be fetched without a session the lesson is open to; a copied link dies within the hour and names the lesson it was minted for; a recording opens nothing in an address bar; course material never sits under public/storage; a file in use cannot be deleted; and the same checks hold whether the bytes are on this server or in a bucket.
Does not: a viewer with developer tools can still save the stream their own player receives (a client that sends no Sec-Fetch-Dest is let through), and nothing stops a screen recording — the opt-in watermark only names whoever made one (Security and privacy). A bucket’s contents are not in the backups: they are the provider’s to keep. For video that must not be copied, use a host that plays only on this domain (the Video block’s Bunny Stream).
app-modules/library/tests/Feature: the model and its words (MediaAssetTest), the constraints, RecordUsesTest, who sees what (MediaAssetAccessTest), the resource with its query count (MediaLibraryResourceTest), the picker’s offer and its three refusals (AssetPickerTest), RelocateMediaTest (the overlapping moves among them) and MediaMovesTest. app-modules/learning/tests/Feature: AssetMediaTest and PrivateMediaTest for media.show. app-modules/ops/tests/Feature: BucketProviderTest, MediaConfigurationTest, MediaDisksTest, CheckBucketTest, BucketGuideTest, MediaSettingsPageTest, MediaLocationsTest. The host: tests/Feature/{MediaUploadTest,AddFromUrlTest,PlayableMediaRulesTest,SvgUploadTest}.php and tests/Unit/RemoteFileTest.php; access: MediaOriginsTest and RateLimitTest.
HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.