@adonis-agora/media 0.10.4 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +105 -11
  3. package/dist/assets/spa/assets/index--0MZJr6W.css +1 -0
  4. package/dist/assets/spa/assets/index-Bo658_9z.js +83 -0
  5. package/dist/assets/spa/assets/index-Bo658_9z.js.map +1 -0
  6. package/dist/assets/spa/index.html +20 -0
  7. package/dist/configure.d.ts +6 -2
  8. package/dist/configure.d.ts.map +1 -1
  9. package/dist/configure.js +8 -2
  10. package/dist/configure.js.map +1 -1
  11. package/dist/providers/dashboard_provider.d.ts +29 -0
  12. package/dist/providers/dashboard_provider.d.ts.map +1 -0
  13. package/dist/providers/dashboard_provider.js +520 -0
  14. package/dist/providers/dashboard_provider.js.map +1 -0
  15. package/dist/providers/media_provider.d.ts.map +1 -1
  16. package/dist/providers/media_provider.js +34 -2
  17. package/dist/providers/media_provider.js.map +1 -1
  18. package/dist/src/dashboard/auth.d.ts +76 -0
  19. package/dist/src/dashboard/auth.d.ts.map +1 -0
  20. package/dist/src/dashboard/auth.js +134 -0
  21. package/dist/src/dashboard/auth.js.map +1 -0
  22. package/dist/src/dashboard/cookie.d.ts +20 -0
  23. package/dist/src/dashboard/cookie.d.ts.map +1 -0
  24. package/dist/src/dashboard/cookie.js +44 -0
  25. package/dist/src/dashboard/cookie.js.map +1 -0
  26. package/dist/src/dashboard/define_config.d.ts +56 -0
  27. package/dist/src/dashboard/define_config.d.ts.map +1 -0
  28. package/dist/src/dashboard/define_config.js +5 -0
  29. package/dist/src/dashboard/define_config.js.map +1 -0
  30. package/dist/src/dashboard/index.d.ts +10 -0
  31. package/dist/src/dashboard/index.d.ts.map +1 -0
  32. package/dist/src/dashboard/index.js +9 -0
  33. package/dist/src/dashboard/index.js.map +1 -0
  34. package/dist/src/dashboard/object_insights.d.ts +42 -0
  35. package/dist/src/dashboard/object_insights.d.ts.map +1 -0
  36. package/dist/src/dashboard/object_insights.js +42 -0
  37. package/dist/src/dashboard/object_insights.js.map +1 -0
  38. package/dist/src/dashboard/service.d.ts +215 -0
  39. package/dist/src/dashboard/service.d.ts.map +1 -0
  40. package/dist/src/dashboard/service.js +470 -0
  41. package/dist/src/dashboard/service.js.map +1 -0
  42. package/dist/src/dashboard/spa.d.ts +29 -0
  43. package/dist/src/dashboard/spa.d.ts.map +1 -0
  44. package/dist/src/dashboard/spa.js +47 -0
  45. package/dist/src/dashboard/spa.js.map +1 -0
  46. package/dist/src/dashboard/types.d.ts +201 -0
  47. package/dist/src/dashboard/types.d.ts.map +1 -0
  48. package/dist/src/dashboard/types.js +10 -0
  49. package/dist/src/dashboard/types.js.map +1 -0
  50. package/dist/stubs/config/media_dashboard.stub +39 -0
  51. package/package.json +16 -4
@@ -0,0 +1,20 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="robots" content="noindex" />
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@400;500;600;700&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <title>Media · Agora</title>
14
+ <script type="module" crossorigin src="/__MEDIA_DASHBOARD__/assets/index-Bo658_9z.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/__MEDIA_DASHBOARD__/assets/index--0MZJr6W.css">
16
+ </head>
17
+ <body>
18
+ <div id="root"></div>
19
+ </body>
20
+ </html>
@@ -3,8 +3,12 @@ import type Configure from '@adonisjs/core/commands/configure';
3
3
  * `node ace configure @adonis-agora/media` — auto-wires the package:
4
4
  *
5
5
  * 1. registers the media service provider in `adonisrc.ts`;
6
- * 2. publishes `config/media.ts`;
7
- * 3. publishes the Lucid migrations for the optional `lucid` store and the resumable (TUS) session
6
+ * 2. registers the dashboard provider — the management console SPA ships embedded in this package
7
+ * (its built assets are copied in at `pnpm build` time), so this alone is enough to get it; no
8
+ * separate `@adonis-agora/media-dashboard` install is required (though that package still exists,
9
+ * standalone, for hosts that prefer registering it directly);
10
+ * 3. publishes `config/media.ts` and `config/media_dashboard.ts`;
11
+ * 4. publishes the Lucid migrations for the optional `lucid` store and the resumable (TUS) session
8
12
  * store (run `node ace migration:run`; delete either if you only use the in-memory equivalents).
