nuxt-media-library 0.0.44 → 0.0.48
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/README.md +71 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +4 -1
- package/dist/runtime/components/MLAssetManager/Processing.vue +3 -1
- package/dist/runtime/components/MLUploader/Item.d.vue.ts +2 -2
- package/dist/runtime/components/MLUploader/Item.vue.d.ts +2 -2
- package/dist/runtime/composables/useMLAssetPreview.js +17 -1
- package/dist/runtime/composables/useMLMediaGrants.d.ts +23 -0
- package/dist/runtime/composables/useMLMediaGrants.js +22 -0
- package/dist/runtime/composables/useMLServiceUpload.d.ts +36 -0
- package/dist/runtime/composables/useMLServiceUpload.js +32 -0
- package/dist/runtime/server/create-ml-proxy.d.ts +16 -0
- package/dist/runtime/server/create-ml-proxy.js +138 -40
- package/dist/runtime/server/grants.d.ts +124 -0
- package/dist/runtime/server/grants.js +161 -0
- package/dist/runtime/server/index.d.ts +2 -0
- package/dist/runtime/server/index.js +7 -0
- package/dist/runtime/server/uploads-proxy.js +5 -2
- package/dist/runtime/types/asset.d.ts +3 -1
- package/dist/runtime/utils/grants/listeners.d.ts +2 -0
- package/dist/runtime/utils/grants/listeners.js +8 -0
- package/dist/runtime/utils/grants/store.d.ts +26 -0
- package/dist/runtime/utils/grants/store.js +37 -0
- package/dist/runtime/utils/uploader/serviceUpload.d.ts +84 -0
- package/dist/runtime/utils/uploader/serviceUpload.js +109 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -82,6 +82,77 @@ If you use the uploader component in Nuxt, provide config via the Nuxt plugin
|
|
|
82
82
|
</template>
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
### Authorizing `/manage` with media grants
|
|
86
|
+
|
|
87
|
+
The module's server proxies forward `/api/media-library/manage/*` to the
|
|
88
|
+
media library. Without an `authorize` hook they forward the browser's
|
|
89
|
+
credentials (or your static headers) as before. With one, every `/manage`
|
|
90
|
+
request is checked by your rules and sent upstream with a short-lived
|
|
91
|
+
**media grant** scoped to exactly that request (server:
|
|
92
|
+
`docs/security/media-grants.md`). Your app never re-implements the
|
|
93
|
+
endpoints, and no long-lived credential reaches the media library from the
|
|
94
|
+
browser.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
// server/api/media-library/[...].ts
|
|
98
|
+
import { createMLCatchAllProxy, specFromNeed } from '@fritill-team/nuxt-media-library/server'
|
|
99
|
+
|
|
100
|
+
const COVER = { kind: 'image', visibility: 'public', allowedMimeTypes: ['image/*'], maxSizeBytes: 5_000_000 }
|
|
101
|
+
|
|
102
|
+
export default createMLCatchAllProxy({
|
|
103
|
+
async authorize(event, need) {
|
|
104
|
+
const user = await requireUser(event) // your session
|
|
105
|
+
const subject = String(user.id)
|
|
106
|
+
if (need.scope === 'upload' && !need.sessionId) { // creating an upload
|
|
107
|
+
return user.canUpload ? { scopes: ['upload'], profile: COVER, subject } : false
|
|
108
|
+
}
|
|
109
|
+
if (need.taskId) return false // or look up the task's asset
|
|
110
|
+
if (need.scope === 'manage:*') return user.isAdmin // tenant-wide list, stream
|
|
111
|
+
return (need.assetIds ?? []).every(id => user.canEdit(id))
|
|
112
|
+
? { ...specFromNeed(need), subject }
|
|
113
|
+
: false
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- `need`: `{ scope, method, path, assetIds?, taskId?, sessionId?, upload? }`
|
|
119
|
+
(`upload` is the create body: MIME type, size, file name).
|
|
120
|
+
- Return `false` (403), `true` (the need as is), or a spec
|
|
121
|
+
`{ scopes, assetIds?, sessionIds?, profile?, subject?, ttlSeconds? }`.
|
|
122
|
+
Creating an upload needs a `profile`: kind, visibility and processing come
|
|
123
|
+
from it, not from the browser. `manage:*` is only accepted for requests
|
|
124
|
+
that need it.
|
|
125
|
+
- Grants are issued with the private runtime config
|
|
126
|
+
`mediaLibrary.serviceToken` (`NUXT_MEDIA_LIBRARY_SERVICE_TOKEN`), or pass
|
|
127
|
+
`issueGrant(spec)` to get them from your backend (e.g. the Python
|
|
128
|
+
client's `create_grant_router`). They are cached per spec until shortly
|
|
129
|
+
before they expire; an upload's parts, finalize and abort reuse the grant
|
|
130
|
+
that created the session.
|
|
131
|
+
- `/delivery` and the processing metadata routes are not affected.
|
|
132
|
+
|
|
133
|
+
### Showing assets of several services: read grants
|
|
134
|
+
|
|
135
|
+
Every link comes from the media library; each service decides only for its
|
|
136
|
+
own assets by returning a read grant (`mlg_…`, e.g. the Python client's
|
|
137
|
+
`client.grants.read(ids)`) with the data that references them. Hand the
|
|
138
|
+
grants to the module and every preview resolve sends them in one request:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
const { addMediaGrants } = useMLMediaGrants()
|
|
142
|
+
|
|
143
|
+
const lesson = await $fetch('/api/lessons/42') // course service
|
|
144
|
+
const profile = await $fetch('/api/profile') // profile service
|
|
145
|
+
addMediaGrants(lesson.mediaGrant, profile.mediaGrant) // "mlg_…" or { grant, expiresAt }
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- Live grants (up to 10, latest expiry first) go in `X-Media-Grants` on
|
|
149
|
+
`POST /api/media-library/delivery/assets/resolve`; the proxy forwards
|
|
150
|
+
them. Expired ones are dropped.
|
|
151
|
+
- Assets no grant lists come back `forbidden` from the server and are shown
|
|
152
|
+
as broken (`isBroken(id)`), as before; they are resolved again whenever
|
|
153
|
+
grants are added.
|
|
154
|
+
- Grants live in the browser only, never in server-side state.
|
|
155
|
+
|
|
85
156
|
### Vue installation (non-Nuxt)
|
|
86
157
|
|
|
87
158
|
```bash
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -93,7 +93,10 @@ const module$1 = defineNuxtModule({
|
|
|
93
93
|
...existingPrivate,
|
|
94
94
|
baseURL: existingPrivate.baseURL ?? envBaseURL ?? options.baseURL ?? "",
|
|
95
95
|
routes: existingPrivate.routes ?? options.routes ?? routes,
|
|
96
|
-
headers: resolvedHeaders
|
|
96
|
+
headers: resolvedHeaders,
|
|
97
|
+
// Only read when a proxy has `authorize` and no `issueGrant` (media
|
|
98
|
+
// grants). Declared so NUXT_MEDIA_LIBRARY_SERVICE_TOKEN applies.
|
|
99
|
+
serviceToken: existingPrivate.serviceToken ?? ""
|
|
97
100
|
};
|
|
98
101
|
nuxt.options.runtimeConfig.public.mediaLibrary = {
|
|
99
102
|
...existingPublic,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed, ref, onMounted, watch } from "vue";
|
|
3
3
|
import { useMLResourcePolicy } from "../../composables/resourcePolicy/useMLResourcePolicy";
|
|
4
|
+
import { mediaGrantHeaders } from "../../composables/useMLMediaGrants";
|
|
4
5
|
const props = defineProps({
|
|
5
6
|
assetId: { type: String, required: true },
|
|
6
7
|
manager: { type: Object, required: true }
|
|
@@ -34,7 +35,8 @@ async function resolveVideoUrl() {
|
|
|
34
35
|
}
|
|
35
36
|
try {
|
|
36
37
|
const result = await $fetch(
|
|
37
|
-
`/api/media-library/delivery/assets/${encodeURIComponent(props.assetId)}
|
|
38
|
+
`/api/media-library/delivery/assets/${encodeURIComponent(props.assetId)}`,
|
|
39
|
+
{ headers: mediaGrantHeaders() }
|
|
38
40
|
);
|
|
39
41
|
videoUrl.value = result.downloadUrl ?? null;
|
|
40
42
|
} catch {
|
|
@@ -12,14 +12,14 @@ type __VLS_Props = {
|
|
|
12
12
|
uploadFailedText?: string;
|
|
13
13
|
};
|
|
14
14
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
15
|
-
remove: () => any;
|
|
16
15
|
error: (message: string) => any;
|
|
16
|
+
remove: () => any;
|
|
17
17
|
progress: (percent: number) => any;
|
|
18
18
|
uploaded: (assetIds: string[]) => any;
|
|
19
19
|
canceled: () => any;
|
|
20
20
|
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
21
|
-
onRemove?: (() => any) | undefined;
|
|
22
21
|
onError?: ((message: string) => any) | undefined;
|
|
22
|
+
onRemove?: (() => any) | undefined;
|
|
23
23
|
onProgress?: ((percent: number) => any) | undefined;
|
|
24
24
|
onUploaded?: ((assetIds: string[]) => any) | undefined;
|
|
25
25
|
onCanceled?: (() => any) | undefined;
|
|
@@ -12,14 +12,14 @@ type __VLS_Props = {
|
|
|
12
12
|
uploadFailedText?: string;
|
|
13
13
|
};
|
|
14
14
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
15
|
-
remove: () => any;
|
|
16
15
|
error: (message: string) => any;
|
|
16
|
+
remove: () => any;
|
|
17
17
|
progress: (percent: number) => any;
|
|
18
18
|
uploaded: (assetIds: string[]) => any;
|
|
19
19
|
canceled: () => any;
|
|
20
20
|
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
21
|
-
onRemove?: (() => any) | undefined;
|
|
22
21
|
onError?: ((message: string) => any) | undefined;
|
|
22
|
+
onRemove?: (() => any) | undefined;
|
|
23
23
|
onProgress?: ((percent: number) => any) | undefined;
|
|
24
24
|
onUploaded?: ((assetIds: string[]) => any) | undefined;
|
|
25
25
|
onCanceled?: (() => any) | undefined;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { ref } from "vue";
|
|
2
2
|
import { $fetch } from "ofetch";
|
|
3
|
+
import { mediaGrantHeaders } from "./useMLMediaGrants.js";
|
|
4
|
+
import { onMediaGrantsAdded } from "../utils/grants/listeners.js";
|
|
3
5
|
const assets = ref({});
|
|
4
6
|
const pending = ref(/* @__PURE__ */ new Set());
|
|
5
7
|
const broken = ref(/* @__PURE__ */ new Set());
|
|
6
8
|
const forbidden = ref(/* @__PURE__ */ new Set());
|
|
7
9
|
const loading = ref(false);
|
|
10
|
+
const lockedByGrant = /* @__PURE__ */ new Set();
|
|
8
11
|
function computeSources(asset) {
|
|
9
12
|
const sources = [];
|
|
10
13
|
for (const rendition of asset.renditions ?? []) {
|
|
@@ -62,9 +65,13 @@ async function fetchAssets(ids) {
|
|
|
62
65
|
try {
|
|
63
66
|
const result = await $fetch("/api/media-library/delivery/assets/resolve", {
|
|
64
67
|
method: "POST",
|
|
65
|
-
body: { assetIds: targetIds }
|
|
68
|
+
body: { assetIds: targetIds },
|
|
69
|
+
headers: mediaGrantHeaders()
|
|
66
70
|
});
|
|
67
71
|
for (const item of result.items ?? []) {
|
|
72
|
+
if (item.status === "forbidden") {
|
|
73
|
+
lockedByGrant.add(item.assetId);
|
|
74
|
+
}
|
|
68
75
|
if (item.status !== "ok" || !item.asset) {
|
|
69
76
|
broken.value.add(item.assetId);
|
|
70
77
|
continue;
|
|
@@ -135,6 +142,15 @@ async function flushPending() {
|
|
|
135
142
|
pending.value.clear();
|
|
136
143
|
await fetchAssets(ids);
|
|
137
144
|
}
|
|
145
|
+
onMediaGrantsAdded(() => {
|
|
146
|
+
if (!lockedByGrant.size) return;
|
|
147
|
+
for (const id of lockedByGrant) {
|
|
148
|
+
broken.value.delete(id);
|
|
149
|
+
pending.value.add(id);
|
|
150
|
+
}
|
|
151
|
+
lockedByGrant.clear();
|
|
152
|
+
if (import.meta.client) scheduleFetch();
|
|
153
|
+
});
|
|
138
154
|
export const useMLAssetPreview = () => {
|
|
139
155
|
return {
|
|
140
156
|
assets,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { GrantInput, HeldGrant } from '../utils/grants/store.js';
|
|
2
|
+
/**
|
|
3
|
+
* Add read grants your services returned (`"mlg_…"` or `{ grant, expiresAt }`).
|
|
4
|
+
* Assets the server answered "forbidden" for are resolved again, since a
|
|
5
|
+
* new grant may list them. Every delivery resolve sends the live grants in `X-Media-Grants`.
|
|
6
|
+
*/
|
|
7
|
+
declare function addMediaGrants(...grants: GrantInput[]): void;
|
|
8
|
+
declare function clearMediaGrants(): void;
|
|
9
|
+
/** Headers to send on a `/delivery` request. */
|
|
10
|
+
declare function mediaGrantHeaders(): Record<string, string>;
|
|
11
|
+
export { addMediaGrants, clearMediaGrants, mediaGrantHeaders };
|
|
12
|
+
export declare const useMLMediaGrants: () => {
|
|
13
|
+
grants: import("vue").Ref<{
|
|
14
|
+
grant: string;
|
|
15
|
+
expiresAt: number;
|
|
16
|
+
}[], HeldGrant[] | {
|
|
17
|
+
grant: string;
|
|
18
|
+
expiresAt: number;
|
|
19
|
+
}[]>;
|
|
20
|
+
addMediaGrants: typeof addMediaGrants;
|
|
21
|
+
clearMediaGrants: typeof clearMediaGrants;
|
|
22
|
+
mediaGrantHeaders: typeof mediaGrantHeaders;
|
|
23
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ref } from "vue";
|
|
2
|
+
import { grantHeaders, mergeGrants } from "../utils/grants/store.js";
|
|
3
|
+
import { notifyMediaGrantsAdded } from "../utils/grants/listeners.js";
|
|
4
|
+
const held = ref([]);
|
|
5
|
+
function addMediaGrants(...grants) {
|
|
6
|
+
if (!import.meta.client) return;
|
|
7
|
+
held.value = mergeGrants(held.value, grants);
|
|
8
|
+
notifyMediaGrantsAdded();
|
|
9
|
+
}
|
|
10
|
+
function clearMediaGrants() {
|
|
11
|
+
held.value = [];
|
|
12
|
+
}
|
|
13
|
+
function mediaGrantHeaders() {
|
|
14
|
+
return import.meta.client ? grantHeaders(held.value) : {};
|
|
15
|
+
}
|
|
16
|
+
export { addMediaGrants, clearMediaGrants, mediaGrantHeaders };
|
|
17
|
+
export const useMLMediaGrants = () => ({
|
|
18
|
+
grants: held,
|
|
19
|
+
addMediaGrants,
|
|
20
|
+
clearMediaGrants,
|
|
21
|
+
mediaGrantHeaders
|
|
22
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { UploadProgress } from '../types/uploader.js';
|
|
2
|
+
import type { ServiceUploadOptions, ServiceUploadResult } from '../utils/uploader/serviceUpload.js';
|
|
3
|
+
export type { ServiceUploadOptions, ServiceUploadResult } from '../utils/uploader/serviceUpload.js';
|
|
4
|
+
/**
|
|
5
|
+
* Upload a file through one of the app's backend services (py_client
|
|
6
|
+
* `create_upload_router`). The service starts the upload and hands out a
|
|
7
|
+
* grant for that session; the file goes straight to the media library.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* const { upload, abort, uploading, progress, error } = useMLServiceUpload()
|
|
11
|
+
* const { sessionId, assetId } = await upload(file, {
|
|
12
|
+
* start: '/api/videos/uploads',
|
|
13
|
+
* body: { profile: 'lesson-video', context: { lessonId } },
|
|
14
|
+
* onProgress: p => console.log(p.percent),
|
|
15
|
+
* })
|
|
16
|
+
* await $fetch(`/api/videos/lessons/${lessonId}/video`, { method: 'PUT', body: { sessionId, assetId } })
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* One upload at a time per call of the composable; call it once per
|
|
20
|
+
* upload field. The media library must allow the site's origin (CORS).
|
|
21
|
+
*/
|
|
22
|
+
export declare function useMLServiceUpload(): {
|
|
23
|
+
upload: (file: File, options: ServiceUploadOptions) => Promise<ServiceUploadResult>;
|
|
24
|
+
abort: () => void;
|
|
25
|
+
uploading: import("vue").Ref<boolean, boolean>;
|
|
26
|
+
progress: import("vue").Ref<{
|
|
27
|
+
totalBytes: number;
|
|
28
|
+
uploadedBytes: number;
|
|
29
|
+
percent: number;
|
|
30
|
+
} | null, UploadProgress | {
|
|
31
|
+
totalBytes: number;
|
|
32
|
+
uploadedBytes: number;
|
|
33
|
+
percent: number;
|
|
34
|
+
} | null>;
|
|
35
|
+
error: import("vue").Ref<unknown, unknown>;
|
|
36
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ref } from "vue";
|
|
2
|
+
import { ChunkUploader } from "../utils/uploader/uploader.js";
|
|
3
|
+
import { createServiceUpload } from "../utils/uploader/serviceUpload.js";
|
|
4
|
+
export function useMLServiceUpload() {
|
|
5
|
+
const uploading = ref(false);
|
|
6
|
+
const progress = ref(null);
|
|
7
|
+
const error = ref(null);
|
|
8
|
+
const runner = createServiceUpload({
|
|
9
|
+
fetch: (url, opts) => $fetch(url, opts),
|
|
10
|
+
createUploader: (cfg) => new ChunkUploader(cfg)
|
|
11
|
+
});
|
|
12
|
+
async function upload(file, options) {
|
|
13
|
+
uploading.value = true;
|
|
14
|
+
progress.value = null;
|
|
15
|
+
error.value = null;
|
|
16
|
+
try {
|
|
17
|
+
return await runner.upload(file, {
|
|
18
|
+
...options,
|
|
19
|
+
onProgress: (p) => {
|
|
20
|
+
progress.value = p;
|
|
21
|
+
options.onProgress?.(p);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
} catch (err) {
|
|
25
|
+
error.value = err;
|
|
26
|
+
throw err;
|
|
27
|
+
} finally {
|
|
28
|
+
uploading.value = false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { upload, abort: runner.abort, uploading, progress, error };
|
|
32
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { H3Event } from 'h3';
|
|
2
|
+
import type { MLAuthorize, MLIssueGrant } from './grants.js';
|
|
2
3
|
export type MLProxyType = 'assets' | 'uploads' | 'delivery' | 'processing' | 'resource-policy';
|
|
3
4
|
export interface MLProxyOptions {
|
|
4
5
|
/**
|
|
@@ -8,6 +9,21 @@ export interface MLProxyOptions {
|
|
|
8
9
|
* from runtimeConfig.
|
|
9
10
|
*/
|
|
10
11
|
requestHeaders?: (event: H3Event, incoming: Record<string, string>) => Record<string, string> | Promise<Record<string, string>>;
|
|
12
|
+
/**
|
|
13
|
+
* Media grants (server docs/security/media-grants.md). When set, `/manage`
|
|
14
|
+
* requests no longer carry the browser's or the static credentials: for
|
|
15
|
+
* each one the proxy asks `authorize(event, need)` (your rules: the user
|
|
16
|
+
* from your session, the asset ids in `need`) and sends a short-lived
|
|
17
|
+
* grant scoped to exactly that. `false` → 403. Unknown `/manage` routes →
|
|
18
|
+
* 404. `/delivery` is not affected.
|
|
19
|
+
*/
|
|
20
|
+
authorize?: MLAuthorize<H3Event>;
|
|
21
|
+
/**
|
|
22
|
+
* How grants are issued. Default: `POST {baseURL}/manage/grants` with the
|
|
23
|
+
* private runtime config `mediaLibrary.serviceToken` (the Nuxt server is
|
|
24
|
+
* the app backend). Pass your own to ask your backend instead.
|
|
25
|
+
*/
|
|
26
|
+
issueGrant?: MLIssueGrant;
|
|
11
27
|
}
|
|
12
28
|
/**
|
|
13
29
|
* Create a Nitro event handler that replicates one of the media library's
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import { createError, defineEventHandler, getRequestHeaders, getRequestURL, proxyRequest,
|
|
1
|
+
import { createError, defineEventHandler, getRequestHeaders, getRequestURL, getRequestWebStream, proxyRequest, readBody, sendProxy, setResponseStatus } from "h3";
|
|
2
2
|
import { useRuntimeConfig } from "#imports";
|
|
3
|
+
import {
|
|
4
|
+
cachedIssuer,
|
|
5
|
+
grantFor,
|
|
6
|
+
grantNeed,
|
|
7
|
+
MLGrantIssueError,
|
|
8
|
+
serviceTokenIssuer,
|
|
9
|
+
SessionGrants,
|
|
10
|
+
stripProfileFields
|
|
11
|
+
} from "./grants.js";
|
|
3
12
|
const HOP_BY_HOP = ["connection", "transfer-encoding", "keep-alive", "upgrade", "expect", "host"];
|
|
4
13
|
function stripHopByHop(headers) {
|
|
5
14
|
return Object.fromEntries(Object.entries(headers).filter(([k]) => !HOP_BY_HOP.includes(k)));
|
|
@@ -33,7 +42,7 @@ function resolveRoute(route, params) {
|
|
|
33
42
|
return value === void 0 ? "" : String(value);
|
|
34
43
|
});
|
|
35
44
|
}
|
|
36
|
-
async function handleAssetsProxy(event, baseURL, staticHeaders, assetsBase,
|
|
45
|
+
async function handleAssetsProxy(event, baseURL, staticHeaders, assetsBase, options) {
|
|
37
46
|
const reqUrl = getRequestURL(event);
|
|
38
47
|
const prefix = "/api/media-library/manage/assets";
|
|
39
48
|
const rest = reqUrl.pathname.slice(prefix.length);
|
|
@@ -51,19 +60,15 @@ async function handleAssetsProxy(event, baseURL, staticHeaders, assetsBase, requ
|
|
|
51
60
|
if (isItem && !["GET", "PATCH", "DELETE"].includes(method)) throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
|
52
61
|
if (isSubResource && method !== "GET") throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
|
53
62
|
const upstreamUrl = buildUpstreamUrl(baseURL, `${assetsBase}${trimmedRest}`, reqUrl.search);
|
|
54
|
-
|
|
55
|
-
const result = await proxyRequest(event, upstreamUrl.toString(), { headers, streamRequest: true });
|
|
56
|
-
if (event.node.res.statusCode >= 400) {
|
|
57
|
-
console.error(`[media-library] assets-proxy upstream responded ${event.node.res.statusCode} for ${method} ${upstreamUrl}`);
|
|
58
|
-
}
|
|
59
|
-
return result;
|
|
63
|
+
return forward(event, "assets", upstreamUrl, method, baseURL, staticHeaders, options);
|
|
60
64
|
}
|
|
61
|
-
async function handleUploadsProxy(event, baseURL, staticHeaders, routes,
|
|
65
|
+
async function handleUploadsProxy(event, baseURL, staticHeaders, routes, options) {
|
|
62
66
|
const reqUrl = getRequestURL(event);
|
|
63
67
|
const prefix = "/api/media-library/manage/uploads";
|
|
64
68
|
const rest = reqUrl.pathname.slice(prefix.length);
|
|
65
69
|
const method = event.node.req.method || "GET";
|
|
66
70
|
let upstreamPath = "";
|
|
71
|
+
let upstreamMethod = method;
|
|
67
72
|
if (rest === "/sessions" || rest === "/sessions/") {
|
|
68
73
|
if (method !== "POST") throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
|
69
74
|
upstreamPath = routes.createSession;
|
|
@@ -79,7 +84,8 @@ async function handleUploadsProxy(event, baseURL, staticHeaders, routes, request
|
|
|
79
84
|
if (method !== "POST") throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
|
80
85
|
upstreamPath = resolveRoute(routes.finalize, { id: finalizeMatch[1] });
|
|
81
86
|
} else if (abortMatch && routes.abort) {
|
|
82
|
-
if (method !== "DELETE") throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
|
87
|
+
if (method !== "POST" && method !== "DELETE") throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
|
88
|
+
upstreamMethod = "POST";
|
|
83
89
|
upstreamPath = resolveRoute(routes.abort, { id: abortMatch[1] });
|
|
84
90
|
} else if (sessionMatch) {
|
|
85
91
|
if (method !== "GET") throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
|
@@ -88,14 +94,9 @@ async function handleUploadsProxy(event, baseURL, staticHeaders, routes, request
|
|
|
88
94
|
}
|
|
89
95
|
if (!upstreamPath) throw createError({ statusCode: 404, statusMessage: "Unknown upload route" });
|
|
90
96
|
const upstreamUrl = buildUpstreamUrl(baseURL, upstreamPath, reqUrl.search);
|
|
91
|
-
|
|
92
|
-
const result = await proxyRequest(event, upstreamUrl.toString(), { headers, streamRequest: true });
|
|
93
|
-
if (event.node.res.statusCode >= 400) {
|
|
94
|
-
console.error(`[media-library] uploads-proxy upstream responded ${event.node.res.statusCode} for ${method} ${upstreamUrl}`);
|
|
95
|
-
}
|
|
96
|
-
return result;
|
|
97
|
+
return forward(event, "uploads", upstreamUrl, upstreamMethod, baseURL, staticHeaders, options);
|
|
97
98
|
}
|
|
98
|
-
async function handleDeliveryProxy(event, baseURL, staticHeaders,
|
|
99
|
+
async function handleDeliveryProxy(event, baseURL, staticHeaders, options) {
|
|
99
100
|
const reqUrl = getRequestURL(event);
|
|
100
101
|
const prefix = "/api/media-library/delivery";
|
|
101
102
|
const rest = reqUrl.pathname.slice(prefix.length);
|
|
@@ -109,14 +110,14 @@ async function handleDeliveryProxy(event, baseURL, staticHeaders, requestHeaders
|
|
|
109
110
|
if (isResolve && method !== "POST") throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
|
110
111
|
if (isItem && method !== "GET") throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
|
111
112
|
const upstreamUrl = buildUpstreamUrl(baseURL, `/delivery${trimmedRest}`, reqUrl.search);
|
|
112
|
-
const headers = await buildProxyHeaders(event, staticHeaders,
|
|
113
|
+
const headers = await buildProxyHeaders(event, staticHeaders, options?.requestHeaders);
|
|
113
114
|
const result = await proxyRequest(event, upstreamUrl.toString(), { headers, streamRequest: true });
|
|
114
115
|
if (event.node.res.statusCode >= 400) {
|
|
115
116
|
console.error(`[media-library] delivery-proxy upstream responded ${event.node.res.statusCode} for ${method} ${upstreamUrl}`);
|
|
116
117
|
}
|
|
117
118
|
return result;
|
|
118
119
|
}
|
|
119
|
-
async function handleProcessingProxy(event, baseURL, staticHeaders,
|
|
120
|
+
async function handleProcessingProxy(event, baseURL, staticHeaders, options) {
|
|
120
121
|
const reqUrl = getRequestURL(event);
|
|
121
122
|
const prefix = "/api/media-library/manage/processing";
|
|
122
123
|
const rest = reqUrl.pathname.slice(prefix.length);
|
|
@@ -124,15 +125,9 @@ async function handleProcessingProxy(event, baseURL, staticHeaders, requestHeade
|
|
|
124
125
|
const allowed = /^\/stream$/.test(rest) && method === "GET" || /^\/assets\/[^/]+\/stream$/.test(rest) && method === "GET" || /^\/assets\/[^/]+\/tasks$/.test(rest) && method === "GET" || /^\/assets\/[^/]+\/process$/.test(rest) && method === "POST" || /^\/tasks\/[^/]+\/retry$/.test(rest) && method === "POST" || /^\/tasks\/[^/]+\/configure$/.test(rest) && method === "POST" || /^\/processors$/.test(rest) && method === "GET";
|
|
125
126
|
if (!allowed) throw createError({ statusCode: 404, statusMessage: "Not found" });
|
|
126
127
|
const upstreamUrl = buildUpstreamUrl(baseURL, `/manage/processing${rest}`, reqUrl.search);
|
|
127
|
-
|
|
128
|
-
const hasBody = method !== "GET" && method !== "HEAD";
|
|
129
|
-
const body = hasBody ? await readRawBody(event, false) : void 0;
|
|
130
|
-
return proxyRequest(event, upstreamUrl.toString(), {
|
|
131
|
-
headers,
|
|
132
|
-
...body !== void 0 ? { body } : {}
|
|
133
|
-
});
|
|
128
|
+
return forward(event, "processing", upstreamUrl, method, baseURL, staticHeaders, options);
|
|
134
129
|
}
|
|
135
|
-
async function handleResourcePolicyProxy(event, baseURL, staticHeaders,
|
|
130
|
+
async function handleResourcePolicyProxy(event, baseURL, staticHeaders, options) {
|
|
136
131
|
const reqUrl = getRequestURL(event);
|
|
137
132
|
const prefix = "/api/media-library/manage/resource-policies";
|
|
138
133
|
const rest = reqUrl.pathname.slice(prefix.length);
|
|
@@ -152,10 +147,113 @@ async function handleResourcePolicyProxy(event, baseURL, staticHeaders, requestH
|
|
|
152
147
|
if (isCollection && method !== "GET" && !(isDev && method === "POST")) throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
|
153
148
|
if (isItem && method !== "GET" && !(isDev && writeMethods.includes(method))) throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
|
154
149
|
const upstreamUrl = buildUpstreamUrl(baseURL, `/manage/resource-policies${trimmedRest}`, reqUrl.search);
|
|
155
|
-
|
|
156
|
-
|
|
150
|
+
return forward(event, "resource-policy", upstreamUrl, method, baseURL, staticHeaders, options);
|
|
151
|
+
}
|
|
152
|
+
const SESSION_GRANTS = new SessionGrants();
|
|
153
|
+
const issuers = /* @__PURE__ */ new WeakMap();
|
|
154
|
+
let defaultIssuer;
|
|
155
|
+
function grantIssuer(options, baseURL, staticHeaders) {
|
|
156
|
+
if (options.issueGrant) {
|
|
157
|
+
let issue = issuers.get(options.issueGrant);
|
|
158
|
+
if (!issue) issuers.set(options.issueGrant, issue = cachedIssuer(options.issueGrant));
|
|
159
|
+
return issue;
|
|
160
|
+
}
|
|
161
|
+
const mlConfig = useRuntimeConfig().mediaLibrary ?? {};
|
|
162
|
+
const serviceToken = mlConfig.serviceToken;
|
|
163
|
+
if (!serviceToken) {
|
|
164
|
+
throw createError({ statusCode: 500, statusMessage: "mediaLibrary.serviceToken (private runtime config) or issueGrant is required with authorize." });
|
|
165
|
+
}
|
|
166
|
+
const tenant = headerValue(staticHeaders, "x-tenant");
|
|
167
|
+
const key = `${baseURL}|${tenant ?? ""}|${serviceToken}`;
|
|
168
|
+
if (defaultIssuer?.key !== key) {
|
|
169
|
+
defaultIssuer = { key, issue: cachedIssuer(serviceTokenIssuer({ baseURL, serviceToken, tenant })) };
|
|
170
|
+
}
|
|
171
|
+
return defaultIssuer.issue;
|
|
172
|
+
}
|
|
173
|
+
function headerValue(headers, name) {
|
|
174
|
+
const entry = Object.entries(headers).find(([k]) => k.toLowerCase() === name);
|
|
175
|
+
return entry?.[1] || void 0;
|
|
176
|
+
}
|
|
177
|
+
const JSON_BODY_ROUTES = [/\/manage\/assets\/resolve$/, /\/manage\/uploads\/sessions$/];
|
|
178
|
+
async function forward(event, name, upstreamUrl, method, baseURL, staticHeaders, options) {
|
|
179
|
+
if (!options?.authorize) {
|
|
180
|
+
const headers2 = await buildProxyHeaders(event, staticHeaders, options?.requestHeaders);
|
|
181
|
+
return logged(event, name, method, upstreamUrl, proxyRequest(event, upstreamUrl.toString(), { headers: headers2, streamRequest: true, fetchOptions: { method } }));
|
|
182
|
+
}
|
|
183
|
+
const base = upstreamHeaders(await buildProxyHeaders(event, staticHeaders, options.requestHeaders));
|
|
184
|
+
const needsBody = method === "POST" && JSON_BODY_ROUTES.some((re) => re.test(upstreamUrl.pathname));
|
|
185
|
+
let body = needsBody ? await readBody(event) ?? {} : void 0;
|
|
186
|
+
const need = grantNeed(method, upstreamUrl.pathname, body);
|
|
187
|
+
if (need === null) throw createError({ statusCode: 404, statusMessage: "Not found" });
|
|
188
|
+
let headers = base;
|
|
189
|
+
if (need !== "public") {
|
|
190
|
+
let result;
|
|
191
|
+
try {
|
|
192
|
+
result = await grantFor(event, need, options.authorize, grantIssuer(options, baseURL, staticHeaders), SESSION_GRANTS);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (error instanceof MLGrantIssueError) {
|
|
195
|
+
console.error(`[media-library] ${error.message}`);
|
|
196
|
+
throw createError({ statusCode: 502, statusMessage: "Media library refused to issue a grant" });
|
|
197
|
+
}
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
if ("status" in result) throw createError({ statusCode: result.status, statusMessage: result.message });
|
|
201
|
+
headers = { ...base, Authorization: `Bearer ${result.grant.grant}` };
|
|
202
|
+
if (need.upload && result.spec.profile && body) body = stripProfileFields(body);
|
|
203
|
+
if (need.scope === "upload" && !need.sessionId) {
|
|
204
|
+
const json = JSON.stringify(body ?? {});
|
|
205
|
+
const response = await fetch(upstreamUrl.toString(), {
|
|
206
|
+
method: "POST",
|
|
207
|
+
// The body was rewritten: never reuse the browser's content-length.
|
|
208
|
+
headers: { ...withoutLength(headers), "content-type": "application/json" },
|
|
209
|
+
body: json
|
|
210
|
+
});
|
|
211
|
+
const text = await response.text();
|
|
212
|
+
let payload = text;
|
|
213
|
+
try {
|
|
214
|
+
payload = text ? JSON.parse(text) : null;
|
|
215
|
+
} catch {
|
|
216
|
+
}
|
|
217
|
+
const id = payload?.id;
|
|
218
|
+
if (response.ok && id) SESSION_GRANTS.set(String(id), result.grant);
|
|
219
|
+
setResponseStatus(event, response.status);
|
|
220
|
+
const contentType = response.headers.get("content-type");
|
|
221
|
+
if (contentType) event.node.res.setHeader("content-type", contentType);
|
|
222
|
+
return payload;
|
|
223
|
+
}
|
|
224
|
+
if (need.sessionId && /\/(?:finalize|abort)$/.test(need.path)) {
|
|
225
|
+
const sessionId = need.sessionId;
|
|
226
|
+
event.node.res.once("finish", () => {
|
|
227
|
+
if (event.node.res.statusCode < 400) SESSION_GRANTS.delete(sessionId);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const fetchOptions = { method, headers };
|
|
232
|
+
if (body !== void 0) {
|
|
233
|
+
const json = JSON.stringify(body);
|
|
234
|
+
fetchOptions.body = json;
|
|
235
|
+
fetchOptions.headers = { ...withoutLength(headers), "content-type": "application/json" };
|
|
236
|
+
} else if (PAYLOAD_METHODS.has((event.node.req.method || "GET").toUpperCase())) {
|
|
237
|
+
fetchOptions.body = getRequestWebStream(event);
|
|
238
|
+
fetchOptions.duplex = "half";
|
|
239
|
+
}
|
|
240
|
+
return logged(event, name, method, upstreamUrl, sendProxy(event, upstreamUrl.toString(), { fetchOptions }));
|
|
241
|
+
}
|
|
242
|
+
const PAYLOAD_METHODS = /* @__PURE__ */ new Set(["PATCH", "POST", "PUT", "DELETE"]);
|
|
243
|
+
const NOT_FORWARDED = ["authorization", "cookie", "accept-encoding", "transfer-encoding", "connection", "keep-alive", "upgrade", "expect", "host", "content-length"];
|
|
244
|
+
function withoutLength(headers) {
|
|
245
|
+
return Object.fromEntries(Object.entries(headers).filter(([k]) => k.toLowerCase() !== "content-length"));
|
|
246
|
+
}
|
|
247
|
+
function upstreamHeaders(headers) {
|
|
248
|
+
const out = Object.fromEntries(Object.entries(headers).filter(([k]) => !NOT_FORWARDED.includes(k.toLowerCase())));
|
|
249
|
+
const length = headerValue(headers, "content-length");
|
|
250
|
+
if (length) out["content-length"] = length;
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
async function logged(event, name, method, url, request) {
|
|
254
|
+
const result = await request;
|
|
157
255
|
if (event.node.res.statusCode >= 400) {
|
|
158
|
-
console.error(`[media-library]
|
|
256
|
+
console.error(`[media-library] ${name}-proxy upstream responded ${event.node.res.statusCode} for ${method} ${url}`);
|
|
159
257
|
}
|
|
160
258
|
return result;
|
|
161
259
|
}
|
|
@@ -183,16 +281,16 @@ export function createMLCatchAllProxy(options) {
|
|
|
183
281
|
baseURL,
|
|
184
282
|
staticHeaders,
|
|
185
283
|
mlConfig.routes?.assets || "/manage/assets",
|
|
186
|
-
options
|
|
284
|
+
options
|
|
187
285
|
);
|
|
188
286
|
case "uploads":
|
|
189
|
-
return handleUploadsProxy(event, baseURL, staticHeaders, mlConfig.routes, options
|
|
287
|
+
return handleUploadsProxy(event, baseURL, staticHeaders, mlConfig.routes, options);
|
|
190
288
|
case "delivery":
|
|
191
|
-
return handleDeliveryProxy(event, baseURL, staticHeaders, options
|
|
289
|
+
return handleDeliveryProxy(event, baseURL, staticHeaders, options);
|
|
192
290
|
case "processing":
|
|
193
|
-
return handleProcessingProxy(event, baseURL, staticHeaders, options
|
|
291
|
+
return handleProcessingProxy(event, baseURL, staticHeaders, options);
|
|
194
292
|
case "resource-policy":
|
|
195
|
-
return handleResourcePolicyProxy(event, baseURL, staticHeaders, options
|
|
293
|
+
return handleResourcePolicyProxy(event, baseURL, staticHeaders, options);
|
|
196
294
|
}
|
|
197
295
|
});
|
|
198
296
|
}
|
|
@@ -209,7 +307,7 @@ export function createMLProxy(type, options) {
|
|
|
209
307
|
baseURL,
|
|
210
308
|
staticHeaders,
|
|
211
309
|
mlConfig.routes?.assets || "/manage/assets",
|
|
212
|
-
options
|
|
310
|
+
options
|
|
213
311
|
);
|
|
214
312
|
case "uploads":
|
|
215
313
|
return handleUploadsProxy(
|
|
@@ -217,14 +315,14 @@ export function createMLProxy(type, options) {
|
|
|
217
315
|
baseURL,
|
|
218
316
|
staticHeaders,
|
|
219
317
|
mlConfig.routes,
|
|
220
|
-
options
|
|
318
|
+
options
|
|
221
319
|
);
|
|
222
320
|
case "delivery":
|
|
223
|
-
return handleDeliveryProxy(event, baseURL, staticHeaders, options
|
|
321
|
+
return handleDeliveryProxy(event, baseURL, staticHeaders, options);
|
|
224
322
|
case "processing":
|
|
225
|
-
return handleProcessingProxy(event, baseURL, staticHeaders, options
|
|
323
|
+
return handleProcessingProxy(event, baseURL, staticHeaders, options);
|
|
226
324
|
case "resource-policy":
|
|
227
|
-
return handleResourcePolicyProxy(event, baseURL, staticHeaders, options
|
|
325
|
+
return handleResourcePolicyProxy(event, baseURL, staticHeaders, options);
|
|
228
326
|
default:
|
|
229
327
|
throw createError({ statusCode: 500, statusMessage: `Unknown MLProxy type: ${type}` });
|
|
230
328
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media grants for the proxy (server docs/security/media-grants.md).
|
|
3
|
+
*
|
|
4
|
+
* With an `authorize` hook, the proxy no longer forwards the browser's
|
|
5
|
+
* credentials to `/manage`. For each request it works out what the call
|
|
6
|
+
* needs (`MLGrantNeed`), asks the app (`authorize`), gets a short-lived grant
|
|
7
|
+
* scoped to exactly that and sends `Authorization: Bearer mlg_…` upstream.
|
|
8
|
+
* The media library enforces the grant, so the app's rules decide and the
|
|
9
|
+
* app never re-implements the endpoints.
|
|
10
|
+
*
|
|
11
|
+
* Pure functions plus `fetch`: no h3 imports, so they are unit-testable.
|
|
12
|
+
*/
|
|
13
|
+
export type MLGrantScope = 'upload' | 'read' | 'update' | 'delete' | 'process' | 'renditions' | 'manage:*';
|
|
14
|
+
/** What one proxied `/manage` request needs. */
|
|
15
|
+
export interface MLGrantNeed {
|
|
16
|
+
scope: MLGrantScope;
|
|
17
|
+
/** Upstream method and path (after the proxy's rewrite). */
|
|
18
|
+
method: string;
|
|
19
|
+
path: string;
|
|
20
|
+
/** Assets the request acts on (path id, or `assetIds` in the body). */
|
|
21
|
+
assetIds?: string[];
|
|
22
|
+
/** Processing task the request acts on (its asset is checked upstream). */
|
|
23
|
+
taskId?: string;
|
|
24
|
+
/** Upload session the request continues. */
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
/** Parsed JSON body of `POST /manage/uploads/sessions` (MIME type, size, file name…). */
|
|
27
|
+
upload?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
/** Upload profile, as `POST /manage/grants` takes it (camelCase). */
|
|
30
|
+
export interface MLGrantProfile {
|
|
31
|
+
kind: string;
|
|
32
|
+
visibility?: 'private' | 'public' | 'restricted';
|
|
33
|
+
collectionName?: string;
|
|
34
|
+
allowedMimeTypes?: string[];
|
|
35
|
+
maxSizeBytes?: number;
|
|
36
|
+
processingConfig?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
/** What to issue. Returned by `authorize` when the default is not enough. */
|
|
39
|
+
export interface MLGrantSpec {
|
|
40
|
+
scopes: MLGrantScope[];
|
|
41
|
+
assetIds?: string[];
|
|
42
|
+
sessionIds?: string[];
|
|
43
|
+
profile?: MLGrantProfile;
|
|
44
|
+
/** The app's user reference; logged by the media library. */
|
|
45
|
+
subject?: string;
|
|
46
|
+
ttlSeconds?: number;
|
|
47
|
+
}
|
|
48
|
+
export interface MLIssuedGrant {
|
|
49
|
+
grant: string;
|
|
50
|
+
/** ISO timestamp. */
|
|
51
|
+
expiresAt: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The app's decision. `false`/`null`/`undefined`: 403. `true`: issue exactly
|
|
55
|
+
* the need (scope plus its asset or session; for a task, the app must return
|
|
56
|
+
* a spec with the task's asset ids). An upload create needs a spec with a
|
|
57
|
+
* `profile`: the profile, not the browser, decides kind, visibility and
|
|
58
|
+
* processing.
|
|
59
|
+
*/
|
|
60
|
+
export type MLAuthorize<E = unknown> = (event: E, need: MLGrantNeed) => boolean | MLGrantSpec | null | undefined | Promise<boolean | MLGrantSpec | null | undefined>;
|
|
61
|
+
/**
|
|
62
|
+
* The need of an upstream `/manage` request, `'public'` for metadata routes
|
|
63
|
+
* that need no grant, or `null` when the route is unknown (deny).
|
|
64
|
+
*/
|
|
65
|
+
export declare function grantNeed(method: string, upstreamPath: string, body?: unknown): MLGrantNeed | 'public' | null;
|
|
66
|
+
/** The spec `authorize: true` stands for. */
|
|
67
|
+
export declare function specFromNeed(need: MLGrantNeed): MLGrantSpec;
|
|
68
|
+
/** The upload-create body to forward under a grant with a profile. */
|
|
69
|
+
export declare function stripProfileFields(body: Record<string, unknown>): Record<string, unknown>;
|
|
70
|
+
export type MLIssueGrant = (spec: MLGrantSpec) => Promise<MLIssuedGrant>;
|
|
71
|
+
/**
|
|
72
|
+
* Issue grants from the Nuxt server itself with the tenant's service token
|
|
73
|
+
* (`POST {baseURL}/manage/grants`). Use when the Nuxt server is the app
|
|
74
|
+
* backend; otherwise pass an `issueGrant` that asks your backend (e.g. the
|
|
75
|
+
* Python client's `create_grant_router`).
|
|
76
|
+
*/
|
|
77
|
+
export declare function serviceTokenIssuer(options: {
|
|
78
|
+
baseURL: string;
|
|
79
|
+
serviceToken: string;
|
|
80
|
+
tenant?: string;
|
|
81
|
+
fetch?: typeof fetch;
|
|
82
|
+
}): MLIssueGrant;
|
|
83
|
+
export declare class MLGrantIssueError extends Error {
|
|
84
|
+
status: number;
|
|
85
|
+
detail: string;
|
|
86
|
+
constructor(status: number, detail: string);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Reuse an issued grant for the same spec until shortly before it expires.
|
|
90
|
+
* Specs include `subject`, so users never share grants unless the app
|
|
91
|
+
* returns specs without one.
|
|
92
|
+
*/
|
|
93
|
+
export declare function cachedIssuer(issue: MLIssueGrant, options?: {
|
|
94
|
+
marginSeconds?: number;
|
|
95
|
+
maxEntries?: number;
|
|
96
|
+
now?: () => number;
|
|
97
|
+
}): MLIssueGrant;
|
|
98
|
+
/**
|
|
99
|
+
* Remember which grant created an upload session, so its parts, finalize
|
|
100
|
+
* and abort reuse it (the media library binds the session to that grant).
|
|
101
|
+
* `authorize` is still asked for every request.
|
|
102
|
+
* In-memory: with several Nitro instances a miss asks `authorize` again with
|
|
103
|
+
* the session id.
|
|
104
|
+
*/
|
|
105
|
+
export declare class SessionGrants {
|
|
106
|
+
private now;
|
|
107
|
+
private max;
|
|
108
|
+
private grants;
|
|
109
|
+
constructor(now?: () => number, max?: number);
|
|
110
|
+
set(sessionId: string, grant: MLIssuedGrant): void;
|
|
111
|
+
get(sessionId: string): MLIssuedGrant | undefined;
|
|
112
|
+
delete(sessionId: string): void;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Resolve the grant for one request. Returns the issued grant, or a status
|
|
116
|
+
* to answer with (403 refused, 404 unknown route).
|
|
117
|
+
*/
|
|
118
|
+
export declare function grantFor<E>(event: E, need: MLGrantNeed, authorize: MLAuthorize<E>, issue: MLIssueGrant, sessions?: SessionGrants): Promise<{
|
|
119
|
+
grant: MLIssuedGrant;
|
|
120
|
+
spec: MLGrantSpec;
|
|
121
|
+
} | {
|
|
122
|
+
status: 403 | 422;
|
|
123
|
+
message: string;
|
|
124
|
+
}>;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
const UUIDish = "[^/]+";
|
|
2
|
+
const ROUTES = [
|
|
3
|
+
["GET", /^\/manage\/assets$/, "manage:*", null],
|
|
4
|
+
["POST", /^\/manage\/assets\/resolve$/, "read", "body"],
|
|
5
|
+
["POST", /^\/manage\/assets\/bulk-visibility$/, "update", "body"],
|
|
6
|
+
["GET", new RegExp(`^/manage/assets/(${UUIDish})$`), "read", "asset"],
|
|
7
|
+
["PATCH", new RegExp(`^/manage/assets/(${UUIDish})$`), "update", "asset"],
|
|
8
|
+
["DELETE", new RegExp(`^/manage/assets/(${UUIDish})$`), "delete", "asset"],
|
|
9
|
+
["GET", new RegExp(`^/manage/assets/(${UUIDish})/renditions$`), "read", "asset"],
|
|
10
|
+
["POST", new RegExp(`^/manage/assets/(${UUIDish})/renditions/[^/]+$`), "renditions", "asset"],
|
|
11
|
+
["POST", new RegExp(`^/manage/processing/assets/(${UUIDish})/process$`), "process", "asset"],
|
|
12
|
+
["GET", new RegExp(`^/manage/processing/assets/(${UUIDish})/(?:tasks|stream)$`), "read", "asset"],
|
|
13
|
+
["POST", new RegExp(`^/manage/processing/tasks/(${UUIDish})/(?:configure|retry)$`), "process", "task"],
|
|
14
|
+
["GET", /^\/manage\/processing\/stream$/, "manage:*", null],
|
|
15
|
+
["POST", /^\/manage\/uploads\/sessions$/, "upload", null],
|
|
16
|
+
["GET", new RegExp(`^/manage/uploads/sessions/(${UUIDish})$`), "upload", "session"],
|
|
17
|
+
["PUT", new RegExp(`^/manage/uploads/sessions/(${UUIDish})/parts/[^/]+$`), "upload", "session"],
|
|
18
|
+
["POST", new RegExp(`^/manage/uploads/sessions/(${UUIDish})/(?:finalize|abort)$`), "upload", "session"],
|
|
19
|
+
["GET", /^\/manage\/resource-policies(?:\/[^/]+)?$/, "manage:*", null],
|
|
20
|
+
["POST", /^\/manage\/resource-policies$/, "manage:*", null],
|
|
21
|
+
["PUT", /^\/manage\/resource-policies\/[^/]+$/, "manage:*", null],
|
|
22
|
+
["DELETE", /^\/manage\/resource-policies\/[^/]+$/, "manage:*", null]
|
|
23
|
+
];
|
|
24
|
+
const PUBLIC = [
|
|
25
|
+
/^\/manage\/processing\/processors$/,
|
|
26
|
+
/^\/manage\/processing-options\//,
|
|
27
|
+
/^\/manage\/resource-policies\/schema$/,
|
|
28
|
+
/^\/manage\/resource-policies\/pipelines\/available-steps$/
|
|
29
|
+
];
|
|
30
|
+
export function grantNeed(method, upstreamPath, body) {
|
|
31
|
+
const path = upstreamPath.replace(/\/+$/, "").replace(/^.*?(?=\/manage\/)/, "");
|
|
32
|
+
method = method.toUpperCase();
|
|
33
|
+
if (PUBLIC.some((re) => re.test(path))) return "public";
|
|
34
|
+
for (const [m, re, scope, target] of ROUTES) {
|
|
35
|
+
if (m !== method) continue;
|
|
36
|
+
const match = path.match(re);
|
|
37
|
+
if (!match) continue;
|
|
38
|
+
const need = { scope, method, path };
|
|
39
|
+
const id = match[1] ? decodeURIComponent(match[1]) : void 0;
|
|
40
|
+
if (target === "asset") need.assetIds = [id];
|
|
41
|
+
else if (target === "task") need.taskId = id;
|
|
42
|
+
else if (target === "session") need.sessionId = id;
|
|
43
|
+
else if (target === "body") {
|
|
44
|
+
const ids = body?.assetIds;
|
|
45
|
+
need.assetIds = Array.isArray(ids) ? ids.map(String) : [];
|
|
46
|
+
}
|
|
47
|
+
if (scope === "upload" && !target && body && typeof body === "object") {
|
|
48
|
+
need.upload = body;
|
|
49
|
+
}
|
|
50
|
+
return need;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
export function specFromNeed(need) {
|
|
55
|
+
const spec = { scopes: [need.scope] };
|
|
56
|
+
if (need.assetIds?.length) spec.assetIds = need.assetIds;
|
|
57
|
+
if (need.sessionId) spec.sessionIds = [need.sessionId];
|
|
58
|
+
return spec;
|
|
59
|
+
}
|
|
60
|
+
const PROFILE_FIELDS = ["kind", "visibility", "processingConfig", "processing_config", "resourceType", "resource_type", "collectionName", "collection_name"];
|
|
61
|
+
export function stripProfileFields(body) {
|
|
62
|
+
return Object.fromEntries(Object.entries(body).filter(([k]) => !PROFILE_FIELDS.includes(k)));
|
|
63
|
+
}
|
|
64
|
+
export function serviceTokenIssuer(options) {
|
|
65
|
+
const doFetch = options.fetch ?? fetch;
|
|
66
|
+
const url = new URL("./manage/grants", options.baseURL.endsWith("/") ? options.baseURL : `${options.baseURL}/`);
|
|
67
|
+
return async (spec) => {
|
|
68
|
+
const headers = {
|
|
69
|
+
"Authorization": `Bearer ${options.serviceToken}`,
|
|
70
|
+
"Content-Type": "application/json"
|
|
71
|
+
};
|
|
72
|
+
if (options.tenant) headers["X-Tenant"] = options.tenant;
|
|
73
|
+
const response = await doFetch(url.toString(), { method: "POST", headers, body: JSON.stringify(spec) });
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const detail = await response.text().catch(() => "");
|
|
76
|
+
throw new MLGrantIssueError(response.status, detail);
|
|
77
|
+
}
|
|
78
|
+
const body = await response.json();
|
|
79
|
+
return { grant: body.grant, expiresAt: body.expiresAt };
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export class MLGrantIssueError extends Error {
|
|
83
|
+
constructor(status, detail) {
|
|
84
|
+
super(`media library refused to issue a grant (${status}): ${detail}`);
|
|
85
|
+
this.status = status;
|
|
86
|
+
this.detail = detail;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function cachedIssuer(issue, options = {}) {
|
|
90
|
+
const margin = (options.marginSeconds ?? 30) * 1e3;
|
|
91
|
+
const max = options.maxEntries ?? 1e3;
|
|
92
|
+
const now = options.now ?? Date.now;
|
|
93
|
+
const cache = /* @__PURE__ */ new Map();
|
|
94
|
+
return async (spec) => {
|
|
95
|
+
const key = canonicalKey(spec);
|
|
96
|
+
const hit = cache.get(key);
|
|
97
|
+
if (hit && hit.until > now()) return { grant: hit.grant, expiresAt: hit.expiresAt };
|
|
98
|
+
const issued = await issue(spec);
|
|
99
|
+
const until = Date.parse(issued.expiresAt) - margin;
|
|
100
|
+
if (cache.size >= max) cache.delete(cache.keys().next().value);
|
|
101
|
+
if (until > now()) cache.set(key, { ...issued, until });
|
|
102
|
+
return issued;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function canonicalKey(spec) {
|
|
106
|
+
const sorted = (v) => v ? [...v].sort() : void 0;
|
|
107
|
+
return JSON.stringify({
|
|
108
|
+
s: sorted(spec.scopes),
|
|
109
|
+
a: sorted(spec.assetIds),
|
|
110
|
+
n: sorted(spec.sessionIds),
|
|
111
|
+
p: spec.profile ? stableStringify(spec.profile) : void 0,
|
|
112
|
+
u: spec.subject,
|
|
113
|
+
t: spec.ttlSeconds
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function stableStringify(value) {
|
|
117
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
118
|
+
if (value && typeof value === "object") {
|
|
119
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
|
|
120
|
+
}
|
|
121
|
+
return JSON.stringify(value);
|
|
122
|
+
}
|
|
123
|
+
export class SessionGrants {
|
|
124
|
+
constructor(now = Date.now, max = 5e3) {
|
|
125
|
+
this.now = now;
|
|
126
|
+
this.max = max;
|
|
127
|
+
}
|
|
128
|
+
grants = /* @__PURE__ */ new Map();
|
|
129
|
+
set(sessionId, grant) {
|
|
130
|
+
if (this.grants.size >= this.max) this.grants.delete(this.grants.keys().next().value);
|
|
131
|
+
this.grants.set(sessionId, grant);
|
|
132
|
+
}
|
|
133
|
+
get(sessionId) {
|
|
134
|
+
const grant = this.grants.get(sessionId);
|
|
135
|
+
if (grant && Date.parse(grant.expiresAt) - 5e3 > this.now()) return grant;
|
|
136
|
+
this.grants.delete(sessionId);
|
|
137
|
+
return void 0;
|
|
138
|
+
}
|
|
139
|
+
delete(sessionId) {
|
|
140
|
+
this.grants.delete(sessionId);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export async function grantFor(event, need, authorize, issue, sessions) {
|
|
144
|
+
const decision = await authorize(event, need);
|
|
145
|
+
if (!decision) return { status: 403, message: "Not allowed" };
|
|
146
|
+
if (need.sessionId && sessions) {
|
|
147
|
+
const known = sessions.get(need.sessionId);
|
|
148
|
+
if (known) return { grant: known, spec: { scopes: ["upload"], sessionIds: [need.sessionId] } };
|
|
149
|
+
}
|
|
150
|
+
const spec = decision === true ? specFromNeed(need) : decision;
|
|
151
|
+
if (spec.scopes.includes("manage:*") && need.scope !== "manage:*") {
|
|
152
|
+
return { status: 403, message: "authorize returned manage:* for a scoped request" };
|
|
153
|
+
}
|
|
154
|
+
if (need.scope === "upload" && !need.sessionId && !spec.profile && !spec.scopes.includes("manage:*")) {
|
|
155
|
+
return { status: 422, message: "authorize must return a profile for upload sessions" };
|
|
156
|
+
}
|
|
157
|
+
if (need.taskId && decision === true) {
|
|
158
|
+
return { status: 422, message: "authorize must return assetIds for processing tasks" };
|
|
159
|
+
}
|
|
160
|
+
return { grant: await issue(spec), spec };
|
|
161
|
+
}
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export { createMLProxy, createMLCatchAllProxy } from './create-ml-proxy.js';
|
|
2
2
|
export type { MLProxyType, MLProxyOptions } from './create-ml-proxy.js';
|
|
3
|
+
export { cachedIssuer, grantNeed, MLGrantIssueError, serviceTokenIssuer, specFromNeed, } from './grants.js';
|
|
4
|
+
export type { MLAuthorize, MLGrantNeed, MLGrantProfile, MLGrantScope, MLGrantSpec, MLIssueGrant, MLIssuedGrant, } from './grants.js';
|
|
@@ -34,6 +34,7 @@ export default defineEventHandler(async (event) => {
|
|
|
34
34
|
const rest = path.slice(prefix.length);
|
|
35
35
|
let upstreamPath = "";
|
|
36
36
|
const method = event.node.req.method || "GET";
|
|
37
|
+
let upstreamMethod = method;
|
|
37
38
|
if (rest === "/sessions" || rest === "/sessions/") {
|
|
38
39
|
if (method !== "POST") {
|
|
39
40
|
throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
|
@@ -55,9 +56,10 @@ export default defineEventHandler(async (event) => {
|
|
|
55
56
|
}
|
|
56
57
|
upstreamPath = resolveRoute(resolvedRoutes.finalize, { id: finalizeMatch[1] });
|
|
57
58
|
} else if (abortMatch && resolvedRoutes.abort) {
|
|
58
|
-
if (method !== "DELETE") {
|
|
59
|
+
if (method !== "POST" && method !== "DELETE") {
|
|
59
60
|
throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
|
60
61
|
}
|
|
62
|
+
upstreamMethod = "POST";
|
|
61
63
|
upstreamPath = resolveRoute(resolvedRoutes.abort, { id: abortMatch[1] });
|
|
62
64
|
} else if (sessionMatch) {
|
|
63
65
|
if (method !== "GET") {
|
|
@@ -86,7 +88,8 @@ export default defineEventHandler(async (event) => {
|
|
|
86
88
|
delete proxyHeaders.host;
|
|
87
89
|
const result = await proxyRequest(event, upstreamUrl.toString(), {
|
|
88
90
|
headers: proxyHeaders,
|
|
89
|
-
streamRequest: true
|
|
91
|
+
streamRequest: true,
|
|
92
|
+
fetchOptions: { method: upstreamMethod }
|
|
90
93
|
});
|
|
91
94
|
if (event.node.res.statusCode >= 400) {
|
|
92
95
|
console.error(`[media-library] uploads-proxy upstream responded ${event.node.res.statusCode} for ${method} ${upstreamUrl.toString()}`);
|
|
@@ -164,9 +164,11 @@ export interface DeliveryAssetListResponse {
|
|
|
164
164
|
}
|
|
165
165
|
export interface DeliveryBatchResolveItem {
|
|
166
166
|
assetId: string;
|
|
167
|
-
status: 'ok' | 'not_found';
|
|
167
|
+
status: 'ok' | 'not_found' | 'forbidden' | 'error';
|
|
168
168
|
asset: DeliveryAsset | null;
|
|
169
169
|
error: string | null;
|
|
170
|
+
/** Public thumbnail, also sent for forbidden and error items. */
|
|
171
|
+
thumbnailUrl?: string | null;
|
|
170
172
|
}
|
|
171
173
|
export interface DeliveryBatchResolveResponse {
|
|
172
174
|
items: DeliveryBatchResolveItem[];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media grants the browser holds (server docs/security/media-grants.md).
|
|
3
|
+
*
|
|
4
|
+
* Each service returns a read grant (`mlg_…`) with the data that references
|
|
5
|
+
* its assets. The app adds them here; every delivery resolve sends the live
|
|
6
|
+
* ones in `X-Media-Grants`, so one request resolves assets of several
|
|
7
|
+
* services. Pure (no Vue), so it is unit-testable.
|
|
8
|
+
*/
|
|
9
|
+
export declare const GRANTS_HEADER = "X-Media-Grants";
|
|
10
|
+
/** Server limit per request. */
|
|
11
|
+
export declare const MAX_GRANTS = 10;
|
|
12
|
+
export interface HeldGrant {
|
|
13
|
+
grant: string;
|
|
14
|
+
/** Epoch milliseconds. */
|
|
15
|
+
expiresAt: number;
|
|
16
|
+
}
|
|
17
|
+
export type GrantInput = string | {
|
|
18
|
+
grant: string;
|
|
19
|
+
expiresAt?: string | number | null;
|
|
20
|
+
} | null | undefined;
|
|
21
|
+
/** `exp` of an `mlg_<jwt>` grant, in epoch ms, or null when unreadable. */
|
|
22
|
+
export declare function grantExpiry(grant: string): number | null;
|
|
23
|
+
/** `held` plus `inputs`: deduplicated, expired ones dropped, newest kept. */
|
|
24
|
+
export declare function mergeGrants(held: HeldGrant[], inputs: GrantInput[], now?: number, max?: number): HeldGrant[];
|
|
25
|
+
/** Headers for a delivery request: the `MAX_GRANTS` live grants expiring last. */
|
|
26
|
+
export declare function grantHeaders(held: HeldGrant[], now?: number): Record<string, string>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const GRANTS_HEADER = "X-Media-Grants";
|
|
2
|
+
export const MAX_GRANTS = 10;
|
|
3
|
+
const MARGIN_MS = 5e3;
|
|
4
|
+
export function grantExpiry(grant) {
|
|
5
|
+
const jwt = grant.startsWith("mlg_") ? grant.slice(4) : grant;
|
|
6
|
+
const payload = jwt.split(".")[1];
|
|
7
|
+
if (!payload) return null;
|
|
8
|
+
try {
|
|
9
|
+
const json = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(payload.length / 4) * 4, "=")));
|
|
10
|
+
return typeof json.exp === "number" ? json.exp * 1e3 : null;
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function normalize(input) {
|
|
16
|
+
if (!input) return null;
|
|
17
|
+
const grant = typeof input === "string" ? input : input.grant;
|
|
18
|
+
if (!grant || !grant.startsWith("mlg_")) return null;
|
|
19
|
+
let expiresAt = null;
|
|
20
|
+
if (typeof input !== "string" && input.expiresAt != null) {
|
|
21
|
+
expiresAt = typeof input.expiresAt === "number" ? input.expiresAt : Date.parse(input.expiresAt);
|
|
22
|
+
}
|
|
23
|
+
if (expiresAt == null || Number.isNaN(expiresAt)) expiresAt = grantExpiry(grant);
|
|
24
|
+
if (expiresAt == null) return null;
|
|
25
|
+
return { grant, expiresAt };
|
|
26
|
+
}
|
|
27
|
+
export function mergeGrants(held, inputs, now = Date.now(), max = 50) {
|
|
28
|
+
const byToken = /* @__PURE__ */ new Map();
|
|
29
|
+
for (const g of [...held, ...inputs.map(normalize)]) {
|
|
30
|
+
if (g && g.expiresAt - MARGIN_MS > now) byToken.set(g.grant, g);
|
|
31
|
+
}
|
|
32
|
+
return [...byToken.values()].sort((a, b) => b.expiresAt - a.expiresAt).slice(0, max);
|
|
33
|
+
}
|
|
34
|
+
export function grantHeaders(held, now = Date.now()) {
|
|
35
|
+
const live = held.filter((g) => g.expiresAt - MARGIN_MS > now).slice(0, MAX_GRANTS);
|
|
36
|
+
return live.length ? { [GRANTS_HEADER]: live.map((g) => g.grant).join(", ") } : {};
|
|
37
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { ChunkUploaderConfig, UploadFileOptions, UploadProgress, UploadResult } from '../../types/uploader.js';
|
|
2
|
+
/**
|
|
3
|
+
* Browser uploads started by one of the app's backend services
|
|
4
|
+
* (py_client `create_upload_router`): the service creates the upload
|
|
5
|
+
* session and returns a grant for it; the browser uploads straight to the
|
|
6
|
+
* media library, refreshing the grant through the service, and gets back
|
|
7
|
+
* `{ sessionId, assetId }` to send to the service's attach endpoint.
|
|
8
|
+
*/
|
|
9
|
+
/** What the service's start endpoint answers (201). */
|
|
10
|
+
export interface ServiceUploadStart {
|
|
11
|
+
sessionId: string;
|
|
12
|
+
grant: string;
|
|
13
|
+
expiresAt: string;
|
|
14
|
+
uploadBaseUrl: string;
|
|
15
|
+
tenant: string;
|
|
16
|
+
partSizeBytes?: number;
|
|
17
|
+
}
|
|
18
|
+
/** What the service's refresh endpoint answers. */
|
|
19
|
+
export interface ServiceUploadGrant {
|
|
20
|
+
sessionId?: string;
|
|
21
|
+
grant: string;
|
|
22
|
+
expiresAt: string;
|
|
23
|
+
}
|
|
24
|
+
export interface ServiceUploadOptions {
|
|
25
|
+
/** The service's start endpoint, e.g. `/api/videos/uploads`. */
|
|
26
|
+
start: string;
|
|
27
|
+
/**
|
|
28
|
+
* Extra fields for the start request, e.g. `{ profile, context }`.
|
|
29
|
+
* `mimeType`, `sizeBytes` and `filename` are taken from the file
|
|
30
|
+
* (fields given here win).
|
|
31
|
+
*/
|
|
32
|
+
body?: Record<string, unknown>;
|
|
33
|
+
/** The refresh endpoint. Default: `${start}/${sessionId}/grant`. */
|
|
34
|
+
refresh?: string | ((sessionId: string) => string);
|
|
35
|
+
/** Headers for the service's start and refresh requests (e.g. CSRF). */
|
|
36
|
+
headers?: Record<string, string>;
|
|
37
|
+
/** Refresh the grant this long before it expires. Default 60 s. */
|
|
38
|
+
refreshMarginMs?: number;
|
|
39
|
+
/** Parallel part uploads. Default 3. */
|
|
40
|
+
concurrency?: number;
|
|
41
|
+
onProgress?: (p: UploadProgress) => void;
|
|
42
|
+
onState?: (s: {
|
|
43
|
+
phase: string;
|
|
44
|
+
message?: string;
|
|
45
|
+
}) => void;
|
|
46
|
+
}
|
|
47
|
+
export interface ServiceUploadResult {
|
|
48
|
+
sessionId: string;
|
|
49
|
+
assetId: string;
|
|
50
|
+
}
|
|
51
|
+
type FetchLike = <T>(url: string, opts: {
|
|
52
|
+
method: string;
|
|
53
|
+
body?: unknown;
|
|
54
|
+
headers?: Record<string, string>;
|
|
55
|
+
}) => Promise<T>;
|
|
56
|
+
interface UploaderLike {
|
|
57
|
+
uploadFile: (file: File, options: UploadFileOptions) => Promise<UploadResult>;
|
|
58
|
+
abort: () => void;
|
|
59
|
+
}
|
|
60
|
+
export interface ServiceUploadDeps {
|
|
61
|
+
fetch: FetchLike;
|
|
62
|
+
createUploader: (cfg: ChunkUploaderConfig) => UploaderLike;
|
|
63
|
+
now?: () => number;
|
|
64
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
65
|
+
clearTimer?: (handle: unknown) => void;
|
|
66
|
+
}
|
|
67
|
+
/** The media library routes a session grant allows (relative to `uploadBaseUrl`). */
|
|
68
|
+
export declare const serviceUploadRoutes: {
|
|
69
|
+
createSession: string;
|
|
70
|
+
getSession: (id: string) => string;
|
|
71
|
+
uploadPart: (id: string, n: number) => string;
|
|
72
|
+
finalize: (id: string) => string;
|
|
73
|
+
abort: (id: string) => string;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* One upload at a time per instance: `upload` runs the flow, `abort` stops
|
|
77
|
+
* it (the parts already sent stay with the session until it expires).
|
|
78
|
+
*/
|
|
79
|
+
export declare function createServiceUpload(deps: ServiceUploadDeps): {
|
|
80
|
+
upload: (file: File, options: ServiceUploadOptions) => Promise<ServiceUploadResult>;
|
|
81
|
+
abort: () => void;
|
|
82
|
+
isRunning: () => boolean;
|
|
83
|
+
};
|
|
84
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const SESSIONS = "/manage/uploads/sessions";
|
|
2
|
+
const DEFAULT_PART_SIZE = 8 * 1024 * 1024;
|
|
3
|
+
const MIN_REFRESH_DELAY_MS = 1e3;
|
|
4
|
+
export const serviceUploadRoutes = {
|
|
5
|
+
createSession: SESSIONS,
|
|
6
|
+
getSession: (id) => `${SESSIONS}/${encodeURIComponent(id)}`,
|
|
7
|
+
uploadPart: (id, n) => `${SESSIONS}/${encodeURIComponent(id)}/parts/${n}`,
|
|
8
|
+
finalize: (id) => `${SESSIONS}/${encodeURIComponent(id)}/finalize`,
|
|
9
|
+
abort: (id) => `${SESSIONS}/${encodeURIComponent(id)}/abort`
|
|
10
|
+
};
|
|
11
|
+
export function createServiceUpload(deps) {
|
|
12
|
+
const now = deps.now ?? Date.now;
|
|
13
|
+
const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
14
|
+
const clearTimer = deps.clearTimer ?? ((h) => clearTimeout(h));
|
|
15
|
+
let current = null;
|
|
16
|
+
const abortError = () => new DOMException("Aborted", "AbortError");
|
|
17
|
+
async function upload(file, options) {
|
|
18
|
+
if (current) throw new Error("An upload is already running; abort it first.");
|
|
19
|
+
const run = { uploader: null, stop: () => {
|
|
20
|
+
}, aborted: false };
|
|
21
|
+
current = run;
|
|
22
|
+
try {
|
|
23
|
+
return await runUpload(file, options, run);
|
|
24
|
+
} finally {
|
|
25
|
+
run.stop();
|
|
26
|
+
current = null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function runUpload(file, options, run) {
|
|
30
|
+
const start = options.start.replace(/\/+$/, "");
|
|
31
|
+
const margin = options.refreshMarginMs ?? 6e4;
|
|
32
|
+
options.onState?.({ phase: "service.start" });
|
|
33
|
+
const started = await deps.fetch(start, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: options.headers,
|
|
36
|
+
body: {
|
|
37
|
+
mimeType: file.type || "application/octet-stream",
|
|
38
|
+
sizeBytes: file.size,
|
|
39
|
+
filename: file.name,
|
|
40
|
+
...options.body
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
if (run.aborted) throw abortError();
|
|
44
|
+
const sessionId = started.sessionId;
|
|
45
|
+
const refreshUrl = typeof options.refresh === "function" ? options.refresh(sessionId) : options.refresh ?? `${start}/${encodeURIComponent(sessionId)}/grant`;
|
|
46
|
+
let grant = started.grant;
|
|
47
|
+
let timer = null;
|
|
48
|
+
let stopped = false;
|
|
49
|
+
let refreshError = null;
|
|
50
|
+
const schedule = (expiresAt) => {
|
|
51
|
+
const expiresMs = Date.parse(expiresAt);
|
|
52
|
+
if (Number.isNaN(expiresMs)) return;
|
|
53
|
+
const delay = Math.max(MIN_REFRESH_DELAY_MS, expiresMs - margin - now());
|
|
54
|
+
timer = setTimer(refresh, delay);
|
|
55
|
+
};
|
|
56
|
+
async function refresh() {
|
|
57
|
+
timer = null;
|
|
58
|
+
if (stopped) return;
|
|
59
|
+
try {
|
|
60
|
+
const next = await deps.fetch(refreshUrl, { method: "POST", headers: options.headers });
|
|
61
|
+
if (stopped) return;
|
|
62
|
+
grant = next.grant;
|
|
63
|
+
refreshError = null;
|
|
64
|
+
schedule(next.expiresAt);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
refreshError = err;
|
|
67
|
+
if (!stopped) timer = setTimer(refresh, Math.max(MIN_REFRESH_DELAY_MS, margin / 4));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
run.stop = () => {
|
|
71
|
+
stopped = true;
|
|
72
|
+
if (timer !== null) clearTimer(timer);
|
|
73
|
+
timer = null;
|
|
74
|
+
};
|
|
75
|
+
const uploader = deps.createUploader({
|
|
76
|
+
baseURL: started.uploadBaseUrl.replace(/\/+$/, ""),
|
|
77
|
+
routes: serviceUploadRoutes,
|
|
78
|
+
partSize: started.partSizeBytes ?? DEFAULT_PART_SIZE,
|
|
79
|
+
concurrency: options.concurrency ?? 3,
|
|
80
|
+
checksum: { algorithm: "SHA-256", perPart: true, final: true },
|
|
81
|
+
headers: () => ({ "Authorization": `Bearer ${grant}`, "X-Tenant": started.tenant })
|
|
82
|
+
});
|
|
83
|
+
run.uploader = uploader;
|
|
84
|
+
schedule(started.expiresAt);
|
|
85
|
+
try {
|
|
86
|
+
const result = await uploader.uploadFile(file, {
|
|
87
|
+
sessionId,
|
|
88
|
+
create: { mime_type: file.type || "application/octet-stream" },
|
|
89
|
+
onProgress: options.onProgress,
|
|
90
|
+
onState: options.onState
|
|
91
|
+
});
|
|
92
|
+
const assetId = result.finalize?.asset_id;
|
|
93
|
+
if (!assetId) throw new Error("The media library did not return an asset id.");
|
|
94
|
+
return { sessionId, assetId: String(assetId) };
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (refreshError && !(err instanceof DOMException && err.name === "AbortError")) {
|
|
97
|
+
throw new Error("Upload failed after the upload grant could not be refreshed.", { cause: err });
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function abort() {
|
|
103
|
+
if (!current) return;
|
|
104
|
+
current.aborted = true;
|
|
105
|
+
current.stop();
|
|
106
|
+
current.uploader?.abort();
|
|
107
|
+
}
|
|
108
|
+
return { upload, abort, isRunning: () => current !== null };
|
|
109
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nuxt-media-library",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.48",
|
|
4
4
|
"description": "Nuxt module for media library management — chunked uploads, asset previews, and resource policies",
|
|
5
|
-
"repository":
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/fritill-team/media_library_module.git"
|
|
8
|
+
},
|
|
6
9
|
"license": "MIT",
|
|
7
10
|
"type": "module",
|
|
8
11
|
"types": "./dist/types.d.mts",
|