@od-oneapp/storage 2026.1.1301

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 (69) hide show
  1. package/README.md +854 -0
  2. package/dist/client-next.d.mts +61 -0
  3. package/dist/client-next.d.mts.map +1 -0
  4. package/dist/client-next.mjs +111 -0
  5. package/dist/client-next.mjs.map +1 -0
  6. package/dist/client-utils-Dx6W25iz.d.mts +43 -0
  7. package/dist/client-utils-Dx6W25iz.d.mts.map +1 -0
  8. package/dist/client.d.mts +28 -0
  9. package/dist/client.d.mts.map +1 -0
  10. package/dist/client.mjs +183 -0
  11. package/dist/client.mjs.map +1 -0
  12. package/dist/env-BVHLmQdh.mjs +128 -0
  13. package/dist/env-BVHLmQdh.mjs.map +1 -0
  14. package/dist/env.mjs +3 -0
  15. package/dist/health-check-D7LnnDec.mjs +746 -0
  16. package/dist/health-check-D7LnnDec.mjs.map +1 -0
  17. package/dist/health-check-im_huJ59.d.mts +116 -0
  18. package/dist/health-check-im_huJ59.d.mts.map +1 -0
  19. package/dist/index.d.mts +60 -0
  20. package/dist/index.d.mts.map +1 -0
  21. package/dist/index.mjs +3 -0
  22. package/dist/keys.d.mts +37 -0
  23. package/dist/keys.d.mts.map +1 -0
  24. package/dist/keys.mjs +253 -0
  25. package/dist/keys.mjs.map +1 -0
  26. package/dist/server-edge.d.mts +28 -0
  27. package/dist/server-edge.d.mts.map +1 -0
  28. package/dist/server-edge.mjs +88 -0
  29. package/dist/server-edge.mjs.map +1 -0
  30. package/dist/server-next.d.mts +183 -0
  31. package/dist/server-next.d.mts.map +1 -0
  32. package/dist/server-next.mjs +1353 -0
  33. package/dist/server-next.mjs.map +1 -0
  34. package/dist/server.d.mts +70 -0
  35. package/dist/server.d.mts.map +1 -0
  36. package/dist/server.mjs +384 -0
  37. package/dist/server.mjs.map +1 -0
  38. package/dist/types.d.mts +321 -0
  39. package/dist/types.d.mts.map +1 -0
  40. package/dist/types.mjs +3 -0
  41. package/dist/validation.d.mts +101 -0
  42. package/dist/validation.d.mts.map +1 -0
  43. package/dist/validation.mjs +590 -0
  44. package/dist/validation.mjs.map +1 -0
  45. package/dist/vercel-blob-07Sx0Akn.d.mts +31 -0
  46. package/dist/vercel-blob-07Sx0Akn.d.mts.map +1 -0
  47. package/dist/vercel-blob-DA8HaYuw.mjs +158 -0
  48. package/dist/vercel-blob-DA8HaYuw.mjs.map +1 -0
  49. package/package.json +111 -0
  50. package/src/actions/blob-upload.ts +171 -0
  51. package/src/actions/index.ts +23 -0
  52. package/src/actions/mediaActions.ts +1071 -0
  53. package/src/actions/productMediaActions.ts +538 -0
  54. package/src/auth-helpers.ts +386 -0
  55. package/src/capabilities.ts +225 -0
  56. package/src/client-next.ts +184 -0
  57. package/src/client-utils.ts +292 -0
  58. package/src/client.ts +102 -0
  59. package/src/constants.ts +88 -0
  60. package/src/health-check.ts +81 -0
  61. package/src/multi-storage.ts +230 -0
  62. package/src/multipart.ts +497 -0
  63. package/src/retry-utils.test.ts +118 -0
  64. package/src/retry-utils.ts +59 -0
  65. package/src/server-edge.ts +129 -0
  66. package/src/server-next.ts +14 -0
  67. package/src/server.ts +666 -0
  68. package/src/validation.test.ts +312 -0
  69. package/src/validation.ts +827 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vercel-blob-07Sx0Akn.d.mts","names":[],"sources":["../providers/vercel-blob.ts"],"mappings":";;;cAsBa,kBAAA,YAA8B,eAAA;EAAA,QACjC,KAAA;cAEI,KAAA;EAON,MAAA,CAAO,GAAA,WAAc,OAAA;EAQrB,QAAA,CAAS,GAAA,WAAc,OAAA,CAAQ,IAAA;EAgC/B,MAAA,CAAO,GAAA,WAAc,OAAA;EAmBrB,WAAA,CAAY,GAAA,WAAc,OAAA,CAAQ,aAAA;EAmBlC,MAAA,CAAO,GAAA,UAAa,QAAA;IAAa,SAAA;EAAA,IAAuB,OAAA;EAWxD,IAAA,CAAK,OAAA,GAAU,WAAA,GAAc,OAAA,CAAQ,aAAA;EA6BrC,MAAA,CACJ,GAAA,UACA,IAAA,EAAM,WAAA,GAAc,IAAA,GAAO,MAAA,GAAS,IAAA,GAAO,cAAA,EAC3C,OAAA,GAAU,aAAA,GACT,OAAA,CAAQ,aAAA;EAgDL,qBAAA,CACJ,GAAA,UACA,QAAA,GAAW,aAAA,GACV,OAAA;IAAU,QAAA;IAAkB,GAAA;EAAA;EAKzB,UAAA,CAAA,GAAc,OAAA;IAAU,IAAA;IAAc,UAAA;EAAA;EAMtC,uBAAA,CAAA,GAA2B,OAAA,CAAQ,aAAA;EAMnC,oBAAA,CAAA,GAAwB,OAAA;EAMxB,qBAAA,CAAA,GAAyB,OAAA,CAAQ,kBAAA;EAMvC,eAAA,CAAA,GAAmB,mBAAA;AAAA"}
