@btst/stack 2.8.1 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/README.md +3 -2
  2. package/dist/components/markdown/index.d.cts +15 -2
  3. package/dist/components/markdown/index.d.mts +15 -2
  4. package/dist/components/markdown/index.d.ts +15 -2
  5. package/dist/packages/stack/src/plugins/blog/client/components/forms/image-field.cjs +30 -1
  6. package/dist/packages/stack/src/plugins/blog/client/components/forms/image-field.mjs +30 -1
  7. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.cjs +49 -9
  8. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.mjs +50 -10
  9. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.cjs +77 -9
  10. package/dist/packages/stack/src/plugins/blog/client/components/forms/markdown-editor.mjs +77 -9
  11. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.cjs +24 -5
  12. package/dist/packages/stack/src/plugins/cms/client/components/forms/content-form.mjs +24 -5
  13. package/dist/packages/stack/src/plugins/cms/client/components/forms/file-upload.cjs +47 -13
  14. package/dist/packages/stack/src/plugins/cms/client/components/forms/file-upload.mjs +47 -13
  15. package/dist/packages/stack/src/plugins/kanban/client/components/forms/board-form.cjs +1 -1
  16. package/dist/packages/stack/src/plugins/kanban/client/components/forms/board-form.mjs +1 -1
  17. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.cjs +6 -2
  18. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.mjs +6 -2
  19. package/dist/packages/stack/src/plugins/media/api/adapters/local.cjs +55 -0
  20. package/dist/packages/stack/src/plugins/media/api/adapters/local.mjs +37 -0
  21. package/dist/packages/stack/src/plugins/media/api/getters.cjs +83 -0
  22. package/dist/packages/stack/src/plugins/media/api/getters.mjs +78 -0
  23. package/dist/packages/stack/src/plugins/media/api/mutations.cjs +88 -0
  24. package/dist/packages/stack/src/plugins/media/api/mutations.mjs +82 -0
  25. package/dist/packages/stack/src/plugins/media/api/plugin.cjs +525 -0
  26. package/dist/packages/stack/src/plugins/media/api/plugin.mjs +523 -0
  27. package/dist/packages/stack/src/plugins/media/api/query-key-defs.cjs +19 -0
  28. package/dist/packages/stack/src/plugins/media/api/query-key-defs.mjs +16 -0
  29. package/dist/packages/stack/src/plugins/media/api/serializers.cjs +17 -0
  30. package/dist/packages/stack/src/plugins/media/api/serializers.mjs +14 -0
  31. package/dist/packages/stack/src/plugins/media/api/storage-adapter.cjs +15 -0
  32. package/dist/packages/stack/src/plugins/media/api/storage-adapter.mjs +11 -0
  33. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-card.cjs +129 -0
  34. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-card.mjs +127 -0
  35. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.cjs +58 -0
  36. package/dist/packages/stack/src/plugins/media/client/components/media-picker/asset-preview-button.mjs +56 -0
  37. package/dist/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.cjs +94 -0
  38. package/dist/packages/stack/src/plugins/media/client/components/media-picker/browse-tab.mjs +92 -0
  39. package/dist/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.cjs +171 -0
  40. package/dist/packages/stack/src/plugins/media/client/components/media-picker/folder-tree.mjs +168 -0
  41. package/dist/packages/stack/src/plugins/media/client/components/media-picker/index.cjs +308 -0
  42. package/dist/packages/stack/src/plugins/media/client/components/media-picker/index.mjs +305 -0
  43. package/dist/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.cjs +104 -0
  44. package/dist/packages/stack/src/plugins/media/client/components/media-picker/upload-tab.mjs +102 -0
  45. package/dist/packages/stack/src/plugins/media/client/components/media-picker/url-tab.cjs +70 -0
  46. package/dist/packages/stack/src/plugins/media/client/components/media-picker/url-tab.mjs +68 -0
  47. package/dist/packages/stack/src/plugins/media/client/components/media-picker/utils.cjs +21 -0
  48. package/dist/packages/stack/src/plugins/media/client/components/media-picker/utils.mjs +17 -0
  49. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.cjs +35 -0
  50. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.internal.cjs +125 -0
  51. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.internal.mjs +123 -0
  52. package/dist/packages/stack/src/plugins/media/client/components/pages/library-page.mjs +33 -0
  53. package/dist/packages/stack/src/plugins/media/client/hooks/use-media.cjs +222 -0
  54. package/dist/packages/stack/src/plugins/media/client/hooks/use-media.mjs +214 -0
  55. package/dist/packages/stack/src/plugins/media/client/plugin.cjs +94 -0
  56. package/dist/packages/stack/src/plugins/media/client/plugin.mjs +92 -0
  57. package/dist/packages/stack/src/plugins/media/client/upload.cjs +121 -0
  58. package/dist/packages/stack/src/plugins/media/client/upload.mjs +119 -0
  59. package/dist/packages/stack/src/plugins/media/client/utils/image-compression.cjs +67 -0
  60. package/dist/packages/stack/src/plugins/media/client/utils/image-compression.mjs +65 -0
  61. package/dist/packages/stack/src/plugins/media/db.cjs +62 -0
  62. package/dist/packages/stack/src/plugins/media/db.mjs +60 -0
  63. package/dist/packages/stack/src/plugins/media/schemas.cjs +41 -0
  64. package/dist/packages/stack/src/plugins/media/schemas.mjs +35 -0
  65. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.cjs +18 -1
  66. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-block.mjs +19 -2
  67. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-dialog.cjs +2 -2
  68. package/dist/packages/ui/src/components/minimal-tiptap/components/image/image-edit-dialog.mjs +2 -2
  69. package/dist/packages/ui/src/components/minimal-tiptap/components/section/five.cjs +3 -2
  70. package/dist/packages/ui/src/components/minimal-tiptap/components/section/five.mjs +3 -2
  71. package/dist/packages/ui/src/components/minimal-tiptap/minimal-tiptap.cjs +12 -5
  72. package/dist/packages/ui/src/components/minimal-tiptap/minimal-tiptap.mjs +12 -5
  73. package/dist/plugins/blog/client/index.d.cts +58 -1
  74. package/dist/plugins/blog/client/index.d.mts +58 -1
  75. package/dist/plugins/blog/client/index.d.ts +58 -1
  76. package/dist/plugins/cms/client/index.d.cts +73 -3
  77. package/dist/plugins/cms/client/index.d.mts +73 -3
  78. package/dist/plugins/cms/client/index.d.ts +73 -3
  79. package/dist/plugins/kanban/api/index.d.cts +1 -1
  80. package/dist/plugins/kanban/api/index.d.mts +1 -1
  81. package/dist/plugins/kanban/api/index.d.ts +1 -1
  82. package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
  83. package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
  84. package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
  85. package/dist/plugins/kanban/client/index.d.cts +1 -1
  86. package/dist/plugins/kanban/client/index.d.mts +1 -1
  87. package/dist/plugins/kanban/client/index.d.ts +1 -1
  88. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  89. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  90. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  91. package/dist/plugins/media/api/adapters/s3.cjs +106 -0
  92. package/dist/plugins/media/api/adapters/s3.d.cts +60 -0
  93. package/dist/plugins/media/api/adapters/s3.d.mts +60 -0
  94. package/dist/plugins/media/api/adapters/s3.d.ts +60 -0
  95. package/dist/plugins/media/api/adapters/s3.mjs +104 -0
  96. package/dist/plugins/media/api/adapters/vercel-blob.cjs +53 -0
  97. package/dist/plugins/media/api/adapters/vercel-blob.d.cts +41 -0
  98. package/dist/plugins/media/api/adapters/vercel-blob.d.mts +41 -0
  99. package/dist/plugins/media/api/adapters/vercel-blob.d.ts +41 -0
  100. package/dist/plugins/media/api/adapters/vercel-blob.mjs +51 -0
  101. package/dist/plugins/media/api/index.cjs +26 -0
  102. package/dist/plugins/media/api/index.d.cts +116 -0
  103. package/dist/plugins/media/api/index.d.mts +116 -0
  104. package/dist/plugins/media/api/index.d.ts +116 -0
  105. package/dist/plugins/media/api/index.mjs +6 -0
  106. package/dist/plugins/media/client/components/index.cjs +10 -0
  107. package/dist/plugins/media/client/components/index.d.cts +55 -0
  108. package/dist/plugins/media/client/components/index.d.mts +55 -0
  109. package/dist/plugins/media/client/components/index.d.ts +55 -0
  110. package/dist/plugins/media/client/components/index.mjs +2 -0
  111. package/dist/plugins/media/client/hooks/index.cjs +13 -0
  112. package/dist/plugins/media/client/hooks/index.d.cts +53 -0
  113. package/dist/plugins/media/client/hooks/index.d.mts +53 -0
  114. package/dist/plugins/media/client/hooks/index.d.ts +53 -0
  115. package/dist/plugins/media/client/hooks/index.mjs +1 -0
  116. package/dist/plugins/media/client/index.cjs +9 -0
  117. package/dist/plugins/media/client/index.d.cts +242 -0
  118. package/dist/plugins/media/client/index.d.mts +242 -0
  119. package/dist/plugins/media/client/index.d.ts +242 -0
  120. package/dist/plugins/media/client/index.mjs +2 -0
  121. package/dist/plugins/media/client.css +1 -0
  122. package/dist/plugins/media/query-keys.cjs +72 -0
  123. package/dist/plugins/media/query-keys.d.cts +49 -0
  124. package/dist/plugins/media/query-keys.d.mts +49 -0
  125. package/dist/plugins/media/query-keys.d.ts +49 -0
  126. package/dist/plugins/media/query-keys.mjs +70 -0
  127. package/dist/plugins/media/style.css +1 -0
  128. package/dist/shared/{stack.DRpeDS6X.d.ts → stack.BMx2QYOK.d.ts} +25 -0
  129. package/dist/shared/stack.BttDsJJn.d.cts +109 -0
  130. package/dist/shared/stack.BttDsJJn.d.mts +109 -0
  131. package/dist/shared/stack.BttDsJJn.d.ts +109 -0
  132. package/dist/shared/stack.C7vfOBmO.d.mts +63 -0
  133. package/dist/shared/stack.CAni8dnD.d.cts +63 -0
  134. package/dist/shared/stack.CI8iRKKi.d.cts +286 -0
  135. package/dist/shared/stack.CLcnSF_b.d.cts +25 -0
  136. package/dist/shared/stack.CLcnSF_b.d.mts +25 -0
  137. package/dist/shared/stack.CLcnSF_b.d.ts +25 -0
  138. package/dist/shared/stack.CYSwntXC.d.ts +63 -0
  139. package/dist/shared/{stack.Jb0kQDJC.d.mts → stack.Cd6McBu1.d.mts} +25 -0
  140. package/dist/shared/stack.DJDjdG64.d.ts +286 -0
  141. package/dist/shared/{stack.BxFl46lB.d.cts → stack.DxQl8Wa1.d.cts} +25 -0
  142. package/dist/shared/stack.FgBVDSPi.d.mts +286 -0
  143. package/package.json +113 -4
  144. package/src/plugins/blog/client/components/forms/image-field.tsx +35 -4
  145. package/src/plugins/blog/client/components/forms/markdown-editor-with-overrides.tsx +67 -12
  146. package/src/plugins/blog/client/components/forms/markdown-editor.tsx +106 -10
  147. package/src/plugins/blog/client/overrides.ts +58 -1
  148. package/src/plugins/cms/client/components/forms/content-form.tsx +26 -7
  149. package/src/plugins/cms/client/components/forms/file-upload.tsx +73 -15
  150. package/src/plugins/cms/client/overrides.ts +57 -2
  151. package/src/plugins/kanban/client/components/forms/board-form.tsx +1 -1
  152. package/src/plugins/kanban/client/components/forms/task-form.tsx +7 -1
  153. package/src/plugins/kanban/client/overrides.ts +25 -0
  154. package/src/plugins/media/__tests__/__stubs__/vercel-blob-server.ts +9 -0
  155. package/src/plugins/media/__tests__/getters.test.ts +274 -0
  156. package/src/plugins/media/__tests__/mutations.test.ts +299 -0
  157. package/src/plugins/media/__tests__/plugin.test.ts +752 -0
  158. package/src/plugins/media/__tests__/query-key-defs.test.ts +54 -0
  159. package/src/plugins/media/__tests__/storage-adapters.test.ts +351 -0
  160. package/src/plugins/media/api/adapters/local.ts +79 -0
  161. package/src/plugins/media/api/adapters/s3.ts +198 -0
  162. package/src/plugins/media/api/adapters/vercel-blob.ts +131 -0
  163. package/src/plugins/media/api/getters.ts +174 -0
  164. package/src/plugins/media/api/index.ts +41 -0
  165. package/src/plugins/media/api/mutations.ts +179 -0
  166. package/src/plugins/media/api/plugin.ts +855 -0
  167. package/src/plugins/media/api/query-key-defs.ts +41 -0
  168. package/src/plugins/media/api/serializers.ts +28 -0
  169. package/src/plugins/media/api/storage-adapter.ts +139 -0
  170. package/src/plugins/media/client/components/index.tsx +6 -0
  171. package/src/plugins/media/client/components/media-picker/asset-card.tsx +150 -0
  172. package/src/plugins/media/client/components/media-picker/asset-preview-button.tsx +67 -0
  173. package/src/plugins/media/client/components/media-picker/browse-tab.tsx +116 -0
  174. package/src/plugins/media/client/components/media-picker/folder-tree.tsx +188 -0
  175. package/src/plugins/media/client/components/media-picker/index.tsx +347 -0
  176. package/src/plugins/media/client/components/media-picker/upload-tab.tsx +108 -0
  177. package/src/plugins/media/client/components/media-picker/url-tab.tsx +72 -0
  178. package/src/plugins/media/client/components/media-picker/utils.ts +17 -0
  179. package/src/plugins/media/client/components/pages/library-page.internal.tsx +134 -0
  180. package/src/plugins/media/client/components/pages/library-page.tsx +42 -0
  181. package/src/plugins/media/client/hooks/index.tsx +9 -0
  182. package/src/plugins/media/client/hooks/use-media.tsx +289 -0
  183. package/src/plugins/media/client/index.ts +4 -0
  184. package/src/plugins/media/client/overrides.ts +127 -0
  185. package/src/plugins/media/client/plugin.tsx +184 -0
  186. package/src/plugins/media/client/upload.ts +171 -0
  187. package/src/plugins/media/client/utils/image-compression.ts +131 -0
  188. package/src/plugins/media/client.css +1 -0
  189. package/src/plugins/media/db.ts +62 -0
  190. package/src/plugins/media/query-keys.ts +96 -0
  191. package/src/plugins/media/schemas.ts +37 -0
  192. package/src/plugins/media/style.css +1 -0
  193. package/src/plugins/media/types.ts +26 -0
  194. package/dist/shared/{stack.BOokfhZD.d.cts → stack.B6S3cgwN.d.cts} +16 -16
  195. package/dist/shared/{stack.CWxAl9K3.d.mts → stack.Bzfx-_lq.d.mts} +16 -16
  196. package/dist/shared/{stack.BvCR4-9H.d.ts → stack.j5SFLC1d.d.ts} +16 -16
@@ -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;