@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.
- package/dist/modules/assets/assetRestController.d.mts.map +1 -1
- package/dist/modules/assets/assetRestController.mjs +9 -10
- package/dist/modules/assets/assetRestController.mjs.map +1 -1
- package/dist/modules/assets/assetUtils.mjs +28 -5
- package/dist/modules/assets/assetUtils.mjs.map +1 -1
- package/dist/modules/assets/screenshotRestController.d.mts.map +1 -1
- package/dist/modules/assets/screenshotRestController.mjs +3 -2
- package/dist/modules/assets/screenshotRestController.mjs.map +1 -1
- package/dist/modules/icons/iconRestController.d.mts.map +1 -1
- package/dist/modules/icons/iconRestController.mjs +3 -2
- package/dist/modules/icons/iconRestController.mjs.map +1 -1
- package/dist/modules/icons/iconUtils.mjs +29 -1
- package/dist/modules/icons/iconUtils.mjs.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/assetContentType.test.ts +169 -0
- package/src/modules/assets/assetRestController.ts +18 -15
- package/src/modules/assets/assetUtils.ts +39 -4
- package/src/modules/assets/screenshotRestController.ts +6 -2
- package/src/modules/icons/iconRestController.ts +5 -2
- package/src/modules/icons/iconRouter.ts +7 -3
- package/src/modules/icons/iconUtils.ts +26 -12
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"assetRestController.d.mts","names":[],"sources":["../../../src/modules/assets/assetRestController.ts"],"mappings":";;;
|
|
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.
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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 =
|
|
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":"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"}
|
|
@@ -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
|
-
|
|
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
|
|
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":";;;
|
|
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
|
|
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
|
|
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":";;;
|
|
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":"
|
|
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\
|
|
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
|
|
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
|
|
48
|
-
"@igstack/app-catalog-shared-core": "6.3.0
|
|
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": "
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
111
|
-
|
|
112
|
-
:
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
}
|