@igstack/app-catalog-backend-core 6.3.0-alpha-20261001192340 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"assetRestController.d.mts","names":[],"sources":["../../../src/modules/assets/assetRestController.ts"],"mappings":";;;UAwBiB,yBAAA;;AAAjB;;EAIE,QAAA;AAAA;;;;;;;;;;iBA0Bc,2BAAA,CACd,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,yBAAA"}
1
+ {"version":3,"file":"assetRestController.d.mts","names":[],"sources":["../../../src/modules/assets/assetRestController.ts"],"mappings":";;;UA8BiB,yBAAA;;AAAjB;;EAIE,QAAA;AAAA;;;;;;;;;;iBA0Bc,2BAAA,CACd,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,yBAAA"}
@@ -1,5 +1,5 @@
1
1
  import { getDbClient } from "../../db/client.mjs";
2
- import { getImageFormat, isRasterImage, resizeImage } from "./assetUtils.mjs";
2
+ import { getImageFormat, isRasterImage, mimeTypeForAsset, resizeImage, stripAssetExtension } from "./assetUtils.mjs";
3
3
  import { upsertAsset } from "./upsertAsset.mjs";
4
4
  import { isNotModified, setRevalidatingCacheHeaders } from "./assetCache.mjs";
5
5
  import sharp from "sharp";
@@ -45,7 +45,7 @@ function registerAssetRestController(router, config) {
45
45
  prisma,
46
46
  buffer: req.file.buffer,
47
47
  name,
48
- originalFilename: req.file.filename,
48
+ originalFilename: req.file.originalname,
49
49
  assetType
50
50
  });
51
51
  res.status(201).json({ id });
@@ -56,7 +56,7 @@ function registerAssetRestController(router, config) {
56
56
  });
