@dudousxd/nestjs-media-dashboard
Standalone /media console — disks, live uploads, and the media library, with your choice of auth.
A bundled React SPA + JSON API you mount alongside MediaModule to browse disks, watch live uploads, and inspect the media library — without writing an admin UI of your own.
import { MediaDashboardModule } from '@dudousxd/nestjs-media-dashboard';
@Module({
imports: [
MediaModule.forRoot({ /* … */ }),
MediaDashboardModule.forRoot({
basePath: '/media',
apiBasePath: '/api/media/console',
}),
],
})
export class AppModule {}Exclude both paths from any global /api prefix — the SPA (basePath) is a page route, not an endpoint.
MediaDashboardModule.forRoot(options)
interface MediaDashboardOptions {
basePath?: string; // default '/media'
apiBasePath?: string; // default '<basePath>/api'
actions?: boolean; // enable delete/copy/move/abort — default false (read-only)
auth?: ConsoleAuthOptions; // built-in session-cookie login
guards?: Array<Type<CanActivate> | CanActivate>; // bring-your-own guard(s)
imports?: DynamicModule['imports']; // DI resolution path for `guards`
objectInsights?: ObjectInsightProvider[]; // host context shown in the preview
objectUrls?: 'auto' | 'proxy'; // presigned store URLs, or same-origin — default 'auto'
}| Option | Purpose |
|---|---|
basePath | Where the SPA is served. Keep it out of an /api prefix so it reads as a UI, not an endpoint. |
apiBasePath | Where the JSON API is mounted (what the SPA fetches). Put it under your app's /api prefix so it inherits the app's proxy/auth rules. |
actions | Enables the destructive endpoints (delete object/record, copy/move, abort upload). The read API is always available. |
auth | Telescope-style built-in session-cookie login — see below. |
guards / imports | Front the console with your own CanActivate(s) instead of, or alongside, auth — see Securing the console. |
objectInsights | Annotate an object in the preview with what your app knows about it — see Object insights. |
objectUrls | Whether an object's reported url is presigned straight to the store or routed through this server — see Reaching an object's bytes. |
MediaDashboardModule.forRootAsync(options) mirrors this, resolving auth through an injected useAuth(...deps) factory (for hooks that need your DB/services). guards and the mount paths stay static fields on the async options object — guard stamping happens at module-build time, before any factory runs.
Built-in auth
Omit auth to leave the console open (front it another way — a reverse proxy, or guards below). Set it to gate the console behind a signed session cookie with your own login/session hooks:
MediaDashboardModule.forRoot({
auth: {
secret: process.env.CONSOLE_SECRET!,
ttl: '8h',
login: async (username, password) => {
const user = await accounts.verify(username, password);
return user ? { id: user.id, roles: user.roles } : null;
},
},
});The SPA renders a login screen until a valid cookie exists. A cookie past half its TTL is silently renewed on the next request (sliding session).
The password submitted is passed through to login verbatim — including an empty string, since the built-in login screen never requires one — the hook owns whether it matters.
Re-checking the user on renewal with revalidate
Sliding renewal alone never consults the host again — a deactivated or demoted operator would keep console access for as long as the tab stayed open. Add revalidate to re-check on that same renewal path:
MediaDashboardModule.forRoot({
auth: {
secret: process.env.CONSOLE_SECRET!,
login: async (username, password) => {
const user = await accounts.verify(username, password);
return user ? { id: user.id, roles: user.roles } : null;
},
revalidate: async ({ id }) => {
const account = await accounts.findById(id);
return account?.active === true;
},
},
});revalidate receives the already-minted session user ({ id, name?, roles }), not the raw request — the console's own XHRs carry no host credential, so re-running session here would just return null and log everyone out. Returning false, or throwing, clears the cookie and denies the request with the same 401 an absent cookie gets.
revalidate only runs when a cookie is renewed (past half its TTL), so revocation isn't immediate — a revoked operator can keep console access for up to ttl / 2 after the change lands. It's also not an auth mode on its own: it can't mint a session, so it doesn't count toward the login/session "at least one required" boot check.
Your own page for an unauthenticated visit with unauthenticatedPage
Under Mode A (session), a visitor who navigates straight to /media with no cookie gets the SPA, which then renders its built-in auth screen: "open this console from your application". That copy is generic because the library cannot know who hosts it — it can't name your launcher, link to it, or look like the rest of your product.
unauthenticatedPage hands that response to you instead — and, because the decision happens before the shell is served, the bundle never loads at all for someone with no session:
MediaDashboardModule.forRoot({
auth: {
secret: process.env.CONSOLE_SECRET!,
session: (request) => resolveAdmin(request),
unauthenticatedPage: ({ request, response, basePath }) => {
// `request`/`response` are your platform's own objects (Express here). Render however you
// already render — a template engine, @dudousxd/nestjs-inertia, a plain string.
(response as Response).status(401).render('console-locked', { returnTo: basePath });
},
},
});The hook owns the response: it must write AND end it. The page is served at the console's own URL, so /media stays /media.
It cannot open the console — it only runs when the request has no valid session, and every data route stays behind the console's own guard regardless. If it throws, or returns without writing, the library logs one warning and serves the SPA as before, so a broken page can't hang the request or turn a navigation into a 500.
Mode-A-only. With login configured the hook is ignored, because under Mode B the login form the visitor needs is inside the bundle this page would replace — gating the shell would lock you out of your own console. To ship your own login UI, use Mode A plus this hook and POST <apiBasePath>/session from your page.
Previews
Clicking Preview on an object opens it inline, with a renderer chosen from its content type first,
its filename second, and the broad image/*-style families last. The name usually beats the label:
an object uploaded to S3 without an explicit type arrives as application/octet-stream, which
describes nothing.
| Renderer | Opened for | How it reads |
|---|---|---|
| Image / video / audio | image/*, video/*, audio/* and the usual extensions | The object's own URL |
application/pdf, .pdf | Streamed inline through the same-origin proxy, so a signed URL carrying Content-Disposition: attachment still renders instead of downloading | |
| Text | text/*, .txt .json .csv .tsv .log .xml .yaml .sql | Head sample (8 MB), rendered raw, as pretty JSON, or as a CSV/TSV grid |
| Markdown | text/markdown, .md | Head sample, rendered and sanitized, with a source toggle |
| NDJSON | .ndjson .jsonl | Head sample, one object per line, columns unioned across rows |
| Spreadsheet | .xlsx .xls .xlsm .ods | Whole file (a workbook is a zip and can't be sampled), capped at 15 MB |
| SQLite | .sqlite .sqlite3 .db | Byte ranges — the database is queried in place, never downloaded |
| Parquet | .parquet | Byte ranges — footer, then only the column chunks the visible rows need |
| Archive | .zip .jar .whl .tar .tgz | Byte ranges — the ZIP central directory lives at the end of the file, so listing a 2 GB archive costs tens of KB. Clicking a text entry reads and inflates just that entry. tar/tar.gz have no index and are listed from a labelled head sample |
| Certificate | .pem .crt .cer .der | Parsed to subject/issuer/validity/SANs. Private key blocks are named and never rendered |
| Hex | anything else | Byte ranges — a windowed hex + ASCII dump you can seek through |
The last row is the important one: there is no "no preview available" state any more. Whatever the console cannot identify is still bytes, and bytes render as hex.
The range-backed renderers need a disk whose driver reports capabilities.ranged.
Both bundled drivers do. On a disk that doesn't, they say so rather than pulling the whole object
down.
Everything past the plain media types is code-split: opening the console does not load the SQLite engine, the Parquet reader, SheetJS, the inflater, the X.509 parser or the Markdown renderer. Each arrives on the first preview that needs it.
Object insights
The console can describe a file only as storage sees it: key, size, content type, last modified.
Everything that makes the file mean something lives in your app — which knowledge base indexed
this PDF, which work order this scan belongs to, whether processing has run. objectInsights is the
seam for handing that back:
MediaDashboardModule.forRoot({
objectInsights: [
{
id: 'knowledge-base',
async resolve({ disk, key }) {
if (!key.startsWith('rag/')) return null; // not ours — render nothing
const doc = await ingestionLog.get(key);
if (!doc) return null;
return {
title: 'Knowledge base',
facts: [
{ label: 'Status', value: doc.status },
{ label: 'Chunks', value: String(doc.chunks ?? 0) },
],
links: [{ label: 'Open in the control panel', href: `/ctrl/rag/${doc.collection}` }],
};
},
},
],
});Use forRootAsync({ useObjectInsights, injectObjectInsights }) when a provider needs injected
services — which is the usual case, since a provider that says anything interesting needs a
repository. injectObjectInsights defaults to inject (the list useAuth uses) but is its own
option, because insight providers rarely want the same dependencies the auth hooks do.
Data, not components. A provider returns an ObjectInsight — title, plus optional facts
(label/value rows), links and a one-line note — and the console renders it. It is not a slot you
draw into: the console ships as a prebuilt SPA bundle, so there is nowhere for a host to inject
React. The cost is a fixed vocabulary; the benefit is that the console stays ignorant of every
domain plugged into it.
Three behaviours worth knowing:
nullmeans "nothing to say" and renders nothing. Most providers care about one key prefix, so this is the common return.- A provider that throws is skipped, logged server-side, and the others still render. Annotation must never be able to stop an admin opening a file.
resolveruns on every preview, so keep it to one indexed lookup. Providers run concurrently.
Link hrefs must be relative (/ctrl/...) or http(s). Protocol-relative (//host) and other
schemes (javascript:, data:) are dropped rather than rendered — the guard for a provider that
interpolated user-supplied text into a URL.
Securing the console with your own guards
auth is the console's own turnkey login. If your app already has an auth guard — a session/cookie check, an InertiaAuthGuard, whatever fronts the rest of your app — you don't need a second login system. guards (+ imports for its dependencies) lets you front the console with THAT guard instead, on a single forRoot/forRootAsync call:
MediaDashboardModule.forRoot({
guards: [ConsoleAuthGuard],
imports: [AuthModule], // resolves ConsoleAuthGuard's own dependencies
});The guard fronts BOTH the page (a full-page GET to basePath) and the JSON API (fetch/XHR calls the SPA makes). A full-page navigation carries only cookies, never an Authorization header — a guard that only reads a bearer token will 401 every browser visit to the console, even an already-logged-in admin's. Authenticate from a cookie (optionally falling back to a header for callers that do carry one), and respond differently per surface: a redirect for the page, a plain 401/403 for the API.
guards is stamped on the read + action JSON API controllers alongside their own built-in MediaConsoleGuard (a no-op unless you also configured auth) — a request must pass BOTH. It is deliberately not applied to the auth controller that mints the auth session cookie itself (/login, /session, /logout, /me): that controller can't be made to require the very auth it grants. guards and auth compose — set one, the other, or both:
MediaDashboardModule.forRoot({
auth: { secret: process.env.CONSOLE_SECRET!, login: /* … */ },
guards: [ConsoleAuthGuard], // e.g. "must already be signed into the app"
imports: [AuthModule],
});With both configured, a request needs a valid ConsoleAuthGuard pass and a valid console session cookie — useful when the console should require your app's own SSO on top of its own separate login.
Programmatic access with MediaConsoleService
Everything the console UI does, it does through one injectable — MediaConsoleService. It's the read + action logic behind the JSON API, and because MediaDashboardModule re-exports it you can inject it into your own code (a cron that prunes stale uploads, a support tool, a custom admin screen) without going through HTTP:
import { MediaConsoleService } from '@dudousxd/nestjs-media-dashboard';
@Injectable()
export class HousekeepingService {
constructor(private readonly console: MediaConsoleService) {}
async report() {
const { disks } = this.console.listDisks();
const topology = this.console.topology(); // { hasStore, hasUploads, disks, actions }
for (const disk of disks) {
const { folders, files, cursor } = await this.console.listObjects(disk.name, { prefix: '' });
// …walk folders, page with `cursor`…
}
// Library (needs a MediaStore that implements the query SPI — see Custom store)
const { records } = await this.console.listLibrary({ collection: 'gallery', limit: 100 });
}
}It resolves the storage manager, the MediaStore, and the upload-session store as optional dependencies, so a host missing any of them still boots — each method degrades to an empty shape ({ disks: [] }, { records: [] }, …) instead of throwing. The full surface:
| Method | Returns | Notes |
|---|---|---|
listDisks() | DiskListResponse | Registered disks + each one's capabilities. |
listObjects(disk, { prefix, cursor, limit }) | ObjectListResponse | One level of a disk — { folders, files, cursor }. See Folders. |
objectDetail(disk, key) | ObjectDetailResponse | Size, content type, and a preview/download URL (signed on presign-capable disks). |
objectStream(disk, key, range?) | { stream, contentType, size, range? } | Raw byte stream for the inline preview proxy. With a range it streams just that slice (both bounds inclusive); size stays the whole object, so the controller can build Content-Range. Unsatisfiable → RangeNotSatisfiableException (416). |
putObject(disk, key, body, contentType?) | void | Writes an object from a stream. Buffered + size-capped. Action. |
createFolder / deleteFolder | void | Folder marker create / recursive delete. Action. |
copyObject / moveObject | void | Single-object copy/move, same disk or cross-disk. Action. |
moveFolder / copyFolder | void | Whole-subtree copy/move. Action. See Folders. |
listUploads({ disk?, prefix? }) | UploadListResponse | In-progress resumable sessions. |
uploadDetail(id) / abortUpload(id) | UploadDetailResponse / void | Inspect / cancel a session (abort is an action). |
listCollections() | CollectionsResponse | Per-collection counts + byte totals (uses MediaStore.aggregate). |
listLibrary({ collection?, disk?, cursor?, limit? }) | LibraryListResponse | Paginated media records (uses MediaStore.list). |
libraryDetail(id) / deleteLibraryRecord(id) | LibraryDetailResponse / void | One record + variant URLs / delete (delete is an action). |
topology() | Topology | What's wired: { hasStore, hasUploads, disks, actions }. |
Library methods need the query SPI
listCollections/listLibrary require a MediaStore that implements the optional count/aggregate/list methods. The four bundled ORM stores do; a custom store returns empty shapes until it adds them — see the MediaStore query SPI.
The JSON API
The SPA talks to the service over a small JSON API mounted at apiBasePath. It's split across three controllers, and every route below is relative to apiBasePath (e.g. /media/api). The read controller is always mounted; the action controller only when actions: true; the auth controller (/me, /login, /logout, /session) is always mounted and never guarded (it mints the very session the guard checks).
The response shapes are exported as types — see the client subpath.
Read endpoints
| Method + path | Query | Response |
|---|---|---|
GET disks | — | DiskListResponse |
GET disks/:disk/objects | prefix?, cursor?, limit? | ObjectListResponse |
GET disks/:disk/object | key | ObjectDetailResponse |
GET disks/:disk/object/raw | key | raw bytes, Content-Disposition: inline |
GET disks/:disk/object/download | key | raw bytes, Content-Disposition: attachment |
GET disks/:disk/object/insights | key | ObjectInsightsResponse |
GET uploads | disk?, prefix? | UploadListResponse |
GET uploads/:id | — | UploadDetailResponse |
GET library/collections | — | CollectionsResponse |
GET library | collection?, disk?, cursor?, limit? | LibraryListResponse |
GET library/:id | — | LibraryDetailResponse |
GET topology | — | Topology |
Actions
Present only with actions: true. All return 204 No Content. On copy/move routes the :disk in the path is the source; the JSON body's optional toDisk names a cross-disk destination.
| Method + path | Body / query |
|---|---|
DELETE disks/:disk/object | key (query) |
POST disks/:disk/copy | { from, to, toDisk? } |
POST disks/:disk/move | { from, to, toDisk? } |
POST disks/:disk/upload | key + type? (query); raw application/octet-stream body |
POST disks/:disk/folder | { prefix } |
DELETE disks/:disk/folder | prefix (query) |
POST disks/:disk/move-folder | { from, to, toDisk? } |
POST disks/:disk/copy-folder | { from, to, toDisk? } |
POST uploads/:id/abort | — |
DELETE library/:id | — |
The folder routes are covered in depth on the Folders page.
Reaching an object's bytes
There are two ways the console gets at a file, and they answer different questions.
Downloading — the Download button on a file row, in the preview header, and on a library record
— always goes through GET disks/:disk/object/download, this server's own same-origin route. It
answers with Content-Disposition: attachment named after the key's last segment (in both RFC 6266
forms, so a non-ASCII name survives), and it honours Range, so an interrupted download of a large
object resumes rather than restarting. It is a read: it needs no actions: true.
Being same-origin is the point. Saving a file is the console's own action, so it has to work on a host whose browser cannot reach the object store at all — no network route to the bucket, no CORS grant, or a policy against client-to-store traffic.
Opening — the Open ↗ link, and an image variant's src — uses the url reported by
GET disks/:disk/object and GET library/:id. That one is configurable:
MediaDashboardModule.forRoot({
objectUrls: 'proxy', // default: 'auto'
})| Value | url is | Use it when |
|---|---|---|
auto (default) | a presigned, expiring URL straight to the store (5 min), or the driver's plain URL when it cannot presign | the browser can reach the store. The URL survives a copy-paste out of the console and needs nothing from this server. |
proxy | the console's own object/raw route, same-origin | the browser cannot reach the store. The trade: the URL only means anything to a session that can reach this server, and every byte travels through it. |
objectUrls does not affect downloading — that route is same-origin either way.
The typed browser client (/client subpath)
@dudousxd/nestjs-media-dashboard/client is a pure-types + tiny-runtime entry, safe to import into any browser bundle — it carries no NestJS. It exports mediaConsoleClient, a typed wrapper over every endpoint above (it rides the session cookie via credentials: 'same-origin'), plus every response type. This is what the bundled SPA uses, and what you'd import to build your own admin screen against the same API:
import {
mediaConsoleClient,
type MediaConsoleClient,
type ObjectListResponse,
} from '@dudousxd/nestjs-media-dashboard/client';
// Auth gate: 'open' | 'authenticated' | 'login'
const state = await mediaConsoleClient.me();
const { disks } = await mediaConsoleClient.disks();
const page: ObjectListResponse = await mediaConsoleClient.objects('s3', { prefix: 'photos/' });
const { record, variants } = await mediaConsoleClient.libraryRecord('abc123');
// Actions (need `actions: true` on the server)
await mediaConsoleClient.createFolder('s3', 'invoices/2024');
await mediaConsoleClient.moveObject('s3', 'a/x.jpg', 's3', 'b/x.jpg');
await mediaConsoleClient.uploadObject('s3', 'imports/data.csv', file); // raw bytes, MIME preservedIt also carries a few browser-only conveniences the raw API doesn't: objectRawUrl(disk, key) (a same-origin inline URL to embed a preview a signed cross-origin URL would force-download), objectTextHead(disk, key, maxBytes) (sample the head of a large text file without pulling it all down), objectBytes(disk, key) (fetch raw bytes for binary previews), and objectRange(disk, key, start, end) (fetch one byte range — end inclusive). objectRange throws if the server answers 200 instead of 206: a proxy that strips the Range header would otherwise stream the entire object into what the caller believes is a 64 KB page. MediaConsoleClient is the type of the whole client object, handy for wrapping or mocking it.
By default it fetches from /media/api; the UI controller injects the real base at runtime via window.__MEDIA_API__ / window.__MEDIA_BASE__, so a non-default apiBasePath is picked up automatically.
MediaConsoleApiModule
MediaDashboardModule is the batteries-included mount — SPA plus API at fixed paths. Underneath it sits MediaConsoleApiModule, the API-only dynamic module that holds the three controllers and MediaConsoleService. MediaDashboardModule composes it (and re-exports it, which is why MediaConsoleService is injectable in importers). You rarely register it yourself, but it's exported for hosts that serve the SPA their own way (a separate static host, a different framework) and only want the JSON API wired into Nest with custom routing:
import { MediaConsoleApiModule } from '@dudousxd/nestjs-media-dashboard';Its register() takes the same building blocks MediaDashboardModule feeds it — actions, the auth provider, guards/imports, and the cookie Path — and exports MediaConsoleService. Prefer MediaDashboardModule.forRoot/forRootAsync unless you specifically need to own the mount.
Peer dependencies
@dudousxd/nestjs-media-core, @nestjs/common / @nestjs/core (^10, ^11 or ^12), and rxjs. The bundled SPA ships its own React copy — no extra frontend install needed.