@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.
- package/dist/admin/sync-admin-bundle.d.ts +9 -0
- package/dist/admin/sync-admin-bundle.js +120 -0
- package/dist/admin-bundle/.velora-version +1 -0
- 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
- 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
- package/dist/admin-bundle/assets/{content-trash.schema-DfXHkC-w.js → content-trash.schema-tvJ4Lbos.js} +5 -5
- package/dist/admin-bundle/assets/{hostInit--C4xK6nj.js → hostInit-B-6-34XY.js} +1 -1
- package/dist/admin-bundle/assets/{index-DhMY-0sC.js → index-CHCGuSbb.js} +1 -1
- package/dist/admin-bundle/assets/{index-BIAF0ShP.js → index-DYO7-IK8.js} +33 -33
- package/dist/admin-bundle/assets/{mf-entry-bootstrap-0-aef015cd.js → mf-entry-bootstrap-0-7b0ce2fe.js} +2 -2
- package/dist/admin-bundle/assets/{remoteEntry-CNyN68bc.js → remoteEntry-J2yJXsGB.js} +2 -2
- package/dist/admin-bundle/index.html +4 -4
- package/dist/app.d.ts +16 -1
- package/dist/app.js +63 -4
- package/dist/auth/api-keys.d.ts +1 -0
- package/dist/auth/api-keys.js +5 -1
- package/dist/auth/route-rate-limits.d.ts +4 -0
- package/dist/auth/route-rate-limits.js +13 -0
- package/dist/bin-sync-admin.d.ts +2 -0
- package/dist/bin-sync-admin.js +39 -0
- package/dist/bundled-templates/com.velora.blog/package.tar.gz +0 -0
- package/dist/config/public-url.d.ts +3 -0
- package/dist/config/public-url.js +45 -0
- package/dist/db/core-migrations/steps.js +95 -16
- package/dist/docs-bundle/pagefind/fragment/en_42f5356.pf_fragment +0 -0
- package/dist/docs-bundle/pagefind/fragment/en_67dccf8.pf_fragment +0 -0
- package/dist/docs-bundle/pagefind/fragment/en_80941b8.pf_fragment +0 -0
- package/dist/docs-bundle/pagefind/fragment/en_a64440b.pf_fragment +0 -0
- package/dist/docs-bundle/pagefind/fragment/en_eb168aa.pf_fragment +0 -0
- package/dist/docs-bundle/pagefind/index/en_8450ec7.pf_index +0 -0
- package/dist/docs-bundle/pagefind/index/en_c285f7b.pf_index +0 -0
- package/dist/docs-bundle/pagefind/index/en_e8cada7.pf_index +0 -0
- package/dist/docs-bundle/pagefind/pagefind-entry.json +1 -1
- package/dist/docs-bundle/pagefind/pagefind.en_57d2f8922f.pf_meta +0 -0
- package/dist/docs-bundle/v1/using-velora/getting-started/index.html +5 -3
- package/dist/docs-bundle/v1/using-velora/headless-api/index.html +10 -4
- package/dist/docs-bundle/v1/using-velora/index.html +6 -4
- package/dist/docs-bundle/v1/using-velora/installation/index.html +73 -15
- package/dist/docs-bundle/v1/using-velora/media/index.html +118 -34
- package/dist/graphql/schema.d.ts +1 -0
- package/dist/graphql/schema.js +5 -2
- package/dist/lib.d.ts +1 -0
- package/dist/lib.js +45 -11
- package/dist/media/media-service.d.ts +14 -15
- package/dist/media/media-service.js +121 -59
- package/dist/media/media-tokens.d.ts +6 -0
- package/dist/media/media-tokens.js +88 -0
- package/dist/media/storage/local.d.ts +16 -0
- package/dist/media/storage/local.js +99 -0
- package/dist/media/storage/s3.d.ts +25 -0
- package/dist/media/storage/s3.js +134 -0
- package/dist/media/storage/types.d.ts +21 -0
- package/dist/media/storage/types.js +44 -0
- package/dist/routes/admin-static.d.ts +2 -0
- package/dist/routes/admin-static.js +42 -4
- package/dist/routes/media-public.d.ts +6 -0
- package/dist/routes/media-public.js +265 -0
- package/dist/routes/media.d.ts +2 -1
- package/dist/routes/media.js +95 -5
- package/dist/routes/plugin-storage.js +5 -1
- package/dist/routes/public-api.js +5 -3
- package/dist/routes/published-item.d.ts +2 -1
- package/dist/routes/published-item.js +28 -5
- package/dist/scripts/copy-admin-bundle.js +9 -1
- package/package.json +7 -6
- package/prisma/schema.prisma +16 -3
- package/dist/docs-bundle/pagefind/fragment/en_1d9eb24.pf_fragment +0 -0
- package/dist/docs-bundle/pagefind/fragment/en_3099406.pf_fragment +0 -0
- package/dist/docs-bundle/pagefind/fragment/en_9aa652e.pf_fragment +0 -0
- package/dist/docs-bundle/pagefind/fragment/en_d855550.pf_fragment +0 -0
- package/dist/docs-bundle/pagefind/fragment/en_e6d88d8.pf_fragment +0 -0
- package/dist/docs-bundle/pagefind/index/en_5feeed7.pf_index +0 -0
- package/dist/docs-bundle/pagefind/index/en_c6981f9.pf_index +0 -0
- package/dist/docs-bundle/pagefind/index/en_ce4f6bf.pf_index +0 -0
- 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
|
+
};
|
package/dist/routes/media.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod";
|
|
2
|
-
import type
|
|
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
|
}
|