57
57
  router.get(`${basePath}/:id`, async (req, res) => {
58
58
  try {
59
- const { id } = req.params;
59
+ const id = stripAssetExtension(req.params["id"] ?? "");
60
60
  const asset = await prisma.dbAsset.findUnique({
61
61
  where: { id },
62
62
  select: {
@@ -76,14 +76,14 @@ function registerAssetRestController(router, config) {
76
76
  const wParam = req.query["w"];
77
77
  const width = wParam ? Number.parseInt(wParam, 10) : void 0;
78
78
  let outBuffer = asset.content;
79
- let outMime = asset.mimeType;
80
- const shouldResize = resizeEnabled && isRasterImage(asset.mimeType) && !!width && Number.isFinite(width) && width > 0;
79
+ let outMime = mimeTypeForAsset(asset.name, asset.mimeType);
80
+ const shouldResize = resizeEnabled && isRasterImage(outMime) && !!width && Number.isFinite(width) && width > 0;
81
81
  if (isNotModified(req, setRevalidatingCacheHeaders(res, asset.checksum, shouldResize ? `w${width}` : void 0))) {
82
82
  res.status(304).end();
83
83
  return;
84
84
  }
85
85
  if (shouldResize) {
86
- const fmt = getImageFormat(asset.mimeType) || "jpeg";
86
+ const fmt = getImageFormat(outMime) || "jpeg";
87
87
  const buf = await resizeImage(Buffer.from(asset.content), width, void 0, fmt);
88
88
  outBuffer = new Uint8Array(buf);
89
89
  outMime = `image/${fmt}`;
@@ -145,15 +145,14 @@ function registerAssetRestController(router, config) {
145
145
  const wParam = req.query["w"];
146
146
  const width = wParam ? Number.parseInt(wParam, 10) : void 0;
147
147
  let outBuffer = asset.content;
148
- let outMime = asset.mimeType;
149
- const isRaster = asset.mimeType.startsWith("image/") && !asset.mimeType.includes("svg");
150
- const shouldResize = resizeEnabled && isRaster && !!width && Number.isFinite(width) && width > 0;
148
+ let outMime = mimeTypeForAsset(asset.name, asset.mimeType);
149
+ const shouldResize = resizeEnabled && isRasterImage(outMime) && !!width && Number.isFinite(width) && width > 0;
151
150
  if (isNotModified(req, setRevalidatingCacheHeaders(res, asset.checksum, shouldResize ? `w${width}` : void 0))) {
152
151
  res.status(304).end();
153
152
  return;
154
153
  }
155
154
  if (shouldResize) {
156
- const fmt = asset.mimeType.includes("png") ? "png" : asset.mimeType.includes("webp") ? "webp" : "jpeg";
155
+ const fmt = getImageFormat(outMime) || "jpeg";
157
156
  let buf;
158
157
  const pipeline = sharp(Buffer.from(asset.content)).resize({
159
158
  width,
@@ -1 +1 @@
1
- {"version":3,"file":"assetRestController.mjs","names":[],"sources":["../../../src/modules/assets/assetRestController.ts"],"sourcesContent":["import type { Request, Response, Router } from 'express'\nimport multer from 'multer'\nimport sharp from 'sharp'\nimport { getDbClient } from '../../db'\nimport { getImageFormat, isRasterImage, resizeImage } from './assetUtils'\nimport { upsertAsset } from './upsertAsset'\nimport { isNotModified, setRevalidatingCacheHeaders } from './assetCache'\n\n// Configure multer for memory storage\nconst upload = multer({\n storage: multer.memoryStorage(),\n limits: {\n fileSize: 10 * 1024 * 1024, // 10MB limit\n },\n fileFilter: (_req, file, cb) => {\n // Accept images only\n if (!file.mimetype.startsWith('image/')) {\n cb(new Error('Only image files are allowed'))\n return\n }\n cb(null, true)\n },\n})\n\nexport interface AssetRestControllerConfig {\n /**\n * Base path for asset endpoints (e.g., '/api/assets')\n */\n basePath: string\n}\n\nexport interface ParseAssetParams {\n buffer: Buffer\n originalFilename: string\n fileSize?: number\n}\n\nexport interface ParseAssetReturn {\n checksum: string\n fileSize: number\n mimeType: string\n width?: number\n height?: number\n}\n\n/**\n * Registers REST endpoints for universal asset upload and retrieval\n *\n * Endpoints:\n * - POST {basePath}/upload - Upload a new asset (multipart/form-data)\n * - GET {basePath}/:id - Get asset binary by ID\n * - GET {basePath}/:id/metadata - Get asset metadata only\n * - GET {basePath}/by-name/:name - Get asset binary by name\n */\nexport function registerAssetRestController(\n router: Router,\n config: AssetRestControllerConfig,\n): void {\n const { basePath } = config\n const prisma = getDbClient()\n\n // Upload endpoint - accepts multipart/form-data\n router.post(\n `${basePath}/upload`,\n upload.single('asset'),\n async (req: Request, res: Response) => {\n try {\n if (!req.file) {\n res.status(400).json({ error: 'No file uploaded' })\n return\n }\n\n const name = req.body['name'] as string\n const assetType = req.body['assetType']\n\n if (!name) {\n res.status(400).json({ error: 'Name is required' })\n return\n }\n\n const id = await upsertAsset({\n prisma,\n buffer: req.file.buffer,\n name,\n originalFilename: req.file.filename,\n assetType,\n })\n\n res.status(201).json({ id })\n } catch (error) {\n console.error('Error uploading asset:', error)\n res.status(500).json({ error: 'Failed to upload asset' })\n }\n },\n )\n\n // Get asset binary by ID\n router.get(`${basePath}/:id`, async (req: Request, res: Response) => {\n try {\n const { id } = req.params\n\n const asset = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n content: true,\n mimeType: true,\n name: true,\n width: true,\n height: true,\n checksum: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n const resizeEnabled =\n String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'\n const wParam = req.query['w'] as string | undefined\n const width = wParam ? Number.parseInt(wParam, 10) : undefined\n\n let outBuffer: Uint8Array = asset.content\n let outMime = asset.mimeType\n\n const shouldResize =\n resizeEnabled &&\n isRasterImage(asset.mimeType) &&\n !!width &&\n Number.isFinite(width) &&\n width > 0\n\n // Answered before resizing, so an unchanged asset costs no image work.\n const etag = setRevalidatingCacheHeaders(\n res,\n asset.checksum,\n shouldResize ? `w${width}` : undefined,\n )\n if (isNotModified(req, etag)) {\n res.status(304).end()\n return\n }\n\n if (shouldResize) {\n const fmt = getImageFormat(asset.mimeType) || 'jpeg'\n const buf = await resizeImage(\n Buffer.from(asset.content),\n width,\n undefined,\n fmt,\n )\n outBuffer = new Uint8Array(buf)\n outMime = `image/${fmt}`\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', outMime)\n res.setHeader('Content-Disposition', `inline; filename=\"${asset.name}\"`)\n\n // Send binary content (resized if requested)\n res.send(outBuffer)\n } catch (error) {\n console.error('Error fetching asset:', error)\n res.status(500).json({ error: 'Failed to fetch asset' })\n }\n })\n\n // Get asset metadata only (no binary content)\n router.get(\n `${basePath}/:id/metadata`,\n async (req: Request, res: Response) => {\n try {\n const { id } = req.params\n\n const asset = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n id: true,\n name: true,\n assetType: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n updatedAt: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n res.json(asset)\n } catch (error) {\n console.error('Error fetching asset metadata:', error)\n res.status(500).json({ error: 'Failed to fetch asset metadata' })\n }\n },\n )\n\n // Get asset binary by name\n router.get(\n `${basePath}/by-name/:name`,\n async (req: Request, res: Response) => {\n try {\n const { name } = req.params\n\n const asset = await prisma.dbAsset.findUnique({\n where: { name },\n select: {\n content: true,\n mimeType: true,\n name: true,\n width: true,\n height: true,\n checksum: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n const resizeEnabled =\n String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'\n const wParam = req.query['w'] as string | undefined\n const width = wParam ? Number.parseInt(wParam, 10) : undefined\n\n let outBuffer: Uint8Array = asset.content\n let outMime = asset.mimeType\n\n const isRaster =\n asset.mimeType.startsWith('image/') && !asset.mimeType.includes('svg')\n const shouldResize =\n resizeEnabled &&\n isRaster &&\n !!width &&\n Number.isFinite(width) &&\n width > 0\n\n // Answered before resizing, so an unchanged asset costs no image work.\n const etag = setRevalidatingCacheHeaders(\n res,\n asset.checksum,\n shouldResize ? `w${width}` : undefined,\n )\n if (isNotModified(req, etag)) {\n res.status(304).end()\n return\n }\n\n if (shouldResize) {\n const fmt = asset.mimeType.includes('png')\n ? 'png'\n : asset.mimeType.includes('webp')\n ? 'webp'\n : 'jpeg'\n\n let buf: Buffer\n const pipeline = sharp(Buffer.from(asset.content)).resize({\n width,\n fit: 'inside',\n withoutEnlargement: true,\n })\n if (fmt === 'png') {\n buf = await pipeline.png().toBuffer()\n outMime = 'image/png'\n } else if (fmt === 'webp') {\n buf = await pipeline.webp().toBuffer()\n outMime = 'image/webp'\n } else {\n buf = await pipeline.jpeg().toBuffer()\n outMime = 'image/jpeg'\n }\n outBuffer = new Uint8Array(buf)\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', outMime)\n res.setHeader('Content-Disposition', `inline; filename=\"${asset.name}\"`)\n\n // Send binary content (resized if requested)\n res.send(outBuffer)\n } catch (error) {\n console.error('Error fetching asset by name:', error)\n res.status(500).json({ error: 'Failed to fetch asset' })\n }\n },\n )\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"assetRestController.mjs","names":[],"sources":["../../../src/modules/assets/assetRestController.ts"],"sourcesContent":["import type { Request, Response, Router } from 'express'\nimport multer from 'multer'\nimport sharp from 'sharp'\nimport { getDbClient } from '../../db'\nimport {\n getImageFormat,\n isRasterImage,\n mimeTypeForAsset,\n resizeImage,\n stripAssetExtension,\n} from './assetUtils'\nimport { upsertAsset } from './upsertAsset'\nimport { isNotModified, setRevalidatingCacheHeaders } from './assetCache'\n\n// Configure multer for memory storage\nconst upload = multer({\n storage: multer.memoryStorage(),\n limits: {\n fileSize: 10 * 1024 * 1024, // 10MB limit\n },\n fileFilter: (_req, file, cb) => {\n // Accept images only\n if (!file.mimetype.startsWith('image/')) {\n cb(new Error('Only image files are allowed'))\n return\n }\n cb(null, true)\n },\n})\n\nexport interface AssetRestControllerConfig {\n /**\n * Base path for asset endpoints (e.g., '/api/assets')\n */\n basePath: string\n}\n\nexport interface ParseAssetParams {\n buffer: Buffer\n originalFilename: string\n fileSize?: number\n}\n\nexport interface ParseAssetReturn {\n checksum: string\n fileSize: number\n mimeType: string\n width?: number\n height?: number\n}\n\n/**\n * Registers REST endpoints for universal asset upload and retrieval\n *\n * Endpoints:\n * - POST {basePath}/upload - Upload a new asset (multipart/form-data)\n * - GET {basePath}/:id - Get asset binary by ID\n * - GET {basePath}/:id/metadata - Get asset metadata only\n * - GET {basePath}/by-name/:name - Get asset binary by name\n */\nexport function registerAssetRestController(\n router: Router,\n config: AssetRestControllerConfig,\n): void {\n const { basePath } = config\n const prisma = getDbClient()\n\n // Upload endpoint - accepts multipart/form-data\n router.post(\n `${basePath}/upload`,\n upload.single('asset'),\n async (req: Request, res: Response) => {\n try {\n if (!req.file) {\n res.status(400).json({ error: 'No file uploaded' })\n return\n }\n\n const name = req.body['name'] as string\n const assetType = req.body['assetType']\n\n if (!name) {\n res.status(400).json({ error: 'Name is required' })\n return\n }\n\n const id = await upsertAsset({\n prisma,\n buffer: req.file.buffer,\n name,\n // `filename` is only set by multer's disk storage; in memory the\n // uploaded name lives in `originalname`, and it is what decides the\n // stored content type.\n originalFilename: req.file.originalname,\n assetType,\n })\n\n res.status(201).json({ id })\n } catch (error) {\n console.error('Error uploading asset:', error)\n res.status(500).json({ error: 'Failed to upload asset' })\n }\n },\n )\n\n // Get asset binary by ID\n router.get(`${basePath}/:id`, async (req: Request, res: Response) => {\n try {\n const id = stripAssetExtension(req.params['id'] ?? '')\n\n const asset = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n content: true,\n mimeType: true,\n name: true,\n width: true,\n height: true,\n checksum: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n const resizeEnabled =\n String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'\n const wParam = req.query['w'] as string | undefined\n const width = wParam ? Number.parseInt(wParam, 10) : undefined\n\n let outBuffer: Uint8Array = asset.content\n let outMime = mimeTypeForAsset(asset.name, asset.mimeType)\n\n const shouldResize =\n resizeEnabled &&\n isRasterImage(outMime) &&\n !!width &&\n Number.isFinite(width) &&\n width > 0\n\n // Answered before resizing, so an unchanged asset costs no image work.\n const etag = setRevalidatingCacheHeaders(\n res,\n asset.checksum,\n shouldResize ? `w${width}` : undefined,\n )\n if (isNotModified(req, etag)) {\n res.status(304).end()\n return\n }\n\n if (shouldResize) {\n const fmt = getImageFormat(outMime) || 'jpeg'\n const buf = await resizeImage(\n Buffer.from(asset.content),\n width,\n undefined,\n fmt,\n )\n outBuffer = new Uint8Array(buf)\n outMime = `image/${fmt}`\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', outMime)\n res.setHeader('Content-Disposition', `inline; filename=\"${asset.name}\"`)\n\n // Send binary content (resized if requested)\n res.send(outBuffer)\n } catch (error) {\n console.error('Error fetching asset:', error)\n res.status(500).json({ error: 'Failed to fetch asset' })\n }\n })\n\n // Get asset metadata only (no binary content)\n router.get(\n `${basePath}/:id/metadata`,\n async (req: Request, res: Response) => {\n try {\n const { id } = req.params\n\n const asset = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n id: true,\n name: true,\n assetType: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n updatedAt: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n res.json(asset)\n } catch (error) {\n console.error('Error fetching asset metadata:', error)\n res.status(500).json({ error: 'Failed to fetch asset metadata' })\n }\n },\n )\n\n // Get asset binary by name\n router.get(\n `${basePath}/by-name/:name`,\n async (req: Request, res: Response) => {\n try {\n const { name } = req.params\n\n const asset = await prisma.dbAsset.findUnique({\n where: { name },\n select: {\n content: true,\n mimeType: true,\n name: true,\n width: true,\n height: true,\n checksum: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n const resizeEnabled =\n String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'\n const wParam = req.query['w'] as string | undefined\n const width = wParam ? Number.parseInt(wParam, 10) : undefined\n\n let outBuffer: Uint8Array = asset.content\n let outMime = mimeTypeForAsset(asset.name, asset.mimeType)\n\n const shouldResize =\n resizeEnabled &&\n isRasterImage(outMime) &&\n !!width &&\n Number.isFinite(width) &&\n width > 0\n\n // Answered before resizing, so an unchanged asset costs no image work.\n const etag = setRevalidatingCacheHeaders(\n res,\n asset.checksum,\n shouldResize ? `w${width}` : undefined,\n )\n if (isNotModified(req, etag)) {\n res.status(304).end()\n return\n }\n\n if (shouldResize) {\n const fmt = getImageFormat(outMime) || 'jpeg'\n\n let buf: Buffer\n const pipeline = sharp(Buffer.from(asset.content)).resize({\n width,\n fit: 'inside',\n withoutEnlargement: true,\n })\n if (fmt === 'png') {\n buf = await pipeline.png().toBuffer()\n outMime = 'image/png'\n } else if (fmt === 'webp') {\n buf = await pipeline.webp().toBuffer()\n outMime = 'image/webp'\n } else {\n buf = await pipeline.jpeg().toBuffer()\n outMime = 'image/jpeg'\n }\n outBuffer = new Uint8Array(buf)\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', outMime)\n res.setHeader('Content-Disposition', `inline; filename=\"${asset.name}\"`)\n\n // Send binary content (resized if requested)\n res.send(outBuffer)\n } catch (error) {\n console.error('Error fetching asset by name:', error)\n res.status(500).json({ error: 'Failed to fetch asset' })\n }\n },\n )\n}\n"],"mappings":";;;;;;;;AAeA,MAAM,SAAS,OAAO;CACpB,SAAS,OAAO,eAAe;CAC/B,QAAQ,EACN,UAAU,KAAK,OAAO,MACvB;CACD,aAAa,MAAM,MAAM,OAAO;AAE9B,MAAI,CAAC,KAAK,SAAS,WAAW,SAAS,EAAE;AACvC,sBAAG,IAAI,MAAM,+BAA+B,CAAC;AAC7C;;AAEF,KAAG,MAAM,KAAK;;CAEjB,CAAC;;;;;;;;;;AAgCF,SAAgB,4BACd,QACA,QACM;CACN,MAAM,EAAE,aAAa;CACrB,MAAM,SAAS,aAAa;AAG5B,QAAO,KACL,GAAG,SAAS,UACZ,OAAO,OAAO,QAAQ,EACtB,OAAO,KAAc,QAAkB;AACrC,MAAI;AACF,OAAI,CAAC,IAAI,MAAM;AACb,QAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,oBAAoB,CAAC;AACnD;;GAGF,MAAM,OAAO,IAAI,KAAK;GACtB,MAAM,YAAY,IAAI,KAAK;AAE3B,OAAI,CAAC,MAAM;AACT,QAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,oBAAoB,CAAC;AACnD;;GAGF,MAAM,KAAK,MAAM,YAAY;IAC3B;IACA,QAAQ,IAAI,KAAK;IACjB;IAIA,kBAAkB,IAAI,KAAK;IAC3B;IACD,CAAC;AAEF,OAAI,OAAO,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC;WACrB,OAAO;AACd,WAAQ,MAAM,0BAA0B,MAAM;AAC9C,OAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,0BAA0B,CAAC;;GAG9D;AAGD,QAAO,IAAI,GAAG,SAAS,OAAO,OAAO,KAAc,QAAkB;AACnE,MAAI;GACF,MAAM,KAAK,oBAAoB,IAAI,OAAO,SAAS,GAAG;GAEtD,MAAM,QAAQ,MAAM,OAAO,QAAQ,WAAW;IAC5C,OAAO,EAAE,IAAI;IACb,QAAQ;KACN,SAAS;KACT,UAAU;KACV,MAAM;KACN,OAAO;KACP,QAAQ;KACR,UAAU;KACX;IACF,CAAC;AAEF,OAAI,CAAC,OAAO;AACV,QAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,mBAAmB,CAAC;AAClD;;GAGF,MAAM,gBACJ,OAAO,QAAQ,IAAI,4BAA4B,OAAO,KAAK;GAC7D,MAAM,SAAS,IAAI,MAAM;GACzB,MAAM,QAAQ,SAAS,OAAO,SAAS,QAAQ,GAAG,GAAG;GAErD,IAAI,YAAwB,MAAM;GAClC,IAAI,UAAU,iBAAiB,MAAM,MAAM,MAAM,SAAS;GAE1D,MAAM,eACJ,iBACA,cAAc,QAAQ,IACtB,CAAC,CAAC,SACF,OAAO,SAAS,MAAM,IACtB,QAAQ;AAQV,OAAI,cAAc,KALL,4BACX,KACA,MAAM,UACN,eAAe,IAAI,UAAU,OAC9B,CAC2B,EAAE;AAC5B,QAAI,OAAO,IAAI,CAAC,KAAK;AACrB;;AAGF,OAAI,cAAc;IAChB,MAAM,MAAM,eAAe,QAAQ,IAAI;IACvC,MAAM,MAAM,MAAM,YAChB,OAAO,KAAK,MAAM,QAAQ,EAC1B,OACA,QACA,IACD;AACD,gBAAY,IAAI,WAAW,IAAI;AAC/B,cAAU,SAAS;;AAIrB,OAAI,UAAU,gBAAgB,QAAQ;AACtC,OAAI,UAAU,uBAAuB,qBAAqB,MAAM,KAAK,GAAG;AAGxE,OAAI,KAAK,UAAU;WACZ,OAAO;AACd,WAAQ,MAAM,yBAAyB,MAAM;AAC7C,OAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,yBAAyB,CAAC;;GAE1D;AAGF,QAAO,IACL,GAAG,SAAS,gBACZ,OAAO,KAAc,QAAkB;AACrC,MAAI;GACF,MAAM,EAAE,OAAO,IAAI;GAEnB,MAAM,QAAQ,MAAM,OAAO,QAAQ,WAAW;IAC5C,OAAO,EAAE,IAAI;IACb,QAAQ;KACN,IAAI;KACJ,MAAM;KACN,WAAW;KACX,UAAU;KACV,UAAU;KACV,OAAO;KACP,QAAQ;KACR,WAAW;KACX,WAAW;KACZ;IACF,CAAC;AAEF,OAAI,CAAC,OAAO;AACV,QAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,mBAAmB,CAAC;AAClD;;AAGF,OAAI,KAAK,MAAM;WACR,OAAO;AACd,WAAQ,MAAM,kCAAkC,MAAM;AACtD,OAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,kCAAkC,CAAC;;GAGtE;AAGD,QAAO,IACL,GAAG,SAAS,iBACZ,OAAO,KAAc,QAAkB;AACrC,MAAI;GACF,MAAM,EAAE,SAAS,IAAI;GAErB,MAAM,QAAQ,MAAM,OAAO,QAAQ,WAAW;IAC5C,OAAO,EAAE,MAAM;IACf,QAAQ;KACN,SAAS;KACT,UAAU;KACV,MAAM;KACN,OAAO;KACP,QAAQ;KACR,UAAU;KACX;IACF,CAAC;AAEF,OAAI,CAAC,OAAO;AACV,QAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,mBAAmB,CAAC;AAClD;;GAGF,MAAM,gBACJ,OAAO,QAAQ,IAAI,4BAA4B,OAAO,KAAK;GAC7D,MAAM,SAAS,IAAI,MAAM;GACzB,MAAM,QAAQ,SAAS,OAAO,SAAS,QAAQ,GAAG,GAAG;GAErD,IAAI,YAAwB,MAAM;GAClC,IAAI,UAAU,iBAAiB,MAAM,MAAM,MAAM,SAAS;GAE1D,MAAM,eACJ,iBACA,cAAc,QAAQ,IACtB,CAAC,CAAC,SACF,OAAO,SAAS,MAAM,IACtB,QAAQ;AAQV,OAAI,cAAc,KALL,4BACX,KACA,MAAM,UACN,eAAe,IAAI,UAAU,OAC9B,CAC2B,EAAE;AAC5B,QAAI,OAAO,IAAI,CAAC,KAAK;AACrB;;AAGF,OAAI,cAAc;IAChB,MAAM,MAAM,eAAe,QAAQ,IAAI;IAEvC,IAAI;IACJ,MAAM,WAAW,MAAM,OAAO,KAAK,MAAM,QAAQ,CAAC,CAAC,OAAO;KACxD;KACA,KAAK;KACL,oBAAoB;KACrB,CAAC;AACF,QAAI,QAAQ,OAAO;AACjB,WAAM,MAAM,SAAS,KAAK,CAAC,UAAU;AACrC,eAAU;eACD,QAAQ,QAAQ;AACzB,WAAM,MAAM,SAAS,MAAM,CAAC,UAAU;AACtC,eAAU;WACL;AACL,WAAM,MAAM,SAAS,MAAM,CAAC,UAAU;AACtC,eAAU;;AAEZ,gBAAY,IAAI,WAAW,IAAI;;AAIjC,OAAI,UAAU,gBAAgB,QAAQ;AACtC,OAAI,UAAU,uBAAuB,qBAAqB,MAAM,KAAK,GAAG;AAGxE,OAAI,KAAK,UAAU;WACZ,OAAO;AACd,WAAQ,MAAM,iCAAiC,MAAM;AACrD,OAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,yBAAyB,CAAC;;GAG7D"}
@@ -1,3 +1,4 @@
1
+ import { getExtensionFromFilename, getMimeTypeFromExtension, isKnownImageMimeType } from "../icons/iconUtils.mjs";
1
2
  import { createHash } from "node:crypto";
2
3
  import sharp from "sharp";
3
4
 
@@ -61,13 +62,36 @@ function getImageFormat(mimeType) {
61
62
  function isRasterImage(mimeType) {
62
63
  return mimeType.startsWith("image/") && !mimeType.includes("svg");
63
64
  }
65
+ /**
66
+ * The content type an asset is served (and stored) with.
67
+ *
68
+ * The filename's extension decides, because it is the one piece of the asset a
69
+ * human chose and a reader can see. `sharp`'s detected format is only a fallback
70
+ * for a file that arrived without a usable extension, and an unrecognised value
71
+ * degrades to `application/octet-stream` rather than being turned into an
72
+ * `image/<whatever-the-library-called-it>` type that no browser accepts.
73
+ */
74
+ function mimeTypeForAsset(filename, fallback) {
75
+ const byExtension = getMimeTypeFromExtension(getExtensionFromFilename(filename ?? ""));
76
+ if (byExtension !== "application/octet-stream") return byExtension;
77
+ if (fallback && isKnownImageMimeType(fallback)) return fallback.toLowerCase();
78
+ return "application/octet-stream";
79
+ }
80
+ /**
81
+ * `<id>.png` -> `<id>`. Asset URLs may carry the file extension so that a
82
+ * browser, a CDN and "save image as" all see the real file type; the stored id
83
+ * itself never contains a dot, so stripping one is unambiguous.
84
+ */
85
+ function stripAssetExtension(id) {
86
+ return id.replace(/\.[A-Za-z0-9]+$/, "");
87
+ }
64
88
  async function parseAssetMeta(p) {
65
89
  const { width, height, format, size } = await sharp(p.buffer).metadata();
66
90
  return {
67
91
  checksum: generateChecksum(p.buffer),
68
92
  width,
69
93
  height,
70
- mimeType: format ? {
94
+ mimeType: mimeTypeForAsset(p.originalFilename, format ? {
71
95
  jpeg: "image/jpeg",
72
96
  jpg: "image/jpeg",
73
97
  png: "image/png",
@@ -76,13 +100,12 @@ async function parseAssetMeta(p) {
76
100
  tiff: "image/tiff",
77
101
  gif: "image/gif",
78
102
  heif: "image/heif",
79
- svg: "image/svg+xml",
80
- raw: "application/octet-stream"
81
- }[format] ?? `image/${format}` : "application/octet-stream",
103
+ svg: "image/svg+xml"
104
+ }[format] : void 0),
82
105
  fileSize: size || 0
83
106
  };
84
107
  }
85
108
 
86
109
  //#endregion
87
- export { generateChecksum, getImageDimensions, getImageFormat, isRasterImage, parseAssetMeta, resizeImage };
110
+ export { generateChecksum, getImageDimensions, getImageFormat, isRasterImage, mimeTypeForAsset, parseAssetMeta, resizeImage, stripAssetExtension };
88
111
  //# sourceMappingURL=assetUtils.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"assetUtils.mjs","names":[],"sources":["../../../src/modules/assets/assetUtils.ts"],"sourcesContent":["import { createHash } from 'node:crypto'\nimport type { FormatEnum } from 'sharp'\nimport sharp from 'sharp'\nimport type { ParseAssetParams, ParseAssetReturn } from './assetRestController'\n\n/**\n * Extract image dimensions from a buffer using sharp\n */\nexport async function getImageDimensions(\n buffer: Buffer,\n): Promise<{ width?: number; height?: number }> {\n try {\n const metadata = await sharp(buffer).metadata()\n return {\n width: metadata.width,\n height: metadata.height,\n }\n } catch (error) {\n console.error('Error extracting image dimensions:', error)\n return { width: undefined, height: undefined }\n }\n}\n\n/**\n * Resize an image buffer to the specified dimensions\n * @param buffer - The image buffer to resize\n * @param width - Target width (optional)\n * @param height - Target height (optional)\n * @param format - Output format ('png', 'jpeg', 'webp'), auto-detected if not provided\n */\nexport async function resizeImage(\n buffer: Buffer,\n width?: number,\n height?: number,\n format?: 'png' | 'jpeg' | 'webp',\n): Promise<Buffer> {\n let pipeline = sharp(buffer)\n\n // Apply resize if dimensions provided\n if (width || height) {\n pipeline = pipeline.resize({\n width,\n height,\n fit: 'inside',\n withoutEnlargement: true,\n })\n }\n\n // Apply format conversion if specified\n if (format === 'png') {\n pipeline = pipeline.png()\n } else if (format === 'webp') {\n pipeline = pipeline.webp()\n } else if (format === 'jpeg') {\n pipeline = pipeline.jpeg()\n }\n\n return pipeline.toBuffer()\n}\n\n/**\n * Generate SHA-256 checksum for a buffer\n */\nexport function generateChecksum(buffer: Buffer): string {\n return createHash('sha256').update(buffer).digest('hex')\n}\n\n/**\n * Detect image format from mime type\n */\nexport function getImageFormat(\n mimeType: string,\n): 'png' | 'webp' | 'jpeg' | null {\n if (mimeType.includes('png')) return 'png'\n if (mimeType.includes('webp')) return 'webp'\n if (mimeType.includes('jpeg') || mimeType.includes('jpg')) return 'jpeg'\n return null\n}\n\n/**\n * Check if a mime type represents a raster image (not SVG)\n */\nexport function isRasterImage(mimeType: string): boolean {\n return mimeType.startsWith('image/') && !mimeType.includes('svg')\n}\n\nexport async function parseAssetMeta(\n p: ParseAssetParams,\n): Promise<ParseAssetReturn> {\n // Get image dimensions using our utility\n const { width, height, format, size } = await sharp(p.buffer).metadata()\n\n const formatToMime: Partial<Record<keyof FormatEnum, string>> = {\n jpeg: 'image/jpeg',\n jpg: 'image/jpeg',\n png: 'image/png',\n webp: 'image/webp',\n avif: 'image/avif',\n tiff: 'image/tiff',\n gif: 'image/gif',\n heif: 'image/heif',\n svg: 'image/svg+xml',\n raw: 'application/octet-stream',\n }\n\n return {\n checksum: generateChecksum(p.buffer),\n width,\n height,\n mimeType: format\n ? (formatToMime[format] ?? `image/${format}`)\n : 'application/octet-stream',\n fileSize: size || 0,\n }\n}\n"],"mappings":";;;;;;;AAQA,eAAsB,mBACpB,QAC8C;AAC9C,KAAI;EACF,MAAM,WAAW,MAAM,MAAM,OAAO,CAAC,UAAU;AAC/C,SAAO;GACL,OAAO,SAAS;GAChB,QAAQ,SAAS;GAClB;UACM,OAAO;AACd,UAAQ,MAAM,sCAAsC,MAAM;AAC1D,SAAO;GAAE,OAAO;GAAW,QAAQ;GAAW;;;;;;;;;;AAWlD,eAAsB,YACpB,QACA,OACA,QACA,QACiB;CACjB,IAAI,WAAW,MAAM,OAAO;AAG5B,KAAI,SAAS,OACX,YAAW,SAAS,OAAO;EACzB;EACA;EACA,KAAK;EACL,oBAAoB;EACrB,CAAC;AAIJ,KAAI,WAAW,MACb,YAAW,SAAS,KAAK;UAChB,WAAW,OACpB,YAAW,SAAS,MAAM;UACjB,WAAW,OACpB,YAAW,SAAS,MAAM;AAG5B,QAAO,SAAS,UAAU;;;;;AAM5B,SAAgB,iBAAiB,QAAwB;AACvD,QAAO,WAAW,SAAS,CAAC,OAAO,OAAO,CAAC,OAAO,MAAM;;;;;AAM1D,SAAgB,eACd,UACgC;AAChC,KAAI,SAAS,SAAS,MAAM,CAAE,QAAO;AACrC,KAAI,SAAS,SAAS,OAAO,CAAE,QAAO;AACtC,KAAI,SAAS,SAAS,OAAO,IAAI,SAAS,SAAS,MAAM,CAAE,QAAO;AAClE,QAAO;;;;;AAMT,SAAgB,cAAc,UAA2B;AACvD,QAAO,SAAS,WAAW,SAAS,IAAI,CAAC,SAAS,SAAS,MAAM;;AAGnE,eAAsB,eACpB,GAC2B;CAE3B,MAAM,EAAE,OAAO,QAAQ,QAAQ,SAAS,MAAM,MAAM,EAAE,OAAO,CAAC,UAAU;AAexE,QAAO;EACL,UAAU,iBAAiB,EAAE,OAAO;EACpC;EACA;EACA,UAAU,SAjBoD;GAC9D,MAAM;GACN,KAAK;GACL,KAAK;GACL,MAAM;GACN,MAAM;GACN,MAAM;GACN,KAAK;GACL,MAAM;GACN,KAAK;GACL,KAAK;GACN,CAOmB,WAAW,SAAS,WAClC;EACJ,UAAU,QAAQ;EACnB"}
1
+ {"version":3,"file":"assetUtils.mjs","names":[],"sources":["../../../src/modules/assets/assetUtils.ts"],"sourcesContent":["import { createHash } from 'node:crypto'\nimport type { FormatEnum } from 'sharp'\nimport sharp from 'sharp'\nimport {\n getExtensionFromFilename,\n getMimeTypeFromExtension,\n isKnownImageMimeType,\n} from '../icons/iconUtils'\nimport type { ParseAssetParams, ParseAssetReturn } from './assetRestController'\n\n/**\n * Extract image dimensions from a buffer using sharp\n */\nexport async function getImageDimensions(\n buffer: Buffer,\n): Promise<{ width?: number; height?: number }> {\n try {\n const metadata = await sharp(buffer).metadata()\n return {\n width: metadata.width,\n height: metadata.height,\n }\n } catch (error) {\n console.error('Error extracting image dimensions:', error)\n return { width: undefined, height: undefined }\n }\n}\n\n/**\n * Resize an image buffer to the specified dimensions\n * @param buffer - The image buffer to resize\n * @param width - Target width (optional)\n * @param height - Target height (optional)\n * @param format - Output format ('png', 'jpeg', 'webp'), auto-detected if not provided\n */\nexport async function resizeImage(\n buffer: Buffer,\n width?: number,\n height?: number,\n format?: 'png' | 'jpeg' | 'webp',\n): Promise<Buffer> {\n let pipeline = sharp(buffer)\n\n // Apply resize if dimensions provided\n if (width || height) {\n pipeline = pipeline.resize({\n width,\n height,\n fit: 'inside',\n withoutEnlargement: true,\n })\n }\n\n // Apply format conversion if specified\n if (format === 'png') {\n pipeline = pipeline.png()\n } else if (format === 'webp') {\n pipeline = pipeline.webp()\n } else if (format === 'jpeg') {\n pipeline = pipeline.jpeg()\n }\n\n return pipeline.toBuffer()\n}\n\n/**\n * Generate SHA-256 checksum for a buffer\n */\nexport function generateChecksum(buffer: Buffer): string {\n return createHash('sha256').update(buffer).digest('hex')\n}\n\n/**\n * Detect image format from mime type\n */\nexport function getImageFormat(\n mimeType: string,\n): 'png' | 'webp' | 'jpeg' | null {\n if (mimeType.includes('png')) return 'png'\n if (mimeType.includes('webp')) return 'webp'\n if (mimeType.includes('jpeg') || mimeType.includes('jpg')) return 'jpeg'\n return null\n}\n\n/**\n * Check if a mime type represents a raster image (not SVG)\n */\nexport function isRasterImage(mimeType: string): boolean {\n return mimeType.startsWith('image/') && !mimeType.includes('svg')\n}\n\n/**\n * The content type an asset is served (and stored) with.\n *\n * The filename's extension decides, because it is the one piece of the asset a\n * human chose and a reader can see. `sharp`'s detected format is only a fallback\n * for a file that arrived without a usable extension, and an unrecognised value\n * degrades to `application/octet-stream` rather than being turned into an\n * `image/<whatever-the-library-called-it>` type that no browser accepts.\n */\nexport function mimeTypeForAsset(\n filename: string | undefined,\n fallback?: string,\n): string {\n const byExtension = getMimeTypeFromExtension(\n getExtensionFromFilename(filename ?? ''),\n )\n if (byExtension !== 'application/octet-stream') return byExtension\n if (fallback && isKnownImageMimeType(fallback)) return fallback.toLowerCase()\n return 'application/octet-stream'\n}\n\n/**\n * `<id>.png` -> `<id>`. Asset URLs may carry the file extension so that a\n * browser, a CDN and \"save image as\" all see the real file type; the stored id\n * itself never contains a dot, so stripping one is unambiguous.\n */\nexport function stripAssetExtension(id: string): string {\n return id.replace(/\\.[A-Za-z0-9]+$/, '')\n}\n\nexport async function parseAssetMeta(\n p: ParseAssetParams,\n): Promise<ParseAssetReturn> {\n // Get image dimensions using our utility\n const { width, height, format, size } = await sharp(p.buffer).metadata()\n\n const formatToMime: Partial<Record<keyof FormatEnum, string>> = {\n jpeg: 'image/jpeg',\n jpg: 'image/jpeg',\n png: 'image/png',\n webp: 'image/webp',\n avif: 'image/avif',\n tiff: 'image/tiff',\n gif: 'image/gif',\n heif: 'image/heif',\n svg: 'image/svg+xml',\n }\n\n return {\n checksum: generateChecksum(p.buffer),\n width,\n height,\n mimeType: mimeTypeForAsset(\n p.originalFilename,\n format ? formatToMime[format] : undefined,\n ),\n fileSize: size || 0,\n }\n}\n"],"mappings":";;;;;;;;AAaA,eAAsB,mBACpB,QAC8C;AAC9C,KAAI;EACF,MAAM,WAAW,MAAM,MAAM,OAAO,CAAC,UAAU;AAC/C,SAAO;GACL,OAAO,SAAS;GAChB,QAAQ,SAAS;GAClB;UACM,OAAO;AACd,UAAQ,MAAM,sCAAsC,MAAM;AAC1D,SAAO;GAAE,OAAO;GAAW,QAAQ;GAAW;;;;;;;;;;AAWlD,eAAsB,YACpB,QACA,OACA,QACA,QACiB;CACjB,IAAI,WAAW,MAAM,OAAO;AAG5B,KAAI,SAAS,OACX,YAAW,SAAS,OAAO;EACzB;EACA;EACA,KAAK;EACL,oBAAoB;EACrB,CAAC;AAIJ,KAAI,WAAW,MACb,YAAW,SAAS,KAAK;UAChB,WAAW,OACpB,YAAW,SAAS,MAAM;UACjB,WAAW,OACpB,YAAW,SAAS,MAAM;AAG5B,QAAO,SAAS,UAAU;;;;;AAM5B,SAAgB,iBAAiB,QAAwB;AACvD,QAAO,WAAW,SAAS,CAAC,OAAO,OAAO,CAAC,OAAO,MAAM;;;;;AAM1D,SAAgB,eACd,UACgC;AAChC,KAAI,SAAS,SAAS,MAAM,CAAE,QAAO;AACrC,KAAI,SAAS,SAAS,OAAO,CAAE,QAAO;AACtC,KAAI,SAAS,SAAS,OAAO,IAAI,SAAS,SAAS,MAAM,CAAE,QAAO;AAClE,QAAO;;;;;AAMT,SAAgB,cAAc,UAA2B;AACvD,QAAO,SAAS,WAAW,SAAS,IAAI,CAAC,SAAS,SAAS,MAAM;;;;;;;;;;;AAYnE,SAAgB,iBACd,UACA,UACQ;CACR,MAAM,cAAc,yBAClB,yBAAyB,YAAY,GAAG,CACzC;AACD,KAAI,gBAAgB,2BAA4B,QAAO;AACvD,KAAI,YAAY,qBAAqB,SAAS,CAAE,QAAO,SAAS,aAAa;AAC7E,QAAO;;;;;;;AAQT,SAAgB,oBAAoB,IAAoB;AACtD,QAAO,GAAG,QAAQ,mBAAmB,GAAG;;AAG1C,eAAsB,eACpB,GAC2B;CAE3B,MAAM,EAAE,OAAO,QAAQ,QAAQ,SAAS,MAAM,MAAM,EAAE,OAAO,CAAC,UAAU;AAcxE,QAAO;EACL,UAAU,iBAAiB,EAAE,OAAO;EACpC;EACA;EACA,UAAU,iBACR,EAAE,kBACF,SAlB4D;GAC9D,MAAM;GACN,KAAK;GACL,KAAK;GACL,MAAM;GACN,MAAM;GACN,MAAM;GACN,KAAK;GACL,MAAM;GACN,KAAK;GACN,CAQyB,UAAU,OACjC;EACD,UAAU,QAAQ;EACnB"}
@@ -1 +1 @@
1
- {"version":3,"file":"screenshotRestController.d.mts","names":[],"sources":["../../../src/modules/assets/screenshotRestController.ts"],"mappings":";;;UAKiB,8BAAA;;AAAjB;;EAIE,QAAA;AAAA;;AAWF;;;;;;;iBAAgB,gCAAA,CACd,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,8BAAA"}
1
+ {"version":3,"file":"screenshotRestController.d.mts","names":[],"sources":["../../../src/modules/assets/screenshotRestController.ts"],"mappings":";;;UAMiB,8BAAA;;AAAjB;;EAIE,QAAA;AAAA;;AAWF;;;;;;;iBAAgB,gCAAA,CACd,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,8BAAA"}
@@ -1,4 +1,5 @@
1
1
  import { getDbClient } from "../../db/client.mjs";
2
+ import { mimeTypeForAsset, stripAssetExtension } from "./assetUtils.mjs";
2
3
  import { isNotModified, setRevalidatingCacheHeaders } from "./assetCache.mjs";
3
4
  import sharp from "sharp";
4
5
 
@@ -83,7 +84,7 @@ function registerScreenshotRestController(router, config) {
83
84
  });
84
85
  router.get(`${basePath}/:id`, async (req, res) => {
85
86
  try {
86
- const { id } = req.params;
87
+ const id = stripAssetExtension(req.params["id"] ?? "");
87
88
  const sizeParam = req.query.size;
88
89
  const targetSize = sizeParam ? parseInt(sizeParam, 10) : void 0;
89
90
  const screenshot = await getDbClient().dbAsset.findUnique({
@@ -112,7 +113,7 @@ function registerScreenshotRestController(router, config) {
112
113
  } catch (resizeError) {
113
114
  console.error("Error resizing screenshot:", resizeError);
114
115
  }
115
- res.setHeader("Content-Type", screenshot.mimeType);
116
+ res.setHeader("Content-Type", mimeTypeForAsset(screenshot.name, screenshot.mimeType));
116
117
  res.setHeader("Content-Disposition", `inline; filename="${screenshot.name}"`);
117
118
  res.send(content);
118
119
  } catch (error) {
@@ -1 +1 @@
1
- {"version":3,"file":"screenshotRestController.mjs","names":[],"sources":["../../../src/modules/assets/screenshotRestController.ts"],"sourcesContent":["import type { Request, Response, Router } from 'express'\nimport sharp from 'sharp'\nimport { getDbClient } from '../../db'\nimport { isNotModified, setRevalidatingCacheHeaders } from './assetCache'\n\nexport interface ScreenshotRestControllerConfig {\n /**\n * Base path for screenshot endpoints (e.g., '/api/screenshots')\n */\n basePath: string\n}\n\n/**\n * Registers REST endpoints for screenshot retrieval\n *\n * Endpoints:\n * - GET {basePath}/app/:appId - Get all screenshots for an app\n * - GET {basePath}/:id - Get screenshot binary by ID\n * - GET {basePath}/:id/metadata - Get screenshot metadata only\n */\nexport function registerScreenshotRestController(\n router: Router,\n config: ScreenshotRestControllerConfig,\n): void {\n const { basePath } = config\n\n // Get all screenshots for an app\n router.get(\n `${basePath}/app/:appSlug`,\n async (req: Request, res: Response) => {\n try {\n const { appSlug } = req.params\n\n const prisma = getDbClient()\n\n // Find app by slug\n const app = await prisma.dbResource.findUnique({\n where: { slug: appSlug },\n select: { screenshotIds: true },\n })\n\n if (!app) {\n res.status(404).json({ error: 'App not found' })\n return\n }\n\n // Fetch all screenshots for the app, then sort by screenshotIds order\n const results = await prisma.dbAsset.findMany({\n where: {\n id: { in: app.screenshotIds },\n assetType: 'screenshot',\n },\n select: {\n id: true,\n name: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n },\n })\n\n const screenshots = app.screenshotIds\n .map((id) => results.find((r) => r.id === id))\n .filter((r) => r !== undefined)\n\n res.json(screenshots)\n } catch (error) {\n console.error('Error fetching app screenshots:', error)\n res.status(500).json({ error: 'Failed to fetch screenshots' })\n }\n },\n )\n\n // Get first screenshot for an app (convenience endpoint)\n router.get(\n `${basePath}/app/:appSlug/first`,\n async (req: Request, res: Response) => {\n try {\n const { appSlug } = req.params\n\n const prisma = getDbClient()\n\n // Find app by slug\n const app = await prisma.dbResource.findUnique({\n where: { slug: appSlug },\n select: { screenshotIds: true },\n })\n\n if (!app || app.screenshotIds.length === 0) {\n res.status(404).json({ error: 'No screenshots found' })\n return\n }\n\n // Fetch first screenshot\n const screenshot = await prisma.dbAsset.findUnique({\n where: { id: app.screenshotIds[0] },\n select: {\n id: true,\n name: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n },\n })\n\n if (!screenshot) {\n res.status(404).json({ error: 'Screenshot not found' })\n return\n }\n\n res.json(screenshot)\n } catch (error) {\n console.error('Error fetching first screenshot:', error)\n res.status(500).json({ error: 'Failed to fetch screenshot' })\n }\n },\n )\n\n // Get screenshot binary by ID\n router.get(`${basePath}/:id`, async (req: Request, res: Response) => {\n try {\n const { id } = req.params\n const sizeParam = req.query.size as string | undefined\n const targetSize = sizeParam ? parseInt(sizeParam, 10) : undefined\n\n const prisma = getDbClient()\n const screenshot = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n content: true,\n mimeType: true,\n name: true,\n checksum: true,\n },\n })\n\n if (!screenshot) {\n res.status(404).json({ error: 'Screenshot not found' })\n return\n }\n\n // Answered before resizing, so an unchanged screenshot costs no image work.\n const etag = setRevalidatingCacheHeaders(\n res,\n screenshot.checksum,\n targetSize && targetSize > 0 ? `s${targetSize}` : undefined,\n )\n if (isNotModified(req, etag)) {\n res.status(304).end()\n return\n }\n\n let content: Uint8Array | Buffer = screenshot.content\n\n // Resize if size parameter provided\n if (targetSize && targetSize > 0) {\n try {\n content = await sharp(screenshot.content)\n .resize(targetSize, targetSize, {\n fit: 'inside',\n withoutEnlargement: true,\n })\n .toBuffer()\n } catch (resizeError) {\n console.error('Error resizing screenshot:', resizeError)\n // Fall back to original if resize fails\n }\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', screenshot.mimeType)\n res.setHeader(\n 'Content-Disposition',\n `inline; filename=\"${screenshot.name}\"`,\n )\n\n // Send binary content\n res.send(content)\n } catch (error) {\n console.error('Error fetching screenshot:', error)\n res.status(500).json({ error: 'Failed to fetch screenshot' })\n }\n })\n\n // Get screenshot metadata only (no binary content)\n router.get(\n `${basePath}/:id/metadata`,\n async (req: Request, res: Response) => {\n try {\n const { id } = req.params\n\n const prisma = getDbClient()\n const screenshot = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n id: true,\n name: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n updatedAt: true,\n },\n })\n\n if (!screenshot) {\n res.status(404).json({ error: 'Screenshot not found' })\n return\n }\n\n res.json(screenshot)\n } catch (error) {\n console.error('Error fetching screenshot metadata:', error)\n res.status(500).json({ error: 'Failed to fetch screenshot metadata' })\n }\n },\n )\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"screenshotRestController.mjs","names":[],"sources":["../../../src/modules/assets/screenshotRestController.ts"],"sourcesContent":["import type { Request, Response, Router } from 'express'\nimport sharp from 'sharp'\nimport { getDbClient } from '../../db'\nimport { isNotModified, setRevalidatingCacheHeaders } from './assetCache'\nimport { mimeTypeForAsset, stripAssetExtension } from './assetUtils'\n\nexport interface ScreenshotRestControllerConfig {\n /**\n * Base path for screenshot endpoints (e.g., '/api/screenshots')\n */\n basePath: string\n}\n\n/**\n * Registers REST endpoints for screenshot retrieval\n *\n * Endpoints:\n * - GET {basePath}/app/:appId - Get all screenshots for an app\n * - GET {basePath}/:id - Get screenshot binary by ID\n * - GET {basePath}/:id/metadata - Get screenshot metadata only\n */\nexport function registerScreenshotRestController(\n router: Router,\n config: ScreenshotRestControllerConfig,\n): void {\n const { basePath } = config\n\n // Get all screenshots for an app\n router.get(\n `${basePath}/app/:appSlug`,\n async (req: Request, res: Response) => {\n try {\n const { appSlug } = req.params\n\n const prisma = getDbClient()\n\n // Find app by slug\n const app = await prisma.dbResource.findUnique({\n where: { slug: appSlug },\n select: { screenshotIds: true },\n })\n\n if (!app) {\n res.status(404).json({ error: 'App not found' })\n return\n }\n\n // Fetch all screenshots for the app, then sort by screenshotIds order\n const results = await prisma.dbAsset.findMany({\n where: {\n id: { in: app.screenshotIds },\n assetType: 'screenshot',\n },\n select: {\n id: true,\n name: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n },\n })\n\n const screenshots = app.screenshotIds\n .map((id) => results.find((r) => r.id === id))\n .filter((r) => r !== undefined)\n\n res.json(screenshots)\n } catch (error) {\n console.error('Error fetching app screenshots:', error)\n res.status(500).json({ error: 'Failed to fetch screenshots' })\n }\n },\n )\n\n // Get first screenshot for an app (convenience endpoint)\n router.get(\n `${basePath}/app/:appSlug/first`,\n async (req: Request, res: Response) => {\n try {\n const { appSlug } = req.params\n\n const prisma = getDbClient()\n\n // Find app by slug\n const app = await prisma.dbResource.findUnique({\n where: { slug: appSlug },\n select: { screenshotIds: true },\n })\n\n if (!app || app.screenshotIds.length === 0) {\n res.status(404).json({ error: 'No screenshots found' })\n return\n }\n\n // Fetch first screenshot\n const screenshot = await prisma.dbAsset.findUnique({\n where: { id: app.screenshotIds[0] },\n select: {\n id: true,\n name: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n },\n })\n\n if (!screenshot) {\n res.status(404).json({ error: 'Screenshot not found' })\n return\n }\n\n res.json(screenshot)\n } catch (error) {\n console.error('Error fetching first screenshot:', error)\n res.status(500).json({ error: 'Failed to fetch screenshot' })\n }\n },\n )\n\n // Get screenshot binary by ID\n router.get(`${basePath}/:id`, async (req: Request, res: Response) => {\n try {\n const id = stripAssetExtension(req.params['id'] ?? '')\n const sizeParam = req.query.size as string | undefined\n const targetSize = sizeParam ? parseInt(sizeParam, 10) : undefined\n\n const prisma = getDbClient()\n const screenshot = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n content: true,\n mimeType: true,\n name: true,\n checksum: true,\n },\n })\n\n if (!screenshot) {\n res.status(404).json({ error: 'Screenshot not found' })\n return\n }\n\n // Answered before resizing, so an unchanged screenshot costs no image work.\n const etag = setRevalidatingCacheHeaders(\n res,\n screenshot.checksum,\n targetSize && targetSize > 0 ? `s${targetSize}` : undefined,\n )\n if (isNotModified(req, etag)) {\n res.status(304).end()\n return\n }\n\n let content: Uint8Array | Buffer = screenshot.content\n\n // Resize if size parameter provided\n if (targetSize && targetSize > 0) {\n try {\n content = await sharp(screenshot.content)\n .resize(targetSize, targetSize, {\n fit: 'inside',\n withoutEnlargement: true,\n })\n .toBuffer()\n } catch (resizeError) {\n console.error('Error resizing screenshot:', resizeError)\n // Fall back to original if resize fails\n }\n }\n\n // Set appropriate headers\n res.setHeader(\n 'Content-Type',\n mimeTypeForAsset(screenshot.name, screenshot.mimeType),\n )\n res.setHeader(\n 'Content-Disposition',\n `inline; filename=\"${screenshot.name}\"`,\n )\n\n // Send binary content\n res.send(content)\n } catch (error) {\n console.error('Error fetching screenshot:', error)\n res.status(500).json({ error: 'Failed to fetch screenshot' })\n }\n })\n\n // Get screenshot metadata only (no binary content)\n router.get(\n `${basePath}/:id/metadata`,\n async (req: Request, res: Response) => {\n try {\n const { id } = req.params\n\n const prisma = getDbClient()\n const screenshot = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n id: true,\n name: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n updatedAt: true,\n },\n })\n\n if (!screenshot) {\n res.status(404).json({ error: 'Screenshot not found' })\n return\n }\n\n res.json(screenshot)\n } catch (error) {\n console.error('Error fetching screenshot metadata:', error)\n res.status(500).json({ error: 'Failed to fetch screenshot metadata' })\n }\n },\n )\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"iconRestController.d.mts","names":[],"sources":["../../../src/modules/icons/iconRestController.ts"],"mappings":";;;UA0BiB,wBAAA;;AAAjB;;EAIE,QAAA;AAAA;;AAWF;;;;;;;iBAAgB,0BAAA,CACd,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,wBAAA"}
1
+ {"version":3,"file":"iconRestController.d.mts","names":[],"sources":["../../../src/modules/icons/iconRestController.ts"],"mappings":";;;UA2BiB,wBAAA;;AAAjB;;EAIE,QAAA;AAAA;;AAWF;;;;;;;iBAAgB,0BAAA,CACd,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,wBAAA"}
@@ -1,5 +1,6 @@
1
1
  import { getDbClient } from "../../db/client.mjs";
2
2
  import { getExtensionFromFilename, getExtensionFromMimeType } from "./iconUtils.mjs";
3
+ import { mimeTypeForAsset } from "../assets/assetUtils.mjs";
3
4
  import { isNotModified, setRevalidatingCacheHeaders } from "../assets/assetCache.mjs";
4
5
  import { createHash } from "node:crypto";
5
6
  import multer from "multer";
@@ -45,7 +46,7 @@ function registerIconRestController(router, config) {
45
46
  name,
46
47
  assetType: "icon",
47
48
  content: new Uint8Array(req.file.buffer),
48
- mimeType: req.file.mimetype,
49
+ mimeType: mimeTypeForAsset(name, req.file.mimetype),
49
50
  fileSize: req.file.size,
50
51
  checksum
51
52
  } });
@@ -84,7 +85,7 @@ function registerIconRestController(router, config) {
84
85
  res.status(304).end();
85
86
  return;
86
87
  }
87
- res.setHeader("Content-Type", icon.mimeType);
88
+ res.setHeader("Content-Type", mimeTypeForAsset(icon.name, icon.mimeType));
88
89
  res.setHeader("Content-Disposition", `inline; filename="${icon.name}"`);
89
90
  res.send(icon.content);
90
91
  } catch (error) {
@@ -1 +1 @@
1
- {"version":3,"file":"iconRestController.mjs","names":[],"sources":["../../../src/modules/icons/iconRestController.ts"],"sourcesContent":["import type { Request, Response, Router } from 'express'\nimport multer from 'multer'\nimport { createHash } from 'node:crypto'\nimport { getDbClient } from '../../db'\nimport { getExtensionFromFilename, getExtensionFromMimeType } from './iconUtils'\nimport {\n isNotModified,\n setRevalidatingCacheHeaders,\n} from '../assets/assetCache'\n\n// Configure multer for memory storage\nconst upload = multer({\n storage: multer.memoryStorage(),\n limits: {\n fileSize: 10 * 1024 * 1024, // 10MB limit\n },\n fileFilter: (_req, file, cb) => {\n // Accept images only\n if (!file.mimetype.startsWith('image/')) {\n cb(new Error('Only image files are allowed'))\n return\n }\n cb(null, true)\n },\n})\n\nexport interface IconRestControllerConfig {\n /**\n * Base path for icon endpoints (e.g., '/api/icons')\n */\n basePath: string\n}\n\n/**\n * Registers REST endpoints for icon upload and retrieval\n *\n * Endpoints:\n * - POST {basePath}/upload - Upload a new icon (multipart/form-data with 'icon' field and 'name' field)\n * - GET {basePath}/:id - Get icon binary by ID\n * - GET {basePath}/:id/metadata - Get icon metadata only\n */\nexport function registerIconRestController(\n router: Router,\n config: IconRestControllerConfig,\n): void {\n const { basePath } = config\n\n // Upload endpoint - accepts multipart/form-data\n router.post(\n `${basePath}/upload`,\n upload.single('icon'),\n async (req: Request, res: Response) => {\n try {\n if (!req.file) {\n res.status(400).json({ error: 'No file uploaded' })\n return\n }\n\n let name = req.body['name'] as string\n if (!name) {\n res.status(400).json({ error: 'Name is required' })\n return\n }\n\n // Extract extension from original filename or derive from MIME type\n const extension =\n getExtensionFromFilename(req.file.originalname) ||\n getExtensionFromMimeType(req.file.mimetype)\n\n // Add extension to name if not already present\n if (!name.includes('.')) {\n name = `${name}.${extension}`\n }\n\n const prisma = getDbClient()\n const checksum = createHash('sha256')\n .update(req.file.buffer)\n .digest('hex')\n const icon = await prisma.dbAsset.create({\n data: {\n name,\n assetType: 'icon',\n content: new Uint8Array(req.file.buffer),\n mimeType: req.file.mimetype,\n fileSize: req.file.size,\n checksum,\n },\n })\n\n res.status(201).json({\n id: icon.id,\n name: icon.name,\n mimeType: icon.mimeType,\n fileSize: icon.fileSize,\n createdAt: icon.createdAt,\n })\n } catch (error) {\n console.error('Error uploading icon:', error)\n res.status(500).json({ error: 'Failed to upload icon' })\n }\n },\n )\n\n // Get icon binary by name (e.g., /api/icons/jira.svg)\n router.get(`${basePath}/:name`, async (req: Request, res: Response) => {\n try {\n const { name } = req.params\n\n const prisma = getDbClient()\n const icon = await prisma.dbAsset.findFirst({\n where: {\n name,\n assetType: 'icon',\n },\n select: {\n content: true,\n mimeType: true,\n name: true,\n checksum: true,\n },\n })\n\n if (!icon) {\n res.status(404).json({ error: 'Icon not found' })\n return\n }\n\n const etag = setRevalidatingCacheHeaders(res, icon.checksum)\n if (isNotModified(req, etag)) {\n res.status(304).end()\n return\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', icon.mimeType)\n res.setHeader('Content-Disposition', `inline; filename=\"${icon.name}\"`)\n\n // Send binary content\n res.send(icon.content)\n } catch (error) {\n console.error('Error fetching icon:', error)\n res.status(500).json({ error: 'Failed to fetch icon' })\n }\n })\n\n // Get icon metadata only (no binary content)\n router.get(\n `${basePath}/:name/metadata`,\n async (req: Request, res: Response) => {\n try {\n const { name } = req.params\n\n const prisma = getDbClient()\n const icon = await prisma.dbAsset.findFirst({\n where: {\n name,\n assetType: 'icon',\n },\n select: {\n id: true,\n name: true,\n mimeType: true,\n fileSize: true,\n createdAt: true,\n updatedAt: true,\n },\n })\n\n if (!icon) {\n res.status(404).json({ error: 'Icon not found' })\n return\n }\n\n res.json(icon)\n } catch (error) {\n console.error('Error fetching icon metadata:', error)\n res.status(500).json({ error: 'Failed to fetch icon metadata' })\n }\n },\n )\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"iconRestController.mjs","names":[],"sources":["../../../src/modules/icons/iconRestController.ts"],"sourcesContent":["import type { Request, Response, Router } from 'express'\nimport multer from 'multer'\nimport { createHash } from 'node:crypto'\nimport { getDbClient } from '../../db'\nimport { getExtensionFromFilename, getExtensionFromMimeType } from './iconUtils'\nimport {\n isNotModified,\n setRevalidatingCacheHeaders,\n} from '../assets/assetCache'\nimport { mimeTypeForAsset } from '../assets/assetUtils'\n\n// Configure multer for memory storage\nconst upload = multer({\n storage: multer.memoryStorage(),\n limits: {\n fileSize: 10 * 1024 * 1024, // 10MB limit\n },\n fileFilter: (_req, file, cb) => {\n // Accept images only\n if (!file.mimetype.startsWith('image/')) {\n cb(new Error('Only image files are allowed'))\n return\n }\n cb(null, true)\n },\n})\n\nexport interface IconRestControllerConfig {\n /**\n * Base path for icon endpoints (e.g., '/api/icons')\n */\n basePath: string\n}\n\n/**\n * Registers REST endpoints for icon upload and retrieval\n *\n * Endpoints:\n * - POST {basePath}/upload - Upload a new icon (multipart/form-data with 'icon' field and 'name' field)\n * - GET {basePath}/:id - Get icon binary by ID\n * - GET {basePath}/:id/metadata - Get icon metadata only\n */\nexport function registerIconRestController(\n router: Router,\n config: IconRestControllerConfig,\n): void {\n const { basePath } = config\n\n // Upload endpoint - accepts multipart/form-data\n router.post(\n `${basePath}/upload`,\n upload.single('icon'),\n async (req: Request, res: Response) => {\n try {\n if (!req.file) {\n res.status(400).json({ error: 'No file uploaded' })\n return\n }\n\n let name = req.body['name'] as string\n if (!name) {\n res.status(400).json({ error: 'Name is required' })\n return\n }\n\n // Extract extension from original filename or derive from MIME type\n const extension =\n getExtensionFromFilename(req.file.originalname) ||\n getExtensionFromMimeType(req.file.mimetype)\n\n // Add extension to name if not already present\n if (!name.includes('.')) {\n name = `${name}.${extension}`\n }\n\n const prisma = getDbClient()\n const checksum = createHash('sha256')\n .update(req.file.buffer)\n .digest('hex')\n const icon = await prisma.dbAsset.create({\n data: {\n name,\n assetType: 'icon',\n content: new Uint8Array(req.file.buffer),\n // The stored name always carries an extension by now, and it — not\n // the client's claimed content type — decides how the icon is served.\n mimeType: mimeTypeForAsset(name, req.file.mimetype),\n fileSize: req.file.size,\n checksum,\n },\n })\n\n res.status(201).json({\n id: icon.id,\n name: icon.name,\n mimeType: icon.mimeType,\n fileSize: icon.fileSize,\n createdAt: icon.createdAt,\n })\n } catch (error) {\n console.error('Error uploading icon:', error)\n res.status(500).json({ error: 'Failed to upload icon' })\n }\n },\n )\n\n // Get icon binary by name (e.g., /api/icons/jira.svg)\n router.get(`${basePath}/:name`, async (req: Request, res: Response) => {\n try {\n const { name } = req.params\n\n const prisma = getDbClient()\n const icon = await prisma.dbAsset.findFirst({\n where: {\n name,\n assetType: 'icon',\n },\n select: {\n content: true,\n mimeType: true,\n name: true,\n checksum: true,\n },\n })\n\n if (!icon) {\n res.status(404).json({ error: 'Icon not found' })\n return\n }\n\n const etag = setRevalidatingCacheHeaders(res, icon.checksum)\n if (isNotModified(req, etag)) {\n res.status(304).end()\n return\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', mimeTypeForAsset(icon.name, icon.mimeType))\n res.setHeader('Content-Disposition', `inline; filename=\"${icon.name}\"`)\n\n // Send binary content\n res.send(icon.content)\n } catch (error) {\n console.error('Error fetching icon:', error)\n res.status(500).json({ error: 'Failed to fetch icon' })\n }\n })\n\n // Get icon metadata only (no binary content)\n router.get(\n `${basePath}/:name/metadata`,\n async (req: Request, res: Response) => {\n try {\n const { name } = req.params\n\n const prisma = getDbClient()\n const icon = await prisma.dbAsset.findFirst({\n where: {\n name,\n assetType: 'icon',\n },\n select: {\n id: true,\n name: true,\n mimeType: true,\n fileSize: true,\n createdAt: true,\n updatedAt: true,\n },\n })\n\n if (!icon) {\n res.status(404).json({ error: 'Icon not found' })\n return\n }\n\n res.json(icon)\n } catch (error) {\n console.error('Error fetching icon metadata:', error)\n res.status(500).json({ error: 'Failed to fetch icon metadata' })\n }\n },\n )\n}\n"],"mappings":"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"}
@@ -22,7 +22,35 @@ function getExtensionFromMimeType(mimeType) {
22
22
  function getExtensionFromFilename(filename) {
23
23
  return filename.match(/\.([^.]+)$/)?.[1]?.toLowerCase() || "";
24
24
  }
25
+ const MIME_BY_EXTENSION = {
26
+ svg: "image/svg+xml",
27
+ png: "image/png",
28
+ jpg: "image/jpeg",
29
+ jpeg: "image/jpeg",
30
+ webp: "image/webp",
31
+ avif: "image/avif",
32
+ gif: "image/gif",
33
+ bmp: "image/bmp",
34
+ tiff: "image/tiff",
35
+ heif: "image/heif",
36
+ ico: "image/x-icon"
37
+ };
38
+ /**
39
+ * Get MIME type from extension
40
+ */
41
+ function getMimeTypeFromExtension(extension) {
42
+ return MIME_BY_EXTENSION[extension.toLowerCase()] || "application/octet-stream";
43
+ }
44
+ /**
45
+ * Whether a MIME type is one we knowingly serve, rather than something invented
46
+ * from an image library's format name (`image/svg`, `image/magick`). A browser
47
+ * behind `X-Content-Type-Options: nosniff` refuses to render an invented type in
48
+ * an `<img>`, so an unrecognised value must never reach a response header.
49
+ */
50
+ function isKnownImageMimeType(mimeType) {
51
+ return Object.values(MIME_BY_EXTENSION).includes(mimeType.toLowerCase());
52
+ }
25
53
 
26
54
  //#endregion
27
- export { getExtensionFromFilename, getExtensionFromMimeType };
55
+ export { getExtensionFromFilename, getExtensionFromMimeType, getMimeTypeFromExtension, isKnownImageMimeType };
28
56
  //# sourceMappingURL=iconUtils.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"iconUtils.mjs","names":[],"sources":["../../../src/modules/icons/iconUtils.ts"],"sourcesContent":["/**\n * Get file extension from MIME type\n */\nexport function getExtensionFromMimeType(mimeType: string): string {\n const mimeMap: Record<string, string> = {\n 'image/svg+xml': 'svg',\n 'image/png': 'png',\n 'image/jpeg': 'jpg',\n 'image/jpg': 'jpg',\n 'image/webp': 'webp',\n 'image/gif': 'gif',\n 'image/bmp': 'bmp',\n 'image/tiff': 'tiff',\n 'image/x-icon': 'ico',\n 'image/vnd.microsoft.icon': 'ico',\n }\n\n return mimeMap[mimeType.toLowerCase()] || 'bin'\n}\n\n/**\n * Get file extension from filename\n */\nexport function getExtensionFromFilename(filename: string): string {\n const match = filename.match(/\\.([^.]+)$/)\n return match?.[1]?.toLowerCase() || ''\n}\n\n/**\n * Get MIME type from extension\n */\nexport function getMimeTypeFromExtension(extension: string): string {\n const extMap: Record<string, string> = {\n svg: 'image/svg+xml',\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n webp: 'image/webp',\n gif: 'image/gif',\n bmp: 'image/bmp',\n tiff: 'image/tiff',\n ico: 'image/x-icon',\n }\n\n return extMap[extension.toLowerCase()] || 'application/octet-stream'\n}\n"],"mappings":";;;;AAGA,SAAgB,yBAAyB,UAA0B;AAcjE,QAbwC;EACtC,iBAAiB;EACjB,aAAa;EACb,cAAc;EACd,aAAa;EACb,cAAc;EACd,aAAa;EACb,aAAa;EACb,cAAc;EACd,gBAAgB;EAChB,4BAA4B;EAC7B,CAEc,SAAS,aAAa,KAAK;;;;;AAM5C,SAAgB,yBAAyB,UAA0B;AAEjE,QADc,SAAS,MAAM,aAAa,GAC3B,IAAI,aAAa,IAAI"}
1
+ {"version":3,"file":"iconUtils.mjs","names":[],"sources":["../../../src/modules/icons/iconUtils.ts"],"sourcesContent":["/**\n * Get file extension from MIME type\n */\nexport function getExtensionFromMimeType(mimeType: string): string {\n const mimeMap: Record<string, string> = {\n 'image/svg+xml': 'svg',\n 'image/png': 'png',\n 'image/jpeg': 'jpg',\n 'image/jpg': 'jpg',\n 'image/webp': 'webp',\n 'image/gif': 'gif',\n 'image/bmp': 'bmp',\n 'image/tiff': 'tiff',\n 'image/x-icon': 'ico',\n 'image/vnd.microsoft.icon': 'ico',\n }\n\n return mimeMap[mimeType.toLowerCase()] || 'bin'\n}\n\n/**\n * Get file extension from filename\n */\nexport function getExtensionFromFilename(filename: string): string {\n const match = filename.match(/\\.([^.]+)$/)\n return match?.[1]?.toLowerCase() || ''\n}\n\nconst MIME_BY_EXTENSION: Record<string, string> = {\n svg: 'image/svg+xml',\n png: 'image/png',\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n webp: 'image/webp',\n avif: 'image/avif',\n gif: 'image/gif',\n bmp: 'image/bmp',\n tiff: 'image/tiff',\n heif: 'image/heif',\n ico: 'image/x-icon',\n}\n\n/**\n * Get MIME type from extension\n */\nexport function getMimeTypeFromExtension(extension: string): string {\n return (\n MIME_BY_EXTENSION[extension.toLowerCase()] || 'application/octet-stream'\n )\n}\n\n/**\n * Whether a MIME type is one we knowingly serve, rather than something invented\n * from an image library's format name (`image/svg`, `image/magick`). A browser\n * behind `X-Content-Type-Options: nosniff` refuses to render an invented type in\n * an `<img>`, so an unrecognised value must never reach a response header.\n */\nexport function isKnownImageMimeType(mimeType: string): boolean {\n return Object.values(MIME_BY_EXTENSION).includes(mimeType.toLowerCase())\n}\n"],"mappings":";;;;AAGA,SAAgB,yBAAyB,UAA0B;AAcjE,QAbwC;EACtC,iBAAiB;EACjB,aAAa;EACb,cAAc;EACd,aAAa;EACb,cAAc;EACd,aAAa;EACb,aAAa;EACb,cAAc;EACd,gBAAgB;EAChB,4BAA4B;EAC7B,CAEc,SAAS,aAAa,KAAK;;;;;AAM5C,SAAgB,yBAAyB,UAA0B;AAEjE,QADc,SAAS,MAAM,aAAa,GAC3B,IAAI,aAAa,IAAI;;AAGtC,MAAM,oBAA4C;CAChD,KAAK;CACL,KAAK;CACL,KAAK;CACL,MAAM;CACN,MAAM;CACN,MAAM;CACN,KAAK;CACL,KAAK;CACL,MAAM;CACN,MAAM;CACN,KAAK;CACN;;;;AAKD,SAAgB,yBAAyB,WAA2B;AAClE,QACE,kBAAkB,UAAU,aAAa,KAAK;;;;;;;;AAUlD,SAAgB,qBAAqB,UAA2B;AAC9D,QAAO,OAAO,OAAO,kBAAkB,CAAC,SAAS,SAAS,aAAa,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-backend-core",
3
- "version": "6.3.0-alpha-20261001192340",
3
+ "version": "6.3.0",
4
4
  "description": "Backend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -44,8 +44,8 @@
44
44
  "tsyringe": "^4.10.0",
45
45
  "yaml": "^2.8.0",
46
46
  "zod": "^4.3.5",
47
- "@igstack/app-catalog-table-sync": "6.3.0-alpha-20261001192340",
48
- "@igstack/app-catalog-shared-core": "6.3.0-alpha-20261001192340"
47
+ "@igstack/app-catalog-table-sync": "6.3.0",
48
+ "@igstack/app-catalog-shared-core": "6.3.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@tanstack/vite-config": "^0.4.3",
@@ -72,7 +72,7 @@
72
72
  "engines": {
73
73
  "node": ">=24"
74
74
  },
75
- "gitHead": "ff1b865ee95f29d19f5678074ca32c13381b5b16",
75
+ "gitHead": "1d77cb454ad5fe4c483f1c158c60aa2bc21f72b9",
76
76
  "scripts": {
77
77
  "build": "tsdown",
78
78
  "build:lenient": "tsdown",
@@ -0,0 +1,169 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import type { Request, Response, Router } from 'express'
3
+ import * as dbClient from '../db/client'
4
+ import type { PrismaClient } from '../generated/prisma/client'
5
+ import {
6
+ mimeTypeForAsset,
7
+ parseAssetMeta,
8
+ stripAssetExtension,
9
+ } from '../modules/assets/assetUtils'
10
+ import { registerIconRestController } from '../modules/icons/iconRestController'
11
+ import { registerScreenshotRestController } from '../modules/assets/screenshotRestController'
12
+
13
+ describe('mimeTypeForAsset', () => {
14
+ it('takes the type from the extension, not from a wrong stored value', () => {
15
+ expect(mimeTypeForAsset('logo.svg', 'image/svg')).toBe('image/svg+xml')
16
+ expect(mimeTypeForAsset('shot.png', 'image/jpeg')).toBe('image/png')
17
+ })
18
+
19
+ it('falls back to the stored type only when the name has no usable extension', () => {
20
+ expect(mimeTypeForAsset('logo', 'image/png')).toBe('image/png')
21
+ expect(mimeTypeForAsset(undefined, 'image/png')).toBe('image/png')
22
+ })
23
+
24
+ it('never passes through an invented type', () => {
25
+ // What the old `image/${format}` fallback produced. A browser behind
26
+ // nosniff refuses these, so they must not reach a response header.
27
+ expect(mimeTypeForAsset('logo', 'image/svg')).toBe(
28
+ 'application/octet-stream',
29
+ )
30
+ expect(mimeTypeForAsset('logo', 'image/magick')).toBe(
31
+ 'application/octet-stream',
32
+ )
33
+ expect(mimeTypeForAsset('logo')).toBe('application/octet-stream')
34
+ })
35
+ })
36
+
37
+ describe('stripAssetExtension', () => {
38
+ it('lets an asset URL carry the file extension', () => {
39
+ expect(stripAssetExtension('ckl1234abcd.png')).toBe('ckl1234abcd')
40
+ expect(stripAssetExtension('ckl1234abcd')).toBe('ckl1234abcd')
41
+ })
42
+ })
43
+
44
+ describe('parseAssetMeta', () => {
45
+ const SVG = Buffer.from(
46
+ '<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>',
47
+ )
48
+
49
+ it('derives the stored type from the uploaded filename', async () => {
50
+ const meta = await parseAssetMeta({
51
+ buffer: SVG,
52
+ originalFilename: 'example-icon.svg',
53
+ })
54
+
55
+ expect(meta.mimeType).toBe('image/svg+xml')
56
+ })
57
+
58
+ it('does not invent a type for a file that arrives without an extension', async () => {
59
+ const meta = await parseAssetMeta({
60
+ buffer: SVG,
61
+ originalFilename: 'example-icon',
62
+ })
63
+
64
+ // sharp reports format 'svg' here, which the mapping knows; what must never
65
+ // happen is an unmapped format becoming `image/<format>`.
66
+ expect(meta.mimeType).toBe('image/svg+xml')
67
+ expect(meta.mimeType).not.toMatch(/^image\/svg$/)
68
+ })
69
+ })
70
+
71
+ /**
72
+ * Captures a `GET` handler a controller registers, so the test can drive it
73
+ * without standing up an HTTP server.
74
+ */
75
+ const captureHandler = (
76
+ register: (router: Router) => void,
77
+ path: string,
78
+ ): ((req: Request, res: Response) => Promise<void>) => {
79
+ let handler!: (req: Request, res: Response) => Promise<void>
80
+ const router = {
81
+ get: (p: string, fn: (req: Request, res: Response) => Promise<void>) => {
82
+ if (p === path) handler = fn
83
+ },
84
+ post: () => {},
85
+ } as unknown as Router
86
+
87
+ register(router)
88
+ return handler
89
+ }
90
+
91
+ const fakeRes = () => {
92
+ const headers: Record<string, string> = {}
93
+ const res = {
94
+ setHeader: (k: string, v: string) => {
95
+ headers[k] = v
96
+ },
97
+ status: vi.fn().mockReturnThis(),
98
+ json: vi.fn().mockReturnThis(),
99
+ send: vi.fn().mockReturnThis(),
100
+ end: vi.fn().mockReturnThis(),
101
+ }
102
+ return { res: res as unknown as Response, headers, spy: res }
103
+ }
104
+
105
+ describe('serving an asset stored with a wrong mimeType', () => {
106
+ // A row written by the old derivation: the bytes and the name are fine, the
107
+ // persisted type is the invalid `image/svg` that nosniff rejects.
108
+ const LEGACY_ICON = {
109
+ content: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>'),
110
+ mimeType: 'image/svg',
111
+ name: 'example-icon.svg',
112
+ checksum: 'abc123',
113
+ }
114
+
115
+ beforeEach(() => {
116
+ vi.spyOn(dbClient, 'getDbClient').mockReturnValue({
117
+ dbAsset: {
118
+ findFirst: vi.fn().mockResolvedValue(LEGACY_ICON),
119
+ // Only the bare id exists — so a request for `<id>.svg` is found only if
120
+ // the route stripped the extension before the lookup.
121
+ findUnique: vi.fn(({ where }: { where: { id: string } }) =>
122
+ Promise.resolve(where.id === 'ckl1234abcd' ? LEGACY_ICON : null),
123
+ ),
124
+ },
125
+ } as unknown as PrismaClient)
126
+ })
127
+
128
+ it('serves the icon as image/svg+xml anyway', async () => {
129
+ const handler = captureHandler(
130
+ (router) =>
131
+ registerIconRestController(router, { basePath: '/api/icons' }),
132
+ '/api/icons/:name',
133
+ )
134
+ const { res, headers } = fakeRes()
135
+
136
+ await handler(
137
+ {
138
+ params: { name: 'example-icon.svg' },
139
+ headers: {},
140
+ } as unknown as Request,
141
+ res,
142
+ )
143
+
144
+ expect(headers['Content-Type']).toBe('image/svg+xml')
145
+ })
146
+
147
+ it('serves a screenshot by an extension-bearing URL', async () => {
148
+ const handler = captureHandler(
149
+ (router) =>
150
+ registerScreenshotRestController(router, {
151
+ basePath: '/api/screenshots',
152
+ }),
153
+ '/api/screenshots/:id',
154
+ )
155
+ const { res, headers, spy } = fakeRes()
156
+
157
+ await handler(
158
+ {
159
+ params: { id: 'ckl1234abcd.svg' },
160
+ query: {},
161
+ headers: {},
162
+ } as unknown as Request,
163
+ res,
164
+ )
165
+
166
+ expect(spy.status).not.toHaveBeenCalledWith(404)
167
+ expect(headers['Content-Type']).toBe('image/svg+xml')
168
+ })
169
+ })
@@ -2,7 +2,13 @@ import type { Request, Response, Router } from 'express'
2
2
  import multer from 'multer'
3
3
  import sharp from 'sharp'
4
4
  import { getDbClient } from '../../db'
5
- import { getImageFormat, isRasterImage, resizeImage } from './assetUtils'
5
+ import {
6
+ getImageFormat,
7
+ isRasterImage,
8
+ mimeTypeForAsset,
9
+ resizeImage,
10
+ stripAssetExtension,
11
+ } from './assetUtils'
6
12
  import { upsertAsset } from './upsertAsset'
7
13
  import { isNotModified, setRevalidatingCacheHeaders } from './assetCache'
8
14
 
@@ -82,7 +88,10 @@ export function registerAssetRestController(
82
88
  prisma,
83
89
  buffer: req.file.buffer,
84
90
  name,
85
- originalFilename: req.file.filename,
91
+ // `filename` is only set by multer's disk storage; in memory the
92
+ // uploaded name lives in `originalname`, and it is what decides the
93
+ // stored content type.
94
+ originalFilename: req.file.originalname,
86
95
  assetType,
87
96
  })
88
97
 
@@ -97,7 +106,7 @@ export function registerAssetRestController(
97
106
  // Get asset binary by ID
98
107
  router.get(`${basePath}/:id`, async (req: Request, res: Response) => {
99
108
  try {
100
- const { id } = req.params
109
+ const id = stripAssetExtension(req.params['id'] ?? '')
101
110
 
102
111
  const asset = await prisma.dbAsset.findUnique({
103
112
  where: { id },
@@ -122,11 +131,11 @@ export function registerAssetRestController(
122
131
  const width = wParam ? Number.parseInt(wParam, 10) : undefined
123
132
 
124
133
  let outBuffer: Uint8Array = asset.content
125
- let outMime = asset.mimeType
134
+ let outMime = mimeTypeForAsset(asset.name, asset.mimeType)
126
135
 
127
136
  const shouldResize =
128
137
  resizeEnabled &&
129
- isRasterImage(asset.mimeType) &&
138
+ isRasterImage(outMime) &&
130
139
  !!width &&
131
140
  Number.isFinite(width) &&
132
141
  width > 0
@@ -143,7 +152,7 @@ export function registerAssetRestController(
143
152
  }
144
153
 
145
154
  if (shouldResize) {
146
- const fmt = getImageFormat(asset.mimeType) || 'jpeg'
155
+ const fmt = getImageFormat(outMime) || 'jpeg'
147
156
  const buf = await resizeImage(
148
157
  Buffer.from(asset.content),
149
158
  width,
@@ -231,13 +240,11 @@ export function registerAssetRestController(
231
240
  const width = wParam ? Number.parseInt(wParam, 10) : undefined
232
241
 
233
242
  let outBuffer: Uint8Array = asset.content
234
- let outMime = asset.mimeType
243
+ let outMime = mimeTypeForAsset(asset.name, asset.mimeType)
235
244
 
236
- const isRaster =
237
- asset.mimeType.startsWith('image/') && !asset.mimeType.includes('svg')
238
245
  const shouldResize =
239
246
  resizeEnabled &&
240
- isRaster &&
247
+ isRasterImage(outMime) &&
241
248
  !!width &&
242
249
  Number.isFinite(width) &&
243
250
  width > 0
@@ -254,11 +261,7 @@ export function registerAssetRestController(
254
261
  }
255
262
 
256
263
  if (shouldResize) {
257
- const fmt = asset.mimeType.includes('png')
258
- ? 'png'
259
- : asset.mimeType.includes('webp')
260
- ? 'webp'
261
- : 'jpeg'
264
+ const fmt = getImageFormat(outMime) || 'jpeg'
262
265
 
263
266
  let buf: Buffer
264
267
  const pipeline = sharp(Buffer.from(asset.content)).resize({
@@ -1,6 +1,11 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import type { FormatEnum } from 'sharp'
3
3
  import sharp from 'sharp'
4
+ import {
5
+ getExtensionFromFilename,
6
+ getMimeTypeFromExtension,
7
+ isKnownImageMimeType,
8
+ } from '../icons/iconUtils'
4
9
  import type { ParseAssetParams, ParseAssetReturn } from './assetRestController'
5
10
 
6
11
  /**
@@ -84,6 +89,36 @@ export function isRasterImage(mimeType: string): boolean {
84
89
  return mimeType.startsWith('image/') && !mimeType.includes('svg')
85
90
  }
86
91
 
92
+ /**
93
+ * The content type an asset is served (and stored) with.
94
+ *
95
+ * The filename's extension decides, because it is the one piece of the asset a
96
+ * human chose and a reader can see. `sharp`'s detected format is only a fallback
97
+ * for a file that arrived without a usable extension, and an unrecognised value
98
+ * degrades to `application/octet-stream` rather than being turned into an
99
+ * `image/<whatever-the-library-called-it>` type that no browser accepts.
100
+ */
101
+ export function mimeTypeForAsset(
102
+ filename: string | undefined,
103
+ fallback?: string,
104
+ ): string {
105
+ const byExtension = getMimeTypeFromExtension(
106
+ getExtensionFromFilename(filename ?? ''),
107
+ )
108
+ if (byExtension !== 'application/octet-stream') return byExtension
109
+ if (fallback && isKnownImageMimeType(fallback)) return fallback.toLowerCase()
110
+ return 'application/octet-stream'
111
+ }
112
+
113
+ /**
114
+ * `<id>.png` -> `<id>`. Asset URLs may carry the file extension so that a
115
+ * browser, a CDN and "save image as" all see the real file type; the stored id
116
+ * itself never contains a dot, so stripping one is unambiguous.
117
+ */
118
+ export function stripAssetExtension(id: string): string {
119
+ return id.replace(/\.[A-Za-z0-9]+$/, '')
120
+ }
121
+
87
122
  export async function parseAssetMeta(
88
123
  p: ParseAssetParams,
89
124
  ): Promise<ParseAssetReturn> {
@@ -100,16 +135,16 @@ export async function parseAssetMeta(
100
135
  gif: 'image/gif',
101
136
  heif: 'image/heif',
102
137
  svg: 'image/svg+xml',
103
- raw: 'application/octet-stream',
104
138
  }
105
139
 
106
140
  return {
107
141
  checksum: generateChecksum(p.buffer),
108
142
  width,
109
143
  height,
110
- mimeType: format
111
- ? (formatToMime[format] ?? `image/${format}`)
112
- : 'application/octet-stream',
144
+ mimeType: mimeTypeForAsset(
145
+ p.originalFilename,
146
+ format ? formatToMime[format] : undefined,
147
+ ),
113
148
  fileSize: size || 0,
114
149
  }
115
150
  }
@@ -2,6 +2,7 @@ import type { Request, Response, Router } from 'express'
2
2
  import sharp from 'sharp'
3
3
  import { getDbClient } from '../../db'
4
4
  import { isNotModified, setRevalidatingCacheHeaders } from './assetCache'
5
+ import { mimeTypeForAsset, stripAssetExtension } from './assetUtils'
5
6
 
6
7
  export interface ScreenshotRestControllerConfig {
7
8
  /**
@@ -123,7 +124,7 @@ export function registerScreenshotRestController(
123
124
  // Get screenshot binary by ID
124
125
  router.get(`${basePath}/:id`, async (req: Request, res: Response) => {
125
126
  try {
126
- const { id } = req.params
127
+ const id = stripAssetExtension(req.params['id'] ?? '')
127
128
  const sizeParam = req.query.size as string | undefined
128
129
  const targetSize = sizeParam ? parseInt(sizeParam, 10) : undefined
129
130
 
@@ -172,7 +173,10 @@ export function registerScreenshotRestController(
172
173
  }
173
174
 
174
175
  // Set appropriate headers
175
- res.setHeader('Content-Type', screenshot.mimeType)
176
+ res.setHeader(
177
+ 'Content-Type',
178
+ mimeTypeForAsset(screenshot.name, screenshot.mimeType),
179
+ )
176
180
  res.setHeader(
177
181
  'Content-Disposition',
178
182
  `inline; filename="${screenshot.name}"`,
@@ -7,6 +7,7 @@ import {
7
7
  isNotModified,
8
8
  setRevalidatingCacheHeaders,
9
9
  } from '../assets/assetCache'
10
+ import { mimeTypeForAsset } from '../assets/assetUtils'
10
11
 
11
12
  // Configure multer for memory storage
12
13
  const upload = multer({
@@ -81,7 +82,9 @@ export function registerIconRestController(
81
82
  name,
82
83
  assetType: 'icon',
83
84
  content: new Uint8Array(req.file.buffer),
84
- mimeType: req.file.mimetype,
85
+ // The stored name always carries an extension by now, and it — not
86
+ // the client's claimed content type — decides how the icon is served.
87
+ mimeType: mimeTypeForAsset(name, req.file.mimetype),
85
88
  fileSize: req.file.size,
86
89
  checksum,
87
90
  },
@@ -132,7 +135,7 @@ export function registerIconRestController(
132
135
  }
133
136
 
134
137
  // Set appropriate headers
135
- res.setHeader('Content-Type', icon.mimeType)
138
+ res.setHeader('Content-Type', mimeTypeForAsset(icon.name, icon.mimeType))
136
139
  res.setHeader('Content-Disposition', `inline; filename="${icon.name}"`)
137
140
 
138
141
  // Send binary content
@@ -1,6 +1,10 @@
1
1
  import { z } from 'zod'
2
2
  import { getDbClient } from '../../db'
3
- import { generateChecksum, getImageDimensions } from '../assets/assetUtils'
3
+ import {
4
+ generateChecksum,
5
+ getImageDimensions,
6
+ mimeTypeForAsset,
7
+ } from '../assets/assetUtils'
4
8
  import { getExtensionFromMimeType } from './iconUtils'
5
9
  import { adminProcedure, publicProcedure, router } from '../../server/trpcSetup'
6
10
 
@@ -83,7 +87,7 @@ export function createIconRouter() {
83
87
  assetType: 'icon',
84
88
  content: new Uint8Array(buffer),
85
89
  checksum,
86
- mimeType: input.mimeType,
90
+ mimeType: mimeTypeForAsset(name, input.mimeType),
87
91
  fileSize: input.fileSize,
88
92
  width,
89
93
  height,
@@ -172,7 +176,7 @@ export function createIconRouter() {
172
176
  // Return base64 encoded content
173
177
  return {
174
178
  content: Buffer.from(asset.content).toString('base64'),
175
- mimeType: asset.mimeType,
179
+ mimeType: mimeTypeForAsset(asset.name, asset.mimeType),
176
180
  name: asset.name,
177
181
  }
178
182
  }),
@@ -26,21 +26,35 @@ export function getExtensionFromFilename(filename: string): string {
26
26
  return match?.[1]?.toLowerCase() || ''
27
27
  }
28
28
 
29
+ const MIME_BY_EXTENSION: Record<string, string> = {
30
+ svg: 'image/svg+xml',
31
+ png: 'image/png',
32
+ jpg: 'image/jpeg',
33
+ jpeg: 'image/jpeg',
34
+ webp: 'image/webp',
35
+ avif: 'image/avif',
36
+ gif: 'image/gif',
37
+ bmp: 'image/bmp',
38
+ tiff: 'image/tiff',
39
+ heif: 'image/heif',
40
+ ico: 'image/x-icon',
41
+ }
42
+
29
43
  /**
30
44
  * Get MIME type from extension
31
45
  */
32
46
  export function getMimeTypeFromExtension(extension: string): string {
33
- const extMap: Record<string, string> = {
34
- svg: 'image/svg+xml',
35
- png: 'image/png',
36
- jpg: 'image/jpeg',
37
- jpeg: 'image/jpeg',
38
- webp: 'image/webp',
39
- gif: 'image/gif',
40
- bmp: 'image/bmp',
41
- tiff: 'image/tiff',
42
- ico: 'image/x-icon',
43
- }
47
+ return (
48
+ MIME_BY_EXTENSION[extension.toLowerCase()] || 'application/octet-stream'
49
+ )
50
+ }
44
51
 
45
- return extMap[extension.toLowerCase()] || 'application/octet-stream'
52
+ /**
53
+ * Whether a MIME type is one we knowingly serve, rather than something invented
54
+ * from an image library's format name (`image/svg`, `image/magick`). A browser
55
+ * behind `X-Content-Type-Options: nosniff` refuses to render an invented type in
56
+ * an `<img>`, so an unrecognised value must never reach a response header.
57
+ */
58
+ export function isKnownImageMimeType(mimeType: string): boolean {
59
+ return Object.values(MIME_BY_EXTENSION).includes(mimeType.toLowerCase())
46
60
  }