@btst/stack 2.8.0 → 2.9.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/README.md +3 -2
- package/dist/components/markdown/index.d.cts +15 -2
- package/dist/components/markdown/index.d.mts +15 -2
- package/dist/components/markdown/index.d.ts +15 -2
- package/dist/packages/stack/src/plugins/blog/client/components/forms/image-field.cjs +30 -1
- package/dist/packages/stack/src/plugins/blog/client/components/forms/image-field.mjs +30 -1
- package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.cjs +49 -9
- package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.mjs +50 -10
- package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.cjs +77 -9
- package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.mjs +77 -9
- package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.cjs +24 -5
- package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.mjs +24 -5
- package/dist/packages/stack/src/plugins/cms/client/components/forms/file-upload.cjs +47 -13
- package/dist/packages/stack/src/plugins/cms/client/components/forms/file-upload.mjs +47 -13
- package/dist/packages/stack/src/plugins/kanban/client/components/forms/board-form.cjs +1 -1
- package/dist/packages/stack/src/plugins/kanban/client/components/forms/board-form.mjs +1 -1
- package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.cjs +6 -2
- package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.mjs +6 -2
- package/dist/packages/stack/src/plugins/media/api/adapters/local.cjs +55 -0
- package/dist/packages/stack/src/plugins/media/api/adapters/local.mjs +37 -0
- package/dist/packages/stack/src/plugins/media/api/getters.cjs +83 -0
- package/dist/packages/stack/src/plugins/media/api/getters.mjs +78 -0
- package/dist/packages/stack/src/plugins/media/api/mutations.cjs +88 -0
- package/dist/packages/stack/src/plugins/media/api/mutations.mjs +82 -0
- package/dist/packages/stack/src/plugins/media/api/plugin.cjs +525 -0
- package/dist/packages/stack/src/plugins/media/api/plugin.mjs +523 -0
- package/dist/packages/stack/src/plugins/media/api/query-key-defs.cjs +19 -0
- package/dist/packages/stack/src/plugins/media/api/query-key-defs.mjs +16 -0
- package/dist/packages/stack/src/plugins/media/api/serializers.cjs +17 -0
- package/dist/packages/stack/src/plugins/media/api/serializers.mjs +14 -0
- package/dist/packages/stack/src/plugins/media/api/storage-adapter.cjs +15 -0
- package/dist/packages/stack/src/plugins/media/api/storage-adapter.mjs +11 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-card.cjs +129 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-card.mjs +127 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.cjs +58 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.mjs +56 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.cjs +94 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.mjs +92 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.cjs +171 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.mjs +168 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/index.cjs +308 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/index.mjs +305 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.cjs +104 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.mjs +102 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/url-tab.cjs +70 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/url-tab.mjs +68 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/utils.cjs +21 -0
- package/dist/packages/stack/src/plugins/media/client/components/media-picker/utils.mjs +17 -0
- package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.cjs +35 -0
- package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.internal.cjs +125 -0
- package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.internal.mjs +123 -0
- package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.mjs +33 -0
- package/dist/packages/stack/src/plugins/media/client/hooks/use-media.cjs +222 -0
- package/dist/packages/stack/src/plugins/media/client/hooks/use-media.mjs +214 -0
- package/dist/packages/stack/src/plugins/media/client/plugin.cjs +94 -0
- package/dist/packages/stack/src/plugins/media/client/plugin.mjs +92 -0
- package/dist/packages/stack/src/plugins/media/client/upload.cjs +121 -0
- package/dist/packages/stack/src/plugins/media/client/upload.mjs +119 -0
- package/dist/packages/stack/src/plugins/media/client/utils/image-compression.cjs +67 -0
- package/dist/packages/stack/src/plugins/media/client/utils/image-compression.mjs +65 -0
- package/dist/packages/stack/src/plugins/media/db.cjs +62 -0
- package/dist/packages/stack/src/plugins/media/db.mjs +60 -0
- package/dist/packages/stack/src/plugins/media/schemas.cjs +41 -0
- package/dist/packages/stack/src/plugins/media/schemas.mjs +35 -0
- package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.cjs +18 -1
- package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.mjs +19 -2
- package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-dialog.cjs +2 -2
- package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-dialog.mjs +2 -2
- package/dist/packages/ui/src/components/minimal-tiptap/components/section/five.cjs +3 -2
- package/dist/packages/ui/src/components/minimal-tiptap/components/section/five.mjs +3 -2
- package/dist/packages/ui/src/components/minimal-tiptap/minimal-tiptap.cjs +12 -5
- package/dist/packages/ui/src/components/minimal-tiptap/minimal-tiptap.mjs +12 -5
- package/dist/plugins/blog/api/index.d.cts +2 -2
- package/dist/plugins/blog/api/index.d.mts +2 -2
- package/dist/plugins/blog/api/index.d.ts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
- package/dist/plugins/blog/client/index.d.cts +60 -3
- package/dist/plugins/blog/client/index.d.mts +60 -3
- package/dist/plugins/blog/client/index.d.ts +60 -3
- package/dist/plugins/blog/query-keys.d.cts +2 -2
- package/dist/plugins/blog/query-keys.d.mts +2 -2
- package/dist/plugins/blog/query-keys.d.ts +2 -2
- package/dist/plugins/cms/client/index.d.cts +73 -3
- package/dist/plugins/cms/client/index.d.mts +73 -3
- package/dist/plugins/cms/client/index.d.ts +73 -3
- package/dist/plugins/kanban/api/index.d.cts +1 -1
- package/dist/plugins/kanban/api/index.d.mts +1 -1
- package/dist/plugins/kanban/api/index.d.ts +1 -1
- package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
- package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
- package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
- package/dist/plugins/kanban/client/index.d.cts +1 -1
- package/dist/plugins/kanban/client/index.d.mts +1 -1
- package/dist/plugins/kanban/client/index.d.ts +1 -1
- package/dist/plugins/kanban/query-keys.d.cts +1 -1
- package/dist/plugins/kanban/query-keys.d.mts +1 -1
- package/dist/plugins/kanban/query-keys.d.ts +1 -1
- package/dist/plugins/media/api/adapters/s3.cjs +106 -0
- package/dist/plugins/media/api/adapters/s3.d.cts +60 -0
- package/dist/plugins/media/api/adapters/s3.d.mts +60 -0
- package/dist/plugins/media/api/adapters/s3.d.ts +60 -0
- package/dist/plugins/media/api/adapters/s3.mjs +104 -0
- package/dist/plugins/media/api/adapters/vercel-blob.cjs +54 -0
- package/dist/plugins/media/api/adapters/vercel-blob.d.cts +41 -0
- package/dist/plugins/media/api/adapters/vercel-blob.d.mts +41 -0
- package/dist/plugins/media/api/adapters/vercel-blob.d.ts +41 -0
- package/dist/plugins/media/api/adapters/vercel-blob.mjs +52 -0
- package/dist/plugins/media/api/index.cjs +26 -0
- package/dist/plugins/media/api/index.d.cts +116 -0
- package/dist/plugins/media/api/index.d.mts +116 -0
- package/dist/plugins/media/api/index.d.ts +116 -0
- package/dist/plugins/media/api/index.mjs +6 -0
- package/dist/plugins/media/client/components/index.cjs +10 -0
- package/dist/plugins/media/client/components/index.d.cts +55 -0
- package/dist/plugins/media/client/components/index.d.mts +55 -0
- package/dist/plugins/media/client/components/index.d.ts +55 -0
- package/dist/plugins/media/client/components/index.mjs +2 -0
- package/dist/plugins/media/client/hooks/index.cjs +13 -0
- package/dist/plugins/media/client/hooks/index.d.cts +53 -0
- package/dist/plugins/media/client/hooks/index.d.mts +53 -0
- package/dist/plugins/media/client/hooks/index.d.ts +53 -0
- package/dist/plugins/media/client/hooks/index.mjs +1 -0
- package/dist/plugins/media/client/index.cjs +9 -0
- package/dist/plugins/media/client/index.d.cts +242 -0
- package/dist/plugins/media/client/index.d.mts +242 -0
- package/dist/plugins/media/client/index.d.ts +242 -0
- package/dist/plugins/media/client/index.mjs +2 -0
- package/dist/plugins/media/client.css +1 -0
- package/dist/plugins/media/query-keys.cjs +72 -0
- package/dist/plugins/media/query-keys.d.cts +49 -0
- package/dist/plugins/media/query-keys.d.mts +49 -0
- package/dist/plugins/media/query-keys.d.ts +49 -0
- package/dist/plugins/media/query-keys.mjs +70 -0
- package/dist/plugins/media/style.css +1 -0
- package/dist/shared/{stack.DOZ1EXjM.d.mts → stack.6mEHS2WH.d.mts} +3 -3
- package/dist/shared/{stack.DX-tQ93o.d.cts → stack.AJTXI7kw.d.cts} +3 -3
- package/dist/shared/{stack.DRpeDS6X.d.ts → stack.BMx2QYOK.d.ts} +25 -0
- package/dist/shared/stack.BUTXWiG-.d.ts +286 -0
- package/dist/shared/stack.C7Y9sBDg.d.mts +286 -0
- package/dist/shared/stack.C7vfOBmO.d.mts +63 -0
- package/dist/shared/stack.CAni8dnD.d.cts +63 -0
- package/dist/shared/stack.CLcnSF_b.d.cts +25 -0
- package/dist/shared/stack.CLcnSF_b.d.mts +25 -0
- package/dist/shared/stack.CLcnSF_b.d.ts +25 -0
- package/dist/shared/stack.CYSwntXC.d.ts +63 -0
- package/dist/shared/{stack.Jb0kQDJC.d.mts → stack.Cd6McBu1.d.mts} +25 -0
- package/dist/shared/stack.CoBj86jf.d.cts +109 -0
- package/dist/shared/stack.CoBj86jf.d.mts +109 -0
- package/dist/shared/stack.CoBj86jf.d.ts +109 -0
- package/dist/shared/{stack.BXxrFL9R.d.ts → stack.D7HSzZdG.d.ts} +5 -5
- package/dist/shared/{stack.DzOhpIYM.d.mts → stack.DjgpFWq3.d.cts} +5 -5
- package/dist/shared/{stack.BxFl46lB.d.cts → stack.DxQl8Wa1.d.cts} +25 -0
- package/dist/shared/{stack.BSqJrCTM.d.cts → stack.IUeyQKrm.d.mts} +5 -5
- package/dist/shared/{stack.VF6FhyZw.d.ts → stack.QYn-Px94.d.ts} +3 -3
- package/dist/shared/stack.vxskCkim.d.cts +286 -0
- package/package.json +115 -6
- package/src/plugins/blog/client/components/forms/image-field.tsx +35 -4
- package/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx +67 -12
- package/src/plugins/blog/client/components/forms/markdown-editor.tsx +106 -10
- package/src/plugins/blog/client/overrides.ts +58 -1
- package/src/plugins/cms/client/components/forms/content-form.tsx +26 -7
- package/src/plugins/cms/client/components/forms/file-upload.tsx +73 -15
- package/src/plugins/cms/client/overrides.ts +57 -2
- package/src/plugins/kanban/client/components/forms/board-form.tsx +1 -1
- package/src/plugins/kanban/client/components/forms/task-form.tsx +7 -1
- package/src/plugins/kanban/client/overrides.ts +25 -0
- package/src/plugins/media/__tests__/__stubs__/vercel-blob-server.ts +9 -0
- package/src/plugins/media/__tests__/getters.test.ts +274 -0
- package/src/plugins/media/__tests__/mutations.test.ts +299 -0
- package/src/plugins/media/__tests__/plugin.test.ts +752 -0
- package/src/plugins/media/__tests__/query-key-defs.test.ts +54 -0
- package/src/plugins/media/__tests__/storage-adapters.test.ts +351 -0
- package/src/plugins/media/api/adapters/local.ts +79 -0
- package/src/plugins/media/api/adapters/s3.ts +198 -0
- package/src/plugins/media/api/adapters/vercel-blob.ts +132 -0
- package/src/plugins/media/api/getters.ts +174 -0
- package/src/plugins/media/api/index.ts +41 -0
- package/src/plugins/media/api/mutations.ts +179 -0
- package/src/plugins/media/api/plugin.ts +855 -0
- package/src/plugins/media/api/query-key-defs.ts +41 -0
- package/src/plugins/media/api/serializers.ts +28 -0
- package/src/plugins/media/api/storage-adapter.ts +139 -0
- package/src/plugins/media/client/components/index.tsx +6 -0
- package/src/plugins/media/client/components/media-picker/asset-card.tsx +150 -0
- package/src/plugins/media/client/components/media-picker/asset-preview-button.tsx +67 -0
- package/src/plugins/media/client/components/media-picker/browse-tab.tsx +116 -0
- package/src/plugins/media/client/components/media-picker/folder-tree.tsx +188 -0
- package/src/plugins/media/client/components/media-picker/index.tsx +347 -0
- package/src/plugins/media/client/components/media-picker/upload-tab.tsx +108 -0
- package/src/plugins/media/client/components/media-picker/url-tab.tsx +72 -0
- package/src/plugins/media/client/components/media-picker/utils.ts +17 -0
- package/src/plugins/media/client/components/pages/library-page.internal.tsx +134 -0
- package/src/plugins/media/client/components/pages/library-page.tsx +42 -0
- package/src/plugins/media/client/hooks/index.tsx +9 -0
- package/src/plugins/media/client/hooks/use-media.tsx +289 -0
- package/src/plugins/media/client/index.ts +4 -0
- package/src/plugins/media/client/overrides.ts +127 -0
- package/src/plugins/media/client/plugin.tsx +184 -0
- package/src/plugins/media/client/upload.ts +171 -0
- package/src/plugins/media/client/utils/image-compression.ts +131 -0
- package/src/plugins/media/client.css +1 -0
- package/src/plugins/media/db.ts +62 -0
- package/src/plugins/media/query-keys.ts +96 -0
- package/src/plugins/media/schemas.ts +37 -0
- package/src/plugins/media/style.css +1 -0
- package/src/plugins/media/types.ts +26 -0
- package/dist/shared/{stack.BWp0hcm9.d.ts → stack.BQmuNl5p.d.cts} +3 -3
- package/dist/shared/{stack.BWp0hcm9.d.cts → stack.BQmuNl5p.d.mts} +3 -3
- package/dist/shared/{stack.BWp0hcm9.d.mts → stack.BQmuNl5p.d.ts} +3 -3
- package/dist/shared/{stack.BvCR4-9H.d.ts → stack.D4Cea8II.d.ts} +3 -3
- package/dist/shared/{stack.CWxAl9K3.d.mts → stack.HE_IvqV5.d.mts} +3 -3
- package/dist/shared/{stack.BOokfhZD.d.cts → stack.Rtcvl8sS.d.cts} +3 -3
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
import type { DBAdapter as Adapter } from "@btst/db";
|
|
2
|
+
import { defineBackendPlugin, createEndpoint } from "@btst/stack/plugins/api";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { mediaSchema as dbSchema } from "../db";
|
|
5
|
+
import type { Asset, Folder } from "../types";
|
|
6
|
+
import {
|
|
7
|
+
AssetListQuerySchema,
|
|
8
|
+
createAssetSchema,
|
|
9
|
+
updateAssetSchema,
|
|
10
|
+
createFolderSchema,
|
|
11
|
+
uploadTokenRequestSchema,
|
|
12
|
+
} from "../schemas";
|
|
13
|
+
import {
|
|
14
|
+
listAssets,
|
|
15
|
+
getAssetById,
|
|
16
|
+
listFolders,
|
|
17
|
+
getFolderById,
|
|
18
|
+
} from "./getters";
|
|
19
|
+
import {
|
|
20
|
+
createAsset,
|
|
21
|
+
updateAsset,
|
|
22
|
+
deleteAsset,
|
|
23
|
+
createFolder,
|
|
24
|
+
deleteFolder,
|
|
25
|
+
} from "./mutations";
|
|
26
|
+
import {
|
|
27
|
+
isDirectAdapter,
|
|
28
|
+
isS3Adapter,
|
|
29
|
+
isVercelBlobAdapter,
|
|
30
|
+
type StorageAdapter,
|
|
31
|
+
} from "./storage-adapter";
|
|
32
|
+
import { runHookWithShim } from "../../utils";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Sanitize a string for use in an S3 object key.
|
|
36
|
+
* Strips path separators and parent-directory segments to prevent path traversal.
|
|
37
|
+
*/
|
|
38
|
+
function sanitizeS3KeySegment(s: string): string {
|
|
39
|
+
return s.replace(/[/\\]/g, "-").replace(/\.\./g, "_").trim() || "unknown";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function matchesUrlPrefix(url: string, prefix: string): boolean {
|
|
43
|
+
const normalizedPrefix = `${prefix.replace(/\/+$/, "")}/`;
|
|
44
|
+
return url.startsWith(normalizedPrefix);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Context passed to media API hooks.
|
|
49
|
+
*/
|
|
50
|
+
export interface MediaApiContext<
|
|
51
|
+
TBody = unknown,
|
|
52
|
+
TParams = unknown,
|
|
53
|
+
TQuery = unknown,
|
|
54
|
+
> {
|
|
55
|
+
body?: TBody;
|
|
56
|
+
params?: TParams;
|
|
57
|
+
query?: TQuery;
|
|
58
|
+
request?: Request;
|
|
59
|
+
headers?: Headers;
|
|
60
|
+
[key: string]: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Configuration hooks for the media backend plugin.
|
|
65
|
+
* All hooks are optional and allow consumers to customise behaviour.
|
|
66
|
+
*/
|
|
67
|
+
export interface MediaBackendHooks {
|
|
68
|
+
/**
|
|
69
|
+
* Called before a file upload is allowed (both direct and signed adapters).
|
|
70
|
+
* Throw an Error to reject the upload (e.g. if the user is not authenticated).
|
|
71
|
+
*/
|
|
72
|
+
onBeforeUpload?: (
|
|
73
|
+
meta: { filename: string; mimeType: string; size?: number },
|
|
74
|
+
context: MediaApiContext,
|
|
75
|
+
) => Promise<void> | void;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Called after an asset record is created in the database.
|
|
79
|
+
*/
|
|
80
|
+
onAfterUpload?: (
|
|
81
|
+
asset: Asset,
|
|
82
|
+
context: MediaApiContext,
|
|
83
|
+
) => Promise<void> | void;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Called before an asset is deleted. Throw to prevent deletion.
|
|
87
|
+
*/
|
|
88
|
+
onBeforeDelete?: (
|
|
89
|
+
asset: Asset,
|
|
90
|
+
context: MediaApiContext,
|
|
91
|
+
) => Promise<void> | void;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Called after an asset has been deleted from the DB and storage.
|
|
95
|
+
*/
|
|
96
|
+
onAfterDelete?: (
|
|
97
|
+
assetId: string,
|
|
98
|
+
context: MediaApiContext,
|
|
99
|
+
) => Promise<void> | void;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Called before listing assets. Throw to deny access.
|
|
103
|
+
*/
|
|
104
|
+
onBeforeListAssets?: (
|
|
105
|
+
filter: z.infer<typeof AssetListQuerySchema>,
|
|
106
|
+
context: MediaApiContext,
|
|
107
|
+
) => Promise<void> | void;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Called before updating an asset (PATCH). Throw to deny access.
|
|
111
|
+
*/
|
|
112
|
+
onBeforeUpdateAsset?: (
|
|
113
|
+
asset: Asset,
|
|
114
|
+
updates: z.infer<typeof updateAssetSchema>,
|
|
115
|
+
context: MediaApiContext,
|
|
116
|
+
) => Promise<void> | void;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Called before listing folders. Throw to deny access.
|
|
120
|
+
*/
|
|
121
|
+
onBeforeListFolders?: (
|
|
122
|
+
filter: { parentId?: string },
|
|
123
|
+
context: MediaApiContext,
|
|
124
|
+
) => Promise<void> | void;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Called before creating a folder. Throw to deny access.
|
|
128
|
+
*/
|
|
129
|
+
onBeforeCreateFolder?: (
|
|
130
|
+
input: z.infer<typeof createFolderSchema>,
|
|
131
|
+
context: MediaApiContext,
|
|
132
|
+
) => Promise<void> | void;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Called before deleting a folder. Throw to deny access.
|
|
136
|
+
*/
|
|
137
|
+
onBeforeDeleteFolder?: (
|
|
138
|
+
folder: Folder,
|
|
139
|
+
context: MediaApiContext,
|
|
140
|
+
) => Promise<void> | void;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Configuration for the media backend plugin.
|
|
145
|
+
*/
|
|
146
|
+
export interface MediaBackendConfig {
|
|
147
|
+
/**
|
|
148
|
+
* The storage adapter to use for file uploads.
|
|
149
|
+
* - `localAdapter()` — writes to the local filesystem (dev / self-hosted)
|
|
150
|
+
* - `s3Adapter()` — presigned PUT URL (AWS S3, Cloudflare R2, MinIO)
|
|
151
|
+
* - `vercelBlobAdapter()` — signed direct upload via Vercel Blob
|
|
152
|
+
*/
|
|
153
|
+
storageAdapter: StorageAdapter;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Maximum file size in bytes.
|
|
157
|
+
* Enforced server-side for `localAdapter`.
|
|
158
|
+
* Passed into the Vercel Blob token for edge enforcement.
|
|
159
|
+
* Validated against the client-reported size for `s3Adapter`.
|
|
160
|
+
* @default 10485760 (10 MB)
|
|
161
|
+
*/
|
|
162
|
+
maxFileSizeBytes?: number;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* MIME type allowlist (e.g. `["image/jpeg", "image/png"]`).
|
|
166
|
+
* If omitted, all MIME types are accepted.
|
|
167
|
+
* Enforced server-side for `localAdapter`.
|
|
168
|
+
* Passed to Vercel Blob token for edge enforcement.
|
|
169
|
+
* Validated against the client-reported MIME type for `s3Adapter`.
|
|
170
|
+
*/
|
|
171
|
+
allowedMimeTypes?: string[];
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* URL prefixes that are allowed when creating asset records via `POST /media/assets`.
|
|
175
|
+
* When omitted the plugin automatically derives a safe default from the storage adapter:
|
|
176
|
+
* - `s3Adapter` → the configured `publicBaseUrl`
|
|
177
|
+
* - `vercelBlobAdapter` → any URL whose hostname ends with `.public.blob.vercel-storage.com`
|
|
178
|
+
* - `localAdapter` → rejects client-supplied URLs; use `POST /media/upload` instead
|
|
179
|
+
*
|
|
180
|
+
* Provide this option only when you need to override the automatic default (e.g. to allow
|
|
181
|
+
* assets from a CDN in front of your storage that uses a different domain). When using
|
|
182
|
+
* `localAdapter`, setting `allowedUrlPrefixes` explicitly opts `POST /media/assets` back in.
|
|
183
|
+
*/
|
|
184
|
+
allowedUrlPrefixes?: string[];
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Optional lifecycle hooks for the media backend plugin.
|
|
188
|
+
*/
|
|
189
|
+
hooks?: MediaBackendHooks;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Media backend plugin.
|
|
194
|
+
* Provides API endpoints for managing media assets and folders, and supports
|
|
195
|
+
* local, S3-compatible, and Vercel Blob storage backends.
|
|
196
|
+
*
|
|
197
|
+
* @example
|
|
198
|
+
* ```ts
|
|
199
|
+
* import { mediaBackendPlugin, localAdapter } from "@btst/stack/plugins/media/api";
|
|
200
|
+
*
|
|
201
|
+
* mediaBackendPlugin({
|
|
202
|
+
* storageAdapter: localAdapter(),
|
|
203
|
+
* hooks: {
|
|
204
|
+
* onBeforeUpload: async (_meta, ctx) => {
|
|
205
|
+
* const session = await getSession(ctx.headers as Headers);
|
|
206
|
+
* if (!session) throw new Error("Unauthorized");
|
|
207
|
+
* },
|
|
208
|
+
* },
|
|
209
|
+
* })
|
|
210
|
+
* ```
|
|
211
|
+
*/
|
|
212
|
+
export const mediaBackendPlugin = (config: MediaBackendConfig) =>
|
|
213
|
+
defineBackendPlugin({
|
|
214
|
+
name: "media",
|
|
215
|
+
|
|
216
|
+
dbPlugin: dbSchema,
|
|
217
|
+
|
|
218
|
+
api: (adapter: Adapter) => ({
|
|
219
|
+
listAssets: (params?: Parameters<typeof listAssets>[1]) =>
|
|
220
|
+
listAssets(adapter, params),
|
|
221
|
+
getAssetById: (id: string) => getAssetById(adapter, id),
|
|
222
|
+
listFolders: (params?: Parameters<typeof listFolders>[1]) =>
|
|
223
|
+
listFolders(adapter, params),
|
|
224
|
+
getFolderById: (id: string) => getFolderById(adapter, id),
|
|
225
|
+
}),
|
|
226
|
+
|
|
227
|
+
routes: (adapter: Adapter) => {
|
|
228
|
+
const {
|
|
229
|
+
storageAdapter,
|
|
230
|
+
maxFileSizeBytes = 10 * 1024 * 1024,
|
|
231
|
+
allowedMimeTypes,
|
|
232
|
+
allowedUrlPrefixes,
|
|
233
|
+
hooks,
|
|
234
|
+
} = config;
|
|
235
|
+
|
|
236
|
+
function validateMimeType(mimeType: string, ctx: { error: Function }) {
|
|
237
|
+
if (allowedMimeTypes && allowedMimeTypes.length > 0) {
|
|
238
|
+
const allowed = allowedMimeTypes.some((pattern) => {
|
|
239
|
+
if (pattern.endsWith("/*")) {
|
|
240
|
+
return mimeType.startsWith(pattern.slice(0, -1));
|
|
241
|
+
}
|
|
242
|
+
return mimeType === pattern;
|
|
243
|
+
});
|
|
244
|
+
if (!allowed) {
|
|
245
|
+
throw ctx.error(415, {
|
|
246
|
+
message: `MIME type '${mimeType}' is not allowed. Allowed: ${allowedMimeTypes.join(", ")}`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Asset endpoints ────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
const listAssetsEndpoint = createEndpoint(
|
|
255
|
+
"/media/assets",
|
|
256
|
+
{
|
|
257
|
+
method: "GET",
|
|
258
|
+
query: AssetListQuerySchema,
|
|
259
|
+
},
|
|
260
|
+
async (ctx) => {
|
|
261
|
+
const { query, headers } = ctx;
|
|
262
|
+
const context: MediaApiContext = { query, headers };
|
|
263
|
+
|
|
264
|
+
if (hooks?.onBeforeListAssets) {
|
|
265
|
+
await runHookWithShim(
|
|
266
|
+
() => hooks.onBeforeListAssets!(query, context),
|
|
267
|
+
ctx.error,
|
|
268
|
+
"Unauthorized: Cannot list assets",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return listAssets(adapter, query);
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const createAssetEndpoint = createEndpoint(
|
|
277
|
+
"/media/assets",
|
|
278
|
+
{
|
|
279
|
+
method: "POST",
|
|
280
|
+
body: createAssetSchema,
|
|
281
|
+
},
|
|
282
|
+
async (ctx) => {
|
|
283
|
+
const context: MediaApiContext = {
|
|
284
|
+
body: ctx.body,
|
|
285
|
+
headers: ctx.headers,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
if (hooks?.onBeforeUpload) {
|
|
289
|
+
await runHookWithShim(
|
|
290
|
+
() =>
|
|
291
|
+
hooks.onBeforeUpload!(
|
|
292
|
+
{
|
|
293
|
+
filename: ctx.body.filename,
|
|
294
|
+
mimeType: ctx.body.mimeType,
|
|
295
|
+
size: ctx.body.size,
|
|
296
|
+
},
|
|
297
|
+
context,
|
|
298
|
+
),
|
|
299
|
+
ctx.error,
|
|
300
|
+
"Unauthorized: Cannot upload asset",
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
validateMimeType(ctx.body.mimeType, ctx);
|
|
305
|
+
|
|
306
|
+
if (ctx.body.size > maxFileSizeBytes) {
|
|
307
|
+
throw ctx.error(413, {
|
|
308
|
+
message: `File size ${ctx.body.size} bytes exceeds the limit of ${maxFileSizeBytes} bytes`,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
{
|
|
313
|
+
const url = ctx.body.url;
|
|
314
|
+
let urlAllowed = true;
|
|
315
|
+
let denialReason = "";
|
|
316
|
+
|
|
317
|
+
if (allowedUrlPrefixes && allowedUrlPrefixes.length > 0) {
|
|
318
|
+
// Consumer-supplied override — validate against explicit list.
|
|
319
|
+
urlAllowed = allowedUrlPrefixes.some((p) =>
|
|
320
|
+
matchesUrlPrefix(url, p),
|
|
321
|
+
);
|
|
322
|
+
denialReason = `URL must start with one of: ${allowedUrlPrefixes.join(", ")}`;
|
|
323
|
+
} else if (isDirectAdapter(storageAdapter)) {
|
|
324
|
+
// localAdapter writes files server-side via POST /media/upload and returns
|
|
325
|
+
// relative URLs. Reject client-supplied asset URLs unless the consumer
|
|
326
|
+
// explicitly opts into trusted prefixes via allowedUrlPrefixes.
|
|
327
|
+
urlAllowed = false;
|
|
328
|
+
denialReason =
|
|
329
|
+
"Client-supplied asset URLs are not allowed with localAdapter. Use POST /media/upload instead, or configure allowedUrlPrefixes to explicitly allow trusted URL prefixes.";
|
|
330
|
+
} else if (isS3Adapter(storageAdapter)) {
|
|
331
|
+
// Auto-derived from s3Adapter's publicBaseUrl.
|
|
332
|
+
urlAllowed = matchesUrlPrefix(url, storageAdapter.urlPrefix);
|
|
333
|
+
denialReason = `URL must start with the configured S3 publicBaseUrl: ${storageAdapter.urlPrefix}`;
|
|
334
|
+
} else if (isVercelBlobAdapter(storageAdapter)) {
|
|
335
|
+
// Vercel Blob public URLs always belong to a known CDN hostname suffix.
|
|
336
|
+
try {
|
|
337
|
+
const hostname = new URL(url).hostname;
|
|
338
|
+
urlAllowed = hostname.endsWith(
|
|
339
|
+
storageAdapter.urlHostnameSuffix,
|
|
340
|
+
);
|
|
341
|
+
} catch {
|
|
342
|
+
urlAllowed = false;
|
|
343
|
+
}
|
|
344
|
+
denialReason = `URL hostname must end with ${storageAdapter.urlHostnameSuffix}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!urlAllowed) {
|
|
348
|
+
throw ctx.error(400, { message: denialReason });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (ctx.body.folderId) {
|
|
353
|
+
const folder = await getFolderById(adapter, ctx.body.folderId);
|
|
354
|
+
if (!folder) {
|
|
355
|
+
throw ctx.error(404, { message: "Folder not found" });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const asset = await createAsset(adapter, ctx.body);
|
|
360
|
+
|
|
361
|
+
if (hooks?.onAfterUpload) {
|
|
362
|
+
await hooks.onAfterUpload(asset, context);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return asset;
|
|
366
|
+
},
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
const updateAssetEndpoint = createEndpoint(
|
|
370
|
+
"/media/assets/:id",
|
|
371
|
+
{
|
|
372
|
+
method: "PATCH",
|
|
373
|
+
body: updateAssetSchema,
|
|
374
|
+
},
|
|
375
|
+
async (ctx) => {
|
|
376
|
+
const existing = await getAssetById(adapter, ctx.params.id);
|
|
377
|
+
if (!existing) {
|
|
378
|
+
throw ctx.error(404, { message: "Asset not found" });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const context: MediaApiContext = {
|
|
382
|
+
body: ctx.body,
|
|
383
|
+
params: ctx.params,
|
|
384
|
+
headers: ctx.headers,
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
if (hooks?.onBeforeUpdateAsset) {
|
|
388
|
+
await runHookWithShim(
|
|
389
|
+
() => hooks.onBeforeUpdateAsset!(existing, ctx.body, context),
|
|
390
|
+
ctx.error,
|
|
391
|
+
"Unauthorized: Cannot update asset",
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (ctx.body.folderId != null) {
|
|
396
|
+
const folder = await getFolderById(adapter, ctx.body.folderId);
|
|
397
|
+
if (!folder) {
|
|
398
|
+
throw ctx.error(404, { message: "Folder not found" });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const updated = await updateAsset(adapter, ctx.params.id, ctx.body);
|
|
403
|
+
if (!updated) {
|
|
404
|
+
throw ctx.error(404, { message: "Asset not found" });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return updated;
|
|
408
|
+
},
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const deleteAssetEndpoint = createEndpoint(
|
|
412
|
+
"/media/assets/:id",
|
|
413
|
+
{
|
|
414
|
+
method: "DELETE",
|
|
415
|
+
},
|
|
416
|
+
async (ctx) => {
|
|
417
|
+
const context: MediaApiContext = {
|
|
418
|
+
params: ctx.params,
|
|
419
|
+
headers: ctx.headers,
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const asset = await getAssetById(adapter, ctx.params.id);
|
|
423
|
+
if (!asset) {
|
|
424
|
+
throw ctx.error(404, { message: "Asset not found" });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (hooks?.onBeforeDelete) {
|
|
428
|
+
await runHookWithShim(
|
|
429
|
+
() => hooks.onBeforeDelete!(asset, context),
|
|
430
|
+
ctx.error,
|
|
431
|
+
"Unauthorized: Cannot delete asset",
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Delete the storage file FIRST — if this fails the DB record is
|
|
436
|
+
// still intact and the deletion can be retried. Removing the DB
|
|
437
|
+
// record first would silently orphan the file in storage with no
|
|
438
|
+
// way to track or clean it up.
|
|
439
|
+
try {
|
|
440
|
+
await storageAdapter.delete(asset.url);
|
|
441
|
+
} catch (err) {
|
|
442
|
+
console.error(
|
|
443
|
+
`[btst/media] Failed to delete file from storage: ${asset.url}`,
|
|
444
|
+
err,
|
|
445
|
+
);
|
|
446
|
+
throw ctx.error(500, {
|
|
447
|
+
message: "Failed to delete file from storage",
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
await deleteAsset(adapter, ctx.params.id);
|
|
452
|
+
|
|
453
|
+
if (hooks?.onAfterDelete) {
|
|
454
|
+
await hooks.onAfterDelete(ctx.params.id, context);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return { success: true };
|
|
458
|
+
},
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
// ── Folder endpoints ────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
const listFoldersEndpoint = createEndpoint(
|
|
464
|
+
"/media/folders",
|
|
465
|
+
{
|
|
466
|
+
method: "GET",
|
|
467
|
+
query: z.object({
|
|
468
|
+
parentId: z.string().optional(),
|
|
469
|
+
}),
|
|
470
|
+
},
|
|
471
|
+
async (ctx) => {
|
|
472
|
+
const filter = { parentId: ctx.query.parentId };
|
|
473
|
+
const context: MediaApiContext = {
|
|
474
|
+
query: ctx.query,
|
|
475
|
+
headers: ctx.headers,
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
if (hooks?.onBeforeListFolders) {
|
|
479
|
+
await runHookWithShim(
|
|
480
|
+
() => hooks.onBeforeListFolders!(filter, context),
|
|
481
|
+
ctx.error,
|
|
482
|
+
"Unauthorized: Cannot list folders",
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return listFolders(adapter, filter);
|
|
487
|
+
},
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
const createFolderEndpoint = createEndpoint(
|
|
491
|
+
"/media/folders",
|
|
492
|
+
{
|
|
493
|
+
method: "POST",
|
|
494
|
+
body: createFolderSchema,
|
|
495
|
+
},
|
|
496
|
+
async (ctx) => {
|
|
497
|
+
const context: MediaApiContext = {
|
|
498
|
+
body: ctx.body,
|
|
499
|
+
headers: ctx.headers,
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
if (hooks?.onBeforeCreateFolder) {
|
|
503
|
+
await runHookWithShim(
|
|
504
|
+
() => hooks.onBeforeCreateFolder!(ctx.body, context),
|
|
505
|
+
ctx.error,
|
|
506
|
+
"Unauthorized: Cannot create folder",
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return createFolder(adapter, ctx.body);
|
|
511
|
+
},
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
const deleteFolderEndpoint = createEndpoint(
|
|
515
|
+
"/media/folders/:id",
|
|
516
|
+
{
|
|
517
|
+
method: "DELETE",
|
|
518
|
+
},
|
|
519
|
+
async (ctx) => {
|
|
520
|
+
const folder = await getFolderById(adapter, ctx.params.id);
|
|
521
|
+
if (!folder) {
|
|
522
|
+
throw ctx.error(404, { message: "Folder not found" });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const context: MediaApiContext = {
|
|
526
|
+
params: ctx.params,
|
|
527
|
+
headers: ctx.headers,
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
if (hooks?.onBeforeDeleteFolder) {
|
|
531
|
+
await runHookWithShim(
|
|
532
|
+
() => hooks.onBeforeDeleteFolder!(folder, context),
|
|
533
|
+
ctx.error,
|
|
534
|
+
"Unauthorized: Cannot delete folder",
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
await deleteFolder(adapter, ctx.params.id);
|
|
540
|
+
} catch (err) {
|
|
541
|
+
throw ctx.error(409, {
|
|
542
|
+
message:
|
|
543
|
+
err instanceof Error ? err.message : "Cannot delete folder",
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return { success: true };
|
|
548
|
+
},
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
// ── Upload endpoints (adapter-specific) ────────────────────────────────
|
|
552
|
+
|
|
553
|
+
// Direct upload — local adapter only
|
|
554
|
+
const uploadDirectEndpoint = createEndpoint(
|
|
555
|
+
"/media/upload",
|
|
556
|
+
{
|
|
557
|
+
method: "POST",
|
|
558
|
+
metadata: {
|
|
559
|
+
// Tell Better Call this endpoint accepts multipart/form-data so it
|
|
560
|
+
// parses the body into a FormData object and exposes it as ctx.body.
|
|
561
|
+
// Without this, Better Call may pre-read the body stream and calling
|
|
562
|
+
// ctx.request.formData() afterwards fails with "Body already read".
|
|
563
|
+
allowedMediaTypes: ["multipart/form-data"],
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
async (ctx) => {
|
|
567
|
+
if (!isDirectAdapter(storageAdapter)) {
|
|
568
|
+
throw ctx.error(400, {
|
|
569
|
+
message:
|
|
570
|
+
"Direct upload is only supported with the local storage adapter",
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Better Call parses multipart/form-data into a plain object on ctx.body,
|
|
575
|
+
// where each field's value is preserved as-is (File instances for file fields).
|
|
576
|
+
const body = ctx.body as Record<string, unknown> | undefined;
|
|
577
|
+
|
|
578
|
+
if (!body || typeof body !== "object") {
|
|
579
|
+
throw ctx.error(400, {
|
|
580
|
+
message: "Expected multipart/form-data request body",
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const fileRaw = body.file;
|
|
585
|
+
|
|
586
|
+
// Use a duck-type check instead of instanceof File to avoid
|
|
587
|
+
// cross-module-boundary failures (e.g. undici's File vs globalThis.File).
|
|
588
|
+
if (
|
|
589
|
+
!fileRaw ||
|
|
590
|
+
typeof fileRaw !== "object" ||
|
|
591
|
+
typeof (fileRaw as any).arrayBuffer !== "function"
|
|
592
|
+
) {
|
|
593
|
+
throw ctx.error(400, {
|
|
594
|
+
message: "Missing 'file' field in form data",
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (
|
|
599
|
+
typeof (fileRaw as any).size !== "number" ||
|
|
600
|
+
(fileRaw as any).size < 0
|
|
601
|
+
) {
|
|
602
|
+
throw ctx.error(400, {
|
|
603
|
+
message: "File 'size' is missing or invalid",
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
if (
|
|
607
|
+
typeof (fileRaw as any).name !== "string" ||
|
|
608
|
+
!(fileRaw as any).name
|
|
609
|
+
) {
|
|
610
|
+
throw ctx.error(400, {
|
|
611
|
+
message: "File 'name' is missing or invalid",
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
if (typeof (fileRaw as any).type !== "string") {
|
|
615
|
+
throw ctx.error(400, {
|
|
616
|
+
message: "File 'type' is missing or invalid",
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Safe to treat as a File-like object after the duck-type check above.
|
|
621
|
+
const file = fileRaw as Pick<
|
|
622
|
+
File,
|
|
623
|
+
"name" | "type" | "size" | "arrayBuffer"
|
|
624
|
+
>;
|
|
625
|
+
|
|
626
|
+
const context: MediaApiContext = { headers: ctx.headers };
|
|
627
|
+
|
|
628
|
+
if (hooks?.onBeforeUpload) {
|
|
629
|
+
await runHookWithShim(
|
|
630
|
+
() =>
|
|
631
|
+
hooks.onBeforeUpload!(
|
|
632
|
+
{
|
|
633
|
+
filename: file.name,
|
|
634
|
+
mimeType: file.type,
|
|
635
|
+
size: file.size,
|
|
636
|
+
},
|
|
637
|
+
context,
|
|
638
|
+
),
|
|
639
|
+
ctx.error,
|
|
640
|
+
"Unauthorized: Cannot upload asset",
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
validateMimeType(file.type, ctx);
|
|
645
|
+
|
|
646
|
+
if (file.size > maxFileSizeBytes) {
|
|
647
|
+
throw ctx.error(413, {
|
|
648
|
+
message: `File size ${file.size} bytes exceeds the limit of ${maxFileSizeBytes} bytes`,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
653
|
+
const folderId =
|
|
654
|
+
typeof body.folderId === "string" && body.folderId
|
|
655
|
+
? body.folderId
|
|
656
|
+
: undefined;
|
|
657
|
+
|
|
658
|
+
if (folderId) {
|
|
659
|
+
const folder = await getFolderById(adapter, folderId);
|
|
660
|
+
if (!folder) {
|
|
661
|
+
throw ctx.error(404, { message: "Folder not found" });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const { url } = await storageAdapter.upload(buffer, {
|
|
666
|
+
filename: file.name,
|
|
667
|
+
mimeType: file.type,
|
|
668
|
+
size: file.size,
|
|
669
|
+
folderId,
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// Create the DB record. If this fails, clean up the already-uploaded
|
|
673
|
+
// storage file so it does not become a silently orphaned file.
|
|
674
|
+
let asset: Asset;
|
|
675
|
+
try {
|
|
676
|
+
asset = await createAsset(adapter, {
|
|
677
|
+
filename: url.split("/").pop() ?? file.name,
|
|
678
|
+
originalName: file.name,
|
|
679
|
+
mimeType: file.type,
|
|
680
|
+
size: file.size,
|
|
681
|
+
url,
|
|
682
|
+
folderId,
|
|
683
|
+
});
|
|
684
|
+
} catch (err) {
|
|
685
|
+
try {
|
|
686
|
+
await storageAdapter.delete(url);
|
|
687
|
+
} catch (cleanupErr) {
|
|
688
|
+
console.error(
|
|
689
|
+
`[btst/media] Failed to clean up orphaned storage file after DB error: ${url}`,
|
|
690
|
+
cleanupErr,
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
throw err;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (hooks?.onAfterUpload) {
|
|
697
|
+
await hooks.onAfterUpload(asset, context);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return asset;
|
|
701
|
+
},
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
// Token generation — S3 adapter
|
|
705
|
+
const uploadTokenEndpoint = createEndpoint(
|
|
706
|
+
"/media/upload/token",
|
|
707
|
+
{
|
|
708
|
+
method: "POST",
|
|
709
|
+
body: uploadTokenRequestSchema,
|
|
710
|
+
},
|
|
711
|
+
async (ctx) => {
|
|
712
|
+
if (!isS3Adapter(storageAdapter)) {
|
|
713
|
+
throw ctx.error(400, {
|
|
714
|
+
message:
|
|
715
|
+
"Upload token endpoint is only supported with the S3 storage adapter",
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const context: MediaApiContext = {
|
|
720
|
+
body: ctx.body,
|
|
721
|
+
headers: ctx.headers,
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
if (hooks?.onBeforeUpload) {
|
|
725
|
+
await runHookWithShim(
|
|
726
|
+
() =>
|
|
727
|
+
hooks.onBeforeUpload!(
|
|
728
|
+
{
|
|
729
|
+
filename: ctx.body.filename,
|
|
730
|
+
mimeType: ctx.body.mimeType,
|
|
731
|
+
size: ctx.body.size,
|
|
732
|
+
},
|
|
733
|
+
context,
|
|
734
|
+
),
|
|
735
|
+
ctx.error,
|
|
736
|
+
"Unauthorized: Cannot upload asset",
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
validateMimeType(ctx.body.mimeType, ctx);
|
|
741
|
+
|
|
742
|
+
if (ctx.body.size > maxFileSizeBytes) {
|
|
743
|
+
throw ctx.error(413, {
|
|
744
|
+
message: `File size ${ctx.body.size} bytes exceeds the limit of ${maxFileSizeBytes} bytes`,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
let folderId: string | undefined = ctx.body.folderId;
|
|
749
|
+
if (folderId) {
|
|
750
|
+
const folder = await getFolderById(adapter, folderId);
|
|
751
|
+
if (!folder) {
|
|
752
|
+
throw ctx.error(404, {
|
|
753
|
+
message: "Folder not found",
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
folderId = folder.id;
|
|
757
|
+
}
|
|
758
|
+
const filename = sanitizeS3KeySegment(ctx.body.filename);
|
|
759
|
+
|
|
760
|
+
return storageAdapter.generateUploadToken({
|
|
761
|
+
filename,
|
|
762
|
+
mimeType: ctx.body.mimeType,
|
|
763
|
+
size: ctx.body.size,
|
|
764
|
+
folderId,
|
|
765
|
+
});
|
|
766
|
+
},
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
// Vercel Blob token exchange — vercel-blob adapter
|
|
770
|
+
const uploadVercelBlobEndpoint = createEndpoint(
|
|
771
|
+
"/media/upload/vercel-blob",
|
|
772
|
+
{
|
|
773
|
+
method: "POST",
|
|
774
|
+
},
|
|
775
|
+
async (ctx) => {
|
|
776
|
+
if (!isVercelBlobAdapter(storageAdapter)) {
|
|
777
|
+
throw ctx.error(400, {
|
|
778
|
+
message:
|
|
779
|
+
"Vercel Blob endpoint is only supported with the vercelBlobAdapter",
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const context: MediaApiContext = { headers: ctx.headers };
|
|
784
|
+
|
|
785
|
+
if (!ctx.request) {
|
|
786
|
+
throw ctx.error(400, {
|
|
787
|
+
message: "Request object is not available",
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return storageAdapter.handleRequest(ctx.request, {
|
|
792
|
+
onBeforeGenerateToken: async (pathname, clientPayload) => {
|
|
793
|
+
const filename = pathname.split("/").pop() ?? pathname;
|
|
794
|
+
let parsed: Record<string, unknown> = {};
|
|
795
|
+
try {
|
|
796
|
+
parsed = clientPayload ? JSON.parse(clientPayload) : {};
|
|
797
|
+
} catch {
|
|
798
|
+
/* ignore invalid JSON — fall back to defaults */
|
|
799
|
+
}
|
|
800
|
+
const mimeType =
|
|
801
|
+
(parsed.mimeType as string | undefined) ??
|
|
802
|
+
"application/octet-stream";
|
|
803
|
+
const size = parsed.size as number | undefined;
|
|
804
|
+
|
|
805
|
+
if (hooks?.onBeforeUpload) {
|
|
806
|
+
await runHookWithShim(
|
|
807
|
+
() =>
|
|
808
|
+
hooks.onBeforeUpload!(
|
|
809
|
+
{ filename, mimeType, size },
|
|
810
|
+
context,
|
|
811
|
+
),
|
|
812
|
+
ctx.error,
|
|
813
|
+
"Unauthorized: Cannot upload asset",
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
validateMimeType(mimeType, ctx);
|
|
818
|
+
|
|
819
|
+
if (size != null && size > maxFileSizeBytes) {
|
|
820
|
+
throw ctx.error(413, {
|
|
821
|
+
message: `File size ${size} bytes exceeds the limit of ${maxFileSizeBytes} bytes`,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return {
|
|
826
|
+
addRandomSuffix: true,
|
|
827
|
+
allowedContentTypes:
|
|
828
|
+
allowedMimeTypes && allowedMimeTypes.length > 0
|
|
829
|
+
? allowedMimeTypes
|
|
830
|
+
: undefined,
|
|
831
|
+
maximumSizeInBytes: maxFileSizeBytes,
|
|
832
|
+
};
|
|
833
|
+
},
|
|
834
|
+
});
|
|
835
|
+
},
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
return {
|
|
839
|
+
listAssets: listAssetsEndpoint,
|
|
840
|
+
createAsset: createAssetEndpoint,
|
|
841
|
+
updateAsset: updateAssetEndpoint,
|
|
842
|
+
deleteAsset: deleteAssetEndpoint,
|
|
843
|
+
listFolders: listFoldersEndpoint,
|
|
844
|
+
createFolder: createFolderEndpoint,
|
|
845
|
+
deleteFolder: deleteFolderEndpoint,
|
|
846
|
+
uploadDirect: uploadDirectEndpoint,
|
|
847
|
+
uploadToken: uploadTokenEndpoint,
|
|
848
|
+
uploadVercelBlob: uploadVercelBlobEndpoint,
|
|
849
|
+
} as const;
|
|
850
|
+
},
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
export type MediaApiRouter = ReturnType<
|
|
854
|
+
ReturnType<typeof mediaBackendPlugin>["routes"]
|
|
855
|
+
>;
|