Aviary
Packages

@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'
}
OptionPurpose
basePathWhere the SPA is served. Keep it out of an /api prefix so it reads as a UI, not an endpoint.
apiBasePathWhere 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.
actionsEnables the destructive endpoints (delete object/record, copy/move, abort upload). The read API is always available.
authTelescope-style built-in session-cookie login — see below.
guards / importsFront the console with your own CanActivate(s) instead of, or alongside, auth — see Securing the console.
objectInsightsAnnotate an object in the preview with what your app knows about it — see Object insights.
objectUrlsWhether 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.

RendererOpened forHow it reads
Image / video / audioimage/*, video/*, audio/* and the usual extensionsThe object's own URL
PDFapplication/pdf, .pdfStreamed inline through the same-origin proxy, so a signed URL carrying Content-Disposition: attachment still renders instead of downloading
Texttext/*, .txt .json .csv .tsv .log .xml .yaml .sqlHead sample (8 MB), rendered raw, as pretty JSON, or as a CSV/TSV grid
Markdowntext/markdown, .mdHead sample, rendered and sanitized, with a source toggle
NDJSON.ndjson .jsonlHead sample, one object per line, columns unioned across rows
Spreadsheet.xlsx .xls .xlsm .odsWhole file (a workbook is a zip and can't be sampled), capped at 15 MB
SQLite.sqlite .sqlite3 .dbByte ranges — the database is queried in place, never downloaded
Parquet.parquetByte ranges — footer, then only the column chunks the visible rows need
Archive.zip .jar .whl .tar .tgzByte 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 .derParsed to subject/issuer/validity/SANs. Private key blocks are named and never rendered
Hexanything elseByte 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 ObjectInsighttitle, 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:

  • null means "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.
  • resolve runs 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:

MethodReturnsNotes
listDisks()DiskListResponseRegistered disks + each one's capabilities.
listObjects(disk, { prefix, cursor, limit })ObjectListResponseOne level of a disk — { folders, files, cursor }. See Folders.
objectDetail(disk, key)ObjectDetailResponseSize, 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?)voidWrites an object from a stream. Buffered + size-capped. Action.
createFolder / deleteFoldervoidFolder marker create / recursive delete. Action.
copyObject / moveObjectvoidSingle-object copy/move, same disk or cross-disk. Action.
moveFolder / copyFoldervoidWhole-subtree copy/move. Action. See Folders.
listUploads({ disk?, prefix? })UploadListResponseIn-progress resumable sessions.
uploadDetail(id) / abortUpload(id)UploadDetailResponse / voidInspect / cancel a session (abort is an action).
listCollections()CollectionsResponsePer-collection counts + byte totals (uses MediaStore.aggregate).
listLibrary({ collection?, disk?, cursor?, limit? })LibraryListResponsePaginated media records (uses MediaStore.list).
libraryDetail(id) / deleteLibraryRecord(id)LibraryDetailResponse / voidOne record + variant URLs / delete (delete is an action).
topology()TopologyWhat'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 + pathQueryResponse
GET disksDiskListResponse
GET disks/:disk/objectsprefix?, cursor?, limit?ObjectListResponse
GET disks/:disk/objectkeyObjectDetailResponse
GET disks/:disk/object/rawkeyraw bytes, Content-Disposition: inline
GET disks/:disk/object/downloadkeyraw bytes, Content-Disposition: attachment
GET disks/:disk/object/insightskeyObjectInsightsResponse
GET uploadsdisk?, prefix?UploadListResponse
GET uploads/:idUploadDetailResponse
GET library/collectionsCollectionsResponse
GET librarycollection?, disk?, cursor?, limit?LibraryListResponse
GET library/:idLibraryDetailResponse
GET topologyTopology

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 + pathBody / query
DELETE disks/:disk/objectkey (query)
POST disks/:disk/copy{ from, to, toDisk? }
POST disks/:disk/move{ from, to, toDisk? }
POST disks/:disk/uploadkey + type? (query); raw application/octet-stream body
POST disks/:disk/folder{ prefix }
DELETE disks/:disk/folderprefix (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'
})
Valueurl isUse it when
auto (default)a presigned, expiring URL straight to the store (5 min), or the driver's plain URL when it cannot presignthe browser can reach the store. The URL survives a copy-paste out of the console and needs nothing from this server.
proxythe console's own object/raw route, same-originthe 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 preserved

It 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.

On this page