@inoo-ch/payload-image-optimizer 1.2.0 → 1.3.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.
@@ -2,29 +2,35 @@ import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { resolveCollectionConfig } from '../defaults.js';
4
4
  import { resolveStaticDir } from '../utilities/resolveStaticDir.js';
5
+ import { isCloudStorage } from '../utilities/storage.js';
5
6
  export const createAfterChangeHook = (resolvedConfig, collectionSlug)=>{
6
7
  return async ({ context, doc, req })=>{
7
8
  if (context?.imageOptimizer_skip) return doc;
8
9
  if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return doc;
9
10
  const collectionConfig = req.payload.collections[collectionSlug].config;
10
- const staticDir = resolveStaticDir(collectionConfig);
11
- const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug);
12
- // Overwrite the file on disk with the processed (stripped/resized/converted) buffer
13
- // Payload 3.0 writes the original buffer to disk; we replace it here
14
- const processedBuffer = context.imageOptimizer_processedBuffer;
15
- if (processedBuffer && doc.filename && staticDir) {
16
- const safeFilename = path.basename(doc.filename);
17
- const filePath = path.join(staticDir, safeFilename);
18
- await fs.writeFile(filePath, processedBuffer);
19
- // If replaceOriginal changed the filename, clean up the old file Payload wrote
20
- const originalFilename = context.imageOptimizer_originalFilename;
21
- if (originalFilename && originalFilename !== safeFilename) {
22
- const oldFilePath = path.join(staticDir, path.basename(originalFilename));
23
- await fs.unlink(oldFilePath).catch(()=>{
24
- // Old file may not exist if Payload used the new filename
25
- });
11
+ const cloudStorage = isCloudStorage(collectionConfig);
12
+ // When using local storage, overwrite the file on disk with the processed buffer.
13
+ // Payload's uploadFiles step writes the original buffer; we replace it here.
14
+ // When using cloud storage, skip the cloud adapter's afterChange hook already
15
+ // uploads the correct buffer from req.file.data (set in our beforeChange hook).
16
+ if (!cloudStorage) {
17
+ const staticDir = resolveStaticDir(collectionConfig);
18
+ const processedBuffer = context.imageOptimizer_processedBuffer;
19
+ if (processedBuffer && doc.filename && staticDir) {
20
+ const safeFilename = path.basename(doc.filename);
21
+ const filePath = path.join(staticDir, safeFilename);
22
+ await fs.writeFile(filePath, processedBuffer);
23
+ // If replaceOriginal changed the filename, clean up the old file Payload wrote
24
+ const originalFilename = context.imageOptimizer_originalFilename;
25
+ if (originalFilename && originalFilename !== safeFilename) {
26
+ const oldFilePath = path.join(staticDir, path.basename(originalFilename));
27
+ await fs.unlink(oldFilePath).catch(()=>{
28
+ // Old file may not exist if Payload used the new filename
29
+ });
30
+ }
26
31
  }
27
32
  }
33
+ const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug);
28
34
  // When replaceOriginal is on and only one format is configured, the main file
29
35
  // is already converted — skip the async job and mark complete immediately.
30
36
  if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1) {
@@ -43,7 +49,26 @@ export const createAfterChangeHook = (resolvedConfig, collectionSlug)=>{
43
49
  });
44
50
  return doc;
45
51
  }
