@velora-cms/server 0.11.0 → 0.12.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.
Files changed (75) hide show
  1. package/dist/admin/sync-admin-bundle.d.ts +9 -0
  2. package/dist/admin/sync-admin-bundle.js +120 -0
  3. package/dist/admin-bundle/.velora-version +1 -0
  4. package/dist/admin-bundle/assets/{_virtual_mf-localSharedImportMap___mfe_internal__velora_admin__mf_owner__1-DOKdWYyj.js → _virtual_mf-localSharedImportMap___mfe_internal__velora_admin__mf_owner__1-DlIgIuFP.js} +1 -1
  5. package/dist/admin-bundle/assets/{_virtual_mf___mfe_internal__velora_admin__mf_owner__1__loadShare___mf_0_velora_mf_2_cms_mf_1_plugin_mf_2_sdk__loadShare__.js-C1-ZAxwO.js → _virtual_mf___mfe_internal__velora_admin__mf_owner__1__loadShare___mf_0_velora_mf_2_cms_mf_1_plugin_mf_2_sdk__loadShare__.js-Bm9EahCG.js} +2 -2
  6. package/dist/admin-bundle/assets/{content-trash.schema-DfXHkC-w.js → content-trash.schema-tvJ4Lbos.js} +5 -5
  7. package/dist/admin-bundle/assets/{hostInit--C4xK6nj.js → hostInit-B-6-34XY.js} +1 -1
  8. package/dist/admin-bundle/assets/{index-DhMY-0sC.js → index-CHCGuSbb.js} +1 -1
  9. package/dist/admin-bundle/assets/{index-BIAF0ShP.js → index-DYO7-IK8.js} +33 -33
  10. package/dist/admin-bundle/assets/{mf-entry-bootstrap-0-aef015cd.js → mf-entry-bootstrap-0-7b0ce2fe.js} +2 -2
  11. package/dist/admin-bundle/assets/{remoteEntry-CNyN68bc.js → remoteEntry-J2yJXsGB.js} +2 -2
  12. package/dist/admin-bundle/index.html +4 -4
  13. package/dist/app.d.ts +16 -1
  14. package/dist/app.js +63 -4
  15. package/dist/auth/api-keys.d.ts +1 -0
  16. package/dist/auth/api-keys.js +5 -1
  17. package/dist/auth/route-rate-limits.d.ts +4 -0
  18. package/dist/auth/route-rate-limits.js +13 -0
  19. package/dist/bin-sync-admin.d.ts +2 -0
  20. package/dist/bin-sync-admin.js +39 -0
  21. package/dist/bundled-templates/com.velora.blog/package.tar.gz +0 -0
  22. package/dist/config/public-url.d.ts +3 -0
  23. package/dist/config/public-url.js +45 -0
  24. package/dist/db/core-migrations/steps.js +95 -16
  25. package/dist/docs-bundle/pagefind/fragment/en_42f5356.pf_fragment +0 -0
  26. package/dist/docs-bundle/pagefind/fragment/en_67dccf8.pf_fragment +0 -0
  27. package/dist/docs-bundle/pagefind/fragment/en_80941b8.pf_fragment +0 -0
  28. package/dist/docs-bundle/pagefind/fragment/en_a64440b.pf_fragment +0 -0
  29. package/dist/docs-bundle/pagefind/fragment/en_eb168aa.pf_fragment +0 -0
  30. package/dist/docs-bundle/pagefind/index/en_8450ec7.pf_index +0 -0
  31. package/dist/docs-bundle/pagefind/index/en_c285f7b.pf_index +0 -0
  32. package/dist/docs-bundle/pagefind/index/en_e8cada7.pf_index +0 -0
  33. package/dist/docs-bundle/pagefind/pagefind-entry.json +1 -1
  34. package/dist/docs-bundle/pagefind/pagefind.en_57d2f8922f.pf_meta +0 -0
  35. package/dist/docs-bundle/v1/using-velora/getting-started/index.html +5 -3
  36. package/dist/docs-bundle/v1/using-velora/headless-api/index.html +10 -4
  37. package/dist/docs-bundle/v1/using-velora/index.html +6 -4
  38. package/dist/docs-bundle/v1/using-velora/installation/index.html +73 -15
  39. package/dist/docs-bundle/v1/using-velora/media/index.html +118 -34
  40. package/dist/graphql/schema.d.ts +1 -0
  41. package/dist/graphql/schema.js +5 -2
  42. package/dist/lib.d.ts +1 -0
  43. package/dist/lib.js +45 -11
  44. package/dist/media/media-service.d.ts +14 -15
  45. package/dist/media/media-service.js +121 -59
  46. package/dist/media/media-tokens.d.ts +6 -0
  47. package/dist/media/media-tokens.js +88 -0
  48. package/dist/media/storage/local.d.ts +16 -0
  49. package/dist/media/storage/local.js +99 -0
  50. package/dist/media/storage/s3.d.ts +25 -0
  51. package/dist/media/storage/s3.js +134 -0
  52. package/dist/media/storage/types.d.ts +21 -0
  53. package/dist/media/storage/types.js +44 -0
  54. package/dist/routes/admin-static.d.ts +2 -0
  55. package/dist/routes/admin-static.js +42 -4
  56. package/dist/routes/media-public.d.ts +6 -0
  57. package/dist/routes/media-public.js +265 -0
  58. package/dist/routes/media.d.ts +2 -1
  59. package/dist/routes/media.js +95 -5
  60. package/dist/routes/plugin-storage.js +5 -1
  61. package/dist/routes/public-api.js +5 -3
  62. package/dist/routes/published-item.d.ts +2 -1
  63. package/dist/routes/published-item.js +28 -5
  64. package/dist/scripts/copy-admin-bundle.js +9 -1
  65. package/package.json +7 -6
  66. package/prisma/schema.prisma +16 -3
  67. package/dist/docs-bundle/pagefind/fragment/en_1d9eb24.pf_fragment +0 -0
  68. package/dist/docs-bundle/pagefind/fragment/en_3099406.pf_fragment +0 -0
  69. package/dist/docs-bundle/pagefind/fragment/en_9aa652e.pf_fragment +0 -0
  70. package/dist/docs-bundle/pagefind/fragment/en_d855550.pf_fragment +0 -0
  71. package/dist/docs-bundle/pagefind/fragment/en_e6d88d8.pf_fragment +0 -0
  72. package/dist/docs-bundle/pagefind/index/en_5feeed7.pf_index +0 -0
  73. package/dist/docs-bundle/pagefind/index/en_c6981f9.pf_index +0 -0
  74. package/dist/docs-bundle/pagefind/index/en_ce4f6bf.pf_index +0 -0
  75. package/dist/docs-bundle/pagefind/pagefind.en_1e94abd852.pf_meta +0 -0