9
13
  *
10
14
  * Requires `@adonisjs/drive` to be installed and configured first (`node ace add @adonisjs/drive`).
@@ -1 +1 @@
1
- {"version":3,"file":"configure.d.ts","sourceRoot":"","sources":["../configure.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,SAAS,MAAM,mCAAmC,CAAC;AAG/D;;;;;;;;;GASG;AACH,wBAAsB,SAAS,CAAC,OAAO,EAAE,SAAS,iBAejD"}
1
+ {"version":3,"file":"configure.d.ts","sourceRoot":"","sources":["../configure.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,SAAS,MAAM,mCAAmC,CAAC;AAG/D;;;;;;;;;;;;;GAaG;AACH,wBAAsB,SAAS,CAAC,OAAO,EAAE,SAAS,iBAiBjD"}
package/dist/configure.js CHANGED
@@ -3,8 +3,12 @@ import { stubsRoot } from './stubs/main.js';
3
3
  * `node ace configure @adonis-agora/media` — auto-wires the package:
4
4
  *
5
5
  * 1. registers the media service provider in `adonisrc.ts`;
6
- * 2. publishes `config/media.ts`;
7
- * 3. publishes the Lucid migrations for the optional `lucid` store and the resumable (TUS) session
6
+ * 2. registers the dashboard provider — the management console SPA ships embedded in this package
7
+ * (its built assets are copied in at `pnpm build` time), so this alone is enough to get it; no
8
+ * separate `@adonis-agora/media-dashboard` install is required (though that package still exists,
9
+ * standalone, for hosts that prefer registering it directly);
10
+ * 3. publishes `config/media.ts` and `config/media_dashboard.ts`;
11
+ * 4. publishes the Lucid migrations for the optional `lucid` store and the resumable (TUS) session
8
12
  * store (run `node ace migration:run`; delete either if you only use the in-memory equivalents).
9
13
  *
10
14
  * Requires `@adonisjs/drive` to be installed and configured first (`node ace add @adonisjs/drive`).
@@ -13,9 +17,11 @@ export async function configure(command) {
13
17
  const codemods = await command.createCodemods();
14
18
  await codemods.updateRcFile((rcFile) => {
15
19
  rcFile.addProvider('@adonis-agora/media/media_provider');
20
+ rcFile.addProvider('@adonis-agora/media/dashboard_provider');
16
21
  });
17
22
  const stubs = stubsRoot();
18
23
  await codemods.makeUsingStub(stubs, 'config/media.stub', {});
24
+ await codemods.makeUsingStub(stubs, 'config/media_dashboard.stub', {});
19
25
  await codemods.makeUsingStub(stubs, 'database/migrations/create_media_table.stub', {});
20
26
  await codemods.makeUsingStub(stubs, 'database/migrations/create_media_upload_sessions_table.stub', {});
21
27
  }
@@ -1 +1 @@
1
- {"version":3,"file":"configure.js","sourceRoot":"","sources":["../configure.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAE5C;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,OAAkB;IAChD,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,cAAc,EAAE,CAAC;IAEhD,MAAM,QAAQ,CAAC,YAAY,CAAC,CAAC,MAAM,EAAE,EAAE;QACrC,MAAM,CAAC,WAAW,CAAC,oCAAoC,CAAC,CAAC;IAC3D,CAAC,CAAC,CAAC;IAEH,MAAM,KAAK,GAAG,SAAS,EAAE,CAAC;IAC1B,MAAM,QAAQ,CAAC,aAAa,CAAC,KAAK,EAAE,mBAAmB,EAAE,EAAE,CAAC,CAAC;IAC7D,MAAM,QAAQ,CAAC,aAAa,CAAC,KAAK,EAAE,6CAA6C,EAAE,EAAE,CAAC,CAAC;IACvF,MAAM,QAAQ,CAAC,aAAa,CAC1B,KAAK,EACL,6DAA6D,EAC7D,EAAE,CACH,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"configure.js","sourceRoot":"","sources":["../configure.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAE5C;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,OAAkB;IAChD,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,cAAc,EAAE,CAAC;IAEhD,MAAM,QAAQ,CAAC,YAAY,CAAC,CAAC,MAAM,EAAE,EAAE;QACrC,MAAM,CAAC,WAAW,CAAC,oCAAoC,CAAC,CAAC;QACzD,MAAM,CAAC,WAAW,CAAC,wCAAwC,CAAC,CAAC;IAC/D,CAAC,CAAC,CAAC;IAEH,MAAM,KAAK,GAAG,SAAS,EAAE,CAAC;IAC1B,MAAM,QAAQ,CAAC,aAAa,CAAC,KAAK,EAAE,mBAAmB,EAAE,EAAE,CAAC,CAAC;IAC7D,MAAM,QAAQ,CAAC,aAAa,CAAC,KAAK,EAAE,6BAA6B,EAAE,EAAE,CAAC,CAAC;IACvE,MAAM,QAAQ,CAAC,aAAa,CAAC,KAAK,EAAE,6CAA6C,EAAE,EAAE,CAAC,CAAC;IACvF,MAAM,QAAQ,CAAC,aAAa,CAC1B,KAAK,EACL,6DAA6D,EAC7D,EAAE,CACH,CAAC;AACJ,CAAC"}
@@ -0,0 +1,29 @@
1
+ import type { ApplicationService } from '@adonisjs/core/types';
2
+ /**
3
+ * Mounts the media-management console INTO `@adonis-agora/media` itself: a React SPA plus a JSON
4
+ * API, both under a configurable path and an optional host auth guard. The API delegates to the
5
+ * already-bound {@link MediaManager} singleton (bound by this same package's `MediaProvider`) via a
6
+ * pure {@link DashboardService} — disk `list`/`stat`/`copy`/`move`/`deleteMany` and the resumable
7
+ * session store. Nothing about storage is reimplemented here.
8
+ *
9
+ * Registering this provider is all a consuming app needs to do to get the dashboard — no separate
10
+ * `@adonis-agora/media-dashboard` install (its provider still exists, standalone, for hosts that
11
+ * prefer it — see `packages/dashboard`):
12
+ * ```ts
13
+ * providers: [
14
+ * () => import('@adonis-agora/media/media_provider'),
15
+ * () => import('@adonis-agora/media/dashboard_provider'),
16
+ * ]
17
+ * ```
18
+ *
19
+ * Route registration is deferred to `app.booted(...)` — same reason `MediaProvider#boot()` defers
20
+ * (see its long comment): the router service isn't safely resolvable synchronously inside a
21
+ * provider's own `boot()`.
22
+ */
23
+ export default class MediaDashboardProvider {
24
+ #private;
25
+ protected app: ApplicationService;
26
+ constructor(app: ApplicationService);
27
+ boot(): Promise<void>;
28
+ }
29
+ //# sourceMappingURL=dashboard_provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dashboard_provider.d.ts","sourceRoot":"","sources":["../../providers/dashboard_provider.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,kBAAkB,EAAqB,MAAM,sBAAsB,CAAC;AA0ClF;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,CAAC,OAAO,OAAO,sBAAsB;;IAC7B,SAAS,CAAC,GAAG,EAAE,kBAAkB;gBAAvB,GAAG,EAAE,kBAAkB;IAEvC,IAAI;CA4aX"}
@@ -0,0 +1,520 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename, resolve as resolvePath } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { SESSION_COOKIE_NAME, parseCookieHeader, serializeSetCookie, } from '../src/dashboard/cookie.js';
5
+ import { DashboardError, DashboardService, resolveConsoleAuth, signSessionCookie, verifySessionCookie, } from '../src/dashboard/index.js';
6
+ import { contentTypeFor, normalizePath, renderIndexHtml } from '../src/dashboard/spa.js';
7
+ import { MediaManager } from '../src/media_manager.js';
8
+ /**
9
+ * Directory of the built SPA (`dist/spa`, copied in by `copy:stubs` — see `package.json` — from
10
+ * `@adonis-agora/media-dashboard`'s own build), relative to this compiled provider
11
+ * (`dist/providers`). Resolved lazily (not at module scope): `fileURLToPath` throws on any
12
+ * non-`file:` `import.meta.url`, which would make this provider unimportable under a test runner
13
+ * that doesn't use real file URLs.
14
+ */
15
+ let spaDir;
16
+ function spaDirectory() {
17
+ spaDir ??= fileURLToPath(new URL('../assets/spa/', import.meta.url));
18
+ return spaDir;
19
+ }
20
+ /** Cookie `Path` for the built-in session — deliberately `/`, not scoped to `apiBase`: the SPA's
21
+ * `basePath` and its `apiBase` are independently configurable and can live at unrelated paths, and a
22
+ * cookie scoped to just one would be withheld on a full-page navigation to the other. The cookie is
23
+ * HttpOnly + HMAC-signed + short-lived, so the broader scope is a reasonable trade. Matches the
24
+ * NestJS sibling console's own `cookiePath: '/'` for the identical reason. */
25
+ const SESSION_COOKIE_PATH = '/';
26
+ /**
27
+ * Mounts the media-management console INTO `@adonis-agora/media` itself: a React SPA plus a JSON
28
+ * API, both under a configurable path and an optional host auth guard. The API delegates to the
29
+ * already-bound {@link MediaManager} singleton (bound by this same package's `MediaProvider`) via a
30
+ * pure {@link DashboardService} — disk `list`/`stat`/`copy`/`move`/`deleteMany` and the resumable
31
+ * session store. Nothing about storage is reimplemented here.
32
+ *
33
+ * Registering this provider is all a consuming app needs to do to get the dashboard — no separate
34
+ * `@adonis-agora/media-dashboard` install (its provider still exists, standalone, for hosts that
35
+ * prefer it — see `packages/dashboard`):
36
+ * ```ts
37
+ * providers: [
38
+ * () => import('@adonis-agora/media/media_provider'),
39
+ * () => import('@adonis-agora/media/dashboard_provider'),
40
+ * ]
41
+ * ```
42
+ *
43
+ * Route registration is deferred to `app.booted(...)` — same reason `MediaProvider#boot()` defers
44
+ * (see its long comment): the router service isn't safely resolvable synchronously inside a
45
+ * provider's own `boot()`.
46
+ */
47
+ export default class MediaDashboardProvider {
48
+ app;
49
+ constructor(app) {
50
+ this.app = app;
51
+ }
52
+ async boot() {
53
+ const config = this.app.config.get('media_dashboard', {});
54
+ if (config.enabled === false)
55
+ return;
56
+ await this.app.booted(async () => {
57
+ const router = await this.app.container.make('router');
58
+ this.#registerRoutes(router, config);
59
+ });
60
+ }
61
+ #registerRoutes(router, config) {
62
+ const basePath = normalizePath(config.basePath ?? '/media/dashboard');
63
+ const apiBase = normalizePath(config.apiBasePath ?? `${basePath}/api`);
64
+ const uploadsBase = normalizePath(config.uploadsPrefix ?? '/media/uploads');
65
+ const tusBase = normalizePath(config.tusPrefix ?? '/media/uploads/tus');
66
+ const actions = config.actions ?? false;
67
+ const diskNames = this.#resolveDiskNames(config);
68
+ const middleware = config.middleware
69
+ ? Array.isArray(config.middleware)
70
+ ? config.middleware
71
+ : [config.middleware]
72
+ : [];
73
+ // Built-in session-cookie login (Mode A/B), independent of `middleware` — see
74
+ // `src/dashboard/auth.ts`. `null` when unconfigured, in which case every gate below is a no-op
75
+ // and the API stays open.
76
+ const auth = resolveConsoleAuth(config.auth);
77
+ const objectInsights = config.objectInsights ?? [];
78
+ const service = async () => new DashboardService(await this.app.container.make(MediaManager), { diskNames, actions }, objectInsights);
79
+ this.#mountAuthRoutes(router, apiBase, auth);
80
+ this.#mountApi(router, apiBase, service, middleware, auth);
81
+ this.#mountSpa(router, basePath, { apiBase, uploadsBase, tusBase, actions }, middleware);
82
+ }
83
+ /**
84
+ * Mints/clears the console session cookie: `GET /me`, `POST /login`, `POST /session`,
85
+ * `POST /logout`. Deliberately NOT gated by `middleware` NOR the session guard below — these
86
+ * routes CREATE the session the guard checks for (mirroring the NestJS sibling console's
87
+ * `MediaConsoleAuthController`, which is likewise excluded from both its own session guard and any
88
+ * host-supplied `guards`). A host `session` hook reads its OWN auth directly off the raw request,
89
+ * so it needs no help from `middleware` here. When `auth` is unset, `/me` reports
90
+ * `authRequired: false` and login/session/logout 404 (nothing to mint or clear).
91
+ */
92
+ #mountAuthRoutes(router, apiBase, auth) {
93
+ const group = router.group(() => {
94
+ router
95
+ .get('/me', async (ctx) => {
96
+ if (!auth) {
97
+ const body = { authRequired: false };
98
+ return ctx.response.status(200).json(body);
99
+ }
100
+ const session = sessionFromRequest(ctx, auth);
101
+ // The UNauthenticated SPA learns which login mode(s) to offer from this 401 body.
102
+ if (!session)
103
+ return ctx.response.status(401).json({ auth: { modes: auth.modes } });
104
+ const body = { user: userInfoFromSession(session) };
105
+ return ctx.response.status(200).json(body);
106
+ })
107
+ .as('media.dashboard.me');
108
+ router
109
+ .post('/login', async (ctx) => {
110
+ if (!auth || !auth.login)
111
+ return ctx.response.status(404).send('');
112
+ const raw = ctx.request.body();
113
+ if (typeof raw.username !== 'string' || typeof raw.password !== 'string') {
114
+ return ctx.response
115
+ .status(400)
116
+ .json({ error: 'body must include string username and password' });
117
+ }
118
+ const user = await runAuthHook(() => auth.login?.(raw.username, raw.password));
119
+ // Uniform 401 for unknown user / bad password — no user-enumeration.
120
+ if (!user)
121
+ return ctx.response.status(401).json({ error: 'invalid credentials' });
122
+ issueSessionCookie(ctx, user, auth);
123
+ const body = { user: userInfoFromUser(user) };
124
+ return ctx.response.status(200).json(body);
125
+ })
126
+ .as('media.dashboard.login');
127
+ router
128
+ .post('/session', async (ctx) => {
129
+ if (!auth || !auth.session)
130
+ return ctx.response.status(404).send('');
131
+ const user = await runAuthHook(() => auth.session?.(ctx.request.request));
132
+ if (!user)
133
+ return ctx.response.status(401).json({ error: 'unauthorized' });
134
+ issueSessionCookie(ctx, user, auth);
135
+ const body = { user: userInfoFromUser(user) };
136
+ return ctx.response.status(200).json(body);
137
+ })
138
+ .as('media.dashboard.session');
139
+ router
140
+ .post('/logout', async (ctx) => {
141
+ // Best-effort: even without auth configured, clearing is harmless.
142
+ clearSessionCookie(ctx);
143
+ return ctx.response.status(204).send('');
144
+ })
145
+ .as('media.dashboard.logout');
146
+ });
147
+ group.prefix(apiBase);
148
+ }
149
+ #mountApi(router, apiBase, service, middleware, auth) {
150
+ const json = (handler, okStatus = 200) => {
151
+ return async (ctx) => {
152
+ try {
153
+ const result = await handler(await service(), ctx);
154
+ if (result === undefined)
155
+ return ctx.response.status(204).send('');
156
+ return ctx.response.status(okStatus).json(result);
157
+ }
158
+ catch (error) {
159
+ const status = error instanceof DashboardError ? error.status : 500;
160
+ const message = error instanceof Error ? error.message : 'dashboard error';
161
+ return ctx.response.status(status).json({ error: message });
162
+ }
163
+ };
164
+ };
165
+ const group = router.group(() => {
166
+ router
167
+ .get('/topology', json(async (svc) => svc.topology()))
168
+ .as('media.dashboard.topology');
169
+ router
170
+ .get('/disks', json(async (svc) => svc.disks()))
171
+ .as('media.dashboard.disks');
172
+ router
173
+ .get('/objects', json(async (svc, ctx) => {
174
+ const disk = str(ctx, 'disk');
175
+ if (!disk)
176
+ throw new DashboardError('disk is required', 400);
177
+ const prefix = str(ctx, 'prefix');
178
+ const cursor = str(ctx, 'cursor');
179
+ const limit = ctx.request.input('limit');
180
+ return svc.objects(disk, {
181
+ ...(prefix !== undefined ? { prefix } : {}),
182
+ ...(cursor !== undefined ? { cursor } : {}),
183
+ ...(limit !== undefined ? { limit: Number(limit) } : {}),
184
+ });
185
+ }))
186
+ .as('media.dashboard.objects');
187
+ router
188
+ .get('/object', json(async (svc, ctx) => {
189
+ const disk = str(ctx, 'disk');
190
+ const key = str(ctx, 'key');
191
+ if (!disk)
192
+ throw new DashboardError('disk is required', 400);
193
+ return svc.object(disk, key ?? '');
194
+ }))
195
+ .as('media.dashboard.object');
196
+ router
197
+ .post('/object', async (ctx) => {
198
+ // Raw bytes as `application/octet-stream` so the host's JSON/urlencoded body parsers never
199
+ // consume the stream; `ctx.request.request` is still the untouched Node request stream for
200
+ // any content type the app's bodyparser wasn't configured to parse. The real MIME rides as
201
+ // the `type` query param and becomes the stored object's Content-Type.
202
+ try {
203
+ const disk = str(ctx, 'disk');
204
+ const key = str(ctx, 'key');
205
+ const type = str(ctx, 'type');
206
+ if (!disk)
207
+ throw new DashboardError('disk is required', 400);
208
+ if (!key)
209
+ throw new DashboardError('key is required', 400);
210
+ await (await service()).putObject(disk, key, ctx.request.request, type);
211
+ return ctx.response.status(204).send('');
212
+ }
213
+ catch (error) {
214
+ const status = error instanceof DashboardError ? error.status : 500;
215
+ const message = error instanceof Error ? error.message : 'dashboard error';
216
+ return ctx.response.status(status).json({ error: message });
217
+ }
218
+ })
219
+ .as('media.dashboard.object.upload');
220
+ router
221
+ .get('/object/raw', async (ctx) => {
222
+ try {
223
+ const disk = str(ctx, 'disk');
224
+ const key = str(ctx, 'key');
225
+ if (!disk)
226
+ throw new DashboardError('disk is required', 400);
227
+ const { stream, contentType, size } = await (await service()).objectStream(disk, key ?? '');
228
+ ctx.response.header('content-type', contentType);
229
+ ctx.response.header('content-disposition', 'inline');
230
+ if (Number.isFinite(size))
231
+ ctx.response.header('content-length', String(size));
232
+ return ctx.response.stream(stream);
233
+ }
234
+ catch (error) {
235
+ const status = error instanceof DashboardError ? error.status : 500;
236
+ const message = error instanceof Error ? error.message : 'dashboard error';
237
+ return ctx.response.status(status).json({ error: message });
238
+ }
239
+ })
240
+ .as('media.dashboard.object.raw');
241
+ router
242
+ .get('/object/insights', json(async (svc, ctx) => {
243
+ const disk = str(ctx, 'disk');
244
+ const key = str(ctx, 'key');
245
+ if (!disk)
246
+ throw new DashboardError('disk is required', 400);
247
+ return svc.objectInsights(disk, key ?? '');
248
+ }))
249
+ .as('media.dashboard.object.insights');
250
+ router
251
+ .get('/uploads', json(async (svc, ctx) => {
252
+ const disk = str(ctx, 'disk');
253
+ const prefix = str(ctx, 'prefix');
254
+ return svc.uploads({
255
+ ...(disk !== undefined ? { disk } : {}),
256
+ ...(prefix !== undefined ? { prefix } : {}),
257
+ });
258
+ }))
259
+ .as('media.dashboard.uploads');
260
+ router
261
+ .get('/uploads/:id', json(async (svc, ctx) => svc.uploadDetail(String(ctx.params.id))))
262
+ .as('media.dashboard.uploads.show');
263
+ router
264
+ .post('/uploads/:id/abort', json(async (svc, ctx) => {
265
+ await svc.abortUpload(String(ctx.params.id));
266
+ }))
267
+ .as('media.dashboard.uploads.abort');
268
+ router
269
+ .get('/collections', json(async (svc, ctx) => {
270
+ const collection = str(ctx, 'collection');
271
+ const ownerType = str(ctx, 'ownerType');
272
+ const ownerId = str(ctx, 'ownerId');
273
+ const prefix = str(ctx, 'prefix');
274
+ const cursor = str(ctx, 'cursor');
275
+ const limit = ctx.request.input('limit');
276
+ return svc.collections({
277
+ ...(collection !== undefined ? { collection } : {}),
278
+ ...(ownerType !== undefined ? { ownerType } : {}),
279
+ ...(ownerId !== undefined ? { ownerId } : {}),
280
+ ...(prefix !== undefined ? { prefix } : {}),
281
+ ...(cursor !== undefined ? { cursor } : {}),
282
+ ...(limit !== undefined ? { limit: Number(limit) } : {}),
283
+ });
284
+ }))
285
+ .as('media.dashboard.collections');
286
+ router
287
+ .get('/collections/summary', json(async (svc) => svc.collectionsSummary()))
288
+ .as('media.dashboard.collections.summary');
289
+ router
290
+ .get('/media-record', json(async (svc, ctx) => {
291
+ const id = str(ctx, 'id');
292
+ if (!id)
293
+ throw new DashboardError('id is required', 400);
294
+ return svc.mediaRecord(id);
295
+ }))
296
+ .as('media.dashboard.media_record');
297
+ router
298
+ .post('/media-record/delete', json(async (svc, ctx) => {
299
+ const body = ctx.request.body();
300
+ if (!body.id)
301
+ throw new DashboardError('id is required', 400);
302
+ await svc.deleteMediaRecord(body.id);
303
+ }))
304
+ .as('media.dashboard.media_record.delete');
305
+ router
306
+ .post('/copy', json(async (svc, ctx) => {
307
+ await svc.copy(copyMoveBody(ctx));
308
+ }))
309
+ .as('media.dashboard.copy');
310
+ router
311
+ .post('/move', json(async (svc, ctx) => {
312
+ await svc.move(copyMoveBody(ctx));
313
+ }))
314
+ .as('media.dashboard.move');
315
+ router
316
+ .post('/delete', json(async (svc, ctx) => {
317
+ const body = ctx.request.body();
318
+ if (!body.disk)
319
+ throw new DashboardError('disk is required', 400);
320
+ await svc.remove({
321
+ disk: body.disk,
322
+ keys: Array.isArray(body.keys) ? body.keys : [],
323
+ });
324
+ }))
325
+ .as('media.dashboard.delete');
326
+ router
327
+ .post('/folder', json(async (svc, ctx) => {
328
+ await svc.createFolder(folderBody(ctx));
329
+ }))
330
+ .as('media.dashboard.folder.create');
331
+ router
332
+ .post('/folder/delete', json(async (svc, ctx) => {
333
+ await svc.deleteFolder(folderBody(ctx));
334
+ }))
335
+ .as('media.dashboard.folder.delete');
336
+ router
337
+ .post('/folder/copy', json(async (svc, ctx) => {
338
+ await svc.copyFolder(copyMoveBody(ctx));
339
+ }))
340
+ .as('media.dashboard.folder.copy');
341
+ router
342
+ .post('/folder/move', json(async (svc, ctx) => {
343
+ await svc.moveFolder(copyMoveBody(ctx));
344
+ }))
345
+ .as('media.dashboard.folder.move');
346
+ });
347
+ group.prefix(apiBase);
348
+ // Host `middleware` first (e.g. an admin check), then the built-in session guard (when `auth` is
349
+ // configured) — a request must pass both. The mutating routes above are further gated at the
350
+ // service layer by `actions` (unconditionally mounted; `DashboardService` 403s when disabled), so
351
+ // read-only hosts can leave them mounted without enabling `actions`.
352
+ applyMiddleware(group, auth ? [...middleware, sessionGuardMiddleware(auth)] : middleware);
353
+ }
354
+ #mountSpa(router, basePath, bootstrap, middleware) {
355
+ const serveIndex = async (ctx) => {
356
+ let html;
357
+ try {
358
+ html = await readFile(resolvePath(spaDirectory(), 'index.html'), 'utf8');
359
+ }
360
+ catch {
361
+ return ctx.response
362
+ .status(404)
363
+ .send('media dashboard SPA is not built (run the package build to emit dist/assets/spa)');
364
+ }
365
+ return ctx.response
366
+ .header('content-type', 'text/html; charset=utf-8')
367
+ .header('cache-control', 'no-store, must-revalidate')
368
+ .send(renderIndexHtml(html, basePath, bootstrap));
369
+ };
370
+ const group = router.group(() => {
371
+ router.get('/', serveIndex).as('media.dashboard.index');
372
+ router
373
+ .get('/assets/:file', async (ctx) => {
374
+ const file = basename(String(ctx.params.file));
375
+ const root = resolvePath(spaDirectory(), 'assets');
376
+ const assetPath = resolvePath(root, file);
377
+ if (!assetPath.startsWith(root))
378
+ return ctx.response.status(404).send('');
379
+ let bytes;
380
+ try {
381
+ bytes = await readFile(assetPath);
382
+ }
383
+ catch {
384
+ return ctx.response.status(404).send('');
385
+ }
386
+ return ctx.response
387
+ .header('content-type', contentTypeFor(file))
388
+ .header('cache-control', 'public, max-age=31536000, immutable')
389
+ .send(bytes);
390
+ })
391
+ .as('media.dashboard.assets');
392
+ });
393
+ group.prefix(basePath);
394
+ applyMiddleware(group, middleware);
395
+ }
396
+ /** Derive browsable disk names from the media config when the console config does not list them. */
397
+ #resolveDiskNames(config) {
398
+ if (config.disks && config.disks.length > 0)
399
+ return config.disks;
400
+ const media = this.app.config.get('media', {});
401
+ const drive = this.app.config.get('drive', {});
402
+ const defaultName = media.disk ?? drive.default ?? 'default';
403
+ const configured = media.disks ? Object.keys(media.disks) : [];
404
+ return Array.from(new Set([defaultName, ...configured]));
405
+ }
406
+ }
407
+ function str(ctx, key) {
408
+ const value = ctx.request.input(key);
409
+ return typeof value === 'string' && value !== '' ? value : undefined;
410
+ }
411
+ function copyMoveBody(ctx) {
412
+ const body = ctx.request.body();
413
+ if (!body.disk)
414
+ throw new DashboardError('disk is required', 400);
415
+ return {
416
+ disk: body.disk,
417
+ from: body.from ?? '',
418
+ to: body.to ?? '',
419
+ ...(body.toDisk !== undefined ? { toDisk: body.toDisk } : {}),
420
+ };
421
+ }
422
+ /** Body for the folder create/delete actions — a disk and the folder prefix. */
423
+ function folderBody(ctx) {
424
+ const body = ctx.request.body();
425
+ if (!body.disk)
426
+ throw new DashboardError('disk is required', 400);
427
+ return { disk: body.disk, prefix: body.prefix ?? '' };
428
+ }
429
+ /** Apply host middleware to a route group without depending on Adonis's exact middleware typings. */
430
+ function applyMiddleware(group, middleware) {
431
+ if (middleware.length === 0)
432
+ return;
433
+ group.use(middleware);
434
+ }
435
+ /* Session-cookie helpers ---------------------------------------------------------------------- */
436
+ /** Run a host hook (login/session) defensively: a throw is a denial (null), never a 500. */
437
+ async function runAuthHook(run) {
438
+ try {
439
+ return (await run()) ?? null;
440
+ }
441
+ catch {
442
+ return null;
443
+ }
444
+ }
445
+ function sessionFromRequest(ctx, auth) {
446
+ const cookieValue = parseCookieHeader(ctx.request.header('cookie'))[SESSION_COOKIE_NAME];
447
+ if (cookieValue === undefined)
448
+ return null;
449
+ return verifySessionCookie(cookieValue, { secret: auth.secret });
450
+ }
451
+ function userInfoFromSession(session) {
452
+ return {
453
+ id: session.sub,
454
+ ...(session.name !== undefined ? { name: session.name } : {}),
455
+ roles: session.roles,
456
+ };
457
+ }
458
+ function userInfoFromUser(user) {
459
+ return {
460
+ id: user.id,
461
+ ...(user.name !== undefined ? { name: user.name } : {}),
462
+ roles: user.roles ?? [],
463
+ };
464
+ }
465
+ function issueSessionCookie(ctx, user, auth, now) {
466
+ const value = signSessionCookie(user, {
467
+ secret: auth.secret,
468
+ ttlMs: auth.ttlMs,
469
+ ...(now !== undefined ? { now } : {}),
470
+ });
471
+ ctx.response.append('set-cookie', serializeSetCookie(SESSION_COOKIE_NAME, value, {
472
+ path: SESSION_COOKIE_PATH,
473
+ maxAgeSeconds: Math.floor(auth.ttlMs / 1000),
474
+ secure: ctx.request.secure(),
475
+ }));
476
+ }
477
+ function clearSessionCookie(ctx) {
478
+ ctx.response.append('set-cookie', serializeSetCookie(SESSION_COOKIE_NAME, '', {
479
+ path: SESSION_COOKIE_PATH,
480
+ maxAgeSeconds: 0,
481
+ secure: ctx.request.secure(),
482
+ clear: true,
483
+ }));
484
+ }
485
+ /**
486
+ * Gates a route group on a valid session cookie. Sliding renewal + revalidation mirrors the NestJS
487
+ * sibling console's `MediaConsoleGuard`: past half the TTL, the host's `revalidate` hook (if any)
488
+ * re-checks the user before a fresh cookie is issued, so a deactivated operator loses access instead
489
+ * of riding a self-renewing cookie.
490
+ */
491
+ function sessionGuardMiddleware(auth) {
492
+ return async (ctx, next) => {
493
+ const session = sessionFromRequest(ctx, auth);
494
+ // Absent/invalid/expired cookie => 401 with the login modes, same shape as `/me`'s refusal.
495
+ if (!session)
496
+ return ctx.response.status(401).json({ auth: { modes: auth.modes } });
497
+ const now = Date.now();
498
+ if (now - session.iat > auth.ttlMs / 2) {
499
+ const user = userInfoFromSession(session);
500
+ if (auth.revalidate) {
501
+ const allowed = await runAuthHookBoolean(() => auth.revalidate?.(user));
502
+ if (!allowed) {
503
+ clearSessionCookie(ctx);
504
+ return ctx.response.status(401).json({ auth: { modes: auth.modes } });
505
+ }
506
+ }
507
+ issueSessionCookie(ctx, user, auth, now);
508
+ }
509
+ return next();
510
+ };
511
+ }
512
+ async function runAuthHookBoolean(run) {
513
+ try {
514
+ return (await run()) ?? false;
515
+ }
516
+ catch {
517
+ return false; // Fail closed.
518
+ }
519
+ }
520
+ //# sourceMappingURL=dashboard_provider.js.map