Media Library Plugin
A media library plugin for Svelar/SvelteKit with file uploads, image conversions, collections, named Svelar storage disks (local or S3), and UI components for uploading, previewing, and browsing media.
Package: @beeblock/svelar-media
Install:
npx svelar plugin:install @beeblock/svelar-media
Imports:
// Plugin registration
import SvelarMediaPlugin from '@beeblock/svelar-media/plugin';
// Server-side ORM/service API
import { Media, MediaService, MediaCollection, HasMedia } from '@beeblock/svelar-media';
// Conversions
import { ConversionBuilder, ConversionWorker, ImageConverter } from '@beeblock/svelar-media/conversions';
// Server-side (controllers, validation)
import { MediaController, MediaResource, MediaUploadRequest, parseMediaUpload, validateMediaUpload } from '@beeblock/svelar-media/server';
// UI components
import { MediaUploader, MediaGallery, MediaPreview } from '@beeblock/svelar-media/ui';
// Types
import type { MediaRecord, MediaModelId, MediaCollectionConfig, ConversionDefinition, DiskType, CustomProperties } from '@beeblock/svelar-media/types';
Use /ui for Svelte components and type-only imports from /types in browser code. The root ORM/service API, /plugin, and HTTP/controller exports are server-side APIs.
Quick Start
1. Register the Plugin
// src/lib/plugins.ts
import SvelarMediaPlugin from '@beeblock/svelar-media/plugin';
import { Storage } from '@beeblock/svelar/storage';
import { env } from '@beeblock/svelar/config';
// Include this disk in the application's central Storage configuration.
Storage.configure({
default: 'local',
disks: {
local: { driver: 'local', root: env('MEDIA_STORAGE_ROOT', 'storage') },
},
});
export const mediaPlugin = new SvelarMediaPlugin({
disk: 'local',
basePath: 'media',
baseUrl: env('MEDIA_BASE_URL', '/storage/media'),
maxUploadSize: 10 * 1024 * 1024, // bytes
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'],
allowedRemoteOrigins: ['https://example.com', 'https://cdn.example.com'],
prefix: '/api',
});
Register mediaPlugin in the application's common plugin bootstrap. Its register() method configures the shared MediaService and binds the service as media in the container. Keep existing disks when adding media storage to an application. The default /plugin export is the registration entry; /server also provides a named SvelarMediaPlugin export. Service configuration is shared across the public server entries.
basePath is a key prefix within the configured disk. baseUrl must point to a browser-safe file-serving location supplied by the application; the plugin does not mount a file server. Private previews and downloads need an authorized application endpoint. S3 disks can be configured through Svelar Storage; the plugin's s3 option also supports a dedicated S3 disk when disk is 's3'.
2. Add Media to a Model
import { Model } from '@beeblock/svelar/orm';
import { HasMedia } from '@beeblock/svelar-media';
class Post extends HasMedia(Model) {
static table = 'posts';
registerMediaCollections() {
this.addMediaCollection('images')
.acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp'])
.maxFileSize(5 * 1024 * 1024);
this.addMediaCollection('documents')
.acceptsMimeTypes(['application/pdf'])
.singleFile();
}
registerMediaConversions() {
this.addMediaConversion('thumb')
.width(200)
.height(200)
.crop('center')
.performOnCollections('images');
this.addMediaConversion('preview')
.width(800)
.nonQueued();
}
}
3. Upload Media
// In an authorized application action/service
const post = await Post.findOrFail(1);
// From a File object
await post.addMedia(file)
.usingName('hero-image')
.usingFileName('hero.jpg')
.withCustomProperties({ alt: 'Hero image' })
.toCollection('images')
.save();
// From a URL
await post.addMediaFromUrl('https://example.com/photo.jpg')
.usingName('external-photo')
.toCollection('images')
.save();
URL imports require an explicitly allowlisted HTTPS origin in allowedRemoteOrigins; the default empty list denies remote imports. Redirects are rejected, downloads are bounded by maxUploadSize, and remoteTimeoutMs controls the request timeout.
Configuration
The SvelarMediaPlugin constructor accepts the following options:
| Option | Type | Default | Description |
|---|---|---|---|
disk |
string |
'local' |
Named Svelar storage disk |
basePath |
string |
'media' |
Key prefix within the disk |
baseUrl |
string |
'/storage/media' |
Application-provided browser URL prefix |
maxUploadSize |
number |
10 * 1024 * 1024 |
Maximum file size in bytes |
allowedMimeTypes |
string[] |
Concrete MIME allowlist below | Exact MIME matches; wildcard entries are not expanded |
connection |
string |
undefined |
Named Svelar database connection; omitted uses the current/default connection |
allowedRemoteOrigins |
string[] |
[] |
Trusted HTTPS origins for URL imports |
remoteTimeoutMs |
number |
10000 |
Remote request timeout in milliseconds |
prefix |
string |
'/api' |
Retained configuration value; published routes remain under /api/media |
s3 |
S3Config |
undefined |
S3 configuration (bucket, region, etc.) |
The default MIME allowlist is image/jpeg, image/png, image/gif, image/webp, image/avif, application/pdf, video/mp4, video/webm, audio/mpeg, audio/ogg, application/zip, text/plain, and text/csv. Model collection limits and per-request rules can further restrict uploads. Image conversions require the optional sharp package and registered conversion definitions.
Core API
HasMedia Mixin
The HasMedia mixin adds media management methods to any Svelar Model:
class Product extends HasMedia(Model) {
static table = 'products';
}
The trait reads the model's declared static primaryKey through getAttribute(). Integer, UUID, ULID, and custom string primary keys are supported. Persist the owner before attaching media; a missing primary key fails validation.
Model target inputs use MediaModelId = string | number. Safe integer numbers become their decimal string representation; nonempty string keys of up to 255 characters retain their exact spelling, including case, leading zeroes, and trailing spaces. NUL characters are rejected. The media table stores model_id as VARCHAR(255), and media.modelId, MediaRecord.model_id, resource responses, and host authorization targets use canonical strings. Media row IDs remain positive numeric IDs. Both model_type and model_id identify the owner exactly; MySQL queries include binary predicates to preserve that identity under case-insensitive collations. Storage paths hash the canonical model ID into a safe directory segment.
For example, an owner model with a generated UUID primary key can declare:
class Asset extends HasMedia(Model) {
static table = 'assets';
static primaryKey = 'asset_key';
static incrementing = false;
static uniqueIds = ['asset_key'];
static uniqueIdType = 'uuid' as const;
}
Its application migration must provide the matching primary/unique key. Use uniqueIdType = 'ulid' for generated ULIDs, or provide a persisted custom key yourself. Authorizers should compare canonical strings, such as target.modelId === String(asset.getAttribute(Asset.primaryKey)).
Instance methods:
| Method | Returns | Description |
|---|---|---|
addMediaCollection(name) |
MediaCollection |
Register a named media collection |
addMediaConversion(name) |
ConversionBuilder |
Register a named image conversion |
addMedia(file) |
MediaAdder |
Start adding a File/Buffer/ArrayBuffer |
addMediaFromUrl(url) |
MediaUrlAdder |
Start adding media from a URL |
getMedia(collection?) |
Promise<Media[]> |
Get all media, optionally filtered by collection |
getFirstMedia(collection?) |
Promise<Media | null> |
Get the first media item in a collection |
getFirstMediaUrl(collection?, conversion?) |
Promise<string> |
Get URL of the first media item |
clearMediaCollection(collection) |
Promise<void> |
Remove all media from a collection |
getMediaCount(collection?) |
Promise<number> |
Count media items |
MediaAdder (Fluent Builder)
Returned by model.addMedia(file):
await post.addMedia(file)
.usingName('my-photo')
.usingFileName('photo.jpg')
.withMimeType('image/jpeg')
.withCustomProperties({ caption: 'A beautiful sunset' })
.toDisk('s3')
.withOrder(1)
.toCollection('images')
.save();
| Method | Description |
|---|---|
.usingName(name) |
Set the media name |
.usingFileName(fileName) |
Set the preferred file name; storage sanitizes it and chooses an extension from the MIME type |
.withMimeType(mimeType) |
Set the MIME type |
.withCustomProperties(props) |
Attach custom key-value metadata |
.toDisk(disk) |
Choose a named storage disk |
.withOrder(order) |
Set the display order |
.toCollection(collection) |
Choose a named collection; defaults to 'default' |
.save() |
Validate, store, and return the media item |
MediaUrlAdder
Returned by model.addMediaFromUrl(url):
await post.addMediaFromUrl('https://cdn.example.com/photo.jpg')
.usingName('cdn-photo')
.toCollection('images')
.save();
Supports .usingName(), .withCustomProperties(), .toDisk(), .withOrder(), .toCollection(), and .save(). File name and MIME type come from the trusted download.
MediaCollection
Configure collection constraints:
this.addMediaCollection('avatar')
.singleFile() // Only one file allowed
.acceptsMimeTypes(['image/jpeg', 'image/png']) // Restrict MIME types
.maxFileSize(2 * 1024 * 1024); // Max size in bytes
Use .maxFiles(count) for a collection limit and .toDisk(name) on the media adder to select its disk. A single-file upload stores the replacement before removing the previously loaded item; this sequence does not provide an atomic collection limit across concurrent uploads.
Media Class
Extends the Svelar ORM Model and represents a stored media item:
const media = await post.getFirstMedia('images');
media.id; // number
media.modelId; // canonical string owner identifier
media.name; // string
media.fileName; // string
media.mimeType; // string
media.size; // number (bytes)
media.collection; // string
media.disk; // named disk (string)
media.customProperties; // Record<string, unknown>
media.orderColumn; // number
media.getUrl(); // Full URL to the original file
media.getUrl('thumb'); // Full URL to a conversion
media.getPath(); // Stored relative key (without basePath)
media.toJSON(); // Serializable object
Use media.delete(), media.move(collection), and media.setCustomProperty(key, value) for its supported mutations. Authorize trusted service calls in the application. MediaResource provides browser-facing JSON with parsed custom properties and conversion URLs while omitting storage paths and disk names; the model's toJSON()/toRecord() expose its stored record.
MediaService
Low-level service for direct media operations:
import { MediaService } from '@beeblock/svelar-media';
const service = MediaService.instance(); // configured by plugin registration
const media = await service.store({
modelType: 'posts',
modelId: post.getAttribute(Post.primaryKey),
collection: 'images',
file: buffer,
fileName: 'photo.jpg',
mimeType: 'image/jpeg',
size: buffer.length,
});
await service.getMedia('posts', post.getAttribute(Post.primaryKey), 'images');
await service.getMediaById(media.id);
await service.deleteMedia(media);
MediaService.configure(options) is an alternative for applications configuring the service directly. Use the connection name to select a core-managed database connection. The service persists through ORM models/repositories and uses the configured disk for originals, conversions, and deletion; direct service calls do not apply HTTP authorization. File storage and database writes are separate operations, so application workflows must account for partial failures.
Image Conversions
ConversionBuilder
Define conversions with a fluent API:
this.addMediaConversion('thumb')
.width(200)
.height(200)
.crop('center')
.quality(80)
.format('webp')
.nonQueued() // Compatibility flag; processing is currently inline
.performOnCollections('images'); // Only apply to specific collections
| Method | Description |
|---|---|
.width(px) |
Set output width |
.height(px) |
Set output height |
.quality(n) |
Set compression quality (1-100) |
.format(fmt) |
Convert to format ('webp', 'png', 'jpeg') |
.crop(position) |
Use a crop position such as 'center', 'top', or 'bottom-right' |
.nonQueued() / .queued() |
Compatibility flags; neither dispatches a queue job |
.performOnCollections(...names) |
Restrict to specific collections |
ImageConverter
Performs the actual image transformation using Sharp:
import { ConversionBuilder, ImageConverter } from '@beeblock/svelar-media/conversions';
const converter = await ImageConverter.create();
const definition = new ConversionBuilder('thumb')
.width(200).height(200).crop('center').quality(80).format('webp').build();
const converted = await converter.convert(inputBuffer, definition);
const outputBuffer = converted.buffer;
ConversionWorker
Processes registered conversions through the media item's storage disk:
import { ConversionWorker } from '@beeblock/svelar-media/conversions';
const definitions = post.getRegisteredConversions();
await ConversionWorker.processConversions(media, definitions);
await ConversionWorker.regenerateConversions(media, definitions);
await ConversionWorker.processBatch(mediaItems, definitions);
The automatic conversion path is inline and best-effort: missing Sharp, unreadable originals, or individual conversion failures can leave an original without a conversion. media.hasConversion(name) reports what was generated. Durable background conversion work requires an application-owned Svelar queue job.
Server-Side
MediaController
Handles media upload, retrieval, and deletion API routes:
Configure Svelar authentication so event.locals.user is trusted request-local state. Register an application-owned resolver in the common server bootstrap before binding routes:
import { MediaController } from '@beeblock/svelar-media/server';
import { authorizeMediaTarget } from '$lib/shared/media-access.server';
MediaController.configure({ authorize: authorizeMediaTarget });
authorizeMediaTarget is an application function with the MediaAuthorizer signature (event, operation, target) => boolean | Promise<boolean>. It must check the operation's registered capability and the persisted owner's policy/tenant access. Register literal capabilities and synchronize their catalogs/profiles in application seeders as described in the permissions guide. Resolve target.modelType through a trusted allowlist and compare target.modelId as a canonical string.
The controller fails closed: anonymous requests return 401; a missing resolver or a denied target returns 403. Generic endpoints also reject Bearer/API-key/Authorization-header requests; supplying the host resolver does not enable a credential-ceiling-aware token route. Mutations retain Svelar origin and CSRF middleware checks, including the default X-CSRF-Token header and XSRF-TOKEN cookie.
// src/routes/api/media/+server.ts
import { MediaController } from '@beeblock/svelar-media/server';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = (event) => MediaController.index(event);
export const POST: RequestHandler = (event) => MediaController.upload(event);
// src/routes/api/media/[id]/+server.ts
import { MediaController } from '@beeblock/svelar-media/server';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = (event) => MediaController.show(event);
export const DELETE: RequestHandler = (event) => MediaController.destroy(event);
index reads model_type, model_id, and optional collection query parameters. The item methods accept the numeric params.id or an id query parameter. Additional static handlers are reorder, updateProperties, and clearCollection; reordering authorizes every numeric media row ID and restricts the request to one owner/collection. Resource success responses use { success: true, data }; validation failures return 422 with validation details where available.
MediaRequest
Use the public parsing helpers or the exported FormRequest classes; there is no exported MediaRequest class:
import { parseMediaUpload, validateMediaUpload } from '@beeblock/svelar-media/server';
// Called within an already authorized application write flow.
const parsed = await parseMediaUpload(event.request);
validateMediaUpload(parsed, { maxSize: 5 * 1024 * 1024 });
// parsed.file, parsed.collection, parsed.modelType, parsed.modelId, etc.
The upload is multipart form data with file, model_type, and model_id; optional fields are collection, name, and JSON-object custom_properties. MediaUploadRequest, MediaDeleteRequest, MediaReorderRequest, MediaPropertiesRequest, and MediaClearRequest validate shared exported schemas and produce DTOs for the controller's action/service flow. Parsing helpers alone do not authorize a target.
UI Components
MediaUploader
Drag-and-drop file uploader with progress:
<script lang="ts">
import { MediaUploader } from '@beeblock/svelar-media/ui';
function handleUpload(media: any) {
console.log('Uploaded:', media);
}
</script>
<MediaUploader
uploadUrl="/api/media"
collection="images"
modelType="posts"
modelId={1}
accept="image/*"
multiple={true}
maxFiles={5}
maxFileSize={5 * 1024 * 1024}
onUploadComplete={handleUpload}
/>
modelId accepts a string or number. The uploader uses same-origin URLs, reads the default CSRF cookie unless csrfToken is supplied, and passes the response's data item to onUploadComplete. Client accept, maxFiles, and maxFileSize checks supplement server rules.
MediaGallery
Display a grid of media items with preview and actions:
<script lang="ts">
import { MediaGallery } from '@beeblock/svelar-media/ui';
let media = $state([]);
</script>
<MediaGallery
items={media}
columns={3}
selectable={true}
deletable={true}
onSelect={(item) => console.log('Selected:', item)}
onDelete={(item) => console.log('Delete:', item)}
/>
MediaPreview
Single media item preview (image, PDF, etc.):
<script lang="ts">
import { MediaPreview } from '@beeblock/svelar-media/ui';
</script>
<MediaPreview
item={mediaItem}
aspectRatio="1"
/>
Migration SQL
Publish and run the actual Svelar migration for the media table:
npx svelar plugin:publish @beeblock/svelar-media
npx svelar migrate
The published file is src/lib/database/migrations/create_media_table.ts, a Migration subclass using the Svelar schema builder. It creates the numeric media row key, model_type VARCHAR(100), model_id VARCHAR(255), the stored path, metadata fields, timestamps, and owner/collection indexes. The service supplies JSON text values for custom_properties and conversions; the portable migration does not use MySQL-incompatible literal defaults on TEXT columns. Table creation belongs in migrations, never application bootstrap or request handlers. MEDIA_MIGRATION_SQL remains a legacy SQLite-specific export; it is not the portable migration or an automatic runtime installer.
This is an alpha plugin schema. Adjust an unpublished migration before its first run if needed. If an application has already run an older integer-model_id migration, publishing a new template does not upgrade that database. Generate a focused application migration, preserve existing records and stored paths, convert owner IDs to canonical strings, and retain the numeric media row key and indexes:
npx svelar make:migration migrate_media_model_identifiers
Review the database-specific conversion against existing data before applying it; keep UTF-8 storage and exact owner type/ID comparisons. Do not drop the table or rerun its create migration to upgrade an existing application.
Full Working Example
The page's authorized server load should supply serialized MediaResource items as data.media. These routes rely on the storage, authentication, and application capability/policy resolver configured above.
<!-- src/routes/posts/[id]/media/+page.svelte -->
<script lang="ts">
import { MediaUploader, MediaGallery } from '@beeblock/svelar-media/ui';
import { apiFetch } from '@beeblock/svelar/http';
interface Props {
data: { post: any; media: any[] };
}
let { data }: Props = $props();
let media = $state<any[]>([]);
$effect(() => { media = data.media; });
async function handleUpload(newMedia: any) {
media = [...media, newMedia];
}
async function handleDelete(item: any) {
const response = await apiFetch(`/api/media/${item.id}`, { method: 'DELETE' });
if (response.ok) media = media.filter((m) => m.id !== item.id);
}
</script>
<h1>Post Media</h1>
<MediaUploader
uploadUrl="/api/media"
collection="images"
modelType="posts"
modelId={data.post.id}
accept="image/*"
multiple={true}
onUploadComplete={handleUpload}
/>
<MediaGallery
items={media}
columns={4}
deletable={true}
onDelete={handleDelete}
/>
// src/routes/api/media/+server.ts
import { MediaController } from '@beeblock/svelar-media/server';
import type { RequestHandler } from './$types';
// Application bootstrap has configured MediaController's capability/policy resolver.
export const GET: RequestHandler = (event) => MediaController.index(event);
export const POST: RequestHandler = (event) => MediaController.upload(event);
// src/routes/api/media/[id]/+server.ts
import { MediaController } from '@beeblock/svelar-media/server';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = (event) => MediaController.show(event);
export const DELETE: RequestHandler = (event) => MediaController.destroy(event);