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