46
- // Queue async format conversion job for remaining variants
52
+ // With cloud storage, variant files cannot be written — skip the async job
53
+ // and mark complete. CDN-level image optimization (e.g. Next.js Image) can
54
+ // serve alternative formats on the fly.
55
+ if (cloudStorage) {
56
+ await req.payload.update({
57
+ collection: collectionSlug,
58
+ id: doc.id,
59
+ data: {
60
+ imageOptimizer: {
61
+ status: 'complete',
62
+ variants: []
63
+ }
64
+ },
65
+ context: {
66
+ imageOptimizer_skip: true
67
+ }
68
+ });
69
+ return doc;
70
+ }
71
+ // Queue async format conversion job for remaining variants (local storage only)
47
72
  await req.payload.jobs.queue({
48
73
  task: 'imageOptimizer_convertFormats',
49
74
  input: {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/hooks/afterChange.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\nimport type { CollectionAfterChangeHook } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\n\nexport const createAfterChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionAfterChangeHook => {\n return async ({ context, doc, req }) => {\n if (context?.imageOptimizer_skip) return doc\n\n if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return doc\n\n const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config\n const staticDir = resolveStaticDir(collectionConfig)\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)\n\n // Overwrite the file on disk with the processed (stripped/resized/converted) buffer\n // Payload 3.0 writes the original buffer to disk; we replace it here\n const processedBuffer = context.imageOptimizer_processedBuffer as Buffer | undefined\n if (processedBuffer && doc.filename && staticDir) {\n const safeFilename = path.basename(doc.filename as string)\n const filePath = path.join(staticDir, safeFilename)\n await fs.writeFile(filePath, processedBuffer)\n\n // If replaceOriginal changed the filename, clean up the old file Payload wrote\n const originalFilename = context.imageOptimizer_originalFilename as string | undefined\n if (originalFilename && originalFilename !== safeFilename) {\n const oldFilePath = path.join(staticDir, path.basename(originalFilename))\n await fs.unlink(oldFilePath).catch(() => {\n // Old file may not exist if Payload used the new filename\n })\n }\n }\n\n // When replaceOriginal is on and only one format is configured, the main file\n // is already converted — skip the async job and mark complete immediately.\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1) {\n await req.payload.update({\n collection: collectionSlug,\n id: doc.id,\n data: {\n imageOptimizer: {\n status: 'complete',\n variants: [],\n },\n },\n context: { imageOptimizer_skip: true },\n })\n return doc\n }\n\n // Queue async format conversion job for remaining variants\n await req.payload.jobs.queue({\n task: 'imageOptimizer_convertFormats',\n input: {\n collectionSlug,\n docId: String(doc.id),\n },\n })\n\n req.payload.jobs.run().catch((err: unknown) => {\n req.payload.logger.error({ err }, 'Image optimizer job runner failed')\n })\n\n return doc\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","resolveStaticDir","createAfterChangeHook","resolvedConfig","collectionSlug","context","doc","req","imageOptimizer_skip","file","data","mimetype","startsWith","collectionConfig","payload","collections","config","staticDir","perCollectionConfig","processedBuffer","imageOptimizer_processedBuffer","filename","safeFilename","basename","filePath","join","writeFile","originalFilename","imageOptimizer_originalFilename","oldFilePath","unlink","catch","replaceOriginal","formats","length","update","collection","id","imageOptimizer","status","variants","jobs","queue","task","input","docId","String","run","err","logger","error"],"mappings":"AAAA,OAAOA,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAIvB,SAASC,uBAAuB,QAAQ,iBAAgB;AACxD,SAASC,gBAAgB,QAAQ,mCAAkC;AAEnE,OAAO,MAAMC,wBAAwB,CACnCC,gBACAC;IAEA,OAAO,OAAO,EAAEC,OAAO,EAAEC,GAAG,EAAEC,GAAG,EAAE;QACjC,IAAIF,SAASG,qBAAqB,OAAOF;QAEzC,IAAI,CAACC,IAAIE,IAAI,IAAI,CAACF,IAAIE,IAAI,CAACC,IAAI,IAAI,CAACH,IAAIE,IAAI,CAACE,QAAQ,EAAEC,WAAW,WAAW,OAAON;QAEpF,MAAMO,mBAAmBN,IAAIO,OAAO,CAACC,WAAW,CAACX,eAAuD,CAACY,MAAM;QAC/G,MAAMC,YAAYhB,iBAAiBY;QAEnC,MAAMK,sBAAsBlB,wBAAwBG,gBAAgBC;QAEpE,oFAAoF;QACpF,qEAAqE;QACrE,MAAMe,kBAAkBd,QAAQe,8BAA8B;QAC9D,IAAID,mBAAmBb,IAAIe,QAAQ,IAAIJ,WAAW;YAChD,MAAMK,eAAevB,KAAKwB,QAAQ,CAACjB,IAAIe,QAAQ;YAC/C,MAAMG,WAAWzB,KAAK0B,IAAI,CAACR,WAAWK;YACtC,MAAMxB,GAAG4B,SAAS,CAACF,UAAUL;YAE7B,+EAA+E;YAC/E,MAAMQ,mBAAmBtB,QAAQuB,+BAA+B;YAChE,IAAID,oBAAoBA,qBAAqBL,cAAc;gBACzD,MAAMO,cAAc9B,KAAK0B,IAAI,CAACR,WAAWlB,KAAKwB,QAAQ,CAACI;gBACvD,MAAM7B,GAAGgC,MAAM,CAACD,aAAaE,KAAK,CAAC;gBACjC,0DAA0D;gBAC5D;YACF;QACF;QAEA,8EAA8E;QAC9E,2EAA2E;QAC3E,IAAIb,oBAAoBc,eAAe,IAAId,oBAAoBe,OAAO,CAACC,MAAM,IAAI,GAAG;YAClF,MAAM3B,IAAIO,OAAO,CAACqB,MAAM,CAAC;gBACvBC,YAAYhC;gBACZiC,IAAI/B,IAAI+B,EAAE;gBACV3B,MAAM;oBACJ4B,gBAAgB;wBACdC,QAAQ;wBACRC,UAAU,EAAE;oBACd;gBACF;gBACAnC,SAAS;oBAAEG,qBAAqB;gBAAK;YACvC;YACA,OAAOF;QACT;QAEA,2DAA2D;QAC3D,MAAMC,IAAIO,OAAO,CAAC2B,IAAI,CAACC,KAAK,CAAC;YAC3BC,MAAM;YACNC,OAAO;gBACLxC;gBACAyC,OAAOC,OAAOxC,IAAI+B,EAAE;YACtB;QACF;QAEA9B,IAAIO,OAAO,CAAC2B,IAAI,CAACM,GAAG,GAAGhB,KAAK,CAAC,CAACiB;YAC5BzC,IAAIO,OAAO,CAACmC,MAAM,CAACC,KAAK,CAAC;gBAAEF;YAAI,GAAG;QACpC;QAEA,OAAO1C;IACT;AACF,EAAC"}
1
+ {"version":3,"sources":["../../src/hooks/afterChange.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\nimport type { CollectionAfterChangeHook } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\nimport { isCloudStorage } from '../utilities/storage.js'\n\nexport const createAfterChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionAfterChangeHook => {\n return async ({ context, doc, req }) => {\n if (context?.imageOptimizer_skip) return doc\n\n if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return doc\n\n const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n\n // When using local storage, overwrite the file on disk with the processed buffer.\n // Payload's uploadFiles step writes the original buffer; we replace it here.\n // When using cloud storage, skip — the cloud adapter's afterChange hook already\n // uploads the correct buffer from req.file.data (set in our beforeChange hook).\n if (!cloudStorage) {\n const staticDir = resolveStaticDir(collectionConfig)\n const processedBuffer = context.imageOptimizer_processedBuffer as Buffer | undefined\n if (processedBuffer && doc.filename && staticDir) {\n const safeFilename = path.basename(doc.filename as string)\n const filePath = path.join(staticDir, safeFilename)\n await fs.writeFile(filePath, processedBuffer)\n\n // If replaceOriginal changed the filename, clean up the old file Payload wrote\n const originalFilename = context.imageOptimizer_originalFilename as string | undefined\n if (originalFilename && originalFilename !== safeFilename) {\n const oldFilePath = path.join(staticDir, path.basename(originalFilename))\n await fs.unlink(oldFilePath).catch(() => {\n // Old file may not exist if Payload used the new filename\n })\n }\n }\n }\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)\n\n // When replaceOriginal is on and only one format is configured, the main file\n // is already converted — skip the async job and mark complete immediately.\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1) {\n await req.payload.update({\n collection: collectionSlug,\n id: doc.id,\n data: {\n imageOptimizer: {\n status: 'complete',\n variants: [],\n },\n },\n context: { imageOptimizer_skip: true },\n })\n return doc\n }\n\n // With cloud storage, variant files cannot be written — skip the async job\n // and mark complete. CDN-level image optimization (e.g. Next.js Image) can\n // serve alternative formats on the fly.\n if (cloudStorage) {\n await req.payload.update({\n collection: collectionSlug,\n id: doc.id,\n data: {\n imageOptimizer: {\n status: 'complete',\n variants: [],\n },\n },\n context: { imageOptimizer_skip: true },\n })\n return doc\n }\n\n // Queue async format conversion job for remaining variants (local storage only)\n await req.payload.jobs.queue({\n task: 'imageOptimizer_convertFormats',\n input: {\n collectionSlug,\n docId: String(doc.id),\n },\n })\n\n req.payload.jobs.run().catch((err: unknown) => {\n req.payload.logger.error({ err }, 'Image optimizer job runner failed')\n })\n\n return doc\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","resolveStaticDir","isCloudStorage","createAfterChangeHook","resolvedConfig","collectionSlug","context","doc","req","imageOptimizer_skip","file","data","mimetype","startsWith","collectionConfig","payload","collections","config","cloudStorage","staticDir","processedBuffer","imageOptimizer_processedBuffer","filename","safeFilename","basename","filePath","join","writeFile","originalFilename","imageOptimizer_originalFilename","oldFilePath","unlink","catch","perCollectionConfig","replaceOriginal","formats","length","update","collection","id","imageOptimizer","status","variants","jobs","queue","task","input","docId","String","run","err","logger","error"],"mappings":"AAAA,OAAOA,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAIvB,SAASC,uBAAuB,QAAQ,iBAAgB;AACxD,SAASC,gBAAgB,QAAQ,mCAAkC;AACnE,SAASC,cAAc,QAAQ,0BAAyB;AAExD,OAAO,MAAMC,wBAAwB,CACnCC,gBACAC;IAEA,OAAO,OAAO,EAAEC,OAAO,EAAEC,GAAG,EAAEC,GAAG,EAAE;QACjC,IAAIF,SAASG,qBAAqB,OAAOF;QAEzC,IAAI,CAACC,IAAIE,IAAI,IAAI,CAACF,IAAIE,IAAI,CAACC,IAAI,IAAI,CAACH,IAAIE,IAAI,CAACE,QAAQ,EAAEC,WAAW,WAAW,OAAON;QAEpF,MAAMO,mBAAmBN,IAAIO,OAAO,CAACC,WAAW,CAACX,eAAuD,CAACY,MAAM;QAC/G,MAAMC,eAAehB,eAAeY;QAEpC,kFAAkF;QAClF,6EAA6E;QAC7E,gFAAgF;QAChF,gFAAgF;QAChF,IAAI,CAACI,cAAc;YACjB,MAAMC,YAAYlB,iBAAiBa;YACnC,MAAMM,kBAAkBd,QAAQe,8BAA8B;YAC9D,IAAID,mBAAmBb,IAAIe,QAAQ,IAAIH,WAAW;gBAChD,MAAMI,eAAexB,KAAKyB,QAAQ,CAACjB,IAAIe,QAAQ;gBAC/C,MAAMG,WAAW1B,KAAK2B,IAAI,CAACP,WAAWI;gBACtC,MAAMzB,GAAG6B,SAAS,CAACF,UAAUL;gBAE7B,+EAA+E;gBAC/E,MAAMQ,mBAAmBtB,QAAQuB,+BAA+B;gBAChE,IAAID,oBAAoBA,qBAAqBL,cAAc;oBACzD,MAAMO,cAAc/B,KAAK2B,IAAI,CAACP,WAAWpB,KAAKyB,QAAQ,CAACI;oBACvD,MAAM9B,GAAGiC,MAAM,CAACD,aAAaE,KAAK,CAAC;oBACjC,0DAA0D;oBAC5D;gBACF;YACF;QACF;QAEA,MAAMC,sBAAsBjC,wBAAwBI,gBAAgBC;QAEpE,8EAA8E;QAC9E,2EAA2E;QAC3E,IAAI4B,oBAAoBC,eAAe,IAAID,oBAAoBE,OAAO,CAACC,MAAM,IAAI,GAAG;YAClF,MAAM5B,IAAIO,OAAO,CAACsB,MAAM,CAAC;gBACvBC,YAAYjC;gBACZkC,IAAIhC,IAAIgC,EAAE;gBACV5B,MAAM;oBACJ6B,gBAAgB;wBACdC,QAAQ;wBACRC,UAAU,EAAE;oBACd;gBACF;gBACApC,SAAS;oBAAEG,qBAAqB;gBAAK;YACvC;YACA,OAAOF;QACT;QAEA,2EAA2E;QAC3E,2EAA2E;QAC3E,wCAAwC;QACxC,IAAIW,cAAc;YAChB,MAAMV,IAAIO,OAAO,CAACsB,MAAM,CAAC;gBACvBC,YAAYjC;gBACZkC,IAAIhC,IAAIgC,EAAE;gBACV5B,MAAM;oBACJ6B,gBAAgB;wBACdC,QAAQ;wBACRC,UAAU,EAAE;oBACd;gBACF;gBACApC,SAAS;oBAAEG,qBAAqB;gBAAK;YACvC;YACA,OAAOF;QACT;QAEA,gFAAgF;QAChF,MAAMC,IAAIO,OAAO,CAAC4B,IAAI,CAACC,KAAK,CAAC;YAC3BC,MAAM;YACNC,OAAO;gBACLzC;gBACA0C,OAAOC,OAAOzC,IAAIgC,EAAE;YACtB;QACF;QAEA/B,IAAIO,OAAO,CAAC4B,IAAI,CAACM,GAAG,GAAGjB,KAAK,CAAC,CAACkB;YAC5B1C,IAAIO,OAAO,CAACoC,MAAM,CAACC,KAAK,CAAC;gBAAEF;YAAI,GAAG;QACpC;QAEA,OAAO3C;IACT;AACF,EAAC"}
@@ -33,8 +33,17 @@ export const createBeforeChangeHook = (resolvedConfig, collectionSlug)=>{
33
33
  if (resolvedConfig.generateThumbHash) {
34
34
  data.imageOptimizer.thumbHash = await generateThumbHash(finalBuffer);
35
35
  }
36
- // Store processed buffer in context for afterChange to write to disk
37
- // (Payload 3.0 does not use modified req.file.data for the disk write)
36
+ // Write processed buffer back to req.file so cloud storage adapters
37
+ // (which read req.file in their afterChange hook) upload the optimized version.
38
+ // Payload's own uploadFiles step does NOT re-read req.file.data for its local
39
+ // disk write, so we also store the buffer in context for our afterChange hook
40
+ // to overwrite the local file when local storage is enabled.
41
+ req.file.data = finalBuffer;
42
+ req.file.size = finalSize;
43
+ if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {
44
+ req.file.name = data.filename;
45
+ req.file.mimetype = data.mimeType;
46
+ }
38
47
  context.imageOptimizer_processedBuffer = finalBuffer;
39
48
  return data;
40
49
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/hooks/beforeChange.ts"],"sourcesContent":["import path from 'path'\nimport type { CollectionBeforeChangeHook } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { convertFormat, generateThumbHash, stripAndResize } from '../processing/index.js'\n\nexport const createBeforeChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionBeforeChangeHook => {\n return async ({ context, data, req }) => {\n if (context?.imageOptimizer_skip) return data\n\n if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return data\n\n const originalSize = req.file.data.length\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)\n\n // Process in memory: strip EXIF, resize, generate blur\n const processed = await stripAndResize(\n req.file.data,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n let finalBuffer = processed.buffer\n let finalSize = processed.size\n\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n // Convert to primary format (first in the formats array)\n const primaryFormat = perCollectionConfig.formats[0]\n const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)\n\n finalBuffer = converted.buffer\n finalSize = converted.size\n\n // Update filename and mimeType so Payload stores the correct metadata\n const originalFilename = data.filename || req.file.name || ''\n const newFilename = `${path.parse(originalFilename).name}.${primaryFormat.format}`\n context.imageOptimizer_originalFilename = originalFilename\n data.filename = newFilename\n data.mimeType = converted.mimeType\n data.filesize = finalSize\n }\n\n data.imageOptimizer = {\n originalSize,\n optimizedSize: finalSize,\n status: 'pending',\n }\n\n if (resolvedConfig.generateThumbHash) {\n data.imageOptimizer.thumbHash = await generateThumbHash(finalBuffer)\n }\n\n // Store processed buffer in context for afterChange to write to disk\n // (Payload 3.0 does not use modified req.file.data for the disk write)\n context.imageOptimizer_processedBuffer = finalBuffer\n\n return data\n }\n}\n"],"names":["path","resolveCollectionConfig","convertFormat","generateThumbHash","stripAndResize","createBeforeChangeHook","resolvedConfig","collectionSlug","context","data","req","imageOptimizer_skip","file","mimetype","startsWith","originalSize","length","perCollectionConfig","processed","maxDimensions","stripMetadata","finalBuffer","buffer","finalSize","size","replaceOriginal","formats","primaryFormat","converted","format","quality","originalFilename","filename","name","newFilename","parse","imageOptimizer_originalFilename","mimeType","filesize","imageOptimizer","optimizedSize","status","thumbHash","imageOptimizer_processedBuffer"],"mappings":"AAAA,OAAOA,UAAU,OAAM;AAIvB,SAASC,uBAAuB,QAAQ,iBAAgB;AACxD,SAASC,aAAa,EAAEC,iBAAiB,EAAEC,cAAc,QAAQ,yBAAwB;AAEzF,OAAO,MAAMC,yBAAyB,CACpCC,gBACAC;IAEA,OAAO,OAAO,EAAEC,OAAO,EAAEC,IAAI,EAAEC,GAAG,EAAE;QAClC,IAAIF,SAASG,qBAAqB,OAAOF;QAEzC,IAAI,CAACC,IAAIE,IAAI,IAAI,CAACF,IAAIE,IAAI,CAACH,IAAI,IAAI,CAACC,IAAIE,IAAI,CAACC,QAAQ,EAAEC,WAAW,WAAW,OAAOL;QAEpF,MAAMM,eAAeL,IAAIE,IAAI,CAACH,IAAI,CAACO,MAAM;QAEzC,MAAMC,sBAAsBhB,wBAAwBK,gBAAgBC;QAEpE,uDAAuD;QACvD,MAAMW,YAAY,MAAMd,eACtBM,IAAIE,IAAI,CAACH,IAAI,EACbQ,oBAAoBE,aAAa,EACjCb,eAAec,aAAa;QAG9B,IAAIC,cAAcH,UAAUI,MAAM;QAClC,IAAIC,YAAYL,UAAUM,IAAI;QAE9B,IAAIP,oBAAoBQ,eAAe,IAAIR,oBAAoBS,OAAO,CAACV,MAAM,GAAG,GAAG;YACjF,yDAAyD;YACzD,MAAMW,gBAAgBV,oBAAoBS,OAAO,CAAC,EAAE;YACpD,MAAME,YAAY,MAAM1B,cAAcgB,UAAUI,MAAM,EAAEK,cAAcE,MAAM,EAAEF,cAAcG,OAAO;YAEnGT,cAAcO,UAAUN,MAAM;YAC9BC,YAAYK,UAAUJ,IAAI;YAE1B,sEAAsE;YACtE,MAAMO,mBAAmBtB,KAAKuB,QAAQ,IAAItB,IAAIE,IAAI,CAACqB,IAAI,IAAI;YAC3D,MAAMC,cAAc,GAAGlC,KAAKmC,KAAK,CAACJ,kBAAkBE,IAAI,CAAC,CAAC,EAAEN,cAAcE,MAAM,EAAE;YAClFrB,QAAQ4B,+BAA+B,GAAGL;YAC1CtB,KAAKuB,QAAQ,GAAGE;YAChBzB,KAAK4B,QAAQ,GAAGT,UAAUS,QAAQ;YAClC5B,KAAK6B,QAAQ,GAAGf;QAClB;QAEAd,KAAK8B,cAAc,GAAG;YACpBxB;YACAyB,eAAejB;YACfkB,QAAQ;QACV;QAEA,IAAInC,eAAeH,iBAAiB,EAAE;YACpCM,KAAK8B,cAAc,CAACG,SAAS,GAAG,MAAMvC,kBAAkBkB;QAC1D;QAEA,qEAAqE;QACrE,uEAAuE;QACvEb,QAAQmC,8BAA8B,GAAGtB;QAEzC,OAAOZ;IACT;AACF,EAAC"}
1
+ {"version":3,"sources":["../../src/hooks/beforeChange.ts"],"sourcesContent":["import path from 'path'\nimport type { CollectionBeforeChangeHook } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { convertFormat, generateThumbHash, stripAndResize } from '../processing/index.js'\n\nexport const createBeforeChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionBeforeChangeHook => {\n return async ({ context, data, req }) => {\n if (context?.imageOptimizer_skip) return data\n\n if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return data\n\n const originalSize = req.file.data.length\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)\n\n // Process in memory: strip EXIF, resize, generate blur\n const processed = await stripAndResize(\n req.file.data,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n let finalBuffer = processed.buffer\n let finalSize = processed.size\n\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n // Convert to primary format (first in the formats array)\n const primaryFormat = perCollectionConfig.formats[0]\n const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)\n\n finalBuffer = converted.buffer\n finalSize = converted.size\n\n // Update filename and mimeType so Payload stores the correct metadata\n const originalFilename = data.filename || req.file.name || ''\n const newFilename = `${path.parse(originalFilename).name}.${primaryFormat.format}`\n context.imageOptimizer_originalFilename = originalFilename\n data.filename = newFilename\n data.mimeType = converted.mimeType\n data.filesize = finalSize\n }\n\n data.imageOptimizer = {\n originalSize,\n optimizedSize: finalSize,\n status: 'pending',\n }\n\n if (resolvedConfig.generateThumbHash) {\n data.imageOptimizer.thumbHash = await generateThumbHash(finalBuffer)\n }\n\n // Write processed buffer back to req.file so cloud storage adapters\n // (which read req.file in their afterChange hook) upload the optimized version.\n // Payload's own uploadFiles step does NOT re-read req.file.data for its local\n // disk write, so we also store the buffer in context for our afterChange hook\n // to overwrite the local file when local storage is enabled.\n req.file.data = finalBuffer\n req.file.size = finalSize\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n req.file.name = data.filename\n req.file.mimetype = data.mimeType\n }\n context.imageOptimizer_processedBuffer = finalBuffer\n\n return data\n }\n}\n"],"names":["path","resolveCollectionConfig","convertFormat","generateThumbHash","stripAndResize","createBeforeChangeHook","resolvedConfig","collectionSlug","context","data","req","imageOptimizer_skip","file","mimetype","startsWith","originalSize","length","perCollectionConfig","processed","maxDimensions","stripMetadata","finalBuffer","buffer","finalSize","size","replaceOriginal","formats","primaryFormat","converted","format","quality","originalFilename","filename","name","newFilename","parse","imageOptimizer_originalFilename","mimeType","filesize","imageOptimizer","optimizedSize","status","thumbHash","imageOptimizer_processedBuffer"],"mappings":"AAAA,OAAOA,UAAU,OAAM;AAIvB,SAASC,uBAAuB,QAAQ,iBAAgB;AACxD,SAASC,aAAa,EAAEC,iBAAiB,EAAEC,cAAc,QAAQ,yBAAwB;AAEzF,OAAO,MAAMC,yBAAyB,CACpCC,gBACAC;IAEA,OAAO,OAAO,EAAEC,OAAO,EAAEC,IAAI,EAAEC,GAAG,EAAE;QAClC,IAAIF,SAASG,qBAAqB,OAAOF;QAEzC,IAAI,CAACC,IAAIE,IAAI,IAAI,CAACF,IAAIE,IAAI,CAACH,IAAI,IAAI,CAACC,IAAIE,IAAI,CAACC,QAAQ,EAAEC,WAAW,WAAW,OAAOL;QAEpF,MAAMM,eAAeL,IAAIE,IAAI,CAACH,IAAI,CAACO,MAAM;QAEzC,MAAMC,sBAAsBhB,wBAAwBK,gBAAgBC;QAEpE,uDAAuD;QACvD,MAAMW,YAAY,MAAMd,eACtBM,IAAIE,IAAI,CAACH,IAAI,EACbQ,oBAAoBE,aAAa,EACjCb,eAAec,aAAa;QAG9B,IAAIC,cAAcH,UAAUI,MAAM;QAClC,IAAIC,YAAYL,UAAUM,IAAI;QAE9B,IAAIP,oBAAoBQ,eAAe,IAAIR,oBAAoBS,OAAO,CAACV,MAAM,GAAG,GAAG;YACjF,yDAAyD;YACzD,MAAMW,gBAAgBV,oBAAoBS,OAAO,CAAC,EAAE;YACpD,MAAME,YAAY,MAAM1B,cAAcgB,UAAUI,MAAM,EAAEK,cAAcE,MAAM,EAAEF,cAAcG,OAAO;YAEnGT,cAAcO,UAAUN,MAAM;YAC9BC,YAAYK,UAAUJ,IAAI;YAE1B,sEAAsE;YACtE,MAAMO,mBAAmBtB,KAAKuB,QAAQ,IAAItB,IAAIE,IAAI,CAACqB,IAAI,IAAI;YAC3D,MAAMC,cAAc,GAAGlC,KAAKmC,KAAK,CAACJ,kBAAkBE,IAAI,CAAC,CAAC,EAAEN,cAAcE,MAAM,EAAE;YAClFrB,QAAQ4B,+BAA+B,GAAGL;YAC1CtB,KAAKuB,QAAQ,GAAGE;YAChBzB,KAAK4B,QAAQ,GAAGT,UAAUS,QAAQ;YAClC5B,KAAK6B,QAAQ,GAAGf;QAClB;QAEAd,KAAK8B,cAAc,GAAG;YACpBxB;YACAyB,eAAejB;YACfkB,QAAQ;QACV;QAEA,IAAInC,eAAeH,iBAAiB,EAAE;YACpCM,KAAK8B,cAAc,CAACG,SAAS,GAAG,MAAMvC,kBAAkBkB;QAC1D;QAEA,oEAAoE;QACpE,gFAAgF;QAChF,8EAA8E;QAC9E,8EAA8E;QAC9E,6DAA6D;QAC7DX,IAAIE,IAAI,CAACH,IAAI,GAAGY;QAChBX,IAAIE,IAAI,CAACY,IAAI,GAAGD;QAChB,IAAIN,oBAAoBQ,eAAe,IAAIR,oBAAoBS,OAAO,CAACV,MAAM,GAAG,GAAG;YACjFN,IAAIE,IAAI,CAACqB,IAAI,GAAGxB,KAAKuB,QAAQ;YAC7BtB,IAAIE,IAAI,CAACC,QAAQ,GAAGJ,KAAK4B,QAAQ;QACnC;QACA7B,QAAQmC,8BAA8B,GAAGtB;QAEzC,OAAOZ;IACT;AACF,EAAC"}
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { resolveCollectionConfig } from '../defaults.js';
4
4
  import { convertFormat } from '../processing/index.js';
5
5
  import { resolveStaticDir } from '../utilities/resolveStaticDir.js';
6
+ import { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js';
6
7
  export const createConvertFormatsHandler = (resolvedConfig)=>{
7
8
  return async ({ input, req })=>{
8
9
  try {
@@ -11,19 +12,40 @@ export const createConvertFormatsHandler = (resolvedConfig)=>{
11
12
  id: input.docId
12
13
  });
13
14
  const collectionConfig = req.payload.collections[input.collectionSlug].config;
15
+ const cloudStorage = isCloudStorage(collectionConfig);
16
+ // Cloud storage: variant files cannot be uploaded without direct adapter access.
17
+ // Mark as complete — CDN-level image optimization handles format conversion.
18
+ if (cloudStorage) {
19
+ await req.payload.update({
20
+ collection: input.collectionSlug,
21
+ id: input.docId,
22
+ data: {
23
+ imageOptimizer: {
24
+ status: 'complete',
25
+ variants: []
26
+ }
27
+ },
28
+ context: {
29
+ imageOptimizer_skip: true
30
+ }
31
+ });
32
+ return {
33
+ output: {
34
+ variantsGenerated: 0
35
+ }
36
+ };
37
+ }
14
38
  const staticDir = resolveStaticDir(collectionConfig);
15
39
  if (!staticDir) {
16
40
  throw new Error(`No staticDir configured for collection "${input.collectionSlug}"`);
17
41
  }
18
- // Sanitize filename to prevent path traversal
19
- const safeFilename = path.basename(doc.filename);
20
- const filePath = path.join(staticDir, safeFilename);
21
- const fileBuffer = await fs.readFile(filePath);
42
+ const fileBuffer = await fetchFileBuffer(doc, collectionConfig);
22
43
  const variants = [];
23
44
  const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug);
24
45
  // When replaceOriginal is on, the main file is already in the primary format —
25
46
  // skip it and only generate variants for the remaining formats.
26
47
  const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0 ? perCollectionConfig.formats.slice(1) : perCollectionConfig.formats;
48
+ const safeFilename = path.basename(doc.filename);
27
49
  for (const format of formatsToGenerate){
28
50
  const result = await convertFormat(fileBuffer, format.format, format.quality);
29
51
  const variantFilename = `${path.parse(safeFilename).name}-optimized.${format.format}`;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tasks/convertFormats.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport type { CollectionSlug } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { convertFormat } from '../processing/index.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\n\nexport const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {\n try {\n const doc = await req.payload.findByID({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n })\n\n const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config\n const staticDir = resolveStaticDir(collectionConfig)\n\n if (!staticDir) {\n throw new Error(`No staticDir configured for collection \"${input.collectionSlug}\"`)\n }\n\n // Sanitize filename to prevent path traversal\n const safeFilename = path.basename(doc.filename)\n const filePath = path.join(staticDir, safeFilename)\n const fileBuffer = await fs.readFile(filePath)\n\n const variants: Array<{\n filename: string\n filesize: number\n format: string\n height: number\n mimeType: string\n url: string\n width: number\n }> = []\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)\n\n // When replaceOriginal is on, the main file is already in the primary format —\n // skip it and only generate variants for the remaining formats.\n const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0\n ? perCollectionConfig.formats.slice(1)\n : perCollectionConfig.formats\n\n for (const format of formatsToGenerate) {\n const result = await convertFormat(fileBuffer, format.format, format.quality)\n const variantFilename = `${path.parse(safeFilename).name}-optimized.${format.format}`\n\n await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)\n\n variants.push({\n format: format.format,\n filename: variantFilename,\n filesize: result.size,\n width: result.width,\n height: result.height,\n mimeType: result.mimeType,\n url: `/api/${input.collectionSlug}/file/${variantFilename}`,\n })\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'complete',\n variants,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n\n return { output: { variantsGenerated: variants.length } }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : String(err)\n\n try {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'error',\n error: errorMessage,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n } catch (updateErr) {\n req.payload.logger.error(\n { err: updateErr },\n 'Failed to persist error status for image optimizer',\n )\n }\n\n throw err\n }\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","convertFormat","resolveStaticDir","createConvertFormatsHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","collectionConfig","collections","config","staticDir","Error","safeFilename","basename","filename","filePath","join","fileBuffer","readFile","variants","perCollectionConfig","formatsToGenerate","replaceOriginal","formats","length","slice","format","result","quality","variantFilename","parse","name","writeFile","buffer","push","filesize","size","width","height","mimeType","url","update","data","imageOptimizer","status","context","imageOptimizer_skip","output","variantsGenerated","err","errorMessage","message","String","error","updateErr","logger"],"mappings":"AAAA,OAAOA,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAKvB,SAASC,uBAAuB,QAAQ,iBAAgB;AACxD,SAASC,aAAa,QAAQ,yBAAwB;AACtD,SAASC,gBAAgB,QAAQ,mCAAkC;AAEnE,OAAO,MAAMC,8BAA8B,CAACC;IAC1C,OAAO,OAAO,EAAEC,KAAK,EAAEC,GAAG,EAAkE;QAC1F,IAAI;YACF,MAAMC,MAAM,MAAMD,IAAIE,OAAO,CAACC,QAAQ,CAAC;gBACrCC,YAAYL,MAAMM,cAAc;gBAChCC,IAAIP,MAAMQ,KAAK;YACjB;YAEA,MAAMC,mBAAmBR,IAAIE,OAAO,CAACO,WAAW,CAACV,MAAMM,cAAc,CAAyC,CAACK,MAAM;YACrH,MAAMC,YAAYf,iBAAiBY;YAEnC,IAAI,CAACG,WAAW;gBACd,MAAM,IAAIC,MAAM,CAAC,wCAAwC,EAAEb,MAAMM,cAAc,CAAC,CAAC,CAAC;YACpF;YAEA,8CAA8C;YAC9C,MAAMQ,eAAepB,KAAKqB,QAAQ,CAACb,IAAIc,QAAQ;YAC/C,MAAMC,WAAWvB,KAAKwB,IAAI,CAACN,WAAWE;YACtC,MAAMK,aAAa,MAAM1B,GAAG2B,QAAQ,CAACH;YAErC,MAAMI,WAQD,EAAE;YAEP,MAAMC,sBAAsB3B,wBAAwBI,gBAAgBC,MAAMM,cAAc;YAExF,+EAA+E;YAC/E,gEAAgE;YAChE,MAAMiB,oBAAoBD,oBAAoBE,eAAe,IAAIF,oBAAoBG,OAAO,CAACC,MAAM,GAAG,IAClGJ,oBAAoBG,OAAO,CAACE,KAAK,CAAC,KAClCL,oBAAoBG,OAAO;YAE/B,KAAK,MAAMG,UAAUL,kBAAmB;gBACtC,MAAMM,SAAS,MAAMjC,cAAcuB,YAAYS,OAAOA,MAAM,EAAEA,OAAOE,OAAO;gBAC5E,MAAMC,kBAAkB,GAAGrC,KAAKsC,KAAK,CAAClB,cAAcmB,IAAI,CAAC,WAAW,EAAEL,OAAOA,MAAM,EAAE;gBAErF,MAAMnC,GAAGyC,SAAS,CAACxC,KAAKwB,IAAI,CAACN,WAAWmB,kBAAkBF,OAAOM,MAAM;gBAEvEd,SAASe,IAAI,CAAC;oBACZR,QAAQA,OAAOA,MAAM;oBACrBZ,UAAUe;oBACVM,UAAUR,OAAOS,IAAI;oBACrBC,OAAOV,OAAOU,KAAK;oBACnBC,QAAQX,OAAOW,MAAM;oBACrBC,UAAUZ,OAAOY,QAAQ;oBACzBC,KAAK,CAAC,KAAK,EAAE1C,MAAMM,cAAc,CAAC,MAAM,EAAEyB,iBAAiB;gBAC7D;YACF;YAEA,MAAM9B,IAAIE,OAAO,CAACwC,MAAM,CAAC;gBACvBtC,YAAYL,MAAMM,cAAc;gBAChCC,IAAIP,MAAMQ,KAAK;gBACfoC,MAAM;oBACJC,gBAAgB;wBACdC,QAAQ;wBACRzB;oBACF;gBACF;gBACA0B,SAAS;oBAAEC,qBAAqB;gBAAK;YACvC;YAEA,OAAO;gBAAEC,QAAQ;oBAAEC,mBAAmB7B,SAASK,MAAM;gBAAC;YAAE;QAC1D,EAAE,OAAOyB,KAAK;YACZ,MAAMC,eAAeD,eAAetC,QAAQsC,IAAIE,OAAO,GAAGC,OAAOH;YAEjE,IAAI;gBACF,MAAMlD,IAAIE,OAAO,CAACwC,MAAM,CAAC;oBACvBtC,YAAYL,MAAMM,cAAc;oBAChCC,IAAIP,MAAMQ,KAAK;oBACfoC,MAAM;wBACJC,gBAAgB;4BACdC,QAAQ;4BACRS,OAAOH;wBACT;oBACF;oBACAL,SAAS;wBAAEC,qBAAqB;oBAAK;gBACvC;YACF,EAAE,OAAOQ,WAAW;gBAClBvD,IAAIE,OAAO,CAACsD,MAAM,CAACF,KAAK,CACtB;oBAAEJ,KAAKK;gBAAU,GACjB;YAEJ;YAEA,MAAML;QACR;IACF;AACF,EAAC"}
1
+ {"version":3,"sources":["../../src/tasks/convertFormats.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport type { CollectionSlug } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { convertFormat } from '../processing/index.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\nimport { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'\n\nexport const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {\n try {\n const doc = await req.payload.findByID({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n })\n\n const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n\n // Cloud storage: variant files cannot be uploaded without direct adapter access.\n // Mark as complete — CDN-level image optimization handles format conversion.\n if (cloudStorage) {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'complete',\n variants: [],\n },\n },\n context: { imageOptimizer_skip: true },\n })\n return { output: { variantsGenerated: 0 } }\n }\n\n const staticDir = resolveStaticDir(collectionConfig)\n if (!staticDir) {\n throw new Error(`No staticDir configured for collection \"${input.collectionSlug}\"`)\n }\n\n const fileBuffer = await fetchFileBuffer(doc, collectionConfig)\n\n const variants: Array<{\n filename: string\n filesize: number\n format: string\n height: number\n mimeType: string\n url: string\n width: number\n }> = []\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)\n\n // When replaceOriginal is on, the main file is already in the primary format —\n // skip it and only generate variants for the remaining formats.\n const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0\n ? perCollectionConfig.formats.slice(1)\n : perCollectionConfig.formats\n\n const safeFilename = path.basename(doc.filename)\n\n for (const format of formatsToGenerate) {\n const result = await convertFormat(fileBuffer, format.format, format.quality)\n const variantFilename = `${path.parse(safeFilename).name}-optimized.${format.format}`\n\n await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)\n\n variants.push({\n format: format.format,\n filename: variantFilename,\n filesize: result.size,\n width: result.width,\n height: result.height,\n mimeType: result.mimeType,\n url: `/api/${input.collectionSlug}/file/${variantFilename}`,\n })\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'complete',\n variants,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n\n return { output: { variantsGenerated: variants.length } }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : String(err)\n\n try {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'error',\n error: errorMessage,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n } catch (updateErr) {\n req.payload.logger.error(\n { err: updateErr },\n 'Failed to persist error status for image optimizer',\n )\n }\n\n throw err\n }\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","convertFormat","resolveStaticDir","fetchFileBuffer","isCloudStorage","createConvertFormatsHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","collectionConfig","collections","config","cloudStorage","update","data","imageOptimizer","status","variants","context","imageOptimizer_skip","output","variantsGenerated","staticDir","Error","fileBuffer","perCollectionConfig","formatsToGenerate","replaceOriginal","formats","length","slice","safeFilename","basename","filename","format","result","quality","variantFilename","parse","name","writeFile","join","buffer","push","filesize","size","width","height","mimeType","url","err","errorMessage","message","String","error","updateErr","logger"],"mappings":"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"}
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { resolveCollectionConfig } from '../defaults.js';
4
4
  import { stripAndResize, generateThumbHash, convertFormat } from '../processing/index.js';
5
5
  import { resolveStaticDir } from '../utilities/resolveStaticDir.js';
6
+ import { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js';
6
7
  export const createRegenerateDocumentHandler = (resolvedConfig)=>{
7
8
  return async ({ input, req })=>{
8
9
  try {
@@ -20,28 +21,12 @@ export const createRegenerateDocumentHandler = (resolvedConfig)=>{
20
21
  };
21
22
  }
22
23
  const collectionConfig = req.payload.collections[input.collectionSlug].config;
23
- const staticDir = resolveStaticDir(collectionConfig);
24
- if (!staticDir) {
25
- throw new Error(`No staticDir configured for collection "${input.collectionSlug}"`);
26
- }
27
- // Sanitize filename to prevent path traversal
28
- const safeFilename = path.basename(doc.filename);
29
- const filePath = path.join(staticDir, safeFilename);
30
- let fileBuffer;
31
- try {
32
- fileBuffer = await fs.readFile(filePath);
33
- } catch {
34
- // If file not on disk, try fetching from URL
35
- if (doc.url) {
36
- const url = doc.url.startsWith('http') ? doc.url : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`;
37
- const response = await fetch(url);
38
- fileBuffer = Buffer.from(await response.arrayBuffer());
39
- } else {
40
- throw new Error(`File not found: ${filePath}`);
41
- }
42
- }
24
+ const cloudStorage = isCloudStorage(collectionConfig);
25
+ const fileBuffer = await fetchFileBuffer(doc, collectionConfig);
43
26
  const originalSize = fileBuffer.length;
44
27
  const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug);
28
+ // Sanitize filename to prevent path traversal
29
+ const safeFilename = path.basename(doc.filename);
45
30
  // Step 1: Strip metadata + resize
46
31
  const processed = await stripAndResize(fileBuffer, perCollectionConfig.maxDimensions, resolvedConfig.stripMetadata);
47
32
  let mainBuffer = processed.buffer;
@@ -57,60 +42,96 @@ export const createRegenerateDocumentHandler = (resolvedConfig)=>{
57
42
  newFilename = `${path.parse(safeFilename).name}.${primaryFormat.format}`;
58
43
  newMimeType = converted.mimeType;
59
44
  }
60
- // Write optimized file to disk
61
- const newFilePath = path.join(staticDir, newFilename);
62
- await fs.writeFile(newFilePath, mainBuffer);
63
- // Clean up old file if filename changed
64
- if (newFilename !== safeFilename) {
65
- await fs.unlink(filePath).catch(()=>{});
66
- }
67
45
  // Step 2: Generate ThumbHash
68
46
  let thumbHash;
69
47
  if (resolvedConfig.generateThumbHash) {
70
48
  thumbHash = await generateThumbHash(mainBuffer);
71
49
  }
72
- // Step 3: Convert to configured formats (skip primary when replaceOriginal)
73
- const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0 ? perCollectionConfig.formats.slice(1) : perCollectionConfig.formats;
50
+ // Step 3: Store the optimized file
74
51
  const variants = [];
75
- for (const format of formatsToGenerate){
76
- const result = await convertFormat(mainBuffer, format.format, format.quality);
77
- const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`;
78
- await fs.writeFile(path.join(staticDir, variantFilename), result.buffer);
79
- variants.push({
80
- format: format.format,
81
- filename: variantFilename,
82
- filesize: result.size,
83
- width: result.width,
84
- height: result.height,
85
- mimeType: result.mimeType,
86
- url: `/api/${input.collectionSlug}/file/${variantFilename}`
52
+ if (cloudStorage) {
53
+ // Cloud storage: re-upload the optimized file via Payload's update API.
54
+ // This triggers the cloud adapter's afterChange hook which uploads to cloud.
55
+ const updateData = {
56
+ imageOptimizer: {
57
+ originalSize,
58
+ optimizedSize: mainSize,
59
+ status: 'complete',
60
+ thumbHash,
61
+ variants: [],
62
+ error: null
63
+ }
64
+ };
65
+ if (newFilename !== safeFilename) {
66
+ updateData.filename = newFilename;
67
+ updateData.filesize = mainSize;
68
+ updateData.mimeType = newMimeType;
69
+ }
70
+ await req.payload.update({
71
+ collection: input.collectionSlug,
72
+ id: input.docId,
73
+ data: updateData,
74
+ file: {
75
+ data: mainBuffer,
76
+ mimetype: newMimeType || doc.mimeType,
77
+ name: newFilename,
78
+ size: mainSize
79
+ },
80
+ context: {
81
+ imageOptimizer_skip: true
82
+ }
87
83
  });
88
- }
89
- // Step 4: Update the document with all optimization data
90
- const updateData = {
91
- imageOptimizer: {
92
- originalSize,
93
- optimizedSize: mainSize,
94
- status: 'complete',
95
- thumbHash,
96
- variants,
97
- error: null
84
+ } else {
85
+ // Local storage: write files to disk
86
+ const staticDir = resolveStaticDir(collectionConfig);
87
+ const newFilePath = path.join(staticDir, newFilename);
88
+ await fs.writeFile(newFilePath, mainBuffer);
89
+ // Clean up old file if filename changed
90
+ if (newFilename !== safeFilename) {
91
+ const oldFilePath = path.join(staticDir, safeFilename);
92
+ await fs.unlink(oldFilePath).catch(()=>{});
98
93
  }
99
- };
100
- // Update filename, mimeType, and filesize when replaceOriginal changed them
101
- if (newFilename !== safeFilename) {
102
- updateData.filename = newFilename;
103
- updateData.filesize = mainSize;
104
- updateData.mimeType = newMimeType;
105
- }
106
- await req.payload.update({
107
- collection: input.collectionSlug,
108
- id: input.docId,
109
- data: updateData,
110
- context: {
111
- imageOptimizer_skip: true
94
+ // Generate variant files (local storage only)
95
+ const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0 ? perCollectionConfig.formats.slice(1) : perCollectionConfig.formats;
96
+ for (const format of formatsToGenerate){
97
+ const result = await convertFormat(mainBuffer, format.format, format.quality);
98
+ const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`;
99
+ await fs.writeFile(path.join(staticDir, variantFilename), result.buffer);
100
+ variants.push({
101
+ format: format.format,
102
+ filename: variantFilename,
103
+ filesize: result.size,
104
+ width: result.width,
105
+ height: result.height,
106
+ mimeType: result.mimeType,
107
+ url: `/api/${input.collectionSlug}/file/${variantFilename}`
108
+ });
112
109
  }
113
- });
110
+ // Update the document with optimization data
111
+ const updateData = {
112
+ imageOptimizer: {
113
+ originalSize,
114
+ optimizedSize: mainSize,
115
+ status: 'complete',
116
+ thumbHash,
117
+ variants,
118
+ error: null
119
+ }
120
+ };
121
+ if (newFilename !== safeFilename) {
122
+ updateData.filename = newFilename;
123
+ updateData.filesize = mainSize;
124
+ updateData.mimeType = newMimeType;
125
+ }
126
+ await req.payload.update({
127
+ collection: input.collectionSlug,
128
+ id: input.docId,
129
+ data: updateData,
130
+ context: {
131
+ imageOptimizer_skip: true
132
+ }
133
+ });
134
+ }
114
135
  return {
115
136
  output: {
116
137
  status: 'complete'
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tasks/regenerateDocument.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport type { CollectionSlug } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { stripAndResize, generateThumbHash, convertFormat } from '../processing/index.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\n\nexport const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {\n try {\n const doc = await req.payload.findByID({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n })\n\n // Skip non-image documents\n if (!doc.mimeType || !doc.mimeType.startsWith('image/')) {\n return { output: { status: 'skipped', reason: 'not-image' } }\n }\n\n const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config\n const staticDir = resolveStaticDir(collectionConfig)\n\n if (!staticDir) {\n throw new Error(`No staticDir configured for collection \"${input.collectionSlug}\"`)\n }\n\n // Sanitize filename to prevent path traversal\n const safeFilename = path.basename(doc.filename)\n const filePath = path.join(staticDir, safeFilename)\n\n let fileBuffer: Buffer\n try {\n fileBuffer = await fs.readFile(filePath)\n } catch {\n // If file not on disk, try fetching from URL\n if (doc.url) {\n const url = doc.url.startsWith('http')\n ? doc.url\n : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`\n const response = await fetch(url)\n fileBuffer = Buffer.from(await response.arrayBuffer())\n } else {\n throw new Error(`File not found: ${filePath}`)\n }\n }\n\n const originalSize = fileBuffer.length\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)\n\n // Step 1: Strip metadata + resize\n const processed = await stripAndResize(\n fileBuffer,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n let mainBuffer = processed.buffer\n let mainSize = processed.size\n let newFilename = safeFilename\n let newMimeType: string | undefined\n\n // Step 1b: If replaceOriginal, convert main file to primary format\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n const primaryFormat = perCollectionConfig.formats[0]\n const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)\n mainBuffer = converted.buffer\n mainSize = converted.size\n newFilename = `${path.parse(safeFilename).name}.${primaryFormat.format}`\n newMimeType = converted.mimeType\n }\n\n // Write optimized file to disk\n const newFilePath = path.join(staticDir, newFilename)\n await fs.writeFile(newFilePath, mainBuffer)\n\n // Clean up old file if filename changed\n if (newFilename !== safeFilename) {\n await fs.unlink(filePath).catch(() => {})\n }\n\n // Step 2: Generate ThumbHash\n let thumbHash: string | undefined\n if (resolvedConfig.generateThumbHash) {\n thumbHash = await generateThumbHash(mainBuffer)\n }\n\n // Step 3: Convert to configured formats (skip primary when replaceOriginal)\n const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0\n ? perCollectionConfig.formats.slice(1)\n : perCollectionConfig.formats\n\n const variants: Array<{\n filename: string\n filesize: number\n format: string\n height: number\n mimeType: string\n url: string\n width: number\n }> = []\n\n for (const format of formatsToGenerate) {\n const result = await convertFormat(mainBuffer, format.format, format.quality)\n const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`\n await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)\n\n variants.push({\n format: format.format,\n filename: variantFilename,\n filesize: result.size,\n width: result.width,\n height: result.height,\n mimeType: result.mimeType,\n url: `/api/${input.collectionSlug}/file/${variantFilename}`,\n })\n }\n\n // Step 4: Update the document with all optimization data\n const updateData: Record<string, any> = {\n imageOptimizer: {\n originalSize,\n optimizedSize: mainSize,\n status: 'complete',\n thumbHash,\n variants,\n error: null,\n },\n }\n\n // Update filename, mimeType, and filesize when replaceOriginal changed them\n if (newFilename !== safeFilename) {\n updateData.filename = newFilename\n updateData.filesize = mainSize\n updateData.mimeType = newMimeType\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: updateData,\n context: { imageOptimizer_skip: true },\n })\n\n return { output: { status: 'complete' } }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : String(err)\n\n try {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'error',\n error: errorMessage,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n } catch (updateErr) {\n req.payload.logger.error(\n { err: updateErr },\n 'Failed to persist error status for image optimizer regeneration',\n )\n }\n\n throw err\n }\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","stripAndResize","generateThumbHash","convertFormat","resolveStaticDir","createRegenerateDocumentHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","mimeType","startsWith","output","status","reason","collectionConfig","collections","config","staticDir","Error","safeFilename","basename","filename","filePath","join","fileBuffer","readFile","url","process","env","NEXT_PUBLIC_SERVER_URL","response","fetch","Buffer","from","arrayBuffer","originalSize","length","perCollectionConfig","processed","maxDimensions","stripMetadata","mainBuffer","buffer","mainSize","size","newFilename","newMimeType","replaceOriginal","formats","primaryFormat","converted","format","quality","parse","name","newFilePath","writeFile","unlink","catch","thumbHash","formatsToGenerate","slice","variants","result","variantFilename","push","filesize","width","height","updateData","imageOptimizer","optimizedSize","error","update","data","context","imageOptimizer_skip","err","errorMessage","message","String","updateErr","logger"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/tasks/regenerateDocument.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport type { CollectionSlug } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { stripAndResize, generateThumbHash, convertFormat } from '../processing/index.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\nimport { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'\n\nexport const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {\n try {\n const doc = await req.payload.findByID({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n })\n\n // Skip non-image documents\n if (!doc.mimeType || !doc.mimeType.startsWith('image/')) {\n return { output: { status: 'skipped', reason: 'not-image' } }\n }\n\n const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n\n const fileBuffer = await fetchFileBuffer(doc, collectionConfig)\n const originalSize = fileBuffer.length\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)\n\n // Sanitize filename to prevent path traversal\n const safeFilename = path.basename(doc.filename)\n\n // Step 1: Strip metadata + resize\n const processed = await stripAndResize(\n fileBuffer,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n let mainBuffer = processed.buffer\n let mainSize = processed.size\n let newFilename = safeFilename\n let newMimeType: string | undefined\n\n // Step 1b: If replaceOriginal, convert main file to primary format\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n const primaryFormat = perCollectionConfig.formats[0]\n const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)\n mainBuffer = converted.buffer\n mainSize = converted.size\n newFilename = `${path.parse(safeFilename).name}.${primaryFormat.format}`\n newMimeType = converted.mimeType\n }\n\n // Step 2: Generate ThumbHash\n let thumbHash: string | undefined\n if (resolvedConfig.generateThumbHash) {\n thumbHash = await generateThumbHash(mainBuffer)\n }\n\n // Step 3: Store the optimized file\n const variants: Array<{\n filename: string\n filesize: number\n format: string\n height: number\n mimeType: string\n url: string\n width: number\n }> = []\n\n if (cloudStorage) {\n // Cloud storage: re-upload the optimized file via Payload's update API.\n // This triggers the cloud adapter's afterChange hook which uploads to cloud.\n const updateData: Record<string, any> = {\n imageOptimizer: {\n originalSize,\n optimizedSize: mainSize,\n status: 'complete',\n thumbHash,\n variants: [],\n error: null,\n },\n }\n\n if (newFilename !== safeFilename) {\n updateData.filename = newFilename\n updateData.filesize = mainSize\n updateData.mimeType = newMimeType\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: updateData,\n file: {\n data: mainBuffer,\n mimetype: newMimeType || doc.mimeType,\n name: newFilename,\n size: mainSize,\n },\n context: { imageOptimizer_skip: true },\n })\n } else {\n // Local storage: write files to disk\n const staticDir = resolveStaticDir(collectionConfig)\n const newFilePath = path.join(staticDir, newFilename)\n await fs.writeFile(newFilePath, mainBuffer)\n\n // Clean up old file if filename changed\n if (newFilename !== safeFilename) {\n const oldFilePath = path.join(staticDir, safeFilename)\n await fs.unlink(oldFilePath).catch(() => {})\n }\n\n // Generate variant files (local storage only)\n const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0\n ? perCollectionConfig.formats.slice(1)\n : perCollectionConfig.formats\n\n for (const format of formatsToGenerate) {\n const result = await convertFormat(mainBuffer, format.format, format.quality)\n const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`\n await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)\n\n variants.push({\n format: format.format,\n filename: variantFilename,\n filesize: result.size,\n width: result.width,\n height: result.height,\n mimeType: result.mimeType,\n url: `/api/${input.collectionSlug}/file/${variantFilename}`,\n })\n }\n\n // Update the document with optimization data\n const updateData: Record<string, any> = {\n imageOptimizer: {\n originalSize,\n optimizedSize: mainSize,\n status: 'complete',\n thumbHash,\n variants,\n error: null,\n },\n }\n\n if (newFilename !== safeFilename) {\n updateData.filename = newFilename\n updateData.filesize = mainSize\n updateData.mimeType = newMimeType\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: updateData,\n context: { imageOptimizer_skip: true },\n })\n }\n\n return { output: { status: 'complete' } }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : String(err)\n\n try {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'error',\n error: errorMessage,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n } catch (updateErr) {\n req.payload.logger.error(\n { err: updateErr },\n 'Failed to persist error status for image optimizer regeneration',\n )\n }\n\n throw err\n }\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","stripAndResize","generateThumbHash","convertFormat","resolveStaticDir","fetchFileBuffer","isCloudStorage","createRegenerateDocumentHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","mimeType","startsWith","output","status","reason","collectionConfig","collections","config","cloudStorage","fileBuffer","originalSize","length","perCollectionConfig","safeFilename","basename","filename","processed","maxDimensions","stripMetadata","mainBuffer","buffer","mainSize","size","newFilename","newMimeType","replaceOriginal","formats","primaryFormat","converted","format","quality","parse","name","thumbHash","variants","updateData","imageOptimizer","optimizedSize","error","filesize","update","data","file","mimetype","context","imageOptimizer_skip","staticDir","newFilePath","join","writeFile","oldFilePath","unlink","catch","formatsToGenerate","slice","result","variantFilename","push","width","height","url","err","errorMessage","Error","message","String","updateErr","logger"],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Returns true when the collection uses cloud/external storage (disableLocalStorage: true).
3
+ * When true, files are uploaded by external adapter hooks — no local FS writes should happen.
4
+ */
5
+ export declare function isCloudStorage(collectionConfig: {
6
+ upload?: boolean | Record<string, any>;
7
+ }): boolean;
8
+ /**
9
+ * Reads a file buffer from local disk or fetches it from URL.
10
+ * Tries local disk first (when available), falls back to URL fetch.
11
+ * This makes the plugin storage-agnostic — works with local FS and cloud storage alike.
12
+ */
13
+ export declare function fetchFileBuffer(doc: {
14
+ filename?: string;
15
+ url?: string;
16
+ }, collectionConfig: {
17
+ upload?: boolean | Record<string, any>;
18
+ }): Promise<Buffer>;
@@ -0,0 +1,39 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { resolveStaticDir } from './resolveStaticDir.js';
4
+ /**
5
+ * Returns true when the collection uses cloud/external storage (disableLocalStorage: true).
6
+ * When true, files are uploaded by external adapter hooks — no local FS writes should happen.
7
+ */ export function isCloudStorage(collectionConfig) {
8
+ return typeof collectionConfig.upload === 'object' && collectionConfig.upload.disableLocalStorage === true;
9
+ }
10
+ /**
11
+ * Reads a file buffer from local disk or fetches it from URL.
12
+ * Tries local disk first (when available), falls back to URL fetch.
13
+ * This makes the plugin storage-agnostic — works with local FS and cloud storage alike.
14
+ */ export async function fetchFileBuffer(doc, collectionConfig) {
15
+ const safeFilename = doc.filename ? path.basename(doc.filename) : undefined;
16
+ // Try local disk first (only when local storage is enabled)
17
+ if (!isCloudStorage(collectionConfig) && safeFilename) {
18
+ const staticDir = resolveStaticDir(collectionConfig);
19
+ if (staticDir) {
20
+ try {
21
+ return await fs.readFile(path.join(staticDir, safeFilename));
22
+ } catch {
23
+ // Fall through to URL fetch
24
+ }
25
+ }
26
+ }
27
+ // Fetch from URL (works for cloud storage and as fallback for local)
28
+ if (doc.url) {
29
+ const url = doc.url.startsWith('http') ? doc.url : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`;
30
+ const response = await fetch(url);
31
+ if (!response.ok) {
32
+ throw new Error(`Failed to fetch file from ${url}: ${response.status} ${response.statusText}`);
33
+ }
34
+ return Buffer.from(await response.arrayBuffer());
35
+ }
36
+ throw new Error(`Cannot read file: no local path or URL available for "${doc.filename}"`);
37
+ }
38
+
39
+ //# sourceMappingURL=storage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utilities/storage.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport { resolveStaticDir } from './resolveStaticDir.js'\n\n/**\n * Returns true when the collection uses cloud/external storage (disableLocalStorage: true).\n * When true, files are uploaded by external adapter hooks — no local FS writes should happen.\n */\nexport function isCloudStorage(collectionConfig: { upload?: boolean | Record<string, any> }): boolean {\n return typeof collectionConfig.upload === 'object' && collectionConfig.upload.disableLocalStorage === true\n}\n\n/**\n * Reads a file buffer from local disk or fetches it from URL.\n * Tries local disk first (when available), falls back to URL fetch.\n * This makes the plugin storage-agnostic — works with local FS and cloud storage alike.\n */\nexport async function fetchFileBuffer(\n doc: { filename?: string; url?: string },\n collectionConfig: { upload?: boolean | Record<string, any> },\n): Promise<Buffer> {\n const safeFilename = doc.filename ? path.basename(doc.filename) : undefined\n\n // Try local disk first (only when local storage is enabled)\n if (!isCloudStorage(collectionConfig) && safeFilename) {\n const staticDir = resolveStaticDir(collectionConfig)\n if (staticDir) {\n try {\n return await fs.readFile(path.join(staticDir, safeFilename))\n } catch {\n // Fall through to URL fetch\n }\n }\n }\n\n // Fetch from URL (works for cloud storage and as fallback for local)\n if (doc.url) {\n const url = doc.url.startsWith('http')\n ? doc.url\n : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`\n const response = await fetch(url)\n if (!response.ok) {\n throw new Error(`Failed to fetch file from ${url}: ${response.status} ${response.statusText}`)\n }\n return Buffer.from(await response.arrayBuffer())\n }\n\n throw new Error(`Cannot read file: no local path or URL available for \"${doc.filename}\"`)\n}\n"],"names":["fs","path","resolveStaticDir","isCloudStorage","collectionConfig","upload","disableLocalStorage","fetchFileBuffer","doc","safeFilename","filename","basename","undefined","staticDir","readFile","join","url","startsWith","process","env","NEXT_PUBLIC_SERVER_URL","response","fetch","ok","Error","status","statusText","Buffer","from","arrayBuffer"],"mappings":"AAAA,OAAOA,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAEvB,SAASC,gBAAgB,QAAQ,wBAAuB;AAExD;;;CAGC,GACD,OAAO,SAASC,eAAeC,gBAA4D;IACzF,OAAO,OAAOA,iBAAiBC,MAAM,KAAK,YAAYD,iBAAiBC,MAAM,CAACC,mBAAmB,KAAK;AACxG;AAEA;;;;CAIC,GACD,OAAO,eAAeC,gBACpBC,GAAwC,EACxCJ,gBAA4D;IAE5D,MAAMK,eAAeD,IAAIE,QAAQ,GAAGT,KAAKU,QAAQ,CAACH,IAAIE,QAAQ,IAAIE;IAElE,4DAA4D;IAC5D,IAAI,CAACT,eAAeC,qBAAqBK,cAAc;QACrD,MAAMI,YAAYX,iBAAiBE;QACnC,IAAIS,WAAW;YACb,IAAI;gBACF,OAAO,MAAMb,GAAGc,QAAQ,CAACb,KAAKc,IAAI,CAACF,WAAWJ;YAChD,EAAE,OAAM;YACN,4BAA4B;YAC9B;QACF;IACF;IAEA,qEAAqE;IACrE,IAAID,IAAIQ,GAAG,EAAE;QACX,MAAMA,MAAMR,IAAIQ,GAAG,CAACC,UAAU,CAAC,UAC3BT,IAAIQ,GAAG,GACP,GAAGE,QAAQC,GAAG,CAACC,sBAAsB,IAAI,KAAKZ,IAAIQ,GAAG,EAAE;QAC3D,MAAMK,WAAW,MAAMC,MAAMN;QAC7B,IAAI,CAACK,SAASE,EAAE,EAAE;YAChB,MAAM,IAAIC,MAAM,CAAC,0BAA0B,EAAER,IAAI,EAAE,EAAEK,SAASI,MAAM,CAAC,CAAC,EAAEJ,SAASK,UAAU,EAAE;QAC/F;QACA,OAAOC,OAAOC,IAAI,CAAC,MAAMP,SAASQ,WAAW;IAC/C;IAEA,MAAM,IAAIL,MAAM,CAAC,sDAAsD,EAAEhB,IAAIE,QAAQ,CAAC,CAAC,CAAC;AAC1F"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inoo-ch/payload-image-optimizer",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Payload CMS plugin for automatic image optimization — WebP/AVIF conversion, resize, EXIF strip, ThumbHash placeholders, and bulk regeneration",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -5,6 +5,7 @@ import type { CollectionAfterChangeHook } from 'payload'
5
5
  import type { ResolvedImageOptimizerConfig } from '../types.js'
6
6
  import { resolveCollectionConfig } from '../defaults.js'
7
7
  import { resolveStaticDir } from '../utilities/resolveStaticDir.js'
8
+ import { isCloudStorage } from '../utilities/storage.js'
8
9
 
9
10
  export const createAfterChangeHook = (
10
11
  resolvedConfig: ResolvedImageOptimizerConfig,
@@ -16,28 +17,33 @@ export const createAfterChangeHook = (
16
17
  if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return doc
17
18
 
18
19
  const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config
19
- const staticDir = resolveStaticDir(collectionConfig)
20
+ const cloudStorage = isCloudStorage(collectionConfig)
20
21
 
21
- const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)
22
-
23
- // Overwrite the file on disk with the processed (stripped/resized/converted) buffer
24
- // Payload 3.0 writes the original buffer to disk; we replace it here
25
- const processedBuffer = context.imageOptimizer_processedBuffer as Buffer | undefined
26
- if (processedBuffer && doc.filename && staticDir) {
27
- const safeFilename = path.basename(doc.filename as string)
28
- const filePath = path.join(staticDir, safeFilename)
29
- await fs.writeFile(filePath, processedBuffer)
22
+ // When using local storage, overwrite the file on disk with the processed buffer.
23
+ // Payload's uploadFiles step writes the original buffer; we replace it here.
24
+ // When using cloud storage, skip the cloud adapter's afterChange hook already
25
+ // uploads the correct buffer from req.file.data (set in our beforeChange hook).
26
+ if (!cloudStorage) {
27
+ const staticDir = resolveStaticDir(collectionConfig)
28
+ const processedBuffer = context.imageOptimizer_processedBuffer as Buffer | undefined
29
+ if (processedBuffer && doc.filename && staticDir) {
30
+ const safeFilename = path.basename(doc.filename as string)
31
+ const filePath = path.join(staticDir, safeFilename)
32
+ await fs.writeFile(filePath, processedBuffer)
30
33
 
31
- // If replaceOriginal changed the filename, clean up the old file Payload wrote
32
- const originalFilename = context.imageOptimizer_originalFilename as string | undefined
33
- if (originalFilename && originalFilename !== safeFilename) {
34
- const oldFilePath = path.join(staticDir, path.basename(originalFilename))
35
- await fs.unlink(oldFilePath).catch(() => {
36
- // Old file may not exist if Payload used the new filename
37
- })
34
+ // If replaceOriginal changed the filename, clean up the old file Payload wrote
35
+ const originalFilename = context.imageOptimizer_originalFilename as string | undefined
36
+ if (originalFilename && originalFilename !== safeFilename) {
37
+ const oldFilePath = path.join(staticDir, path.basename(originalFilename))
38
+ await fs.unlink(oldFilePath).catch(() => {
39
+ // Old file may not exist if Payload used the new filename
40
+ })
41
+ }
38
42
  }
39
43
  }
40
44
 
45
+ const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)
46
+
41
47
  // When replaceOriginal is on and only one format is configured, the main file
42
48
  // is already converted — skip the async job and mark complete immediately.
43
49
  if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1) {
@@ -55,7 +61,25 @@ export const createAfterChangeHook = (
55
61
  return doc
56
62
  }
57
63
 
58
- // Queue async format conversion job for remaining variants
64
+ // With cloud storage, variant files cannot be written — skip the async job
65
+ // and mark complete. CDN-level image optimization (e.g. Next.js Image) can
66
+ // serve alternative formats on the fly.
67
+ if (cloudStorage) {
68
+ await req.payload.update({
69
+ collection: collectionSlug,
70
+ id: doc.id,
71
+ data: {
72
+ imageOptimizer: {
73
+ status: 'complete',
74
+ variants: [],
75
+ },
76
+ },
77
+ context: { imageOptimizer_skip: true },
78
+ })
79
+ return doc
80
+ }
81
+
82
+ // Queue async format conversion job for remaining variants (local storage only)
59
83
  await req.payload.jobs.queue({
60
84
  task: 'imageOptimizer_convertFormats',
61
85
  input: {
@@ -55,8 +55,17 @@ export const createBeforeChangeHook = (
55
55
  data.imageOptimizer.thumbHash = await generateThumbHash(finalBuffer)
56
56
  }
57
57
 
58
- // Store processed buffer in context for afterChange to write to disk
59
- // (Payload 3.0 does not use modified req.file.data for the disk write)
58
+ // Write processed buffer back to req.file so cloud storage adapters
59
+ // (which read req.file in their afterChange hook) upload the optimized version.
60
+ // Payload's own uploadFiles step does NOT re-read req.file.data for its local
61
+ // disk write, so we also store the buffer in context for our afterChange hook
62
+ // to overwrite the local file when local storage is enabled.
63
+ req.file.data = finalBuffer
64
+ req.file.size = finalSize
65
+ if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {
66
+ req.file.name = data.filename
67
+ req.file.mimetype = data.mimeType
68
+ }
60
69
  context.imageOptimizer_processedBuffer = finalBuffer
61
70
 
62
71
  return data
@@ -7,6 +7,7 @@ import type { ResolvedImageOptimizerConfig } from '../types.js'
7
7
  import { resolveCollectionConfig } from '../defaults.js'
8
8
  import { convertFormat } from '../processing/index.js'
9
9
  import { resolveStaticDir } from '../utilities/resolveStaticDir.js'
10
+ import { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'
10
11
 
11
12
  export const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {
12
13
  return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {
@@ -17,16 +18,31 @@ export const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimiz
17
18
  })
18
19
 
19
20
  const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config
20
- const staticDir = resolveStaticDir(collectionConfig)
21
+ const cloudStorage = isCloudStorage(collectionConfig)
22
+
23
+ // Cloud storage: variant files cannot be uploaded without direct adapter access.
24
+ // Mark as complete — CDN-level image optimization handles format conversion.
25
+ if (cloudStorage) {
26
+ await req.payload.update({
27
+ collection: input.collectionSlug as CollectionSlug,
28
+ id: input.docId,
29
+ data: {
30
+ imageOptimizer: {
31
+ status: 'complete',
32
+ variants: [],
33
+ },
34
+ },
35
+ context: { imageOptimizer_skip: true },
36
+ })
37
+ return { output: { variantsGenerated: 0 } }
38
+ }
21
39
 
40
+ const staticDir = resolveStaticDir(collectionConfig)
22
41
  if (!staticDir) {
23
42
  throw new Error(`No staticDir configured for collection "${input.collectionSlug}"`)
24
43
  }
25
44
 
26
- // Sanitize filename to prevent path traversal
27
- const safeFilename = path.basename(doc.filename)
28
- const filePath = path.join(staticDir, safeFilename)
29
- const fileBuffer = await fs.readFile(filePath)
45
+ const fileBuffer = await fetchFileBuffer(doc, collectionConfig)
30
46
 
31
47
  const variants: Array<{
32
48
  filename: string
@@ -46,6 +62,8 @@ export const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimiz
46
62
  ? perCollectionConfig.formats.slice(1)
47
63
  : perCollectionConfig.formats
48
64
 
65
+ const safeFilename = path.basename(doc.filename)
66
+
49
67
  for (const format of formatsToGenerate) {
50
68
  const result = await convertFormat(fileBuffer, format.format, format.quality)
51
69
  const variantFilename = `${path.parse(safeFilename).name}-optimized.${format.format}`
@@ -7,6 +7,7 @@ import type { ResolvedImageOptimizerConfig } from '../types.js'
7
7
  import { resolveCollectionConfig } from '../defaults.js'
8
8
  import { stripAndResize, generateThumbHash, convertFormat } from '../processing/index.js'
9
9
  import { resolveStaticDir } from '../utilities/resolveStaticDir.js'
10
+ import { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'
10
11
 
11
12
  export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {
12
13
  return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {
@@ -22,34 +23,14 @@ export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOpt
22
23
  }
23
24
 
24
25
  const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config
25
- const staticDir = resolveStaticDir(collectionConfig)
26
+ const cloudStorage = isCloudStorage(collectionConfig)
26
27
 
27
- if (!staticDir) {
28
- throw new Error(`No staticDir configured for collection "${input.collectionSlug}"`)
29
- }
28
+ const fileBuffer = await fetchFileBuffer(doc, collectionConfig)
29
+ const originalSize = fileBuffer.length
30
+ const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)
30
31
 
31
32
  // Sanitize filename to prevent path traversal
32
33
  const safeFilename = path.basename(doc.filename)
33
- const filePath = path.join(staticDir, safeFilename)
34
-
35
- let fileBuffer: Buffer
36
- try {
37
- fileBuffer = await fs.readFile(filePath)
38
- } catch {
39
- // If file not on disk, try fetching from URL
40
- if (doc.url) {
41
- const url = doc.url.startsWith('http')
42
- ? doc.url
43
- : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`
44
- const response = await fetch(url)
45
- fileBuffer = Buffer.from(await response.arrayBuffer())
46
- } else {
47
- throw new Error(`File not found: ${filePath}`)
48
- }
49
- }
50
-
51
- const originalSize = fileBuffer.length
52
- const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)
53
34
 
54
35
  // Step 1: Strip metadata + resize
55
36
  const processed = await stripAndResize(
@@ -73,26 +54,13 @@ export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOpt
73
54
  newMimeType = converted.mimeType
74
55
  }
75
56
 
76
- // Write optimized file to disk
77
- const newFilePath = path.join(staticDir, newFilename)
78
- await fs.writeFile(newFilePath, mainBuffer)
79
-
80
- // Clean up old file if filename changed
81
- if (newFilename !== safeFilename) {
82
- await fs.unlink(filePath).catch(() => {})
83
- }
84
-
85
57
  // Step 2: Generate ThumbHash
86
58
  let thumbHash: string | undefined
87
59
  if (resolvedConfig.generateThumbHash) {
88
60
  thumbHash = await generateThumbHash(mainBuffer)
89
61
  }
90
62
 
91
- // Step 3: Convert to configured formats (skip primary when replaceOriginal)
92
- const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0
93
- ? perCollectionConfig.formats.slice(1)
94
- : perCollectionConfig.formats
95
-
63
+ // Step 3: Store the optimized file
96
64
  const variants: Array<{
97
65
  filename: string
98
66
  filesize: number
@@ -103,47 +71,96 @@ export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOpt
103
71
  width: number
104
72
  }> = []
105
73
 
106
- for (const format of formatsToGenerate) {
107
- const result = await convertFormat(mainBuffer, format.format, format.quality)
108
- const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`
109
- await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)
110
-
111
- variants.push({
112
- format: format.format,
113
- filename: variantFilename,
114
- filesize: result.size,
115
- width: result.width,
116
- height: result.height,
117
- mimeType: result.mimeType,
118
- url: `/api/${input.collectionSlug}/file/${variantFilename}`,
74
+ if (cloudStorage) {
75
+ // Cloud storage: re-upload the optimized file via Payload's update API.
76
+ // This triggers the cloud adapter's afterChange hook which uploads to cloud.
77
+ const updateData: Record<string, any> = {
78
+ imageOptimizer: {
79
+ originalSize,
80
+ optimizedSize: mainSize,
81
+ status: 'complete',
82
+ thumbHash,
83
+ variants: [],
84
+ error: null,
85
+ },
86
+ }
87
+
88
+ if (newFilename !== safeFilename) {
89
+ updateData.filename = newFilename
90
+ updateData.filesize = mainSize
91
+ updateData.mimeType = newMimeType
92
+ }
93
+
94
+ await req.payload.update({
95
+ collection: input.collectionSlug as CollectionSlug,
96
+ id: input.docId,
97
+ data: updateData,
98
+ file: {
99
+ data: mainBuffer,
100
+ mimetype: newMimeType || doc.mimeType,
101
+ name: newFilename,
102
+ size: mainSize,
103
+ },
104
+ context: { imageOptimizer_skip: true },
119
105
  })
120
- }
106
+ } else {
107
+ // Local storage: write files to disk
108
+ const staticDir = resolveStaticDir(collectionConfig)
109
+ const newFilePath = path.join(staticDir, newFilename)
110
+ await fs.writeFile(newFilePath, mainBuffer)
111
+
112
+ // Clean up old file if filename changed
113
+ if (newFilename !== safeFilename) {
114
+ const oldFilePath = path.join(staticDir, safeFilename)
115
+ await fs.unlink(oldFilePath).catch(() => {})
116
+ }
121
117
 
122
- // Step 4: Update the document with all optimization data
123
- const updateData: Record<string, any> = {
124
- imageOptimizer: {
125
- originalSize,
126
- optimizedSize: mainSize,
127
- status: 'complete',
128
- thumbHash,
129
- variants,
130
- error: null,
131
- },
132
- }
118
+ // Generate variant files (local storage only)
119
+ const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0
120
+ ? perCollectionConfig.formats.slice(1)
121
+ : perCollectionConfig.formats
122
+
123
+ for (const format of formatsToGenerate) {
124
+ const result = await convertFormat(mainBuffer, format.format, format.quality)
125
+ const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`
126
+ await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)
127
+
128
+ variants.push({
129
+ format: format.format,
130
+ filename: variantFilename,
131
+ filesize: result.size,
132
+ width: result.width,
133
+ height: result.height,
134
+ mimeType: result.mimeType,
135
+ url: `/api/${input.collectionSlug}/file/${variantFilename}`,
136
+ })
137
+ }
133
138
 
134
- // Update filename, mimeType, and filesize when replaceOriginal changed them
135
- if (newFilename !== safeFilename) {
136
- updateData.filename = newFilename
137
- updateData.filesize = mainSize
138
- updateData.mimeType = newMimeType
139
- }
139
+ // Update the document with optimization data
140
+ const updateData: Record<string, any> = {
141
+ imageOptimizer: {
142
+ originalSize,
143
+ optimizedSize: mainSize,
144
+ status: 'complete',
145
+ thumbHash,
146
+ variants,
147
+ error: null,
148
+ },
149
+ }
140
150
 
141
- await req.payload.update({
142
- collection: input.collectionSlug as CollectionSlug,
143
- id: input.docId,
144
- data: updateData,
145
- context: { imageOptimizer_skip: true },
146
- })
151
+ if (newFilename !== safeFilename) {
152
+ updateData.filename = newFilename
153
+ updateData.filesize = mainSize
154
+ updateData.mimeType = newMimeType
155
+ }
156
+
157
+ await req.payload.update({
158
+ collection: input.collectionSlug as CollectionSlug,
159
+ id: input.docId,
160
+ data: updateData,
161
+ context: { imageOptimizer_skip: true },
162
+ })
163
+ }
147
164
 
148
165
  return { output: { status: 'complete' } }
149
166
  } catch (err) {
@@ -0,0 +1,50 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path'
3
+
4
+ import { resolveStaticDir } from './resolveStaticDir.js'
5
+
6
+ /**
7
+ * Returns true when the collection uses cloud/external storage (disableLocalStorage: true).
8
+ * When true, files are uploaded by external adapter hooks — no local FS writes should happen.
9
+ */
10
+ export function isCloudStorage(collectionConfig: { upload?: boolean | Record<string, any> }): boolean {
11
+ return typeof collectionConfig.upload === 'object' && collectionConfig.upload.disableLocalStorage === true
12
+ }
13
+
14
+ /**
15
+ * Reads a file buffer from local disk or fetches it from URL.
16
+ * Tries local disk first (when available), falls back to URL fetch.
17
+ * This makes the plugin storage-agnostic — works with local FS and cloud storage alike.
18
+ */
19
+ export async function fetchFileBuffer(
20
+ doc: { filename?: string; url?: string },
21
+ collectionConfig: { upload?: boolean | Record<string, any> },
22
+ ): Promise<Buffer> {
23
+ const safeFilename = doc.filename ? path.basename(doc.filename) : undefined
24
+
25
+ // Try local disk first (only when local storage is enabled)
26
+ if (!isCloudStorage(collectionConfig) && safeFilename) {
27
+ const staticDir = resolveStaticDir(collectionConfig)
28
+ if (staticDir) {
29
+ try {
30
+ return await fs.readFile(path.join(staticDir, safeFilename))
31
+ } catch {
32
+ // Fall through to URL fetch
33
+ }
34
+ }
35
+ }
36
+
37
+ // Fetch from URL (works for cloud storage and as fallback for local)
38
+ if (doc.url) {
39
+ const url = doc.url.startsWith('http')
40
+ ? doc.url
41
+ : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`
42
+ const response = await fetch(url)
43
+ if (!response.ok) {
44
+ throw new Error(`Failed to fetch file from ${url}: ${response.status} ${response.statusText}`)
45
+ }
46
+ return Buffer.from(await response.arrayBuffer())
47
+ }
48
+
49
+ throw new Error(`Cannot read file: no local path or URL available for "${doc.filename}"`)
50
+ }