@@ -0,0 +1,158 @@
1
+ import { del, head, list, put } from "@vercel/blob";
2
+
3
+ //#region providers/vercel-blob.ts
4
+ /**
5
+ * @fileoverview Vercel Blob storage provider
6
+ *
7
+ * Direct implementation (no @integrations/* dependency) to keep
8
+ * @od-oneapp/storage publishable/portable.
9
+ */
10
+ /** Default download timeout in ms */
11
+ const DEFAULT_DOWNLOAD_TIMEOUT_MS = 3e4;
12
+ var VercelBlobProvider = class {
13
+ token;
14
+ constructor(token) {
15
+ if (!token) throw new Error("Vercel Blob token is required");
16
+ this.token = token;
17
+ }
18
+ async delete(key) {
19
+ if (!key || key.trim() === "") throw new Error("Blob key cannot be empty");
20
+ await del(key, { token: this.token });
21
+ }
22
+ async download(key) {
23
+ if (!key || key.trim() === "") throw new Error("Blob key cannot be empty");
24
+ const controller = new AbortController();
25
+ const timeoutId = setTimeout(() => controller.abort(), DEFAULT_DOWNLOAD_TIMEOUT_MS);
26
+ try {
27
+ const response = await fetch(key, {
28
+ headers: { Authorization: `Bearer ${this.token}` },
29
+ signal: controller.signal
30
+ });
31
+ if (!response.ok) throw new Error(`Failed to download blob: ${response.statusText}`);
32
+ return response.blob();
33
+ } catch (error) {
34
+ if (error instanceof Error && error.name === "AbortError") throw new Error(`Download timeout: Request exceeded ${DEFAULT_DOWNLOAD_TIMEOUT_MS}ms`);
35
+ throw error;
36
+ } finally {
37
+ clearTimeout(timeoutId);
38
+ }
39
+ }
40
+ async exists(key) {
41
+ if (!key || key.trim() === "") throw new Error("Blob key cannot be empty");
42
+ try {
43
+ await head(key, { token: this.token });
44
+ return true;
45
+ } catch (error) {
46
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
47
+ if (error?.status === 404 || message.includes("not found") || message.includes("404")) return false;
48
+ throw error;
49
+ }
50
+ }
51
+ async getMetadata(key) {
52
+ if (!key || key.trim() === "") throw new Error("Blob key cannot be empty");
53
+ const blob = await head(key, { token: this.token });
54
+ return {
55
+ access: "public",
56
+ contentType: blob.contentType,
57
+ downloadUrl: blob.downloadUrl,
58
+ etag: void 0,
59
+ key: blob.pathname,
60
+ lastModified: new Date(blob.uploadedAt),
61
+ size: blob.size,
62
+ url: blob.url
63
+ };
64
+ }
65
+ async getUrl(key, _options) {
66
+ if (!key || key.trim() === "") throw new Error("Blob key cannot be empty");
67
+ return (await head(key, { token: this.token })).url;
68
+ }
69
+ async list(options) {
70
+ return (await list({
71
+ cursor: options?.cursor,
72
+ limit: options?.limit,
73
+ prefix: options?.prefix,
74
+ token: this.token
75
+ })).blobs.map((blob) => ({
76
+ contentType: blob.contentType ?? "application/octet-stream",
77
+ etag: void 0,
78
+ key: blob.pathname,
79
+ lastModified: new Date(blob.uploadedAt),
80
+ size: blob.size,
81
+ downloadUrl: void 0,
82
+ url: blob.url
83
+ }));
84
+ }
85
+ async upload(key, data, options) {
86
+ if (!key || key.trim() === "") throw new Error("Blob key cannot be empty");
87
+ const access = "public";
88
+ const onUploadProgress = options?.onProgress ? (event) => {
89
+ const total = event.total ?? event.loaded;
90
+ options.onProgress?.({
91
+ key,
92
+ loaded: event.loaded,
93
+ total,
94
+ percentage: event.percentage ?? (total ? event.loaded / total * 100 : void 0)
95
+ });
96
+ } : void 0;
97
+ const result = await put(key, data, {
98
+ access,
99
+ addRandomSuffix: options?.addRandomSuffix ?? false,
100
+ allowOverwrite: options?.allowOverwrite,
101
+ cacheControlMaxAge: options?.cacheControlMaxAge,
102
+ contentType: options?.contentType,
103
+ multipart: options?.multipart,
104
+ abortSignal: options?.abortSignal,
105
+ onUploadProgress,
106
+ ...options?.clientPayload && { clientPayload: options.clientPayload },
107
+ token: this.token
108
+ });
109
+ const metadata = await head(result.url, { token: this.token });
110
+ return {
111
+ access,
112
+ contentType: metadata.contentType,
113
+ downloadUrl: metadata.downloadUrl,
114
+ etag: void 0,
115
+ key: result.pathname,
116
+ lastModified: new Date(metadata.uploadedAt),
117
+ metadata: options?.metadata,
118
+ size: metadata.size,
119
+ url: metadata.url ?? result.url
120
+ };
121
+ }
122
+ async createMultipartUpload(key, _options) {
123
+ return {
124
+ uploadId: `vercel-blob-${crypto.randomUUID()}`,
125
+ key
126
+ };
127
+ }
128
+ async uploadPart() {
129
+ throw new Error("Vercel Blob does not support manual multipart uploads. Use multipart: true in upload options instead.");
130
+ }
131
+ async completeMultipartUpload() {
132
+ throw new Error("Vercel Blob does not support manual multipart uploads. Use multipart: true in upload options instead.");
133
+ }
134
+ async abortMultipartUpload() {
135
+ throw new Error("Vercel Blob does not support manual multipart uploads. Use multipart: true in upload options instead.");
136
+ }
137
+ async getPresignedUploadUrl() {
138
+ throw new Error("Vercel Blob does not support presigned URLs. Use direct upload with handleUploadUrl instead.");
139
+ }
140
+ getCapabilities() {
141
+ return {
142
+ multipart: false,
143
+ presignedUrls: false,
144
+ progressTracking: true,
145
+ abortSignal: true,
146
+ metadata: true,
147
+ customDomains: false,
148
+ edgeCompatible: true,
149
+ versioning: false,
150
+ encryption: false,
151
+ directoryListing: true
152
+ };
153
+ }
154
+ };
155
+
156
+ //#endregion
157
+ export { VercelBlobProvider as t };
158
+ //# sourceMappingURL=vercel-blob-DA8HaYuw.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vercel-blob-DA8HaYuw.mjs","names":[],"sources":["../providers/vercel-blob.ts"],"sourcesContent":["/**\n * @fileoverview Vercel Blob storage provider\n *\n * Direct implementation (no @integrations/* dependency) to keep\n * @od-oneapp/storage publishable/portable.\n */\n\nimport { del, head, list, put } from '@vercel/blob';\n\nimport type { UploadProgressEvent } from '@vercel/blob';\nimport type {\n ListOptions,\n PresignedUploadUrl,\n StorageCapabilities,\n StorageObject,\n StorageProvider,\n UploadOptions,\n} from '../types';\n\n/** Default download timeout in ms */\nconst DEFAULT_DOWNLOAD_TIMEOUT_MS = 30_000;\n\nexport class VercelBlobProvider implements StorageProvider {\n private token: string;\n\n constructor(token: string) {\n if (!token) {\n throw new Error('Vercel Blob token is required');\n }\n this.token = token;\n }\n\n async delete(key: string): Promise<void> {\n if (!key || key.trim() === '') {\n throw new Error('Blob key cannot be empty');\n }\n\n await del(key, { token: this.token });\n }\n\n async download(key: string): Promise<Blob> {\n if (!key || key.trim() === '') {\n throw new Error('Blob key cannot be empty');\n }\n\n // Add timeout to prevent hanging requests\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), DEFAULT_DOWNLOAD_TIMEOUT_MS);\n\n try {\n const response = await fetch(key, {\n headers: {\n Authorization: `Bearer ${this.token}`,\n },\n signal: controller.signal,\n });\n\n if (!response.ok) {\n throw new Error(`Failed to download blob: ${response.statusText}`);\n }\n\n return response.blob();\n } catch (error) {\n if (error instanceof Error && error.name === 'AbortError') {\n throw new Error(`Download timeout: Request exceeded ${DEFAULT_DOWNLOAD_TIMEOUT_MS}ms`);\n }\n throw error;\n } finally {\n clearTimeout(timeoutId);\n }\n }\n\n async exists(key: string): Promise<boolean> {\n if (!key || key.trim() === '') {\n throw new Error('Blob key cannot be empty');\n }\n\n try {\n await head(key, { token: this.token });\n return true;\n } catch (error) {\n const message =\n error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();\n const err = error as { status?: number };\n if (err?.status === 404 || message.includes('not found') || message.includes('404')) {\n return false;\n }\n throw error;\n }\n }\n\n async getMetadata(key: string): Promise<StorageObject> {\n if (!key || key.trim() === '') {\n throw new Error('Blob key cannot be empty');\n }\n\n const blob = await head(key, { token: this.token });\n\n return {\n access: 'public', // Vercel Blob defaults to public; private access determined by URL auth\n contentType: blob.contentType,\n downloadUrl: blob.downloadUrl,\n etag: undefined,\n key: blob.pathname,\n lastModified: new Date(blob.uploadedAt),\n size: blob.size,\n url: blob.url,\n };\n }\n\n async getUrl(key: string, _options?: { expiresIn?: number }): Promise<string> {\n if (!key || key.trim() === '') {\n throw new Error('Blob key cannot be empty');\n }\n\n // Vercel Blob URLs are permanent for public files\n // For private files, they include auth in the URL\n const blob = await head(key, { token: this.token });\n return blob.url;\n }\n\n async list(options?: ListOptions): Promise<StorageObject[]> {\n const result = await list({\n cursor: options?.cursor,\n limit: options?.limit,\n prefix: options?.prefix,\n token: this.token,\n });\n\n // Avoid N+1 queries: Use data from list response when available\n // Only make head() calls if content type is truly needed and not available\n return result.blobs.map(\n (blob: {\n pathname: string;\n url: string;\n size: number;\n uploadedAt: Date;\n contentType?: string;\n }) => ({\n contentType: blob.contentType ?? 'application/octet-stream',\n etag: undefined,\n key: blob.pathname,\n lastModified: new Date(blob.uploadedAt),\n size: blob.size,\n downloadUrl: undefined, // Not available from list response\n url: blob.url,\n }),\n );\n }\n\n async upload(\n key: string,\n data: ArrayBuffer | Blob | Buffer | File | ReadableStream,\n options?: UploadOptions,\n ): Promise<StorageObject> {\n if (!key || key.trim() === '') {\n throw new Error('Blob key cannot be empty');\n }\n\n // Vercel Blob only supports public access; private uploads not yet available\n const access = 'public';\n const onUploadProgress: ((event: UploadProgressEvent) => void) | undefined = options?.onProgress\n ? event => {\n const total = event.total ?? event.loaded;\n options.onProgress?.({\n key,\n loaded: event.loaded,\n total,\n percentage: event.percentage ?? (total ? (event.loaded / total) * 100 : undefined),\n });\n }\n : undefined;\n\n const result = await put(key, data, {\n access,\n addRandomSuffix: options?.addRandomSuffix ?? false,\n allowOverwrite: options?.allowOverwrite,\n cacheControlMaxAge: options?.cacheControlMaxAge,\n contentType: options?.contentType,\n multipart: options?.multipart,\n abortSignal: options?.abortSignal,\n onUploadProgress,\n ...(options?.clientPayload && { clientPayload: options.clientPayload }),\n token: this.token,\n });\n\n // Get metadata to retrieve size and download URL\n const metadata = await head(result.url, { token: this.token });\n\n return {\n access,\n contentType: metadata.contentType,\n downloadUrl: metadata.downloadUrl,\n etag: undefined,\n key: result.pathname,\n lastModified: new Date(metadata.uploadedAt),\n metadata: options?.metadata,\n size: metadata.size,\n url: metadata.url ?? result.url,\n };\n }\n\n async createMultipartUpload(\n key: string,\n _options?: UploadOptions,\n ): Promise<{ uploadId: string; key: string }> {\n const uploadId = `vercel-blob-${crypto.randomUUID()}`;\n return { uploadId, key };\n }\n\n async uploadPart(): Promise<{ etag: string; partNumber: number }> {\n throw new Error(\n 'Vercel Blob does not support manual multipart uploads. Use multipart: true in upload options instead.',\n );\n }\n\n async completeMultipartUpload(): Promise<StorageObject> {\n throw new Error(\n 'Vercel Blob does not support manual multipart uploads. Use multipart: true in upload options instead.',\n );\n }\n\n async abortMultipartUpload(): Promise<void> {\n throw new Error(\n 'Vercel Blob does not support manual multipart uploads. Use multipart: true in upload options instead.',\n );\n }\n\n async getPresignedUploadUrl(): Promise<PresignedUploadUrl> {\n throw new Error(\n 'Vercel Blob does not support presigned URLs. Use direct upload with handleUploadUrl instead.',\n );\n }\n\n getCapabilities(): StorageCapabilities {\n return {\n multipart: false,\n presignedUrls: false,\n progressTracking: true,\n abortSignal: true,\n metadata: true,\n customDomains: false,\n edgeCompatible: true,\n versioning: false,\n encryption: false,\n directoryListing: true,\n };\n }\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,111 @@
1
+ {
2
+ "name": "@od-oneapp/storage",
3
+ "version": "2026.1.1301",
4
+ "private": false,
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/OneDigital-Product/monorepo.git",
8
+ "directory": "platform/packages/storage"
9
+ },
10
+ "sideEffects": false,
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/env.d.mts",
15
+ "import": "./dist/env.mjs",
16
+ "default": "./dist/env.mjs"
17
+ },
18
+ "./client": {
19
+ "types": "./dist/client.d.mts",
20
+ "import": "./dist/client.mjs",
21
+ "default": "./dist/client.mjs"
22
+ },
23
+ "./server": {
24
+ "types": "./dist/server.d.mts",
25
+ "import": "./dist/server.mjs",
26
+ "default": "./dist/server.mjs"
27
+ },
28
+ "./client/next": {
29
+ "types": "./dist/client-next.d.mts",
30
+ "import": "./dist/client-next.mjs",
31
+ "default": "./dist/client-next.mjs"
32
+ },
33
+ "./server/next": {
34
+ "types": "./dist/server-next.d.mts",
35
+ "import": "./dist/server-next.mjs",
36
+ "default": "./dist/server-next.mjs"
37
+ },
38
+ "./server/edge": {
39
+ "types": "./dist/server-edge.d.mts",
40
+ "import": "./dist/server-edge.mjs",
41
+ "default": "./dist/server-edge.mjs"
42
+ },
43
+ "./env": {
44
+ "types": "./dist/env.d.mts",
45
+ "import": "./dist/env.mjs",
46
+ "default": "./dist/env.mjs"
47
+ },
48
+ "./keys": {
49
+ "types": "./dist/keys.d.mts",
50
+ "import": "./dist/keys.mjs",
51
+ "default": "./dist/keys.mjs"
52
+ },
53
+ "./types": {
54
+ "types": "./dist/types.d.mts",
55
+ "import": "./dist/types.mjs",
56
+ "default": "./dist/types.mjs"
57
+ },
58
+ "./validation": {
59
+ "types": "./dist/validation.d.mts",
60
+ "import": "./dist/validation.mjs",
61
+ "default": "./dist/validation.mjs"
62
+ }
63
+ },
64
+ "files": [
65
+ "dist",
66
+ "src"
67
+ ],
68
+ "dependencies": {
69
+ "@t3-oss/env-core": "^0.13.10",
70
+ "@vercel/blob": "^2.2.0",
71
+ "nanoid": "^5.1.6",
72
+ "tsdown": "^0.20.3",
73
+ "zod": "4.3.6",
74
+ "@integrations/cloudflare": "2026.1.1301",
75
+ "@repo/shared": "2026.1.1301"
76
+ },
77
+ "devDependencies": {
78
+ "@faker-js/faker": "^10.3.0",
79
+ "@types/react": "19.2.13",
80
+ "@types/react-dom": "19.2.3",
81
+ "@vitest/coverage-v8": "4.0.18",
82
+ "eslint": "9.39.2",
83
+ "knip": "^5.83.1",
84
+ "madge": "8.0.0",
85
+ "next": "16.1.6",
86
+ "typescript": "5.9.3",
87
+ "vitest": "4.0.18",
88
+ "@repo/config": "2026.1.1301",
89
+ "@od-oneapp/observability": "2026.1.1301",
90
+ "@od-oneapp/qa": "2026.1.1301"
91
+ },
92
+ "publishConfig": {
93
+ "access": "restricted",
94
+ "registry": "https://registry.npmjs.org/"
95
+ },
96
+ "scripts": {
97
+ "build": "tsdown",
98
+ "build:publish": "tsdown && node ../../../scripts/prepare-publish.mjs",
99
+ "circular": "madge --circular --extensions ts,tsx,js,jsx .",
100
+ "coverage:collect": "vitest run --coverage --reporter=json",
101
+ "format": "prettier --write --cache --ignore-unknown --ignore-path ../../../.prettierignore .",
102
+ "format:check": "prettier --check --cache --ignore-unknown --ignore-path ../../../.prettierignore .",
103
+ "knip": "knip --reporter json --exclude unlisted,exports,files,binaries,types,duplicates",
104
+ "lint": "eslint . --fix ",
105
+ "test": "vitest run",
106
+ "test:coverage": "vitest run --coverage",
107
+ "test:coverage:json": "vitest run --coverage --reporter=json",
108
+ "test:watch": "vitest",
109
+ "typecheck": "tsc --noEmit"
110
+ }
111
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @fileoverview Blob upload server action
3
+ *
4
+ * Handles Vercel Blob uploads with authentication, validation, and callbacks.
5
+ * Provides secure upload handling for Next.js server actions.
6
+ *
7
+ * @module @od-oneapp/storage/actions/blob-upload
8
+ */
9
+
10
+ 'use server';
11
+
12
+ import { handleUpload } from '@vercel/blob/client';
13
+ import { logError } from '@repo/shared/logs';
14
+
15
+
16
+ import { safeEnv } from '../../env';
17
+ import { type HandleUploadConfig, type OnBeforeGenerateTokenResult } from '../../types';
18
+ import { validateCSRFToken } from '../auth-helpers';
19
+
20
+ import type { PutBlobResult } from '@vercel/blob';
21
+
22
+ /**
23
+ * Callback invoked before processing an upload or token generation
24
+ * Allows validation and configuration of upload parameters
25
+ */
26
+ export type OnBeforeGenerateToken = (
27
+ pathname: string,
28
+ clientPayload?: string,
29
+ ) => Promise<{
30
+ allowed: boolean;
31
+ token?: string;
32
+ allowedContentTypes?: string[];
33
+ maximumSizeInBytes?: number;
34
+ tokenPayload?: string | null;
35
+ }>;
36
+
37
+ /**
38
+ * Callback invoked after upload completes
39
+ * Allows processing of uploaded file metadata
40
+ */
41
+ export type OnUploadCompleted = (
42
+ blob: {
43
+ url: string;
44
+ pathname: string;
45
+ contentType?: string;
46
+ contentDisposition?: string;
47
+ size: number;
48
+ },
49
+ clientPayload?: string,
50
+ ) => Promise<void>;
51
+
52
+ /**
53
+ * Process a multipart/form-data upload via Vercel Blob, enforcing CSRF and environment token checks and invoking optional pre-token and post-upload hooks.
54
+ *
55
+ * @param request - Incoming HTTP Request expected to contain multipart/form-data for the upload.
56
+ * @param config - Optional handlers:
57
+ * - onBeforeGenerateToken(pathname, clientPayload): validate/configure a scoped client token and return `{ allowed, token?, allowedContentTypes?, maximumSizeInBytes?, tokenPayload? }`.
58
+ * - onUploadCompleted(blob, clientPayload): receive completed upload metadata `{ url, pathname, contentType?, contentDisposition?, size }` and the original clientPayload.
59
+ * @returns A Response with a JSON body. On success the body is the value returned by Vercel's `handleUpload`; on error the body is `{ error: string }` and the response status will be 403 for invalid CSRF or 500 for configuration/processing errors.
60
+ */
61
+ export async function handleBlobUpload(
62
+ request: Request,
63
+ config?: HandleUploadConfig,
64
+ ): Promise<Response> {
65
+ try {
66
+ const blobEnv = safeEnv();
67
+
68
+ // CSRF protection (only when enforced)
69
+ if (blobEnv.STORAGE_ENFORCE_CSRF) {
70
+ const csrfToken = request.headers.get('x-csrf-token');
71
+ if (!csrfToken || !validateCSRFToken(csrfToken, request)) {
72
+ return new Response(JSON.stringify({ error: 'Invalid CSRF token' }), {
73
+ status: 403,
74
+ headers: { 'Content-Type': 'application/json' },
75
+ });
76
+ }
77
+ }
78
+ const token = blobEnv.VERCEL_BLOB_READ_WRITE_TOKEN;
79
+
80
+ if (!token) {
81
+ return new Response(
82
+ JSON.stringify({ error: 'VERCEL_BLOB_READ_WRITE_TOKEN not configured' }),
83
+ {
84
+ status: 500,
85
+ headers: { 'Content-Type': 'application/json' },
86
+ },
87
+ );
88
+ }
89
+
90
+ // Parse the request body for handleUpload (Vercel blob client sends JSON events)
91
+ const body = await request.json();
92
+
93
+ // Use Vercel's handleUpload to manage the full upload flow
94
+ const result = await handleUpload({
95
+ body,
96
+ token,
97
+ request,
98
+ onBeforeGenerateToken: async (
99
+ pathname: string,
100
+ clientPayload: string | null,
101
+ _multipart: boolean,
102
+ ) => {
103
+ if (config?.onBeforeGenerateToken) {
104
+ try {
105
+ const result: OnBeforeGenerateTokenResult = await config.onBeforeGenerateToken(
106
+ pathname,
107
+ clientPayload ?? undefined,
108
+ );
109
+ if (!result.allowed) {
110
+ throw new Error('Upload not allowed');
111
+ }
112
+ return {
113
+ allowedContentTypes: result.allowedContentTypes,
114
+ maximumSizeInBytes: result.maximumSizeInBytes,
115
+ tokenPayload: result.tokenPayload,
116
+ };
117
+ } catch (error) {
118
+ throw new Error(
119
+ `Upload validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
120
+ );
121
+ }
122
+ }
123
+ return {};
124
+ },
125
+
126
+ onUploadCompleted: async (payload: { blob: PutBlobResult; tokenPayload?: string | null }) => {
127
+ if (config?.onUploadCompleted) {
128
+ try {
129
+ const { blob, tokenPayload } = payload;
130
+ await config.onUploadCompleted(
131
+ {
132
+ url: blob.url,
133
+ pathname: blob.pathname,
134
+ contentType: blob.contentType,
135
+ contentDisposition: blob.contentDisposition,
136
+ size: (blob as PutBlobResult & { size?: number }).size ?? 0,
137
+ },
138
+ tokenPayload ?? undefined,
139
+ );
140
+ } catch (error) {
141
+ // Don't fail the upload if callback fails
142
+ // Use structured logging for better observability
143
+ logError('onUploadCompleted callback failed', {
144
+ error: error instanceof Error ? error.message : String(error),
145
+ blob: {
146
+ url: payload.blob.url,
147
+ pathname: payload.blob.pathname,
148
+ size: (payload.blob as PutBlobResult & { size?: number }).size,
149
+ contentType: payload.blob.contentType,
150
+ },
151
+ clientPayload: payload.tokenPayload,
152
+ timestamp: new Date().toISOString(),
153
+ });
154
+ }
155
+ }
156
+ },
157
+ });
158
+
159
+ return new Response(JSON.stringify(result), {
160
+ status: 200,
161
+ headers: { 'Content-Type': 'application/json' },
162
+ });
163
+ } catch (error) {
164
+ return new Response(
165
+ JSON.stringify({
166
+ error: error instanceof Error ? error.message : 'Upload failed',
167
+ }),
168
+ { status: 500, headers: { 'Content-Type': 'application/json' } },
169
+ );
170
+ }
171
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @fileoverview Barrel exports for storage actions
3
+ *
4
+ * Re-exports all storage server actions for convenient importing.
5
+ *
6
+ * @module @repo/storage/actions
7
+ */
8
+
9
+ // Barrel exports for storage actions
10
+
11
+ // Media operations
12
+ export * from './mediaActions';
13
+
14
+ // Product media business logic
15
+ export * from './productMediaActions';
16
+
17
+ // Blob upload handling
18
+ export * from './blob-upload';
19
+
20
+ // Additional action files can be added here as needed:
21
+ // export * from './imageActions'; // For image-specific operations
22
+ // export * from './documentActions'; // For document-specific operations
23
+ // export * from './migrationActions'; // For storage migration operations