@adonis-agora/media 0.10.4 → 0.11.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.
- package/CHANGELOG.md +15 -0
- package/README.md +46 -6
- package/dist/assets/spa/assets/index--0MZJr6W.css +1 -0
- package/dist/assets/spa/assets/index-Dvw2uJpF.js +83 -0
- package/dist/assets/spa/assets/index-Dvw2uJpF.js.map +1 -0
- package/dist/assets/spa/index.html +20 -0
- package/dist/configure.d.ts +6 -2
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +8 -2
- package/dist/configure.js.map +1 -1
- package/dist/providers/dashboard_provider.d.ts +29 -0
- package/dist/providers/dashboard_provider.d.ts.map +1 -0
- package/dist/providers/dashboard_provider.js +520 -0
- package/dist/providers/dashboard_provider.js.map +1 -0
- package/dist/src/dashboard/auth.d.ts +76 -0
- package/dist/src/dashboard/auth.d.ts.map +1 -0
- package/dist/src/dashboard/auth.js +134 -0
- package/dist/src/dashboard/auth.js.map +1 -0
- package/dist/src/dashboard/cookie.d.ts +20 -0
- package/dist/src/dashboard/cookie.d.ts.map +1 -0
- package/dist/src/dashboard/cookie.js +44 -0
- package/dist/src/dashboard/cookie.js.map +1 -0
- package/dist/src/dashboard/define_config.d.ts +56 -0
- package/dist/src/dashboard/define_config.d.ts.map +1 -0
- package/dist/src/dashboard/define_config.js +5 -0
- package/dist/src/dashboard/define_config.js.map +1 -0
- package/dist/src/dashboard/index.d.ts +10 -0
- package/dist/src/dashboard/index.d.ts.map +1 -0
- package/dist/src/dashboard/index.js +9 -0
- package/dist/src/dashboard/index.js.map +1 -0
- package/dist/src/dashboard/object_insights.d.ts +42 -0
- package/dist/src/dashboard/object_insights.d.ts.map +1 -0
- package/dist/src/dashboard/object_insights.js +42 -0
- package/dist/src/dashboard/object_insights.js.map +1 -0
- package/dist/src/dashboard/service.d.ts +215 -0
- package/dist/src/dashboard/service.d.ts.map +1 -0
- package/dist/src/dashboard/service.js +470 -0
- package/dist/src/dashboard/service.js.map +1 -0
- package/dist/src/dashboard/spa.d.ts +29 -0
- package/dist/src/dashboard/spa.d.ts.map +1 -0
- package/dist/src/dashboard/spa.js +47 -0
- package/dist/src/dashboard/spa.js.map +1 -0
- package/dist/src/dashboard/types.d.ts +201 -0
- package/dist/src/dashboard/types.d.ts.map +1 -0
- package/dist/src/dashboard/types.js +10 -0
- package/dist/src/dashboard/types.js.map +1 -0
- package/dist/stubs/config/media_dashboard.stub +39 -0
- package/package.json +15 -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-Dvw2uJpF.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>
|
package/dist/configure.d.ts
CHANGED
|
@@ -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.
|
|
7
|
-
*
|
|
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`).
|
package/dist/configure.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"configure.d.ts","sourceRoot":"","sources":["../configure.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,SAAS,MAAM,mCAAmC,CAAC;AAG/D
|
|
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.
|
|
7
|
-
*
|
|
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
|
}
|
package/dist/configure.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"configure.js","sourceRoot":"","sources":["../configure.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAE5C
|
|
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
|