@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,82 @@
|
|
|
1
|
+
async function createAsset(adapter, input) {
|
|
2
|
+
return adapter.create({
|
|
3
|
+
model: "mediaAsset",
|
|
4
|
+
data: {
|
|
5
|
+
filename: input.filename,
|
|
6
|
+
originalName: input.originalName,
|
|
7
|
+
mimeType: input.mimeType,
|
|
8
|
+
size: input.size,
|
|
9
|
+
url: input.url,
|
|
10
|
+
folderId: input.folderId,
|
|
11
|
+
alt: input.alt,
|
|
12
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
async function updateAsset(adapter, id, input) {
|
|
17
|
+
const update = {};
|
|
18
|
+
if (input.alt !== void 0) {
|
|
19
|
+
update.alt = input.alt;
|
|
20
|
+
}
|
|
21
|
+
if ("folderId" in input) {
|
|
22
|
+
update.folderId = input.folderId;
|
|
23
|
+
}
|
|
24
|
+
return adapter.update({
|
|
25
|
+
model: "mediaAsset",
|
|
26
|
+
where: [{ field: "id", value: id, operator: "eq" }],
|
|
27
|
+
update
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async function deleteAsset(adapter, id) {
|
|
31
|
+
await adapter.delete({
|
|
32
|
+
model: "mediaAsset",
|
|
33
|
+
where: [{ field: "id", value: id, operator: "eq" }]
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
async function createFolder(adapter, input) {
|
|
37
|
+
return adapter.create({
|
|
38
|
+
model: "mediaFolder",
|
|
39
|
+
data: {
|
|
40
|
+
name: input.name,
|
|
41
|
+
parentId: input.parentId,
|
|
42
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async function deleteFolder(adapter, id) {
|
|
47
|
+
const allFolderIds = [id];
|
|
48
|
+
const queue = [id];
|
|
49
|
+
while (queue.length > 0) {
|
|
50
|
+
const parentId = queue.shift();
|
|
51
|
+
const children = await adapter.findMany({
|
|
52
|
+
model: "mediaFolder",
|
|
53
|
+
where: [{ field: "parentId", value: parentId, operator: "eq" }]
|
|
54
|
+
});
|
|
55
|
+
for (const child of children) {
|
|
56
|
+
allFolderIds.push(child.id);
|
|
57
|
+
queue.push(child.id);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
let totalAssets = 0;
|
|
61
|
+
for (const folderId of allFolderIds) {
|
|
62
|
+
totalAssets += await adapter.count({
|
|
63
|
+
model: "mediaAsset",
|
|
64
|
+
where: [{ field: "folderId", value: folderId, operator: "eq" }]
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (totalAssets > 0) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Cannot delete folder: it or one of its subfolders contains ${totalAssets} asset(s). Move or delete them first.`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
await adapter.transaction(async (tx) => {
|
|
73
|
+
for (const folderId of [...allFolderIds].reverse()) {
|
|
74
|
+
await tx.delete({
|
|
75
|
+
model: "mediaFolder",
|
|
76
|
+
where: [{ field: "id", value: folderId, operator: "eq" }]
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { createAsset, createFolder, deleteAsset, deleteFolder, updateAsset };
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const api = require('@btst/stack/plugins/api');
|
|
4
|
+
const z = require('zod');
|
|
5
|
+
const db = require('../db.cjs');
|
|
6
|
+
const schemas = require('../schemas.cjs');
|
|
7
|
+
const getters = require('./getters.cjs');
|
|
8
|
+
const mutations = require('./mutations.cjs');
|
|
9
|
+
const storageAdapter = require('./storage-adapter.cjs');
|
|
10
|
+
const utils = require('../../utils.cjs');
|
|
11
|
+
|
|
12
|
+
function sanitizeS3KeySegment(s) {
|
|
13
|
+
return s.replace(/[/\\]/g, "-").replace(/\.\./g, "_").trim() || "unknown";
|
|
14
|
+
}
|
|
15
|
+
function matchesUrlPrefix(url, prefix) {
|
|
16
|
+
const normalizedPrefix = `${prefix.replace(/\/+$/, "")}/`;
|
|
17
|
+
return url.startsWith(normalizedPrefix);
|
|
18
|
+
}
|
|
19
|
+
const mediaBackendPlugin = (config) => api.defineBackendPlugin({
|
|
20
|
+
name: "media",
|
|
21
|
+
dbPlugin: db.mediaSchema,
|
|
22
|
+
api: (adapter) => ({
|
|
23
|
+
listAssets: (params) => getters.listAssets(adapter, params),
|
|
24
|
+
getAssetById: (id) => getters.getAssetById(adapter, id),
|
|
25
|
+
listFolders: (params) => getters.listFolders(adapter, params),
|
|
26
|
+
getFolderById: (id) => getters.getFolderById(adapter, id)
|
|
27
|
+
}),
|
|
28
|
+
routes: (adapter) => {
|
|
29
|
+
const {
|
|
30
|
+
storageAdapter: storageAdapter$1,
|
|
31
|
+
maxFileSizeBytes = 10 * 1024 * 1024,
|
|
32
|
+
allowedMimeTypes,
|
|
33
|
+
allowedUrlPrefixes,
|
|
34
|
+
hooks
|
|
35
|
+
} = config;
|
|
36
|
+
function validateMimeType(mimeType, ctx) {
|
|
37
|
+
if (allowedMimeTypes && allowedMimeTypes.length > 0) {
|
|
38
|
+
const allowed = allowedMimeTypes.some((pattern) => {
|
|
39
|
+
if (pattern.endsWith("/*")) {
|
|
40
|
+
return mimeType.startsWith(pattern.slice(0, -1));
|
|
41
|
+
}
|
|
42
|
+
return mimeType === pattern;
|
|
43
|
+
});
|
|
44
|
+
if (!allowed) {
|
|
45
|
+
throw ctx.error(415, {
|
|
46
|
+
message: `MIME type '${mimeType}' is not allowed. Allowed: ${allowedMimeTypes.join(", ")}`
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const listAssetsEndpoint = api.createEndpoint(
|
|
52
|
+
"/media/assets",
|
|
53
|
+
{
|
|
54
|
+
method: "GET",
|
|
55
|
+
query: schemas.AssetListQuerySchema
|
|
56
|
+
},
|
|
57
|
+
async (ctx) => {
|
|
58
|
+
const { query, headers } = ctx;
|
|
59
|
+
const context = { query, headers };
|
|
60
|
+
if (hooks?.onBeforeListAssets) {
|
|
61
|
+
await utils.runHookWithShim(
|
|
62
|
+
() => hooks.onBeforeListAssets(query, context),
|
|
63
|
+
ctx.error,
|
|
64
|
+
"Unauthorized: Cannot list assets"
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return getters.listAssets(adapter, query);
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
const createAssetEndpoint = api.createEndpoint(
|
|
71
|
+
"/media/assets",
|
|
72
|
+
{
|
|
73
|
+
method: "POST",
|
|
74
|
+
body: schemas.createAssetSchema
|
|
75
|
+
},
|
|
76
|
+
async (ctx) => {
|
|
77
|
+
const context = {
|
|
78
|
+
body: ctx.body,
|
|
79
|
+
headers: ctx.headers
|
|
80
|
+
};
|
|
81
|
+
if (hooks?.onBeforeUpload) {
|
|
82
|
+
await utils.runHookWithShim(
|
|
83
|
+
() => hooks.onBeforeUpload(
|
|
84
|
+
{
|
|
85
|
+
filename: ctx.body.filename,
|
|
86
|
+
mimeType: ctx.body.mimeType,
|
|
87
|
+
size: ctx.body.size
|
|
88
|
+
},
|
|
89
|
+
context
|
|
90
|
+
),
|
|
91
|
+
ctx.error,
|
|
92
|
+
"Unauthorized: Cannot upload asset"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
validateMimeType(ctx.body.mimeType, ctx);
|
|
96
|
+
if (ctx.body.size > maxFileSizeBytes) {
|
|
97
|
+
throw ctx.error(413, {
|
|
98
|
+
message: `File size ${ctx.body.size} bytes exceeds the limit of ${maxFileSizeBytes} bytes`
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
{
|
|
102
|
+
const url = ctx.body.url;
|
|
103
|
+
let urlAllowed = true;
|
|
104
|
+
let denialReason = "";
|
|
105
|
+
if (allowedUrlPrefixes && allowedUrlPrefixes.length > 0) {
|
|
106
|
+
urlAllowed = allowedUrlPrefixes.some(
|
|
107
|
+
(p) => matchesUrlPrefix(url, p)
|
|
108
|
+
);
|
|
109
|
+
denialReason = `URL must start with one of: ${allowedUrlPrefixes.join(", ")}`;
|
|
110
|
+
} else if (storageAdapter.isDirectAdapter(storageAdapter$1)) {
|
|
111
|
+
urlAllowed = false;
|
|
112
|
+
denialReason = "Client-supplied asset URLs are not allowed with localAdapter. Use POST /media/upload instead, or configure allowedUrlPrefixes to explicitly allow trusted URL prefixes.";
|
|
113
|
+
} else if (storageAdapter.isS3Adapter(storageAdapter$1)) {
|
|
114
|
+
urlAllowed = matchesUrlPrefix(url, storageAdapter$1.urlPrefix);
|
|
115
|
+
denialReason = `URL must start with the configured S3 publicBaseUrl: ${storageAdapter$1.urlPrefix}`;
|
|
116
|
+
} else if (storageAdapter.isVercelBlobAdapter(storageAdapter$1)) {
|
|
117
|
+
try {
|
|
118
|
+
const hostname = new URL(url).hostname;
|
|
119
|
+
urlAllowed = hostname.endsWith(
|
|
120
|
+
storageAdapter$1.urlHostnameSuffix
|
|
121
|
+
);
|
|
122
|
+
} catch {
|
|
123
|
+
urlAllowed = false;
|
|
124
|
+
}
|
|
125
|
+
denialReason = `URL hostname must end with ${storageAdapter$1.urlHostnameSuffix}`;
|
|
126
|
+
}
|
|
127
|
+
if (!urlAllowed) {
|
|
128
|
+
throw ctx.error(400, { message: denialReason });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (ctx.body.folderId) {
|
|
132
|
+
const folder = await getters.getFolderById(adapter, ctx.body.folderId);
|
|
133
|
+
if (!folder) {
|
|
134
|
+
throw ctx.error(404, { message: "Folder not found" });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const asset = await mutations.createAsset(adapter, ctx.body);
|
|
138
|
+
if (hooks?.onAfterUpload) {
|
|
139
|
+
await hooks.onAfterUpload(asset, context);
|
|
140
|
+
}
|
|
141
|
+
return asset;
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
const updateAssetEndpoint = api.createEndpoint(
|
|
145
|
+
"/media/assets/:id",
|
|
146
|
+
{
|
|
147
|
+
method: "PATCH",
|
|
148
|
+
body: schemas.updateAssetSchema
|
|
149
|
+
},
|
|
150
|
+
async (ctx) => {
|
|
151
|
+
const existing = await getters.getAssetById(adapter, ctx.params.id);
|
|
152
|
+
if (!existing) {
|
|
153
|
+
throw ctx.error(404, { message: "Asset not found" });
|
|
154
|
+
}
|
|
155
|
+
const context = {
|
|
156
|
+
body: ctx.body,
|
|
157
|
+
params: ctx.params,
|
|
158
|
+
headers: ctx.headers
|
|
159
|
+
};
|
|
160
|
+
if (hooks?.onBeforeUpdateAsset) {
|
|
161
|
+
await utils.runHookWithShim(
|
|
162
|
+
() => hooks.onBeforeUpdateAsset(existing, ctx.body, context),
|
|
163
|
+
ctx.error,
|
|
164
|
+
"Unauthorized: Cannot update asset"
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (ctx.body.folderId != null) {
|
|
168
|
+
const folder = await getters.getFolderById(adapter, ctx.body.folderId);
|
|
169
|
+
if (!folder) {
|
|
170
|
+
throw ctx.error(404, { message: "Folder not found" });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const updated = await mutations.updateAsset(adapter, ctx.params.id, ctx.body);
|
|
174
|
+
if (!updated) {
|
|
175
|
+
throw ctx.error(404, { message: "Asset not found" });
|
|
176
|
+
}
|
|
177
|
+
return updated;
|
|
178
|
+
}
|
|
179
|
+
);
|
|
180
|
+
const deleteAssetEndpoint = api.createEndpoint(
|
|
181
|
+
"/media/assets/:id",
|
|
182
|
+
{
|
|
183
|
+
method: "DELETE"
|
|
184
|
+
},
|
|
185
|
+
async (ctx) => {
|
|
186
|
+
const context = {
|
|
187
|
+
params: ctx.params,
|
|
188
|
+
headers: ctx.headers
|
|
189
|
+
};
|
|
190
|
+
const asset = await getters.getAssetById(adapter, ctx.params.id);
|
|
191
|
+
if (!asset) {
|
|
192
|
+
throw ctx.error(404, { message: "Asset not found" });
|
|
193
|
+
}
|
|
194
|
+
if (hooks?.onBeforeDelete) {
|
|
195
|
+
await utils.runHookWithShim(
|
|
196
|
+
() => hooks.onBeforeDelete(asset, context),
|
|
197
|
+
ctx.error,
|
|
198
|
+
"Unauthorized: Cannot delete asset"
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
await storageAdapter$1.delete(asset.url);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error(
|
|
205
|
+
`[btst/media] Failed to delete file from storage: ${asset.url}`,
|
|
206
|
+
err
|
|
207
|
+
);
|
|
208
|
+
throw ctx.error(500, {
|
|
209
|
+
message: "Failed to delete file from storage"
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
await mutations.deleteAsset(adapter, ctx.params.id);
|
|
213
|
+
if (hooks?.onAfterDelete) {
|
|
214
|
+
await hooks.onAfterDelete(ctx.params.id, context);
|
|
215
|
+
}
|
|
216
|
+
return { success: true };
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
const listFoldersEndpoint = api.createEndpoint(
|
|
220
|
+
"/media/folders",
|
|
221
|
+
{
|
|
222
|
+
method: "GET",
|
|
223
|
+
query: z.z.object({
|
|
224
|
+
parentId: z.z.string().optional()
|
|
225
|
+
})
|
|
226
|
+
},
|
|
227
|
+
async (ctx) => {
|
|
228
|
+
const filter = { parentId: ctx.query.parentId };
|
|
229
|
+
const context = {
|
|
230
|
+
query: ctx.query,
|
|
231
|
+
headers: ctx.headers
|
|
232
|
+
};
|
|
233
|
+
if (hooks?.onBeforeListFolders) {
|
|
234
|
+
await utils.runHookWithShim(
|
|
235
|
+
() => hooks.onBeforeListFolders(filter, context),
|
|
236
|
+
ctx.error,
|
|
237
|
+
"Unauthorized: Cannot list folders"
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return getters.listFolders(adapter, filter);
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
const createFolderEndpoint = api.createEndpoint(
|
|
244
|
+
"/media/folders",
|
|
245
|
+
{
|
|
246
|
+
method: "POST",
|
|
247
|
+
body: schemas.createFolderSchema
|
|
248
|
+
},
|
|
249
|
+
async (ctx) => {
|
|
250
|
+
const context = {
|
|
251
|
+
body: ctx.body,
|
|
252
|
+
headers: ctx.headers
|
|
253
|
+
};
|
|
254
|
+
if (hooks?.onBeforeCreateFolder) {
|
|
255
|
+
await utils.runHookWithShim(
|
|
256
|
+
() => hooks.onBeforeCreateFolder(ctx.body, context),
|
|
257
|
+
ctx.error,
|
|
258
|
+
"Unauthorized: Cannot create folder"
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return mutations.createFolder(adapter, ctx.body);
|
|
262
|
+
}
|
|
263
|
+
);
|
|
264
|
+
const deleteFolderEndpoint = api.createEndpoint(
|
|
265
|
+
"/media/folders/:id",
|
|
266
|
+
{
|
|
267
|
+
method: "DELETE"
|
|
268
|
+
},
|
|
269
|
+
async (ctx) => {
|
|
270
|
+
const folder = await getters.getFolderById(adapter, ctx.params.id);
|
|
271
|
+
if (!folder) {
|
|
272
|
+
throw ctx.error(404, { message: "Folder not found" });
|
|
273
|
+
}
|
|
274
|
+
const context = {
|
|
275
|
+
params: ctx.params,
|
|
276
|
+
headers: ctx.headers
|
|
277
|
+
};
|
|
278
|
+
if (hooks?.onBeforeDeleteFolder) {
|
|
279
|
+
await utils.runHookWithShim(
|
|
280
|
+
() => hooks.onBeforeDeleteFolder(folder, context),
|
|
281
|
+
ctx.error,
|
|
282
|
+
"Unauthorized: Cannot delete folder"
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
await mutations.deleteFolder(adapter, ctx.params.id);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
throw ctx.error(409, {
|
|
289
|
+
message: err instanceof Error ? err.message : "Cannot delete folder"
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return { success: true };
|
|
293
|
+
}
|
|
294
|
+
);
|
|
295
|
+
const uploadDirectEndpoint = api.createEndpoint(
|
|
296
|
+
"/media/upload",
|
|
297
|
+
{
|
|
298
|
+
method: "POST",
|
|
299
|
+
metadata: {
|
|
300
|
+
// Tell Better Call this endpoint accepts multipart/form-data so it
|
|
301
|
+
// parses the body into a FormData object and exposes it as ctx.body.
|
|
302
|
+
// Without this, Better Call may pre-read the body stream and calling
|
|
303
|
+
// ctx.request.formData() afterwards fails with "Body already read".
|
|
304
|
+
allowedMediaTypes: ["multipart/form-data"]
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
async (ctx) => {
|
|
308
|
+
if (!storageAdapter.isDirectAdapter(storageAdapter$1)) {
|
|
309
|
+
throw ctx.error(400, {
|
|
310
|
+
message: "Direct upload is only supported with the local storage adapter"
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const body = ctx.body;
|
|
314
|
+
if (!body || typeof body !== "object") {
|
|
315
|
+
throw ctx.error(400, {
|
|
316
|
+
message: "Expected multipart/form-data request body"
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
const fileRaw = body.file;
|
|
320
|
+
if (!fileRaw || typeof fileRaw !== "object" || typeof fileRaw.arrayBuffer !== "function") {
|
|
321
|
+
throw ctx.error(400, {
|
|
322
|
+
message: "Missing 'file' field in form data"
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
if (typeof fileRaw.size !== "number" || fileRaw.size < 0) {
|
|
326
|
+
throw ctx.error(400, {
|
|
327
|
+
message: "File 'size' is missing or invalid"
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (typeof fileRaw.name !== "string" || !fileRaw.name) {
|
|
331
|
+
throw ctx.error(400, {
|
|
332
|
+
message: "File 'name' is missing or invalid"
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
if (typeof fileRaw.type !== "string") {
|
|
336
|
+
throw ctx.error(400, {
|
|
337
|
+
message: "File 'type' is missing or invalid"
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
const file = fileRaw;
|
|
341
|
+
const context = { headers: ctx.headers };
|
|
342
|
+
if (hooks?.onBeforeUpload) {
|
|
343
|
+
await utils.runHookWithShim(
|
|
344
|
+
() => hooks.onBeforeUpload(
|
|
345
|
+
{
|
|
346
|
+
filename: file.name,
|
|
347
|
+
mimeType: file.type,
|
|
348
|
+
size: file.size
|
|
349
|
+
},
|
|
350
|
+
context
|
|
351
|
+
),
|
|
352
|
+
ctx.error,
|
|
353
|
+
"Unauthorized: Cannot upload asset"
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
validateMimeType(file.type, ctx);
|
|
357
|
+
if (file.size > maxFileSizeBytes) {
|
|
358
|
+
throw ctx.error(413, {
|
|
359
|
+
message: `File size ${file.size} bytes exceeds the limit of ${maxFileSizeBytes} bytes`
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
363
|
+
const folderId = typeof body.folderId === "string" && body.folderId ? body.folderId : void 0;
|
|
364
|
+
if (folderId) {
|
|
365
|
+
const folder = await getters.getFolderById(adapter, folderId);
|
|
366
|
+
if (!folder) {
|
|
367
|
+
throw ctx.error(404, { message: "Folder not found" });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const { url } = await storageAdapter$1.upload(buffer, {
|
|
371
|
+
filename: file.name,
|
|
372
|
+
mimeType: file.type,
|
|
373
|
+
size: file.size,
|
|
374
|
+
folderId
|
|
375
|
+
});
|
|
376
|
+
let asset;
|
|
377
|
+
try {
|
|
378
|
+
asset = await mutations.createAsset(adapter, {
|
|
379
|
+
filename: url.split("/").pop() ?? file.name,
|
|
380
|
+
originalName: file.name,
|
|
381
|
+
mimeType: file.type,
|
|
382
|
+
size: file.size,
|
|
383
|
+
url,
|
|
384
|
+
folderId
|
|
385
|
+
});
|
|
386
|
+
} catch (err) {
|
|
387
|
+
try {
|
|
388
|
+
await storageAdapter$1.delete(url);
|
|
389
|
+
} catch (cleanupErr) {
|
|
390
|
+
console.error(
|
|
391
|
+
`[btst/media] Failed to clean up orphaned storage file after DB error: ${url}`,
|
|
392
|
+
cleanupErr
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
throw err;
|
|
396
|
+
}
|
|
397
|
+
if (hooks?.onAfterUpload) {
|
|
398
|
+
await hooks.onAfterUpload(asset, context);
|
|
399
|
+
}
|
|
400
|
+
return asset;
|
|
401
|
+
}
|
|
402
|
+
);
|
|
403
|
+
const uploadTokenEndpoint = api.createEndpoint(
|
|
404
|
+
"/media/upload/token",
|
|
405
|
+
{
|
|
406
|
+
method: "POST",
|
|
407
|
+
body: schemas.uploadTokenRequestSchema
|
|
408
|
+
},
|
|
409
|
+
async (ctx) => {
|
|
410
|
+
if (!storageAdapter.isS3Adapter(storageAdapter$1)) {
|
|
411
|
+
throw ctx.error(400, {
|
|
412
|
+
message: "Upload token endpoint is only supported with the S3 storage adapter"
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
const context = {
|
|
416
|
+
body: ctx.body,
|
|
417
|
+
headers: ctx.headers
|
|
418
|
+
};
|
|
419
|
+
if (hooks?.onBeforeUpload) {
|
|
420
|
+
await utils.runHookWithShim(
|
|
421
|
+
() => hooks.onBeforeUpload(
|
|
422
|
+
{
|
|
423
|
+
filename: ctx.body.filename,
|
|
424
|
+
mimeType: ctx.body.mimeType,
|
|
425
|
+
size: ctx.body.size
|
|
426
|
+
},
|
|
427
|
+
context
|
|
428
|
+
),
|
|
429
|
+
ctx.error,
|
|
430
|
+
"Unauthorized: Cannot upload asset"
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
validateMimeType(ctx.body.mimeType, ctx);
|
|
434
|
+
if (ctx.body.size > maxFileSizeBytes) {
|
|
435
|
+
throw ctx.error(413, {
|
|
436
|
+
message: `File size ${ctx.body.size} bytes exceeds the limit of ${maxFileSizeBytes} bytes`
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
let folderId = ctx.body.folderId;
|
|
440
|
+
if (folderId) {
|
|
441
|
+
const folder = await getters.getFolderById(adapter, folderId);
|
|
442
|
+
if (!folder) {
|
|
443
|
+
throw ctx.error(404, {
|
|
444
|
+
message: "Folder not found"
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
folderId = folder.id;
|
|
448
|
+
}
|
|
449
|
+
const filename = sanitizeS3KeySegment(ctx.body.filename);
|
|
450
|
+
return storageAdapter$1.generateUploadToken({
|
|
451
|
+
filename,
|
|
452
|
+
mimeType: ctx.body.mimeType,
|
|
453
|
+
size: ctx.body.size,
|
|
454
|
+
folderId
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
);
|
|
458
|
+
const uploadVercelBlobEndpoint = api.createEndpoint(
|
|
459
|
+
"/media/upload/vercel-blob",
|
|
460
|
+
{
|
|
461
|
+
method: "POST"
|
|
462
|
+
},
|
|
463
|
+
async (ctx) => {
|
|
464
|
+
if (!storageAdapter.isVercelBlobAdapter(storageAdapter$1)) {
|
|
465
|
+
throw ctx.error(400, {
|
|
466
|
+
message: "Vercel Blob endpoint is only supported with the vercelBlobAdapter"
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
const context = { headers: ctx.headers };
|
|
470
|
+
if (!ctx.request) {
|
|
471
|
+
throw ctx.error(400, {
|
|
472
|
+
message: "Request object is not available"
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return storageAdapter$1.handleRequest(ctx.request, {
|
|
476
|
+
onBeforeGenerateToken: async (pathname, clientPayload) => {
|
|
477
|
+
const filename = pathname.split("/").pop() ?? pathname;
|
|
478
|
+
let parsed = {};
|
|
479
|
+
try {
|
|
480
|
+
parsed = clientPayload ? JSON.parse(clientPayload) : {};
|
|
481
|
+
} catch {
|
|
482
|
+
}
|
|
483
|
+
const mimeType = parsed.mimeType ?? "application/octet-stream";
|
|
484
|
+
const size = parsed.size;
|
|
485
|
+
if (hooks?.onBeforeUpload) {
|
|
486
|
+
await utils.runHookWithShim(
|
|
487
|
+
() => hooks.onBeforeUpload(
|
|
488
|
+
{ filename, mimeType, size },
|
|
489
|
+
context
|
|
490
|
+
),
|
|
491
|
+
ctx.error,
|
|
492
|
+
"Unauthorized: Cannot upload asset"
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
validateMimeType(mimeType, ctx);
|
|
496
|
+
if (size != null && size > maxFileSizeBytes) {
|
|
497
|
+
throw ctx.error(413, {
|
|
498
|
+
message: `File size ${size} bytes exceeds the limit of ${maxFileSizeBytes} bytes`
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
addRandomSuffix: true,
|
|
503
|
+
allowedContentTypes: allowedMimeTypes && allowedMimeTypes.length > 0 ? allowedMimeTypes : void 0,
|
|
504
|
+
maximumSizeInBytes: maxFileSizeBytes
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
);
|
|
510
|
+
return {
|
|
511
|
+
listAssets: listAssetsEndpoint,
|
|
512
|
+
createAsset: createAssetEndpoint,
|
|
513
|
+
updateAsset: updateAssetEndpoint,
|
|
514
|
+
deleteAsset: deleteAssetEndpoint,
|
|
515
|
+
listFolders: listFoldersEndpoint,
|
|
516
|
+
createFolder: createFolderEndpoint,
|
|
517
|
+
deleteFolder: deleteFolderEndpoint,
|
|
518
|
+
uploadDirect: uploadDirectEndpoint,
|
|
519
|
+
uploadToken: uploadTokenEndpoint,
|
|
520
|
+
uploadVercelBlob: uploadVercelBlobEndpoint
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
exports.mediaBackendPlugin = mediaBackendPlugin;
|