@@ -0,0 +1,134 @@
1
+ import { CreateBucketCommand, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3";
2
+ import { assertValidStorageKey, MediaNotFoundError } from "./types.js";
3
+ // Defaults match the minio service in docker-compose.yml (same treatment
4
+ // as POSTGRES_TEST_URL), so local dev works with zero required config
5
+ // once resolveMediaStorage (media-service.ts) has already decided s3 is
6
+ // the active driver. Point these at real S3/R2 via .env — see
7
+ // .env.example. These defaults are NOT consulted for driver selection
8
+ // itself — see resolveMediaStorage's doc comment for why that used to be
9
+ // the landmine.
10
+ export function s3ConfigFromEnv(env = process.env) {
11
+ // Empty string is treated as unset (LOW-8), same convention
12
+ // resolveMediaStorage's hasAnyS3Var uses — an empty S3_ENDPOINT in a
13
+ // stray .env line is "nothing configured," not "configured to nothing."
14
+ const rawEndpoint = env.S3_ENDPOINT;
15
+ const endpointWasDefaulted = !rawEndpoint;
16
+ return {
17
+ endpoint: rawEndpoint || "http://localhost:9000",
18
+ region: env.S3_REGION || "us-east-1",
19
+ accessKeyId: env.S3_ACCESS_KEY_ID || "velora",
20
+ secretAccessKey: env.S3_SECRET_ACCESS_KEY || "velora-secret",
21
+ bucket: env.S3_BUCKET || "velora-media",
22
+ endpointWasDefaulted,
23
+ };
24
+ }
25
+ // Today's S3 wiring (previously inline in media-service.ts), unchanged
26
+ // apart from implementing MediaStorage instead of being called directly.
27
+ export class S3Storage {
28
+ config;
29
+ kind = "s3";
30
+ client;
31
+ constructor(config) {
32
+ this.config = config;
33
+ this.client = new S3Client({
34
+ endpoint: config.endpoint,
35
+ region: config.region,
36
+ credentials: {
37
+ accessKeyId: config.accessKeyId,
38
+ secretAccessKey: config.secretAccessKey,
39
+ },
40
+ // MinIO and R2 route by path, not by bucket subdomain — and a
41
+ // localhost endpoint can't do virtual-hosted buckets at all.
42
+ forcePathStyle: true,
43
+ });
44
+ }
45
+ // Idempotent; safe to call on startup or from tests. Only creates the
46
+ // bucket when it doesn't exist yet. Must fail LOUDLY and legibly (not
47
+ // a raw SDK error) when S3 is unreachable — this is the boot check
48
+ // that used to silently point a stranger's first deploy at a dev-only
49
+ // MinIO default (S186c §1).
50
+ async ensureReady() {
51
+ try {
52
+ await this.client.send(new HeadBucketCommand({ Bucket: this.config.bucket }));
53
+ return;
54
+ }
55
+ catch {
56
+ // HeadBucket fails the same way whether the bucket is merely
57
+ // missing or the endpoint is unreachable — fall through and let
58
+ // CreateBucketCommand distinguish them: it succeeds if the server
59
+ // answered but had no bucket yet, and fails the same way HeadBucket
60
+ // did if nothing is listening at all.
61
+ }
62
+ try {
63
+ await this.client.send(new CreateBucketCommand({ Bucket: this.config.bucket }));
64
+ }
65
+ catch (error) {
66
+ // LOW-9: name the underlying SDK error (its `name`, e.g.
67
+ // "UnknownEndpoint"/"TimeoutError"/"AccessDenied") rather than just
68
+ // its message — the name is often the faster diagnostic signal —
69
+ // and, when S3_ENDPOINT itself was never set, say so explicitly so
70
+ // a stranger's first deploy doesn't mistake the MinIO dev default
71
+ // for something they configured.
72
+ const errorName = error instanceof Error ? error.name : undefined;
73
+ const errorMessage = error instanceof Error ? error.message : String(error);
74
+ const defaultedNote = this.config.endpointWasDefaulted
75
+ ? " (S3_ENDPOINT is unset — defaulted to http://localhost:9000)"
76
+ : "";
77
+ throw new Error(`Could not reach S3 media storage at ${this.config.endpoint}${defaultedNote} ` +
78
+ `(bucket "${this.config.bucket}"): ${errorName ? `${errorName}: ` : ""}${errorMessage}. ` +
79
+ `Check S3_ENDPOINT and the other S3_* env vars, or set MEDIA_STORAGE=local to use local disk ` +
80
+ `storage instead.`, { cause: error });
81
+ }
82
+ }
83
+ async put(key, data, mimeType) {
84
+ assertValidStorageKey(key);
85
+ await this.client.send(new PutObjectCommand({
86
+ Bucket: this.config.bucket,
87
+ Key: key,
88
+ Body: data,
89
+ ContentType: mimeType,
90
+ ContentLength: data.length,
91
+ }));
92
+ }
93
+ async getStream(key) {
94
+ assertValidStorageKey(key);
95
+ try {
96
+ const result = await this.client.send(new GetObjectCommand({ Bucket: this.config.bucket, Key: key }));
97
+ return { stream: result.Body, size: result.ContentLength };
98
+ }
99
+ catch (error) {
100
+ if (isNotFound(error))
101
+ throw new MediaNotFoundError(key);
102
+ throw error;
103
+ }
104
+ }
105
+ async delete(key) {
106
+ assertValidStorageKey(key);
107
+ // DeleteObject is already idempotent on S3/MinIO — no existence
108
+ // check needed to match LocalDiskStorage's contract.
109
+ await this.client.send(new DeleteObjectCommand({ Bucket: this.config.bucket, Key: key }));
110
+ }
111
+ async exists(key) {
112
+ assertValidStorageKey(key);
113
+ try {
114
+ await this.client.send(new HeadObjectCommand({ Bucket: this.config.bucket, Key: key }));
115
+ return true;
116
+ }
117
+ catch (error) {
118
+ if (isNotFound(error))
119
+ return false;
120
+ throw error;
121
+ }
122
+ }
123
+ }
124
+ function isNotFound(error) {
125
+ if (typeof error !== "object" || error === null)
126
+ return false;
127
+ const name = "name" in error ? error.name : undefined;
128
+ if (name === "NoSuchKey" || name === "NotFound")
129
+ return true;
130
+ const status = "$metadata" in error
131
+ ? error.$metadata?.httpStatusCode
132
+ : undefined;
133
+ return status === 404;
134
+ }
@@ -0,0 +1,21 @@
1
+ import type { Readable } from "node:stream";
2
+ export interface MediaStorage {
3
+ readonly kind: "local" | "s3";
4
+ put(key: string, data: Buffer, mimeType: string): Promise<void>;
5
+ getStream(key: string): Promise<{
6
+ stream: Readable;
7
+ size?: number;
8
+ }>;
9
+ delete(key: string): Promise<void>;
10
+ exists(key: string): Promise<boolean>;
11
+ ensureReady(): Promise<void>;
12
+ }
13
+ export declare class MediaNotFoundError extends Error {
14
+ readonly key: string;
15
+ constructor(key: string);
16
+ }
17
+ export declare class MediaInvalidKeyError extends Error {
18
+ readonly key: string;
19
+ constructor(key: string);
20
+ }
21
+ export declare function assertValidStorageKey(key: string): void;
@@ -0,0 +1,44 @@
1
+ export class MediaNotFoundError extends Error {
2
+ key;
3
+ constructor(key) {
4
+ super(`Media object not found in storage: ${key}`);
5
+ this.key = key;
6
+ this.name = "MediaNotFoundError";
7
+ }
8
+ }
9
+ // Thrown by assertValidStorageKey (below) for any key that isn't a plain,
10
+ // relative, server-generated path fragment. Distinct from
11
+ // MediaNotFoundError: this is "the caller asked for something that could
12
+ // never be a legitimate key," not "no object exists at this key."
13
+ export class MediaInvalidKeyError extends Error {
14
+ key;
15
+ constructor(key) {
16
+ super(`Invalid media storage key: ${JSON.stringify(key)}`);
17
+ this.key = key;
18
+ this.name = "MediaInvalidKeyError";
19
+ }
20
+ }
21
+ const DRIVE_LETTER_RE = /^[A-Za-z]:/;
22
+ // Shared by BOTH drivers (S186c review, HIGH-2): keys are always
23
+ // server-generated (`<uuid>/<sanitized-filename>`, see sanitizeForKey in
24
+ // media-service.ts) and should never contain `.`/`..` segments or be
25
+ // absolute/drive-lettered — but a hostile or buggy caller must be
26
+ // rejected before either driver ever acts on the key, not just when
27
+ // LocalDiskStorage happens to resolve one outside MEDIA_DIR. Hoisted here
28
+ // (rather than duplicated per driver) so S3's key namespace gets the same
29
+ // guarantee even though S3 itself has no filesystem to escape. Must run
30
+ // BEFORE any path resolution (local.ts) or SDK call (s3.ts) — see the
31
+ // contract suite's "hostile keys fail closed" block, which runs the same
32
+ // cases against both drivers.
33
+ export function assertValidStorageKey(key) {
34
+ if (key.length === 0) {
35
+ throw new MediaInvalidKeyError(key);
36
+ }
37
+ if (key.startsWith("/") || key.startsWith("\\") || DRIVE_LETTER_RE.test(key)) {
38
+ throw new MediaInvalidKeyError(key);
39
+ }
40
+ const segments = key.split(/[/\\]/);
41
+ if (segments.some((segment) => segment === "." || segment === "..")) {
42
+ throw new MediaInvalidKeyError(key);
43
+ }
44
+ }
@@ -2,6 +2,8 @@ import type { FastifyInstance } from "fastify";
2
2
  interface AdminDirResolution {
3
3
  dir: string | null;
4
4
  invalidOverride: string | undefined;
5
+ fromOverride: boolean;
6
+ override: string | undefined;
5
7
  }
6
8
  export declare function resolveAdminDir(): AdminDirResolution;
7
9
  export declare function adminStaticRoutes(app: FastifyInstance): Promise<void>;
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import fastifyStatic from "@fastify/static";
5
5
  import { ADMIN_RATE_LIMIT, ipOnlyRateLimitConfig } from "../auth/route-rate-limits.js";
6
6
  import { isAdminUiEnabled } from "../config/admin-ui.js";
7
+ import { readBundleStamp, serverVersion } from "../admin/sync-admin-bundle.js";
7
8
  // A usable admin directory: it must actually be a directory (not a file —
8
9
  // @fastify/static@10.1.3 throws "root must be a directory" at registration
9
10
  // otherwise, crashing buildApp()) and it must contain the SPA's index.html
@@ -29,8 +30,8 @@ export function resolveAdminDir() {
29
30
  // boot.
30
31
  const resolved = resolve(override);
31
32
  if (isUsableAdminDir(resolved))
32
- return { dir: resolved, invalidOverride: undefined };
33
- return { dir: null, invalidOverride: override };
33
+ return { dir: resolved, invalidOverride: undefined, fromOverride: true, override };
34
+ return { dir: null, invalidOverride: override, fromOverride: false, override };
34
35
  }
35
36
  const here = dirname(fileURLToPath(import.meta.url)); // dist/routes or src/routes
36
37
  const candidates = [
@@ -38,7 +39,7 @@ export function resolveAdminDir() {
38
39
  join(here, "..", "..", "..", "..", "apps", "admin-ui", "dist-bundle"),
39
40
  ];
40
41
  const dir = candidates.find((c) => isUsableAdminDir(c)) ?? null;
41
- return { dir, invalidOverride: undefined };
42
+ return { dir, invalidOverride: undefined, fromOverride: false, override: undefined };
42
43
  }
43
44
  // Local copies of docs-static.ts's private helpers rather than a shared
44
45
  // export: that module deliberately keeps them private, and duplicating
@@ -132,7 +133,7 @@ export async function adminStaticRoutes(app) {
132
133
  app.log.info("[admin-static] VELORA_ADMIN=off — the admin SPA is not served");
133
134
  return;
134
135
  }
135
- const { dir: adminDir, invalidOverride } = resolveAdminDir();
136
+ const { dir: adminDir, invalidOverride, fromOverride, override } = resolveAdminDir();
136
137
  // Exact-path redirects. Fastify 5's Reply#redirect signature is
137
138
  // (url, code), not (code, url) — verified against this repo's installed
138
139
  // fastify@5.8.5 source (lib/reply.js), which differs from older majors.
@@ -181,6 +182,43 @@ export async function adminStaticRoutes(app) {
181
182
  index: false,
182
183
  decorateReply: false,
183
184
  });
