@warlock.js/core 5.16.0 → 5.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/esm/cli/commands/migrate.command.mjs +5 -1
  3. package/esm/cli/commands/migrate.command.mjs.map +1 -1
  4. package/esm/cli/commands/seed.command.mjs +2 -1
  5. package/esm/cli/commands/seed.command.mjs.map +1 -1
  6. package/esm/cli/commands/typings-generator.command.mjs +16 -23
  7. package/esm/cli/commands/typings-generator.command.mjs.map +1 -1
  8. package/esm/config/locale-configuration.mjs +7 -1
  9. package/esm/config/locale-configuration.mjs.map +1 -1
  10. package/esm/connectors/http-connector.d.mts.map +1 -1
  11. package/esm/connectors/http-connector.mjs +1 -18
  12. package/esm/connectors/http-connector.mjs.map +1 -1
  13. package/esm/connectors/read-bound-port.mjs +32 -0
  14. package/esm/connectors/read-bound-port.mjs.map +1 -0
  15. package/esm/dev-server/development-server.mjs +6 -2
  16. package/esm/dev-server/development-server.mjs.map +1 -1
  17. package/esm/dev-server/extract-translation-keys.mjs +68 -0
  18. package/esm/dev-server/extract-translation-keys.mjs.map +1 -0
  19. package/esm/dev-server/health-checker/checkers/typescript-health-checker.mjs +5 -1
  20. package/esm/dev-server/health-checker/checkers/typescript-health-checker.mjs.map +1 -1
  21. package/esm/dev-server/health-checker/workers/ts-health.worker.mjs +37 -8
  22. package/esm/dev-server/health-checker/workers/ts-health.worker.mjs.map +1 -1
  23. package/esm/dev-server/translation-keys-sources.mjs +19 -0
  24. package/esm/dev-server/translation-keys-sources.mjs.map +1 -0
  25. package/esm/dev-server/type-generator.mjs +64 -11
  26. package/esm/dev-server/type-generator.mjs.map +1 -1
  27. package/esm/generations/stubs.mjs +7 -6
  28. package/esm/generations/stubs.mjs.map +1 -1
  29. package/esm/http/csrf-default-guard.d.mts +32 -0
  30. package/esm/http/csrf-default-guard.d.mts.map +1 -0
  31. package/esm/http/csrf-default-guard.mjs +156 -0
  32. package/esm/http/csrf-default-guard.mjs.map +1 -0
  33. package/esm/http/csrf-origin-policy.d.mts +53 -0
  34. package/esm/http/csrf-origin-policy.d.mts.map +1 -0
  35. package/esm/http/csrf-origin-policy.mjs +104 -0
  36. package/esm/http/csrf-origin-policy.mjs.map +1 -0
  37. package/esm/http/error-codes.d.mts +14 -1
  38. package/esm/http/error-codes.d.mts.map +1 -1
  39. package/esm/http/error-codes.mjs +13 -0
  40. package/esm/http/error-codes.mjs.map +1 -1
  41. package/esm/http/index.d.mts +7 -1
  42. package/esm/http/index.mjs +7 -1
  43. package/esm/http/middleware/concurrency-limit.middleware.mjs +1 -1
  44. package/esm/http/middleware/idempotency.middleware.mjs +1 -1
  45. package/esm/http/middleware/inject-request-context.d.mts.map +1 -1
  46. package/esm/http/middleware/inject-request-context.mjs +6 -0
  47. package/esm/http/middleware/inject-request-context.mjs.map +1 -1
  48. package/esm/http/middleware/ip-filter.middleware.mjs +1 -1
  49. package/esm/http/middleware/maintenance.middleware.mjs +1 -1
  50. package/esm/http/middleware/max-body-size.middleware.mjs +1 -1
  51. package/esm/http/middleware/rate-limit.middleware.mjs +1 -1
  52. package/esm/http/request.d.mts +2 -2
  53. package/esm/http/request.d.mts.map +1 -1
  54. package/esm/http/request.mjs +7 -3
  55. package/esm/http/request.mjs.map +1 -1
  56. package/esm/http/response.d.mts +3 -3
  57. package/esm/http/response.d.mts.map +1 -1
  58. package/esm/http/response.mjs +7 -1
  59. package/esm/http/response.mjs.map +1 -1
  60. package/esm/http/uploads/detect-image-format.mjs +59 -0
  61. package/esm/http/uploads/detect-image-format.mjs.map +1 -0
  62. package/esm/http/uploads/generate-image-variant.mjs +46 -0
  63. package/esm/http/uploads/generate-image-variant.mjs.map +1 -0
  64. package/esm/http/uploads/generate-image-variants.d.mts +49 -0
  65. package/esm/http/uploads/generate-image-variants.d.mts.map +1 -0
  66. package/esm/http/uploads/generate-image-variants.mjs +141 -0
  67. package/esm/http/uploads/generate-image-variants.mjs.map +1 -0
  68. package/esm/http/uploads/image-variant-types.d.mts +93 -0
  69. package/esm/http/uploads/image-variant-types.d.mts.map +1 -0
  70. package/esm/http/uploads/image-variants-config-error.d.mts +13 -0
  71. package/esm/http/uploads/image-variants-config-error.d.mts.map +1 -0
  72. package/esm/http/uploads/image-variants-config-error.mjs +17 -0
  73. package/esm/http/uploads/image-variants-config-error.mjs.map +1 -0
  74. package/esm/http/uploads/index.d.mts +4 -0
  75. package/esm/http/uploads/index.mjs +5 -0
  76. package/esm/http/uploads/is-path-inside.mjs +26 -0
  77. package/esm/http/uploads/is-path-inside.mjs.map +1 -0
  78. package/esm/http/uploads/load-variant-source.mjs +42 -0
  79. package/esm/http/uploads/load-variant-source.mjs.map +1 -0
  80. package/esm/http/uploads/matches-if-none-match.mjs +13 -0
  81. package/esm/http/uploads/matches-if-none-match.mjs.map +1 -0
  82. package/esm/http/uploads/parse-uploaded-file-query.mjs +51 -0
  83. package/esm/http/uploads/parse-uploaded-file-query.mjs.map +1 -0
  84. package/esm/http/uploads/read-file-head.mjs +20 -0
  85. package/esm/http/uploads/read-file-head.mjs.map +1 -0
  86. package/esm/http/uploads/resolve-image-variants-config.mjs +91 -0
  87. package/esm/http/uploads/resolve-image-variants-config.mjs.map +1 -0
  88. package/esm/http/uploads/resolve-inline-image-content-type.mjs +53 -0
  89. package/esm/http/uploads/resolve-inline-image-content-type.mjs.map +1 -0
  90. package/esm/http/uploads/resolve-original-content-type.mjs +31 -0
  91. package/esm/http/uploads/resolve-original-content-type.mjs.map +1 -0
  92. package/esm/http/uploads/resolve-upload-path.mjs +56 -0
  93. package/esm/http/uploads/resolve-upload-path.mjs.map +1 -0
  94. package/esm/http/uploads/resolve-variant-candidate.mjs +39 -0
  95. package/esm/http/uploads/resolve-variant-candidate.mjs.map +1 -0
  96. package/esm/http/uploads/single-flight.mjs +40 -0
  97. package/esm/http/uploads/single-flight.mjs.map +1 -0
  98. package/esm/http/uploads/uploaded-file.controller.d.mts +24 -0
  99. package/esm/http/uploads/uploaded-file.controller.d.mts.map +1 -0
  100. package/esm/http/uploads/uploaded-file.controller.mjs +174 -0
  101. package/esm/http/uploads/uploaded-file.controller.mjs.map +1 -0
  102. package/esm/http/uploads/variant-cache-key.mjs +45 -0
  103. package/esm/http/uploads/variant-cache-key.mjs.map +1 -0
  104. package/esm/http/uploads/variant-generations.mjs +16 -0
  105. package/esm/http/uploads/variant-generations.mjs.map +1 -0
  106. package/esm/http/uploads-types.d.mts +102 -1
  107. package/esm/http/uploads-types.d.mts.map +1 -1
  108. package/esm/index.d.mts +9 -2
  109. package/esm/index.mjs +8 -2
  110. package/esm/router/types.d.mts +23 -0
  111. package/esm/router/types.d.mts.map +1 -1
  112. package/esm/storage/index.d.mts +1 -0
  113. package/esm/storage/index.mjs +1 -0
  114. package/esm/storage/scoped-storage.d.mts +1 -0
  115. package/esm/storage/scoped-storage.d.mts.map +1 -1
  116. package/esm/storage/scoped-storage.mjs +3 -0
  117. package/esm/storage/scoped-storage.mjs.map +1 -1
  118. package/esm/storage/storage.d.mts +2 -0
  119. package/esm/storage/storage.d.mts.map +1 -1
  120. package/esm/storage/storage.mjs +4 -0
  121. package/esm/storage/storage.mjs.map +1 -1
  122. package/esm/storage/utils/storage-not-initialized-error.d.mts +22 -0
  123. package/esm/storage/utils/storage-not-initialized-error.d.mts.map +1 -0
  124. package/esm/storage/utils/storage-not-initialized-error.mjs +24 -0
  125. package/esm/storage/utils/storage-not-initialized-error.mjs.map +1 -0
  126. package/llms-full.txt +17 -1
  127. package/package.json +22 -21
  128. package/skills/use-localization/SKILL.md +16 -0
  129. package/skills/use-middleware/SKILL.md +1 -1
  130. package/esm/dev-server/translation-type-generator.mjs +0 -28
  131. package/esm/dev-server/translation-type-generator.mjs.map +0 -1