185
+ // S186b drift check: a scaffolded site copies the admin bundle out to
186
+ // VELORA_ADMIN_DIR (public/velora-admin) instead of serving it straight
187
+ // from node_modules, so it can go stale after `npm update` bumps the
188
+ // installed server without a matching `npm run sync-admin`. One clear
189
+ // warning on mismatch, never a hard failure — a stale admin is better
190
+ // than none, and the copied files are never touched without the user
191
+ // asking. Deferred to onReady (rather than run inline here) so it always
192
+ // fires exactly once per boot, after registration has fully succeeded,
193
+ // and so tests can attach a logger spy to the returned app before it
194
+ // runs. Only ever attached for the override path: the packaged/dev
195
+ // candidates resolveAdminDir() falls back to can't drift the same way,
196
+ // and default resolution must never read a stamp at all.
197
+ if (fromOverride) {
198
+ app.addHook("onReady", async () => {
199
+ // Review finding M2: this is a cosmetic drift check, not part of
200
+ // boot proper — nothing in it may ever abort startup. The entire
201
+ // body is wrapped so a surprise (a stamp file that vanishes between
202
+ // registration and ready, a permissions error, anything) is logged
203
+ // at debug and swallowed rather than propagating out of onReady and
204
+ // failing the boot it's merely commenting on.
205
+ try {
206
+ const stamp = readBundleStamp(adminDir);
207
+ const version = serverVersion();
208
+ if (stamp && stamp !== version) {
209
+ // Review finding M3: `override` was captured by resolveAdminDir()
210
+ // above, not re-read from process.env here — it names exactly the
211
+ // value that was resolved into adminDir, immune to the env var
212
+ // changing between registration and this onReady firing.
213
+ app.log.warn(`The admin UI files in ${override} are from ${stamp}, but the server is ` +
214
+ `${version} — run "npm run sync-admin" to update them.`);
215
+ }
216
+ }
217
+ catch (err) {
218
+ app.log.debug({ err }, "[admin-static] admin bundle drift check failed — continuing without it");
219
+ }
220
+ });
221
+ }
184
222
  // The SPA entry point, and the fallback that makes client-side routing
185
223
  // survive a refresh or a pasted deep link. decorateReply: false means
186
224
  // reply.sendFile isn't available here (docs-static.ts reads its 404.html
@@ -0,0 +1,6 @@
1
+ import type { FastifyPluginAsync } from "fastify";
2
+ import type { MediaService } from "../media/media-service.js";
3
+ export interface MediaPublicRoutesOptions {
4
+ mediaService: MediaService;
5
+ }
6
+ export declare const mediaPublicRoutes: FastifyPluginAsync<MediaPublicRoutesOptions>;
@@ -0,0 +1,265 @@
1
+ import { isApiKeyHashValid, hashApiKey, KEY_PREFIX } from "../auth/api-keys.js";
2
+ import { ipOnlyRateLimitConfig, MEDIA_RATE_LIMIT } from "../auth/route-rate-limits.js";
3
+ import { verifyMediaToken } from "../media/media-tokens.js";
4
+ import { MediaNotFoundError } from "../media/storage/types.js";
5
+ import { sanitizeForKey } from "../media/media-service.js";
6
+ // GET /media/:id/:filename — the stable, permanent media route (S186c
7
+ // §3). Registered in the FULL app only (app.ts, beside admin-static.ts —
8
+ // NEVER setup-app.ts, which has no MediaService/Prisma to serve from).
9
+ // Deliberately a plain, unvalidated Fastify route rather than a Zod
10
+ // route: this streams bytes (no JSON response schema makes sense) and
11
+ // needs hand-rolled query-param messaging (reserved renditions params
12
+ // named explicitly) that a generic Zod 400 wouldn't produce. Hidden from
13
+ // OpenAPI below, same onRoute idiom as admin-static.ts.
14
+ //
15
+ // One uniform 404 body for every "you don't get these bytes" case —
16
+ // unknown id, filename mismatch, and EVERY private-item auth failure
17
+ // (no session/key/token at all, an expired token, a tampered token, a
18
+ // token minted for a different id). This is the load-bearing S186c §3
19
+ // "no existence oracle" rule: distinguishing "this id doesn't exist" from
20
+ // "this id exists but you're not allowed to see it" would let a caller
21
+ // probe the media library's private ids by watching which ones 404
22
+ // differently. See the spec's own wording: "Otherwise 401/404 (same body
23
+ // for missing and unauthorized-private)" — resolved here as ALWAYS 404,
24
+ // with the identical body, never a 401 (a 401 with a distinct body/status
25
+ // from the missing-id 404 would reopen exactly the oracle the spec is
26
+ // closing).
27
+ const NOT_FOUND_BODY = { message: "No media file found" };
28
+ function sendNotFound(reply) {
29
+ return reply.code(404).send(NOT_FOUND_BODY);
30
+ }
31
+ // S186c Task 3 fix wave, H1 (probe-verified stored XSS): the upload's
32
+ // mimeType is CLIENT-SUPPLIED at upload time (media.ts's multipart handler
33
+ // trusts whatever the browser/curl sent) and was being replayed here
34
+ // inline with no defenses — an uploaded text/html file executed on the
35
+ // CMS's own origin, the same origin that hosts /velora (the admin SPA) and
36
+ // answers /api/auth's refresh-cookie flow. Two independent layers:
37
+ //
38
+ // 1. X-Content-Type-Options: nosniff on EVERY response from this route
39
+ // (set via an onSend hook below, not per-branch, so it also covers the
40
+ // 400/404 error replies) — belt-and-braces even though layer 2 already
41
+ // forces a safe Content-Type for anything not on the allowlist; it's
42
+ // what stops a browser from sniffing past a CORRECTLY-labeled but
43
+ // unusual type on the inline branch.
44
+ // 2. An INERT-INLINE allowlist, gating `Content-Disposition: inline`
45
+ // itself: only types that cannot carry an active script payload when
46
+ // rendered BY THE BROWSER get served inline. Same idea, same list
47
+ // shape, as the precedent allowlist at
48
+ // plugins/blog/starter/src/pages/og-image/[id].ts:16 (raster images
49
+ // only, for its narrower og:image use case) — extended here
50
+ // deliberately for this route's broader job (it serves every media
51
+ // type the library holds, not just images meant to decode as
52
+ // <img>/<meta property="og:image">): the video/audio/pdf/plain-text
53
+ // additions are all formats a browser can render or play but never
54
+ // execute script from when navigated to directly. Every fetch, decode,
55
+ // and paint model reviewed:
56
+ // - image/*: raster decode into a bitmap, no script execution path.
57
+ // - video/mp4, video/webm, audio/mpeg, audio/ogg, audio/wav: decoded
58
+ // by the media pipeline (frames/samples), never parsed as
59
+ // markup/script.
60
+ // - application/pdf: Chrome/Firefox's built-in PDF VIEWER renders
61
+ // it in an isolated context; it is not treated as same-origin
62
+ // HTML. (A crafted PDF opened in a vulnerable native reader is a
63
+ // reader-software risk, not a same-origin-script risk this route
64
+ // can create or prevent.)
65
+ // - text/plain: browsers render it as literal, unescaped text —
66
+ // never parsed as HTML/script, by spec.
67
+ // Everything else — notably text/html, image/svg+xml (SVG can embed
68
+ // <script>), application/xhtml+xml, and text/xml (XML+XSLT can
69
+ // execute) — falls through to the ELSE branch below: forced
70
+ // Content-Disposition: attachment (browser downloads it, never
71
+ // renders it inline on this origin) AND Content-Type forced to
72
+ // application/octet-stream (defense-in-depth against a client that
73
+ // ignores Content-Disposition and tries to render the body anyway).
74
+ const INERT_INLINE_MIME_TYPES = new Set([
75
+ "image/png",
76
+ "image/jpeg",
77
+ "image/gif",
78
+ "image/webp",
79
+ "image/avif",
80
+ "video/mp4",
81
+ "video/webm",
82
+ "audio/mpeg",
83
+ "audio/ogg",
84
+ "audio/wav",
85
+ "application/pdf",
86
+ "text/plain",
87
+ ]);
88
+ // Content-Disposition's filename param is a quoted string — CR/LF or a
89
+ // bare `"` in it would let a crafted upload filename inject a second
90
+ // header/directive. Reuses media-service.ts's own storage-key sanitizer
91
+ // (already the one source of truth for "what's safe to put in a header
92
+ // built from an uploaded filename") rather than a second, potentially
93
+ // drifting allowlist.
94
+ function sanitizedAttachmentFilename(filename) {
95
+ return sanitizeForKey(filename);
96
+ }
97
+ // Renditions are reserved, not built (S186c §4) — these query params are
98
+ // claimed now so a future rendition implementation isn't a silent
99
+ // behavior change for anyone hitting them today. Named explicitly in the
100
+ // 400 body (rather than folded into "unknown query parameter") so a
101
+ // caller who read the docs and tried `?w=400` early gets told WHY, not a
102
+ // generic-looking validation error.
103
+ const RESERVED_RENDITION_KEYS = new Set(["preset", "w"]);
104
+ // Every other query param this route accepts.
105
+ const ALLOWED_QUERY_KEYS = new Set(["token"]);
106
+ // Silent, non-replying session check — request.jwtVerify() throws on a
107
+ // missing/invalid/expired cookie or bearer JWT, which requireAuth (S162)
108
+ // turns into an immediate 401 reply. This route can't use requireAuth
109
+ // directly: session is only ONE of three ways in (session OR read-scope
110
+ // API key OR ?token=), so a failed check here must fall through to the
111
+ // next check, not reply.
112
+ async function hasAdminSession(request) {
113
+ try {
114
+ await request.jwtVerify();
115
+ return true;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ // Silent, non-replying API-key check — the read-scope idiom
122
+ // (requireApiKeyScope("read") never rejects; ANY live key clears "read",
123
+ // same as this) applied without the reply-on-failure side effect
124
+ // requireApiKey has, for the same OR-chain reason as hasAdminSession
125
+ // above. Deliberately does not distinguish "no header" from "bad key"
126
+ // from "revoked key" — same UNAUTHORIZED_MESSAGE-style uniformity
127
+ // auth/api-keys.ts's own 401 already applies, just without the reply
128
+ // (this route never 401s at all — see the ALWAYS-404 note at the top of
129
+ // this file).
130
+ //
131
+ // L2 fix wave: pre-checks the `vk_` prefix (same shape as
132
+ // auth/api-keys.ts:66's requireApiKey) BEFORE hashing/querying — a
133
+ // presented Bearer value that doesn't even look like one of our keys is
134
+ // rejected on a string comparison instead of spending a SHA-256 + a DB
135
+ // round trip on it. Purely a cost/DoS-surface optimization: the query
136
+ // path below is already correct and would reject the same inputs, just
137
+ // slower.
138
+ async function hasReadApiKey(request) {
139
+ const header = request.headers.authorization;
140
+ if (!header?.startsWith("Bearer "))
141
+ return false;
142
+ const key = header.slice("Bearer ".length).trim();
143
+ if (!key.startsWith(KEY_PREFIX))
144
+ return false;
145
+ return isApiKeyHashValid(hashApiKey(key));
146
+ }
147
+ export const mediaPublicRoutes = async (app, opts) => {
148
+ const { mediaService } = opts;
149
+ // Own rate lane (route-rate-limits.ts's MEDIA_RATE_LIMIT), plugin-level
150
+ // — same ipOnlyRateLimitConfig()-spread-then-override idiom
151
+ // admin-static.ts uses for ADMIN_RATE_LIMIT, and for the identical
152
+ // reason: this route serves anonymous/public-bandwidth traffic that
153
+ // can't bucket on the three-lane jwt:/apiKey: rateLimitConfig (a
154
+ // public image request usually carries neither).
155
+ await app.register(import("@fastify/rate-limit"), {
156
+ ...ipOnlyRateLimitConfig(),
157
+ max: MEDIA_RATE_LIMIT.max(),
158
+ timeWindow: MEDIA_RATE_LIMIT.timeWindow(),
159
+ });
160
+ // Static-bytes surface, not a Zod-validated JSON API — same "hide from
161
+ // Swagger" reasoning admin-static.ts documents at length. Scoped to
162
+ // this encapsulated child, so it can only ever match this route.
163
+ app.addHook("onRoute", (routeOptions) => {
164
+ routeOptions.schema = { ...routeOptions.schema, hide: true };
165
+ });
166
+ // H1: nosniff on EVERY response this plugin sends — an onSend hook
167
+ // rather than a per-branch reply.header() call, so it also covers the
168
+ // 400 (unknown/reserved query param) and 404 replies, not just the
169
+ // 200 byte-serving path. Scoped to this encapsulated child (same as the
170
+ // onRoute hook above), so it can never leak onto an unrelated route.
171
+ app.addHook("onSend", async (_request, reply) => {
172
+ reply.header("X-Content-Type-Options", "nosniff");
173
+ });
174
+ app.get("/media/:id/:filename", async (request, reply) => {
175
+ const { id, filename } = request.params;
176
+ const query = request.query;
177
+ const queryKeys = Object.keys(query);
178
+ // Reserved-renditions params are checked (and named) before the
179
+ // generic unknown-param check, so `?preset=thumb` gets the
180
+ // renditions-specific message even though it would also fail the
181
+ // generic check.
182
+ const reservedHit = queryKeys.filter((key) => RESERVED_RENDITION_KEYS.has(key));
183
+ if (reservedHit.length > 0) {
184
+ return reply.code(400).send({
185
+ message: `Query parameter(s) ${reservedHit.join(", ")} are reserved for future rendition support ` +
186
+ "(S186c §4) and not implemented yet.",
187
+ });
188
+ }
189
+ const unknownKeys = queryKeys.filter((key) => !ALLOWED_QUERY_KEYS.has(key));
190
+ if (unknownKeys.length > 0) {
191
+ return reply.code(400).send({ message: `Unknown query parameter(s): ${unknownKeys.join(", ")}` });
192
+ }
193
+ const media = await mediaService.getById(id);
194
+ // Filename is cosmetic-but-verified (S186c §3): a correct id with the
195
+ // WRONG filename 404s just like an unknown id — no enumeration of a
196
+ // renamed/previous filename for a real item.
197
+ if (!media || media.filename !== filename) {
198
+ return sendNotFound(reply);
199
+ }
200
+ if (media.visibility === "private") {
201
+ const token = typeof query.token === "string" ? query.token : undefined;
202
+ const authorized = (await hasAdminSession(request)) ||
203
+ (await hasReadApiKey(request)) ||
204
+ (token !== undefined && verifyMediaToken(id, token));
205
+ if (!authorized) {
206
+ return sendNotFound(reply);
207
+ }
208
+ }
209
+ try {
210
+ const { stream, size } = await mediaService.getStream(id);
211
+ if (media.visibility === "public") {
212
+ // Keys are content-addressed by upload id (S186c §3) — a replaced
213
+ // file gets a new id, so this can be cached forever.
214
+ //
215
+ // M1 (undocumented limitation, now documented): flipping an item
216
+ // PRIVATE after this header has already gone out does not reach
217
+ // into every browser cache/CDN edge that already stored a copy
218
+ // under this immutable header — a 1-year `max-age=31536000,
219
+ // immutable` copy already sitting in some cache can keep serving
220
+ // that response to whoever holds it, without ever re-asking this
221
+ // server, for up to a year after the flip. The `visibility`
222
+ // check above governs new requests only. There is no cache-
223
+ // invalidation mechanism here (no CDN purge API call, no cache-
224
+ // busting URL change on flip — the URL is deliberately stable
225
+ // across a visibility flip, S186c §2) and adding one is out of
226
+ // scope for this fix; this comment exists so the tradeoff is
227
+ // findable, not silent. See docs/security/threat-model.md and
228
+ // the admin media UI's `mediaLibrary.privateHelp` copy for the
229
+ // operator-facing version of this same caveat.
230
+ reply.header("Cache-Control", "public, max-age=31536000, immutable");
231
+ }
232
+ else {
233
+ // Never let a shared/CDN cache retain a private object under a
234
+ // URL that a later visitor (anonymous, or holding a DIFFERENT
235
+ // token) could replay.
236
+ reply.header("Cache-Control", "private, no-store");
237
+ }
238
+ // H1: gate Content-Disposition: inline on the INERT_INLINE_MIME_TYPES
239
+ // allowlist above, not on the raw client-supplied mimeType — see that
240
+ // constant's doc comment for the full reasoning. Anything not on the
241
+ // allowlist is forced to download as an opaque octet-stream instead
242
+ // of ever being rendered by the browser on this origin.
243
+ if (INERT_INLINE_MIME_TYPES.has(media.mimeType)) {
244
+ reply.header("Content-Disposition", "inline");
245
+ reply.type(media.mimeType);
246
+ }
247
+ else {
248
+ reply.header("Content-Disposition", `attachment; filename="${sanitizedAttachmentFilename(media.filename)}"`);
249
+ reply.type("application/octet-stream");
250
+ }
251
+ if (size !== undefined)
252
+ reply.header("Content-Length", size);
253
+ return reply.send(stream);
254
+ }
255
+ catch (error) {
256
+ // The metadata row exists but the underlying object is gone (a
257
+ // storage-level inconsistency) — still "no bytes for you," same
258
+ // body as every other miss on this route, not a 500.
259
+ if (error instanceof MediaNotFoundError) {
260
+ return sendNotFound(reply);
261
+ }
262
+ throw error;
263
+ }
264
+ });
265
+ };
@@ -1,5 +1,6 @@
1
1
  import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod";
2
- import type { MediaService } from "../media/media-service.js";
2
+ import { type MediaService } from "../media/media-service.js";
3
+ export declare function invalidUploadFilenameReason(filename: string): string | null;
3
4
  export interface MediaRoutesOptions {
4
5
  mediaService: MediaService;
5
6
  }