@@ -0,0 +1,53 @@
1
+ import path from "node:path";
2
+
3
+ //#region ../core/src/http/uploads/resolve-inline-image-content-type.ts
4
+ /**
5
+ * Extensions an upload original may carry for each raster format that is
6
+ * allowed to render inline. Anything else — including `.gif`, since gif is
7
+ * not a variant source format — is never served inline, regardless of what
8
+ * its bytes sniff as.
9
+ */
10
+ const EXTENSION_IMAGE_FORMATS = {
11
+ ".jpg": "jpeg",
12
+ ".jpeg": "jpeg",
13
+ ".png": "png",
14
+ ".webp": "webp",
15
+ ".avif": "avif"
16
+ };
17
+ /**
18
+ * Content type an inline response must advertise for each raster format,
19
+ * used instead of an extension-derived guess.
20
+ */
21
+ const INLINE_IMAGE_CONTENT_TYPES = {
22
+ jpeg: "image/jpeg",
23
+ png: "image/png",
24
+ webp: "image/webp",
25
+ avif: "image/avif"
26
+ };
27
+ /**
28
+ * Content type to serve an upload original inline with, or `undefined` when
29
+ * it must go out as an attachment instead.
30
+ *
31
+ * `sendFile` sets `Content-Type` from the file's EXTENSION, not its bytes.
32
+ * The inline branch in `uploaded-file.controller.ts` only sniffs the bytes to
33
+ * decide "is this raster" — so a JPEG-signature file named `x.html` sniffed
34
+ * as jpeg (raster, so "safe") would still have been sent by `sendFile` as
35
+ * `text/html`: a polyglot JPEG/HTML stored-XSS vector, the reverse of the
36
+ * already-fixed SVG-named-`.png` case.
37
+ *
38
+ * Inline is therefore only safe when the sniffed format AND the
39
+ * extension-derived format agree — the extension maps to the SAME image
40
+ * type the bytes sniffed as. Any mismatch (raster bytes under a non-image or
41
+ * different-image extension) returns `undefined`, sending the caller to the
42
+ * attachment path instead of trusting either signal alone.
43
+ */
44
+ function resolveInlineImageContentType(filePath, sniffedFormat) {
45
+ if (sniffedFormat === void 0) return void 0;
46
+ const extensionFormat = EXTENSION_IMAGE_FORMATS[path.extname(filePath).toLowerCase()];
47
+ if (extensionFormat === void 0 || extensionFormat !== sniffedFormat) return void 0;
48
+ return INLINE_IMAGE_CONTENT_TYPES[extensionFormat];
49
+ }
50
+
51
+ //#endregion
52
+ export { resolveInlineImageContentType };
53
+ //# sourceMappingURL=resolve-inline-image-content-type.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-inline-image-content-type.mjs","names":[],"sources":["../../../../../../../../core/src/http/uploads/resolve-inline-image-content-type.ts"],"sourcesContent":["import path from \"node:path\";\nimport type { DetectedImageFormat, VariantSourceFormat } from \"./image-variant-types\";\n\n/**\n * Extensions an upload original may carry for each raster format that is\n * allowed to render inline. Anything else — including `.gif`, since gif is\n * not a variant source format — is never served inline, regardless of what\n * its bytes sniff as.\n */\nconst EXTENSION_IMAGE_FORMATS: Readonly<Record<string, VariantSourceFormat>> = {\n \".jpg\": \"jpeg\",\n \".jpeg\": \"jpeg\",\n \".png\": \"png\",\n \".webp\": \"webp\",\n \".avif\": \"avif\",\n};\n\n/**\n * Content type an inline response must advertise for each raster format,\n * used instead of an extension-derived guess.\n */\nconst INLINE_IMAGE_CONTENT_TYPES: Readonly<Record<VariantSourceFormat, string>> = {\n jpeg: \"image/jpeg\",\n png: \"image/png\",\n webp: \"image/webp\",\n avif: \"image/avif\",\n};\n\n/**\n * Content type to serve an upload original inline with, or `undefined` when\n * it must go out as an attachment instead.\n *\n * `sendFile` sets `Content-Type` from the file's EXTENSION, not its bytes.\n * The inline branch in `uploaded-file.controller.ts` only sniffs the bytes to\n * decide \"is this raster\" — so a JPEG-signature file named `x.html` sniffed\n * as jpeg (raster, so \"safe\") would still have been sent by `sendFile` as\n * `text/html`: a polyglot JPEG/HTML stored-XSS vector, the reverse of the\n * already-fixed SVG-named-`.png` case.\n *\n * Inline is therefore only safe when the sniffed format AND the\n * extension-derived format agree — the extension maps to the SAME image\n * type the bytes sniffed as. Any mismatch (raster bytes under a non-image or\n * different-image extension) returns `undefined`, sending the caller to the\n * attachment path instead of trusting either signal alone.\n */\nexport function resolveInlineImageContentType(\n filePath: string,\n sniffedFormat: DetectedImageFormat | undefined,\n): string | undefined {\n if (sniffedFormat === undefined) return undefined;\n\n const extensionFormat = EXTENSION_IMAGE_FORMATS[path.extname(filePath).toLowerCase()];\n\n if (extensionFormat === undefined || extensionFormat !== sniffedFormat) return undefined;\n\n return INLINE_IMAGE_CONTENT_TYPES[extensionFormat];\n}\n"],"mappings":";;;;;;;;;AASA,MAAM,0BAAyE;CAC7E,QAAQ;CACR,SAAS;CACT,QAAQ;CACR,SAAS;CACT,SAAS;AACX;;;;;AAMA,MAAM,6BAA4E;CAChF,MAAM;CACN,KAAK;CACL,MAAM;CACN,MAAM;AACR;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,8BACd,UACA,eACoB;CACpB,IAAI,kBAAkB,QAAW,OAAO;CAExC,MAAM,kBAAkB,wBAAwB,KAAK,QAAQ,QAAQ,CAAC,CAAC,YAAY;CAEnF,IAAI,oBAAoB,UAAa,oBAAoB,eAAe,OAAO;CAE/E,OAAO,2BAA2B;AACpC"}
@@ -0,0 +1,31 @@
1
+ import mime from "mime";
2
+
3
+ //#region ../core/src/http/uploads/resolve-original-content-type.ts
4
+ /**
5
+ * Extension-derived MIME types that are never safe to advertise as-is: a
6
+ * browser that trusts one of these will parse and execute the response body
7
+ * (inline `<script>` in an svg, markup and script in html/xhtml/xml), so an
8
+ * upload served under its real type is stored XSS the moment it is fetched
9
+ * from the app's own origin.
10
+ */
11
+ const INLINE_EXECUTABLE_MIME_TYPES = new Set([
12
+ "image/svg+xml",
13
+ "text/html",
14
+ "application/xhtml+xml",
15
+ "text/xml",
16
+ "application/xml"
17
+ ]);
18
+ /**
19
+ * Content type for an upload original that is being served as an
20
+ * `attachment` (see `uploaded-file.controller.ts`): the extension-derived
21
+ * type, except the svg/html/xml family, which is downgraded to
22
+ * `application/octet-stream` so a browser never sniffs or renders it.
23
+ */
24
+ function resolveOriginalContentType(filePath) {
25
+ const type = mime.getType(filePath) || "application/octet-stream";
26
+ return INLINE_EXECUTABLE_MIME_TYPES.has(type) ? "application/octet-stream" : type;
27
+ }
28
+
29
+ //#endregion
30
+ export { resolveOriginalContentType };
31
+ //# sourceMappingURL=resolve-original-content-type.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-original-content-type.mjs","names":[],"sources":["../../../../../../../../core/src/http/uploads/resolve-original-content-type.ts"],"sourcesContent":["import mime from \"mime\";\n\n/**\n * Extension-derived MIME types that are never safe to advertise as-is: a\n * browser that trusts one of these will parse and execute the response body\n * (inline `<script>` in an svg, markup and script in html/xhtml/xml), so an\n * upload served under its real type is stored XSS the moment it is fetched\n * from the app's own origin.\n */\nconst INLINE_EXECUTABLE_MIME_TYPES = new Set([\n \"image/svg+xml\",\n \"text/html\",\n \"application/xhtml+xml\",\n \"text/xml\",\n \"application/xml\",\n]);\n\n/**\n * Content type for an upload original that is being served as an\n * `attachment` (see `uploaded-file.controller.ts`): the extension-derived\n * type, except the svg/html/xml family, which is downgraded to\n * `application/octet-stream` so a browser never sniffs or renders it.\n */\nexport function resolveOriginalContentType(filePath: string): string {\n const type = mime.getType(filePath) || \"application/octet-stream\";\n\n return INLINE_EXECUTABLE_MIME_TYPES.has(type) ? \"application/octet-stream\" : type;\n}\n"],"mappings":";;;;;;;;;;AASA,MAAM,+BAA+B,IAAI,IAAI;CAC3C;CACA;CACA;CACA;CACA;AACF,CAAC;;;;;;;AAQD,SAAgB,2BAA2B,UAA0B;CACnE,MAAM,OAAO,KAAK,QAAQ,QAAQ,KAAK;CAEvC,OAAO,6BAA6B,IAAI,IAAI,IAAI,6BAA6B;AAC/E"}
@@ -0,0 +1,56 @@
1
+ import { isPathInside, isPathWithin } from "./is-path-inside.mjs";
2
+ import path from "node:path";
3
+ import fs from "node:fs/promises";
4
+
5
+ //#region ../core/src/http/uploads/resolve-upload-path.ts
6
+ async function realpathOrSelf(target) {
7
+ try {
8
+ return await fs.realpath(target);
9
+ } catch {
10
+ return target;
11
+ }
12
+ }
13
+ /**
14
+ * Resolve the wildcard of an uploads route to a file inside the storage root.
15
+ *
16
+ * The wildcard arrives already decoded once by the router and is NOT decoded
17
+ * again: a second decode would turn `%252e%252e%252f` into `../`.
18
+ *
19
+ * Returns `undefined` — the caller answers 404 for every case, so the response
20
+ * is no existence oracle — when the path:
21
+ * - is empty, carries a NUL byte, or is absolute
22
+ * - resolves outside the root, or to the root itself
23
+ * - lies in one of the `excludedDirectories` (the variant cache)
24
+ * - does not exist, or is not a regular file
25
+ * - is (or passes through) a symlink whose target leaves the root
26
+ */
27
+ async function resolveUploadPath(wildcard, storageRoot, excludedDirectories = []) {
28
+ if (typeof wildcard !== "string" || wildcard === "" || wildcard.includes("\0")) return;
29
+ if (path.isAbsolute(wildcard) || path.win32.isAbsolute(wildcard) || /^[A-Za-z]:/.test(wildcard)) return;
30
+ const root = path.resolve(storageRoot);
31
+ const resolved = path.resolve(root, wildcard);
32
+ if (!isPathInside(resolved, root)) return void 0;
33
+ for (const excluded of excludedDirectories) if (isPathWithin(resolved, path.resolve(excluded))) return void 0;
34
+ let realPath;
35
+ try {
36
+ realPath = await fs.realpath(resolved);
37
+ } catch {
38
+ return;
39
+ }
40
+ const realRoot = await realpathOrSelf(root);
41
+ if (!isPathInside(realPath, realRoot)) return void 0;
42
+ for (const excluded of excludedDirectories) if (isPathWithin(realPath, await realpathOrSelf(path.resolve(excluded)))) return void 0;
43
+ try {
44
+ if (!(await fs.stat(realPath)).isFile()) return void 0;
45
+ } catch {
46
+ return;
47
+ }
48
+ return {
49
+ absolutePath: realPath,
50
+ relativePath: path.relative(realRoot, realPath).split(path.sep).join("/")
51
+ };
52
+ }
53
+
54
+ //#endregion
55
+ export { resolveUploadPath };
56
+ //# sourceMappingURL=resolve-upload-path.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-upload-path.mjs","names":[],"sources":["../../../../../../../../core/src/http/uploads/resolve-upload-path.ts"],"sourcesContent":["import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { isPathInside, isPathWithin } from \"./is-path-inside\";\n\n/**\n * A request path that resolved to a regular file inside the storage root\n */\nexport type ResolvedUploadPath = {\n /**\n * Real (symlink-free) absolute path of the file\n */\n absolutePath: string;\n\n /**\n * Path of the file relative to the real storage root, `/`-separated\n */\n relativePath: string;\n};\n\nasync function realpathOrSelf(target: string): Promise<string> {\n try {\n return await fs.realpath(target);\n } catch {\n return target;\n }\n}\n\n/**\n * Resolve the wildcard of an uploads route to a file inside the storage root.\n *\n * The wildcard arrives already decoded once by the router and is NOT decoded\n * again: a second decode would turn `%252e%252e%252f` into `../`.\n *\n * Returns `undefined` — the caller answers 404 for every case, so the response\n * is no existence oracle — when the path:\n * - is empty, carries a NUL byte, or is absolute\n * - resolves outside the root, or to the root itself\n * - lies in one of the `excludedDirectories` (the variant cache)\n * - does not exist, or is not a regular file\n * - is (or passes through) a symlink whose target leaves the root\n */\nexport async function resolveUploadPath(\n wildcard: unknown,\n storageRoot: string,\n excludedDirectories: readonly string[] = [],\n): Promise<ResolvedUploadPath | undefined> {\n if (typeof wildcard !== \"string\" || wildcard === \"\" || wildcard.includes(\"\\0\")) {\n return undefined;\n }\n\n if (path.isAbsolute(wildcard) || path.win32.isAbsolute(wildcard) || /^[A-Za-z]:/.test(wildcard)) {\n return undefined;\n }\n\n const root = path.resolve(storageRoot);\n const resolved = path.resolve(root, wildcard);\n\n if (!isPathInside(resolved, root)) return undefined;\n\n for (const excluded of excludedDirectories) {\n if (isPathWithin(resolved, path.resolve(excluded))) return undefined;\n }\n\n let realPath: string;\n\n try {\n realPath = await fs.realpath(resolved);\n } catch {\n return undefined;\n }\n\n const realRoot = await realpathOrSelf(root);\n\n if (!isPathInside(realPath, realRoot)) return undefined;\n\n for (const excluded of excludedDirectories) {\n if (isPathWithin(realPath, await realpathOrSelf(path.resolve(excluded)))) return undefined;\n }\n\n try {\n const stats = await fs.stat(realPath);\n\n if (!stats.isFile()) return undefined;\n } catch {\n return undefined;\n }\n\n return {\n absolutePath: realPath,\n relativePath: path.relative(realRoot, realPath).split(path.sep).join(\"/\"),\n };\n}\n"],"mappings":";;;;;AAmBA,eAAe,eAAe,QAAiC;CAC7D,IAAI;EACF,OAAO,MAAM,GAAG,SAAS,MAAM;CACjC,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;;;;;;;AAgBA,eAAsB,kBACpB,UACA,aACA,sBAAyC,CAAC,GACD;CACzC,IAAI,OAAO,aAAa,YAAY,aAAa,MAAM,SAAS,SAAS,IAAI,GAC3E;CAGF,IAAI,KAAK,WAAW,QAAQ,KAAK,KAAK,MAAM,WAAW,QAAQ,KAAK,aAAa,KAAK,QAAQ,GAC5F;CAGF,MAAM,OAAO,KAAK,QAAQ,WAAW;CACrC,MAAM,WAAW,KAAK,QAAQ,MAAM,QAAQ;CAE5C,IAAI,CAAC,aAAa,UAAU,IAAI,GAAG,OAAO;CAE1C,KAAK,MAAM,YAAY,qBACrB,IAAI,aAAa,UAAU,KAAK,QAAQ,QAAQ,CAAC,GAAG,OAAO;CAG7D,IAAI;CAEJ,IAAI;EACF,WAAW,MAAM,GAAG,SAAS,QAAQ;CACvC,QAAQ;EACN;CACF;CAEA,MAAM,WAAW,MAAM,eAAe,IAAI;CAE1C,IAAI,CAAC,aAAa,UAAU,QAAQ,GAAG,OAAO;CAE9C,KAAK,MAAM,YAAY,qBACrB,IAAI,aAAa,UAAU,MAAM,eAAe,KAAK,QAAQ,QAAQ,CAAC,CAAC,GAAG,OAAO;CAGnF,IAAI;EAGF,IAAI,EAAC,MAFe,GAAG,KAAK,QAAQ,EAE1B,CAAC,OAAO,GAAG,OAAO;CAC9B,QAAQ;EACN;CACF;CAEA,OAAO;EACL,cAAc;EACd,cAAc,KAAK,SAAS,UAAU,QAAQ,CAAC,CAAC,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,GAAG;CAC1E;AACF"}
@@ -0,0 +1,39 @@
1
+ import { HttpError } from "../errors/errors.mjs";
2
+ import { detectImageFormat, isVariantSourceFormat } from "./detect-image-format.mjs";
3
+ import { readFileHead } from "./read-file-head.mjs";
4
+ import { resolveUploadPath } from "./resolve-upload-path.mjs";
5
+ import fs from "node:fs/promises";
6
+
7
+ //#region ../core/src/http/uploads/resolve-variant-candidate.ts
8
+ /**
9
+ * Resolve a path to a variant source, applying the guards both the on-demand
10
+ * route and the `generateImageVariants` ingest helper need: containment
11
+ * (via `resolveUploadPath`), the byte-size limit, and the source format,
12
+ * detected from magic bytes rather than the file extension.
13
+ *
14
+ * @returns `undefined` when the path does not resolve to a file inside the
15
+ * storage root — the caller answers 404, the same as a missing file, so the
16
+ * response is no existence oracle
17
+ * @throws HttpError(413) when the source is over `images.maxSourceBytes`
18
+ * @throws HttpError(415) when the source is not jpeg, png, webp or avif
19
+ */
20
+ async function resolveVariantCandidate(wildcard, storageRoot, images) {
21
+ const source = await resolveUploadPath(wildcard, storageRoot, [images.cacheDirectory]);
22
+ if (!source) return void 0;
23
+ const stats = await fs.stat(source.absolutePath);
24
+ if (stats.size > images.maxSourceBytes) throw new HttpError(413, "The source image is too large.");
25
+ const sourceFormat = detectImageFormat(await readFileHead(source.absolutePath, 512));
26
+ if (!isVariantSourceFormat(sourceFormat)) throw new HttpError(415, "Variants are only available for jpeg, png, webp and avif images.");
27
+ return {
28
+ source,
29
+ stats: {
30
+ size: stats.size,
31
+ mtimeMs: stats.mtimeMs
32
+ },
33
+ sourceFormat
34
+ };
35
+ }
36
+
37
+ //#endregion
38
+ export { resolveVariantCandidate };
39
+ //# sourceMappingURL=resolve-variant-candidate.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-variant-candidate.mjs","names":[],"sources":["../../../../../../../../core/src/http/uploads/resolve-variant-candidate.ts"],"sourcesContent":["import fs from \"node:fs/promises\";\nimport { HttpError } from \"../errors\";\nimport {\n detectImageFormat,\n IMAGE_SIGNATURE_BYTES,\n isVariantSourceFormat,\n} from \"./detect-image-format\";\nimport type { ResolvedImageVariantsConfig, VariantSourceFormat } from \"./image-variant-types\";\nimport { readFileHead } from \"./read-file-head\";\nimport { resolveUploadPath, type ResolvedUploadPath } from \"./resolve-upload-path\";\n\n/**\n * A request path resolved to a usable variant source\n */\nexport type VariantCandidate = {\n source: ResolvedUploadPath;\n stats: { size: number; mtimeMs: number };\n sourceFormat: VariantSourceFormat;\n};\n\n/**\n * Resolve a path to a variant source, applying the guards both the on-demand\n * route and the `generateImageVariants` ingest helper need: containment\n * (via `resolveUploadPath`), the byte-size limit, and the source format,\n * detected from magic bytes rather than the file extension.\n *\n * @returns `undefined` when the path does not resolve to a file inside the\n * storage root — the caller answers 404, the same as a missing file, so the\n * response is no existence oracle\n * @throws HttpError(413) when the source is over `images.maxSourceBytes`\n * @throws HttpError(415) when the source is not jpeg, png, webp or avif\n */\nexport async function resolveVariantCandidate(\n wildcard: unknown,\n storageRoot: string,\n images: ResolvedImageVariantsConfig,\n): Promise<VariantCandidate | undefined> {\n const source = await resolveUploadPath(wildcard, storageRoot, [images.cacheDirectory]);\n\n if (!source) return undefined;\n\n const stats = await fs.stat(source.absolutePath);\n\n if (stats.size > images.maxSourceBytes) {\n throw new HttpError(413, \"The source image is too large.\");\n }\n\n const sourceFormat = detectImageFormat(\n await readFileHead(source.absolutePath, IMAGE_SIGNATURE_BYTES),\n );\n\n if (!isVariantSourceFormat(sourceFormat)) {\n throw new HttpError(415, \"Variants are only available for jpeg, png, webp and avif images.\");\n }\n\n return {\n source,\n stats: { size: stats.size, mtimeMs: stats.mtimeMs },\n sourceFormat,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAgCA,eAAsB,wBACpB,UACA,aACA,QACuC;CACvC,MAAM,SAAS,MAAM,kBAAkB,UAAU,aAAa,CAAC,OAAO,cAAc,CAAC;CAErF,IAAI,CAAC,QAAQ,OAAO;CAEpB,MAAM,QAAQ,MAAM,GAAG,KAAK,OAAO,YAAY;CAE/C,IAAI,MAAM,OAAO,OAAO,gBACtB,MAAM,IAAI,UAAU,KAAK,gCAAgC;CAG3D,MAAM,eAAe,kBACnB,MAAM,aAAa,OAAO,iBAAmC,CAC/D;CAEA,IAAI,CAAC,sBAAsB,YAAY,GACrC,MAAM,IAAI,UAAU,KAAK,kEAAkE;CAG7F,OAAO;EACL;EACA,OAAO;GAAE,MAAM,MAAM;GAAM,SAAS,MAAM;EAAQ;EAClD;CACF;AACF"}
@@ -0,0 +1,40 @@
1
+ //#region ../core/src/http/uploads/single-flight.ts
2
+ /**
3
+ * Runs a task once per key at a time.
4
+ *
5
+ * A caller that arrives while the task for its key is still running gets the
6
+ * same promise instead of starting a second run, so N concurrent cache misses
7
+ * for one derivative generate it once. The entry is dropped when the task
8
+ * settles, success or failure, so a failed run is retried by the next request.
9
+ */
10
+ var SingleFlight = class {
11
+ constructor() {
12
+ this.inflight = /* @__PURE__ */ new Map();
13
+ }
14
+ /**
15
+ * Run `task` for `key`, or join the run already in flight
16
+ */
17
+ run(key, task) {
18
+ const existing = this.inflight.get(key);
19
+ if (existing) return existing;
20
+ const promise = (async () => {
21
+ try {
22
+ return await task();
23
+ } finally {
24
+ this.inflight.delete(key);
25
+ }
26
+ })();
27
+ this.inflight.set(key, promise);
28
+ return promise;
29
+ }
30
+ /**
31
+ * Number of keys currently in flight
32
+ */
33
+ get size() {
34
+ return this.inflight.size;
35
+ }
36
+ };
37
+
38
+ //#endregion
39
+ export { SingleFlight };
40
+ //# sourceMappingURL=single-flight.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"single-flight.mjs","names":[],"sources":["../../../../../../../../core/src/http/uploads/single-flight.ts"],"sourcesContent":["/**\n * Runs a task once per key at a time.\n *\n * A caller that arrives while the task for its key is still running gets the\n * same promise instead of starting a second run, so N concurrent cache misses\n * for one derivative generate it once. The entry is dropped when the task\n * settles, success or failure, so a failed run is retried by the next request.\n */\nexport class SingleFlight<T> {\n protected readonly inflight = new Map<string, Promise<T>>();\n\n /**\n * Run `task` for `key`, or join the run already in flight\n */\n public run(key: string, task: () => Promise<T>): Promise<T> {\n const existing = this.inflight.get(key);\n\n if (existing) return existing;\n\n const promise = (async () => {\n try {\n return await task();\n } finally {\n this.inflight.delete(key);\n }\n })();\n\n this.inflight.set(key, promise);\n\n return promise;\n }\n\n /**\n * Number of keys currently in flight\n */\n public get size(): number {\n return this.inflight.size;\n }\n}\n"],"mappings":";;;;;;;;;AAQA,IAAa,eAAb,MAA6B;;kCACG,IAAI,IAAwB;;;;;CAK1D,AAAO,IAAI,KAAa,MAAoC;EAC1D,MAAM,WAAW,KAAK,SAAS,IAAI,GAAG;EAEtC,IAAI,UAAU,OAAO;EAErB,MAAM,WAAW,YAAY;GAC3B,IAAI;IACF,OAAO,MAAM,KAAK;GACpB,UAAU;IACR,KAAK,SAAS,OAAO,GAAG;GAC1B;EACF,EAAC,CAAE;EAEH,KAAK,SAAS,IAAI,KAAK,OAAO;EAE9B,OAAO;CACT;;;;CAKA,IAAW,OAAe;EACxB,OAAO,KAAK,SAAS;CACvB;AACF"}
@@ -0,0 +1,24 @@
1
+ import { RequestHandler } from "../../router/types.mjs";
2
+ //#region ../core/src/http/uploads/uploaded-file.controller.d.ts
3
+ /**
4
+ * Serves local uploads, and bounded on-demand variants of the images among them.
5
+ *
6
+ * ```ts
7
+ * router.get("/uploads/*", uploadedFileController);
8
+ * ```
9
+ *
10
+ * - `GET /uploads/<path>` sends the original file with a one-year cache.
11
+ * - `GET /uploads/<path>?variant=<name>[&format=webp|avif]` sends a derivative
12
+ * rendered from a variant named in `uploads.images.variants`. No other query
13
+ * key is accepted, so a client can never ask for an arbitrary size.
14
+ *
15
+ * The path must stay inside the storage root (after symlinks are resolved) and
16
+ * outside the variant cache; anything else is a 404, the same answer a missing
17
+ * file gets. Derivatives are cached on disk under a sha256 of the source path,
18
+ * size, mtime, variant and format, written atomically, generated once per key
19
+ * even under concurrent requests, and sent with an immutable cache and an ETag.
20
+ */
21
+ declare const uploadedFileController: RequestHandler;
22
+ //#endregion
23
+ export { uploadedFileController };
24
+ //# sourceMappingURL=uploaded-file.controller.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"uploaded-file.controller.d.mts","names":[],"sources":["../../../../../../../../core/src/http/uploads/uploaded-file.controller.ts"],"mappings":";;;;;AA0HA;;;;AAsIC;;;;;;;;;;;cAtIY,sBAAA,EAAwB,cAsIpC"}
@@ -0,0 +1,174 @@
1
+ import { HttpError } from "../errors/errors.mjs";
2
+ import { storage } from "../../storage/storage.mjs";
3
+ import "../../storage/index.mjs";
4
+ import { uploadsConfig } from "../uploads-config.mjs";
5
+ import { generateImageVariant } from "./generate-image-variant.mjs";
6
+ import { detectImageFormat, isVariantSourceFormat } from "./detect-image-format.mjs";
7
+ import { loadVariantSource } from "./load-variant-source.mjs";
8
+ import { resolveImageVariantsConfig } from "./resolve-image-variants-config.mjs";
9
+ import { readFileHead } from "./read-file-head.mjs";
10
+ import { resolveUploadPath } from "./resolve-upload-path.mjs";
11
+ import { resolveVariantCandidate } from "./resolve-variant-candidate.mjs";
12
+ import { variantCacheKey, variantCachePath } from "./variant-cache-key.mjs";
13
+ import { variantGenerations } from "./variant-generations.mjs";
14
+ import { matchesIfNoneMatch } from "./matches-if-none-match.mjs";
15
+ import { parseUploadedFileQuery } from "./parse-uploaded-file-query.mjs";
16
+ import { resolveInlineImageContentType } from "./resolve-inline-image-content-type.mjs";
17
+ import { resolveOriginalContentType } from "./resolve-original-content-type.mjs";
18
+ import { log } from "@warlock.js/logger";
19
+ import path from "node:path";
20
+ import fs from "node:fs/promises";
21
+
22
+ //#region ../core/src/http/uploads/uploaded-file.controller.ts
23
+ /**
24
+ * One year, in seconds: originals and derivatives are both long-lived
25
+ */
26
+ const ONE_YEAR = 31536e3;
27
+ const CONTENT_TYPES = {
28
+ jpeg: "image/jpeg",
29
+ png: "image/png",
30
+ webp: "image/webp",
31
+ avif: "image/avif"
32
+ };
33
+ async function exists(target) {
34
+ try {
35
+ await fs.access(target);
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+ function notFound(response) {
42
+ return response.notFound({ error: "File Not Found" });
43
+ }
44
+ async function sendVariant(response, target, hash, format) {
45
+ return response.sendBuffer(await fs.readFile(target), {
46
+ contentType: CONTENT_TYPES[format],
47
+ cacheTime: ONE_YEAR,
48
+ immutable: true,
49
+ etag: `"${hash}"`
50
+ });
51
+ }
52
+ /**
53
+ * Serve an upload original that isn't safe to render inline: a non-raster
54
+ * format (html, xml, the svg family, anything unrecognised) is markup a
55
+ * browser will parse and execute, and a raster-sniffed file whose extension
56
+ * doesn't back that format up (see `resolveInlineImageContentType`) is a
57
+ * polyglot — so both go out as a download instead of a rendered response.
58
+ * `Content-Type` stays extension-derived, except the svg/html/xml family,
59
+ * which is downgraded to `application/octet-stream` so the browser never
60
+ * renders it even if it ignores the disposition.
61
+ */
62
+ async function sendOriginalAsAttachment(response, absolutePath) {
63
+ response.header("Content-Security-Policy", "sandbox");
64
+ return response.sendBuffer(await fs.readFile(absolutePath), {
65
+ contentType: resolveOriginalContentType(absolutePath),
66
+ cacheTime: ONE_YEAR,
67
+ immutable: true,
68
+ inline: false,
69
+ filename: path.basename(absolutePath)
70
+ });
71
+ }
72
+ /**
73
+ * Serve an upload original inline, once the sniffed raster format and the
74
+ * extension-derived format have been confirmed to agree. The content type is
75
+ * always the SNIFFED image type, passed explicitly — never `sendFile`'s
76
+ * extension-derived guess, which is exactly what let a JPEG named `.html`
77
+ * out as `text/html` before this existed.
78
+ */
79
+ async function sendOriginalInline(response, absolutePath, contentType) {
80
+ return response.sendBuffer(await fs.readFile(absolutePath), {
81
+ contentType,
82
+ cacheTime: ONE_YEAR,
83
+ immutable: true
84
+ });
85
+ }
86
+ /**
87
+ * Serves local uploads, and bounded on-demand variants of the images among them.
88
+ *
89
+ * ```ts
90
+ * router.get("/uploads/*", uploadedFileController);
91
+ * ```
92
+ *
93
+ * - `GET /uploads/<path>` sends the original file with a one-year cache.
94
+ * - `GET /uploads/<path>?variant=<name>[&format=webp|avif]` sends a derivative
95
+ * rendered from a variant named in `uploads.images.variants`. No other query
96
+ * key is accepted, so a client can never ask for an arbitrary size.
97
+ *
98
+ * The path must stay inside the storage root (after symlinks are resolved) and
99
+ * outside the variant cache; anything else is a 404, the same answer a missing
100
+ * file gets. Derivatives are cached on disk under a sha256 of the source path,
101
+ * size, mtime, variant and format, written atomically, generated once per key
102
+ * even under concurrent requests, and sent with an immutable cache and an ETag.
103
+ */
104
+ const uploadedFileController = async ({ request, response }) => {
105
+ const storageRoot = storage.root();
106
+ if (!storageRoot || storageRoot === ".") {
107
+ log.error("uploads", "serve", "uploadedFileController needs a local storage driver with a root.");
108
+ return notFound(response);
109
+ }
110
+ const query = parseUploadedFileQuery(request.url);
111
+ if (query.type === "invalid") return response.badRequest({ error: query.reason });
112
+ response.header("X-Content-Type-Options", "nosniff");
113
+ if (query.type === "original") {
114
+ const images = uploadsConfig("images");
115
+ const cacheDirectory = images?.cacheDirectory ? [images.cacheDirectory] : [];
116
+ const source = await resolveUploadPath(request.params["*"], storageRoot, [storage.root(".cache/image-variants"), ...cacheDirectory]);
117
+ if (!source) return notFound(response);
118
+ const sniffedFormat = detectImageFormat(await readFileHead(source.absolutePath, 512));
119
+ if (!isVariantSourceFormat(sniffedFormat)) return sendOriginalAsAttachment(response, source.absolutePath);
120
+ const inlineContentType = resolveInlineImageContentType(source.absolutePath, sniffedFormat);
121
+ if (!inlineContentType) return sendOriginalAsAttachment(response, source.absolutePath);
122
+ return sendOriginalInline(response, source.absolutePath, inlineContentType);
123
+ }
124
+ const images = resolveImageVariantsConfig(uploadsConfig("images"), storageRoot);
125
+ if (!images) return response.badRequest({ error: "Image variants are not configured." });
126
+ if (!Object.prototype.hasOwnProperty.call(images.variants, query.variant)) return response.badRequest({ error: `Unknown variant "${query.variant}".` });
127
+ const requestedFormat = query.format;
128
+ if (requestedFormat !== void 0 && !images.formats.includes(requestedFormat)) return response.badRequest({ error: `Format "${query.format}" is not allowed.` });
129
+ const variant = images.variants[query.variant];
130
+ if (variant === void 0) return response.badRequest({ error: `Unknown variant "${query.variant}".` });
131
+ let source;
132
+ try {
133
+ const candidate = await resolveVariantCandidate(request.params["*"], storageRoot, images);
134
+ if (!candidate) return notFound(response);
135
+ const resolvedSource = candidate.source;
136
+ source = resolvedSource;
137
+ const { stats, sourceFormat } = candidate;
138
+ const format = requestedFormat ?? sourceFormat;
139
+ const hash = variantCacheKey({
140
+ relativePath: resolvedSource.relativePath,
141
+ sourceSize: stats.size,
142
+ sourceMtimeMs: stats.mtimeMs,
143
+ variant,
144
+ format
145
+ });
146
+ const target = variantCachePath(images.cacheDirectory, hash, format);
147
+ if (await exists(target)) {
148
+ if (matchesIfNoneMatch(request.header("if-none-match"), `"${hash}"`)) return response.baseResponse.status(304).send();
149
+ return sendVariant(response, target, hash, format);
150
+ }
151
+ await variantGenerations.run(hash, async () => {
152
+ if (await exists(target)) return;
153
+ await generateImageVariant({
154
+ image: await loadVariantSource(resolvedSource.absolutePath, {
155
+ maxSourceBytes: images.maxSourceBytes,
156
+ maxSourcePixels: images.maxSourcePixels,
157
+ expectedFormat: sourceFormat
158
+ }),
159
+ variant,
160
+ format,
161
+ targetPath: target
162
+ });
163
+ });
164
+ return sendVariant(response, target, hash, format);
165
+ } catch (error) {
166
+ if (error instanceof HttpError) return response.send({ error: error.message }, error.status);
167
+ log.error("uploads", "variant", `Could not generate variant "${query.variant}" of ${source?.relativePath ?? request.params["*"]}: ${error instanceof Error ? error.message : String(error)}`);
168
+ return response.serverError({ error: "Could not generate the image variant." });
169
+ }
170
+ };
171
+
172
+ //#endregion
173
+ export { uploadedFileController };
174
+ //# sourceMappingURL=uploaded-file.controller.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"uploaded-file.controller.mjs","names":[],"sources":["../../../../../../../../core/src/http/uploads/uploaded-file.controller.ts"],"sourcesContent":["import { log } from \"@warlock.js/logger\";\nimport fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport type { RequestHandler } from \"../../router\";\nimport { storage } from \"../../storage\";\nimport { HttpError } from \"../errors\";\nimport type { Response } from \"../response\";\nimport { uploadsConfig } from \"../uploads-config\";\nimport {\n detectImageFormat,\n IMAGE_SIGNATURE_BYTES,\n isVariantSourceFormat,\n} from \"./detect-image-format\";\nimport { generateImageVariant } from \"./generate-image-variant\";\nimport type { VariantOutputFormat } from \"./image-variant-types\";\nimport { loadVariantSource } from \"./load-variant-source\";\nimport { matchesIfNoneMatch } from \"./matches-if-none-match\";\nimport { parseUploadedFileQuery } from \"./parse-uploaded-file-query\";\nimport { readFileHead } from \"./read-file-head\";\nimport { resolveImageVariantsConfig } from \"./resolve-image-variants-config\";\nimport { resolveInlineImageContentType } from \"./resolve-inline-image-content-type\";\nimport { resolveOriginalContentType } from \"./resolve-original-content-type\";\nimport { resolveUploadPath, type ResolvedUploadPath } from \"./resolve-upload-path\";\nimport { resolveVariantCandidate } from \"./resolve-variant-candidate\";\nimport { variantCacheKey, variantCachePath } from \"./variant-cache-key\";\nimport { variantGenerations } from \"./variant-generations\";\n\n/**\n * One year, in seconds: originals and derivatives are both long-lived\n */\nconst ONE_YEAR = 31_536_000;\n\nconst CONTENT_TYPES: Record<VariantOutputFormat, string> = {\n jpeg: \"image/jpeg\",\n png: \"image/png\",\n webp: \"image/webp\",\n avif: \"image/avif\",\n};\n\nasync function exists(target: string): Promise<boolean> {\n try {\n await fs.access(target);\n\n return true;\n } catch {\n return false;\n }\n}\n\nfunction notFound(response: Response) {\n return response.notFound({ error: \"File Not Found\" });\n}\n\nasync function sendVariant(\n response: Response,\n target: string,\n hash: string,\n format: VariantOutputFormat,\n) {\n return response.sendBuffer(await fs.readFile(target), {\n contentType: CONTENT_TYPES[format],\n cacheTime: ONE_YEAR,\n immutable: true,\n etag: `\"${hash}\"`,\n });\n}\n\n/**\n * Serve an upload original that isn't safe to render inline: a non-raster\n * format (html, xml, the svg family, anything unrecognised) is markup a\n * browser will parse and execute, and a raster-sniffed file whose extension\n * doesn't back that format up (see `resolveInlineImageContentType`) is a\n * polyglot — so both go out as a download instead of a rendered response.\n * `Content-Type` stays extension-derived, except the svg/html/xml family,\n * which is downgraded to `application/octet-stream` so the browser never\n * renders it even if it ignores the disposition.\n */\nasync function sendOriginalAsAttachment(response: Response, absolutePath: string) {\n response.header(\"Content-Security-Policy\", \"sandbox\");\n\n return response.sendBuffer(await fs.readFile(absolutePath), {\n contentType: resolveOriginalContentType(absolutePath),\n cacheTime: ONE_YEAR,\n immutable: true,\n inline: false,\n filename: path.basename(absolutePath),\n });\n}\n\n/**\n * Serve an upload original inline, once the sniffed raster format and the\n * extension-derived format have been confirmed to agree. The content type is\n * always the SNIFFED image type, passed explicitly — never `sendFile`'s\n * extension-derived guess, which is exactly what let a JPEG named `.html`\n * out as `text/html` before this existed.\n */\nasync function sendOriginalInline(response: Response, absolutePath: string, contentType: string) {\n return response.sendBuffer(await fs.readFile(absolutePath), {\n contentType,\n cacheTime: ONE_YEAR,\n immutable: true,\n });\n}\n\n/**\n * Serves local uploads, and bounded on-demand variants of the images among them.\n *\n * ```ts\n * router.get(\"/uploads/*\", uploadedFileController);\n * ```\n *\n * - `GET /uploads/<path>` sends the original file with a one-year cache.\n * - `GET /uploads/<path>?variant=<name>[&format=webp|avif]` sends a derivative\n * rendered from a variant named in `uploads.images.variants`. No other query\n * key is accepted, so a client can never ask for an arbitrary size.\n *\n * The path must stay inside the storage root (after symlinks are resolved) and\n * outside the variant cache; anything else is a 404, the same answer a missing\n * file gets. Derivatives are cached on disk under a sha256 of the source path,\n * size, mtime, variant and format, written atomically, generated once per key\n * even under concurrent requests, and sent with an immutable cache and an ETag.\n */\nexport const uploadedFileController: RequestHandler = async ({ request, response }) => {\n const storageRoot = storage.root();\n\n if (!storageRoot || storageRoot === \".\") {\n log.error(\n \"uploads\",\n \"serve\",\n \"uploadedFileController needs a local storage driver with a root.\",\n );\n\n return notFound(response);\n }\n\n const query = parseUploadedFileQuery(request.url);\n\n if (query.type === \"invalid\") {\n return response.badRequest({ error: query.reason });\n }\n\n // Every uploads response — originals and variants — tells the browser not\n // to guess a content type from the bytes, so a mislabeled body can never be\n // sniffed into something executable.\n response.header(\"X-Content-Type-Options\", \"nosniff\");\n\n if (query.type === \"original\") {\n const images = uploadsConfig(\"images\");\n const cacheDirectory = images?.cacheDirectory ? [images.cacheDirectory] : [];\n const source = await resolveUploadPath(request.params[\"*\"], storageRoot, [\n storage.root(\".cache/image-variants\"),\n ...cacheDirectory,\n ]);\n\n if (!source) return notFound(response);\n\n // Inline is decided by SNIFFED bytes, never by extension: an svg (or any\n // other non-raster file) named `.png` must still download, not render.\n const head = await readFileHead(source.absolutePath, IMAGE_SIGNATURE_BYTES);\n const sniffedFormat = detectImageFormat(head);\n\n if (!isVariantSourceFormat(sniffedFormat)) {\n return sendOriginalAsAttachment(response, source.absolutePath);\n }\n\n // Inline is only safe when the extension-derived type agrees with the\n // sniffed bytes too — otherwise `sendFile` would advertise the\n // extension's type (e.g. text/html for JPEG bytes named `.html`).\n const inlineContentType = resolveInlineImageContentType(source.absolutePath, sniffedFormat);\n\n if (!inlineContentType) {\n return sendOriginalAsAttachment(response, source.absolutePath);\n }\n\n return sendOriginalInline(response, source.absolutePath, inlineContentType);\n }\n\n const images = resolveImageVariantsConfig(uploadsConfig(\"images\"), storageRoot);\n\n if (!images) {\n return response.badRequest({ error: \"Image variants are not configured.\" });\n }\n\n if (!Object.prototype.hasOwnProperty.call(images.variants, query.variant)) {\n return response.badRequest({ error: `Unknown variant \"${query.variant}\".` });\n }\n\n const requestedFormat = query.format as VariantOutputFormat | undefined;\n\n if (requestedFormat !== undefined && !images.formats.includes(requestedFormat as never)) {\n return response.badRequest({ error: `Format \"${query.format}\" is not allowed.` });\n }\n\n const variant = images.variants[query.variant];\n if (variant === undefined) {\n return response.badRequest({ error: `Unknown variant \"${query.variant}\".` });\n }\n\n let source: ResolvedUploadPath | undefined;\n\n try {\n const candidate = await resolveVariantCandidate(request.params[\"*\"], storageRoot, images);\n\n if (!candidate) return notFound(response);\n\n const resolvedSource = candidate.source;\n\n source = resolvedSource;\n\n const { stats, sourceFormat } = candidate;\n const format: VariantOutputFormat = requestedFormat ?? sourceFormat;\n const hash = variantCacheKey({\n relativePath: resolvedSource.relativePath,\n sourceSize: stats.size,\n sourceMtimeMs: stats.mtimeMs,\n variant,\n format,\n });\n const target = variantCachePath(images.cacheDirectory, hash, format);\n\n if (await exists(target)) {\n if (matchesIfNoneMatch(request.header(\"if-none-match\"), `\"${hash}\"`)) {\n return response.baseResponse.status(304).send();\n }\n\n return sendVariant(response, target, hash, format);\n }\n\n await variantGenerations.run(hash, async () => {\n if (await exists(target)) return;\n\n const image = await loadVariantSource(resolvedSource.absolutePath, {\n maxSourceBytes: images.maxSourceBytes,\n maxSourcePixels: images.maxSourcePixels,\n expectedFormat: sourceFormat,\n });\n\n await generateImageVariant({ image, variant, format, targetPath: target });\n });\n\n return sendVariant(response, target, hash, format);\n } catch (error) {\n if (error instanceof HttpError) {\n return response.send({ error: error.message }, error.status);\n }\n\n log.error(\n \"uploads\",\n \"variant\",\n `Could not generate variant \"${query.variant}\" of ${source?.relativePath ?? request.params[\"*\"]}: ${\n error instanceof Error ? error.message : String(error)\n }`,\n );\n\n return response.serverError({ error: \"Could not generate the image variant.\" });\n }\n};\n"],"mappings":"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"}
@@ -0,0 +1,45 @@
1
+ import path from "node:path";
2
+ import { createHash } from "node:crypto";
3
+
4
+ //#region ../core/src/http/uploads/variant-cache-key.ts
5
+ /**
6
+ * JSON of the variant with its keys sorted, so key order in the app config
7
+ * never changes the hash
8
+ */
9
+ function normalizedVariantJson(variant) {
10
+ const sorted = Object.fromEntries(Object.entries(variant).filter(([, value]) => value !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
11
+ return JSON.stringify(sorted);
12
+ }
13
+ /**
14
+ * The sha256 cache key of a derivative.
15
+ *
16
+ * The source size and mtime are part of the key, so rewriting the source moves
17
+ * every request to a new key and a fresh derivative. The parts are hashed as a
18
+ * JSON array, so no two different inputs can concatenate to the same string.
19
+ */
20
+ function variantCacheKey(input) {
21
+ const material = JSON.stringify([
22
+ input.relativePath,
23
+ input.sourceSize,
24
+ input.sourceMtimeMs,
25
+ normalizedVariantJson(input.variant),
26
+ input.format
27
+ ]);
28
+ return createHash("sha256").update(material).digest("hex");
29
+ }
30
+ /**
31
+ * File extension of a derivative
32
+ */
33
+ function variantExtension(format) {
34
+ return format === "jpeg" ? "jpg" : format;
35
+ }
36
+ /**
37
+ * Where a derivative lives: `<cacheDirectory>/<first 2 hex>/<hash>.<ext>`
38
+ */
39
+ function variantCachePath(cacheDirectory, hash, format) {
40
+ return path.join(cacheDirectory, hash.slice(0, 2), `${hash}.${variantExtension(format)}`);
41
+ }
42
+
43
+ //#endregion
44
+ export { variantCacheKey, variantCachePath };
45
+ //# sourceMappingURL=variant-cache-key.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"variant-cache-key.mjs","names":[],"sources":["../../../../../../../../core/src/http/uploads/variant-cache-key.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\nimport path from \"node:path\";\nimport type { ImageVariantDefinition } from \"../uploads-types\";\nimport type { VariantOutputFormat } from \"./image-variant-types\";\n\n/**\n * Everything a derivative depends on\n */\nexport type VariantCacheKeyInput = {\n relativePath: string;\n sourceSize: number;\n sourceMtimeMs: number;\n variant: ImageVariantDefinition;\n format: VariantOutputFormat;\n};\n\n/**\n * JSON of the variant with its keys sorted, so key order in the app config\n * never changes the hash\n */\nfunction normalizedVariantJson(variant: ImageVariantDefinition): string {\n const sorted = Object.fromEntries(\n Object.entries(variant)\n .filter(([, value]) => value !== undefined)\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),\n );\n\n return JSON.stringify(sorted);\n}\n\n/**\n * The sha256 cache key of a derivative.\n *\n * The source size and mtime are part of the key, so rewriting the source moves\n * every request to a new key and a fresh derivative. The parts are hashed as a\n * JSON array, so no two different inputs can concatenate to the same string.\n */\nexport function variantCacheKey(input: VariantCacheKeyInput): string {\n const material = JSON.stringify([\n input.relativePath,\n input.sourceSize,\n input.sourceMtimeMs,\n normalizedVariantJson(input.variant),\n input.format,\n ]);\n\n return createHash(\"sha256\").update(material).digest(\"hex\");\n}\n\n/**\n * File extension of a derivative\n */\nexport function variantExtension(format: VariantOutputFormat): string {\n return format === \"jpeg\" ? \"jpg\" : format;\n}\n\n/**\n * Where a derivative lives: `<cacheDirectory>/<first 2 hex>/<hash>.<ext>`\n */\nexport function variantCachePath(\n cacheDirectory: string,\n hash: string,\n format: VariantOutputFormat,\n): string {\n return path.join(cacheDirectory, hash.slice(0, 2), `${hash}.${variantExtension(format)}`);\n}\n"],"mappings":";;;;;;;;AAoBA,SAAS,sBAAsB,SAAyC;CACtE,MAAM,SAAS,OAAO,YACpB,OAAO,QAAQ,OAAO,CAAC,CACpB,QAAQ,GAAG,WAAW,UAAU,MAAS,CAAC,CAC1C,MAAM,CAAC,IAAI,CAAC,OAAQ,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,CAAE,CACpD;CAEA,OAAO,KAAK,UAAU,MAAM;AAC9B;;;;;;;;AASA,SAAgB,gBAAgB,OAAqC;CACnE,MAAM,WAAW,KAAK,UAAU;EAC9B,MAAM;EACN,MAAM;EACN,MAAM;EACN,sBAAsB,MAAM,OAAO;EACnC,MAAM;CACR,CAAC;CAED,OAAO,WAAW,QAAQ,CAAC,CAAC,OAAO,QAAQ,CAAC,CAAC,OAAO,KAAK;AAC3D;;;;AAKA,SAAgB,iBAAiB,QAAqC;CACpE,OAAO,WAAW,SAAS,QAAQ;AACrC;;;;AAKA,SAAgB,iBACd,gBACA,MACA,QACQ;CACR,OAAO,KAAK,KAAK,gBAAgB,KAAK,MAAM,GAAG,CAAC,GAAG,GAAG,KAAK,GAAG,iBAAiB,MAAM,GAAG;AAC1F"}
@@ -0,0 +1,16 @@
1
+ import { SingleFlight } from "./single-flight.mjs";
2
+
3
+ //#region ../core/src/http/uploads/variant-generations.ts
4
+ /**
5
+ * Shared in-process single-flight for derivative generation.
6
+ *
7
+ * Both `uploadedFileController` and `generateImageVariants` run generations
8
+ * through this one instance, so concurrent misses for the same cache key —
9
+ * whether they arrive from the on-demand route or from an ingest call —
10
+ * render the derivative once.
11
+ */
12
+ const variantGenerations = new SingleFlight();
13
+
14
+ //#endregion
15
+ export { variantGenerations };
16
+ //# sourceMappingURL=variant-generations.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"variant-generations.mjs","names":[],"sources":["../../../../../../../../core/src/http/uploads/variant-generations.ts"],"sourcesContent":["import { SingleFlight } from \"./single-flight\";\n\n/**\n * Shared in-process single-flight for derivative generation.\n *\n * Both `uploadedFileController` and `generateImageVariants` run generations\n * through this one instance, so concurrent misses for the same cache key —\n * whether they arrive from the on-demand route or from an ingest call —\n * render the derivative once.\n */\nexport const variantGenerations = new SingleFlight<void>();\n"],"mappings":";;;;;;;;;;;AAUA,MAAa,qBAAqB,IAAI,aAAmB"}