@velora-cms/server 0.11.0 → 0.12.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.
- package/README.md +6 -0
- 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_6c5ee82.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/fragment/en_f053753.pf_fragment +0 -0
- package/dist/docs-bundle/pagefind/index/en_33ea61e.pf_index +0 -0
- package/dist/docs-bundle/pagefind/index/en_bb7c383.pf_index +0 -0
- package/dist/docs-bundle/pagefind/index/en_ff6eddf.pf_index +0 -0
- package/dist/docs-bundle/pagefind/pagefind-entry.json +1 -1
- package/dist/docs-bundle/pagefind/pagefind.en_6bbff64ea9.pf_meta +0 -0
- package/dist/docs-bundle/v1/index.html +4 -4
- 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 +12 -11
- 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_1ef320c.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
|
@@ -1,14 +1,9 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
1
2
|
import type { Prisma, PrismaClient } from "@prisma/client";
|
|
2
|
-
import type { MediaFile } from "@velora-cms/api-schemas";
|
|
3
|
+
import type { MediaFile, MediaVisibility } from "@velora-cms/api-schemas";
|
|
3
4
|
import type { Dialect } from "@velora-cms/prisma-variants";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
region: string;
|
|
7
|
-
accessKeyId: string;
|
|
8
|
-
secretAccessKey: string;
|
|
9
|
-
bucket: string;
|
|
10
|
-
}
|
|
11
|
-
export declare function mediaStorageConfigFromEnv(): MediaStorageConfig;
|
|
5
|
+
import type { MediaStorage } from "./storage/types.js";
|
|
6
|
+
export declare function resolveMediaStorage(env?: NodeJS.ProcessEnv): MediaStorage;
|
|
12
7
|
export interface MediaUploadInput {
|
|
13
8
|
filename: string;
|
|
14
9
|
mimeType: string;
|
|
@@ -17,11 +12,10 @@ export interface MediaUploadInput {
|
|
|
17
12
|
}
|
|
18
13
|
export declare class MediaService {
|
|
19
14
|
private readonly prisma;
|
|
20
|
-
private readonly
|
|
15
|
+
private readonly storage;
|
|
21
16
|
private readonly dialect;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
ensureBucket(): Promise<void>;
|
|
17
|
+
constructor(prisma: PrismaClient, storage: MediaStorage, dialect: Dialect);
|
|
18
|
+
ensureReady(): Promise<void>;
|
|
25
19
|
upload(input: MediaUploadInput): Promise<MediaFile>;
|
|
26
20
|
getById(id: string): Promise<MediaFile | null>;
|
|
27
21
|
list(options: {
|
|
@@ -29,8 +23,13 @@ export declare class MediaService {
|
|
|
29
23
|
limit: number;
|
|
30
24
|
search?: string;
|
|
31
25
|
}): Promise<MediaFile[]>;
|
|
32
|
-
getDownloadUrl(id: string
|
|
26
|
+
getDownloadUrl(id: string): Promise<string>;
|
|
27
|
+
getStream(id: string): Promise<{
|
|
28
|
+
stream: Readable;
|
|
29
|
+
size?: number;
|
|
30
|
+
}>;
|
|
33
31
|
delete(id: string): Promise<void>;
|
|
34
|
-
|
|
32
|
+
setVisibility(id: string, visibility: MediaVisibility): Promise<MediaFile>;
|
|
35
33
|
}
|
|
36
34
|
export declare function buildFilenameSearchFilter(search: string, dialect: Dialect): Prisma.StringFilter;
|
|
35
|
+
export declare function sanitizeForKey(filename: string): string;
|
|
@@ -1,24 +1,47 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
2
|
+
import { mintMediaToken } from "./media-tokens.js";
|
|
3
|
+
import { LocalDiskStorage } from "./storage/local.js";
|
|
4
|
+
import { S3Storage, s3ConfigFromEnv } from "./storage/s3.js";
|
|
5
|
+
// Driver selection (S186c §1): MEDIA_STORAGE explicit wins; otherwise ANY
|
|
6
|
+
// S3_* var being set means this deployment configured S3, so pick s3;
|
|
7
|
+
// with nothing set at all, a fresh scaffold gets local disk. This is the
|
|
8
|
+
// fix for the landmine where a stranger's first boot silently dialed the
|
|
9
|
+
// dev-only MinIO default at localhost:9000 (previously baked into
|
|
10
|
+
// S3-config defaults regardless of whether S3 was ever requested) — the
|
|
11
|
+
// defaults in s3ConfigFromEnv now only apply once this function has
|
|
12
|
+
// already decided s3 is the active driver.
|
|
13
|
+
export function resolveMediaStorage(env = process.env) {
|
|
14
|
+
// LOW-8: an empty-string MEDIA_STORAGE (e.g. a stray `MEDIA_STORAGE=`
|
|
15
|
+
// line in .env) is "unset," not a third explicit value — same
|
|
16
|
+
// empty-means-unset convention hasAnyS3Var applies to the S3_* vars.
|
|
17
|
+
const explicit = env.MEDIA_STORAGE || undefined;
|
|
18
|
+
if (explicit !== undefined && explicit !== "local" && explicit !== "s3") {
|
|
19
|
+
throw new Error(`Unknown MEDIA_STORAGE "${explicit}" — expected "local" or "s3".`);
|
|
20
|
+
}
|
|
21
|
+
const driver = explicit ??
|
|
22
|
+
(hasAnyS3Var(env) ? "s3" : "local");
|
|
23
|
+
return driver === "s3"
|
|
24
|
+
? new S3Storage(s3ConfigFromEnv(env))
|
|
25
|
+
: new LocalDiskStorage(env.MEDIA_DIR || "./media");
|
|
26
|
+
}
|
|
27
|
+
// LOW-8: empty string counts as unset for every S3_* var, same as
|
|
28
|
+
// MEDIA_STORAGE above — otherwise a scaffold with a blank `S3_ENDPOINT=`
|
|
29
|
+
// left over in .env would silently flip the driver to s3.
|
|
30
|
+
function hasAnyS3Var(env) {
|
|
31
|
+
return (isSet(env.S3_ENDPOINT) ||
|
|
32
|
+
isSet(env.S3_BUCKET) ||
|
|
33
|
+
isSet(env.S3_ACCESS_KEY_ID) ||
|
|
34
|
+
isSet(env.S3_SECRET_ACCESS_KEY) ||
|
|
35
|
+
isSet(env.S3_REGION));
|
|
36
|
+
}
|
|
37
|
+
function isSet(value) {
|
|
38
|
+
return value !== undefined && value !== "";
|
|
15
39
|
}
|
|
16
40
|
export class MediaService {
|
|
17
41
|
prisma;
|
|
18
|
-
|
|
42
|
+
storage;
|
|
19
43
|
dialect;
|
|
20
|
-
|
|
21
|
-
constructor(prisma, config,
|
|
44
|
+
constructor(prisma, storage,
|
|
22
45
|
// Which Prisma provider the injected client is actually querying against
|
|
23
46
|
// (postgresql | mysql | sqlite) — needed by list()'s search filter,
|
|
24
47
|
// see buildFilenameSearchFilter below. Passed in rather than
|
|
@@ -27,40 +50,19 @@ export class MediaService {
|
|
|
27
50
|
// the real app, a literal in tests).
|
|
28
51
|
dialect) {
|
|
29
52
|
this.prisma = prisma;
|
|
30
|
-
this.
|
|
53
|
+
this.storage = storage;
|
|
31
54
|
this.dialect = dialect;
|
|
32
|
-
this.s3 = new S3Client({
|
|
33
|
-
endpoint: config.endpoint,
|
|
34
|
-
region: config.region,
|
|
35
|
-
credentials: {
|
|
36
|
-
accessKeyId: config.accessKeyId,
|
|
37
|
-
secretAccessKey: config.secretAccessKey,
|
|
38
|
-
},
|
|
39
|
-
// MinIO and R2 route by path, not by bucket subdomain — and a
|
|
40
|
-
// localhost endpoint can't do virtual-hosted buckets at all.
|
|
41
|
-
forcePathStyle: true,
|
|
42
|
-
});
|
|
43
55
|
}
|
|
44
|
-
// Idempotent; safe to call on startup or from tests.
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
await this.s3.send(new CreateBucketCommand({ Bucket: this.config.bucket }));
|
|
52
|
-
}
|
|
56
|
+
// Idempotent; safe to call on startup or from tests. Driver-aware: local
|
|
57
|
+
// disk mkdirs the media directory, s3 confirms/creates the bucket (and
|
|
58
|
+
// fails loudly if unreachable — see S3Storage.ensureReady).
|
|
59
|
+
async ensureReady() {
|
|
60
|
+
await this.storage.ensureReady();
|
|
53
61
|
}
|
|
54
62
|
async upload(input) {
|
|
55
63
|
const id = randomUUID();
|
|
56
64
|
const storageKey = `${id}/${sanitizeForKey(input.filename)}`;
|
|
57
|
-
await this.
|
|
58
|
-
Bucket: this.config.bucket,
|
|
59
|
-
Key: storageKey,
|
|
60
|
-
Body: input.data,
|
|
61
|
-
ContentType: input.mimeType,
|
|
62
|
-
ContentLength: input.data.length,
|
|
63
|
-
}));
|
|
65
|
+
await this.storage.put(storageKey, input.data, input.mimeType);
|
|
64
66
|
try {
|
|
65
67
|
const row = await this.prisma.mediaFile.create({
|
|
66
68
|
data: {
|
|
@@ -69,7 +71,12 @@ export class MediaService {
|
|
|
69
71
|
mimeType: input.mimeType,
|
|
70
72
|
size: input.data.length,
|
|
71
73
|
storageKey,
|
|
72
|
-
|
|
74
|
+
// M5: url is COMPUTED from id+filename (see toMediaFile /
|
|
75
|
+
// stableMediaPath below), never read back from this column —
|
|
76
|
+
// the schema keeps a NOT NULL `url` this task (untouched), so
|
|
77
|
+
// this write is inert filler, not a value anything trusts.
|
|
78
|
+
// storageKey is the honest "what got written here" value.
|
|
79
|
+
url: storageKey,
|
|
73
80
|
uploadedBy: input.uploadedBy,
|
|
74
81
|
},
|
|
75
82
|
});
|
|
@@ -78,9 +85,7 @@ export class MediaService {
|
|
|
78
85
|
catch (error) {
|
|
79
86
|
// Metadata is the source of truth — don't leave an object around that
|
|
80
87
|
// no metadata row points to.
|
|
81
|
-
await this.
|
|
82
|
-
.send(new DeleteObjectCommand({ Bucket: this.config.bucket, Key: storageKey }))
|
|
83
|
-
.catch(() => undefined);
|
|
88
|
+
await this.storage.delete(storageKey).catch(() => undefined);
|
|
84
89
|
throw error;
|
|
85
90
|
}
|
|
86
91
|
}
|
|
@@ -102,28 +107,72 @@ export class MediaService {
|
|
|
102
107
|
});
|
|
103
108
|
return rows.map(toMediaFile);
|
|
104
109
|
}
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
110
|
+
// The stable app path — NOT a time-limited presigned URL (S186c §1/§3).
|
|
111
|
+
// The old presigned-URL mechanism expired in 15 minutes even for public
|
|
112
|
+
// media, which silently broke embedded links.
|
|
113
|
+
//
|
|
114
|
+
// PUBLIC items: the same path forever for a given id+filename — the
|
|
115
|
+
// GET /media/:id/:filename route (media-public.ts) serves it straight
|
|
116
|
+
// from the driver, no auth required.
|
|
117
|
+
//
|
|
118
|
+
// PRIVATE items: that same path PLUS a freshly minted, short-lived
|
|
119
|
+
// `?token=` (media-tokens.ts's mintMediaToken, S186c §3) — a NEW token
|
|
120
|
+
// every call, so this value is for immediate use (an admin list/detail
|
|
121
|
+
// response rendering an `<img src>` right now), not for persisting or
|
|
122
|
+
// reusing across requests. The dedicated "Copy temporary link" mint
|
|
123
|
+
// (POST /api/media/:id/link, routes/media.ts) is the caller-facing way
|
|
124
|
+
// to get a link meant to be saved/shared.
|
|
125
|
+
async getDownloadUrl(id) {
|
|
126
|
+
const row = await this.prisma.mediaFile.findUnique({ where: { id } });
|
|
127
|
+
if (!row) {
|
|
128
|
+
throw new Error(`Media file not found: ${id}`);
|
|
129
|
+
}
|
|
130
|
+
const path = stableMediaPath(row.id, row.filename);
|
|
131
|
+
if (row.visibility === "private") {
|
|
132
|
+
const { token } = mintMediaToken(row.id);
|
|
133
|
+
return `${path}?token=${encodeURIComponent(token)}`;
|
|
134
|
+
}
|
|
135
|
+
return path;
|
|
136
|
+
}
|
|
137
|
+
// Streams the object's bytes straight from the active driver — used by
|
|
138
|
+
// the (future) /media/:id/:filename route and directly by tests that
|
|
139
|
+
// need to assert real content round-trips without going through HTTP.
|
|
140
|
+
async getStream(id) {
|
|
108
141
|
const row = await this.prisma.mediaFile.findUnique({ where: { id } });
|
|
109
142
|
if (!row) {
|
|
110
143
|
throw new Error(`Media file not found: ${id}`);
|
|
111
144
|
}
|
|
112
|
-
return
|
|
145
|
+
return this.storage.getStream(row.storageKey);
|
|
113
146
|
}
|
|
114
147
|
async delete(id) {
|
|
115
148
|
const row = await this.prisma.mediaFile.findUnique({ where: { id } });
|
|
116
149
|
if (!row) {
|
|
117
150
|
throw new Error(`Media file not found: ${id}`);
|
|
118
151
|
}
|
|
119
|
-
await this.
|
|
152
|
+
await this.storage.delete(row.storageKey);
|
|
120
153
|
await this.prisma.mediaFile.delete({ where: { id } });
|
|
121
154
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
155
|
+
// Flips the visibility flag (S186c §2) — updates the row only, never the
|
|
156
|
+
// storage key or the computed URL, in either driver. Throws the same
|
|
157
|
+
// "not found" shape as delete()/getDownloadUrl() above so routes/media.ts
|
|
158
|
+
// can turn it into a 404 with no existence oracle either way.
|
|
159
|
+
async setVisibility(id, visibility) {
|
|
160
|
+
const row = await this.prisma.mediaFile.findUnique({ where: { id } });
|
|
161
|
+
if (!row) {
|
|
162
|
+
throw new Error(`Media file not found: ${id}`);
|
|
163
|
+
}
|
|
164
|
+
const updated = await this.prisma.mediaFile.update({ where: { id }, data: { visibility } });
|
|
165
|
+
return toMediaFile(updated);
|
|
125
166
|
}
|
|
126
167
|
}
|
|
168
|
+
// `/media/<id>/<filename>` — the stable app path from S186c §3, computed
|
|
169
|
+
// from the row rather than stored, so it never has an absolute host/port/
|
|
170
|
+
// endpoint baked in to go stale. filename is the ORIGINAL (unsanitized)
|
|
171
|
+
// name, not the storage key's sanitized segment — the future route
|
|
172
|
+
// verifies it against the stored filename rather than trusting it.
|
|
173
|
+
function stableMediaPath(id, filename) {
|
|
174
|
+
return `/media/${encodeURIComponent(id)}/${encodeURIComponent(filename)}`;
|
|
175
|
+
}
|
|
127
176
|
// Case-insensitive filename search, built per the active Prisma provider's
|
|
128
177
|
// verified capabilities (Session 131 walkthrough, task-4-report.md step 13
|
|
129
178
|
// — SQLite 500'd here because `mode: "insensitive"` isn't a real argument
|
|
@@ -145,8 +194,10 @@ export function buildFilenameSearchFilter(search, dialect) {
|
|
|
145
194
|
return dialect === "postgresql" ? { contains: search, mode: "insensitive" } : { contains: search };
|
|
146
195
|
}
|
|
147
196
|
// Original filename lives untouched in metadata; the object key only keeps
|
|
148
|
-
// characters that are safe in S3 keys and URLs.
|
|
149
|
-
|
|
197
|
+
// characters that are safe in S3 keys and URLs. Exported for routes/media.ts
|
|
198
|
+
// (LOW-11) to reuse the exact same "does this collapse to something inert"
|
|
199
|
+
// check the upload path itself uses, rather than re-deriving it.
|
|
200
|
+
export function sanitizeForKey(filename) {
|
|
150
201
|
return filename.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
151
202
|
}
|
|
152
203
|
function toMediaFile(row) {
|
|
@@ -155,7 +206,18 @@ function toMediaFile(row) {
|
|
|
155
206
|
filename: row.filename,
|
|
156
207
|
mimeType: row.mimeType,
|
|
157
208
|
size: row.size,
|
|
158
|
-
|
|
209
|
+
// M5: computed, never read from row.url — old rows can carry a
|
|
210
|
+
// stale absolute value (a past presigned/S3 URL, a defaulted MinIO
|
|
211
|
+
// endpoint, etc.) that would otherwise leak straight into the API
|
|
212
|
+
// response. See stableMediaPath's doc comment.
|
|
213
|
+
url: stableMediaPath(row.id, row.filename),
|
|
214
|
+
// Prisma types this column as a plain string (schema.prisma has no
|
|
215
|
+
// native enum for it, matching the Role-enum-avoidance pattern
|
|
216
|
+
// elsewhere in this file's dialect notes) — the column is written
|
|
217
|
+
// exclusively through setVisibility/the Prisma default, both of which
|
|
218
|
+
// only ever store "public"/"private", so this cast reflects an
|
|
219
|
+
// invariant this module already enforces, not an unchecked read.
|
|
220
|
+
visibility: row.visibility,
|
|
159
221
|
uploadedBy: row.uploadedBy,
|
|
160
222
|
uploadedAt: row.uploadedAt.toISOString(),
|
|
161
223
|
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const DEFAULT_MEDIA_LINK_TTL_SECONDS = 900;
|
|
2
|
+
export declare function mintMediaToken(id: string, expiresInSeconds?: number, nowMs?: number): {
|
|
3
|
+
token: string;
|
|
4
|
+
expiresAt: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function verifyMediaToken(id: string, token: string, nowMs?: number): boolean;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
// Signed, short-lived media links (S186c §3) — the mechanism behind
|
|
3
|
+
// "Copy temporary link" and the private branch of GET /media/:id/:filename.
|
|
4
|
+
// Deliberately NOT a JWT and NOT the admin session (api-design.md: never
|
|
5
|
+
// conflate a headless/short-lived token with a logged-in session) — same
|
|
6
|
+
// shape as auth/preview-tokens.ts's mintPreviewToken/verifyPreviewToken,
|
|
7
|
+
// applied to media ids instead of content ids:
|
|
8
|
+
//
|
|
9
|
+
// base64url(JSON{id, exp}) + "." + base64url(hmacSignature)
|
|
10
|
+
//
|
|
11
|
+
// Exactly ONE dot, so a real two-dot JWT can never be mistaken for one
|
|
12
|
+
// (and vice versa) — same defense-by-construction as preview tokens.
|
|
13
|
+
//
|
|
14
|
+
// The signing key is a fixed derivation of JWT_SECRET under its own label
|
|
15
|
+
// ("velora-media-link", distinct from preview-tokens.ts's
|
|
16
|
+
// "velora-preview-token") so a leaked media link can never be replayed as
|
|
17
|
+
// a preview token or an admin JWT, and vice versa: three disjoint
|
|
18
|
+
// keyspaces from one root secret. Derived fresh per call, never cached —
|
|
19
|
+
// tests toggle JWT_SECRET across calls in the same run, and caching would
|
|
20
|
+
// silently keep signing/verifying with a stale key.
|
|
21
|
+
const DERIVATION_LABEL = "velora-media-link";
|
|
22
|
+
// Default TTL (S186c §3: "short-lived ... default 15 min"). No env
|
|
23
|
+
// override today — mintMediaToken's own `expiresInSeconds` parameter is
|
|
24
|
+
// the extension point (the mint route always calls it with the default;
|
|
25
|
+
// nothing currently needs a longer/shorter link).
|
|
26
|
+
export const DEFAULT_MEDIA_LINK_TTL_SECONDS = 900;
|
|
27
|
+
function derivedKey() {
|
|
28
|
+
const secret = process.env.JWT_SECRET;
|
|
29
|
+
if (!secret) {
|
|
30
|
+
throw new Error("JWT_SECRET must be set — see .env.example");
|
|
31
|
+
}
|
|
32
|
+
return createHmac("sha256", secret).update(DERIVATION_LABEL).digest();
|
|
33
|
+
}
|
|
34
|
+
function sign(payloadB64) {
|
|
35
|
+
return createHmac("sha256", derivedKey()).update(payloadB64).digest("base64url");
|
|
36
|
+
}
|
|
37
|
+
// nowMs defaults to Date.now() but is overridable — same reason
|
|
38
|
+
// preview-tokens.ts exposes it: deterministic expiry tests with no fake
|
|
39
|
+
// timers.
|
|
40
|
+
export function mintMediaToken(id, expiresInSeconds = DEFAULT_MEDIA_LINK_TTL_SECONDS, nowMs = Date.now()) {
|
|
41
|
+
const exp = Math.floor(nowMs / 1000) + expiresInSeconds;
|
|
42
|
+
const payload = { id, exp };
|
|
43
|
+
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
44
|
+
const signatureB64 = sign(payloadB64);
|
|
45
|
+
return {
|
|
46
|
+
token: `${payloadB64}.${signatureB64}`,
|
|
47
|
+
expiresAt: new Date(exp * 1000).toISOString(),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// Returns false for EVERY failure mode — malformed shape, bad signature,
|
|
51
|
+
// expired, a token minted for a DIFFERENT media id, JWT_SECRET missing —
|
|
52
|
+
// with no distinction between them and never a thrown error. The media
|
|
53
|
+
// route (media-public.ts) relies on this never throwing: a bad `?token=`
|
|
54
|
+
// on an otherwise-valid request must fall through to the same 404 as a
|
|
55
|
+
// missing token, not 500 the request. `id` is the id from the route's own
|
|
56
|
+
// :id param — verifying it against the token's embedded id is what makes
|
|
57
|
+
// a token minted for item A rejected outright when presented against
|
|
58
|
+
// item B's URL (wrong-id), not just against a mismatched signature.
|
|
59
|
+
export function verifyMediaToken(id, token, nowMs = Date.now()) {
|
|
60
|
+
try {
|
|
61
|
+
const parts = token.split(".");
|
|
62
|
+
if (parts.length !== 2)
|
|
63
|
+
return false;
|
|
64
|
+
const [payloadB64, signatureB64] = parts;
|
|
65
|
+
if (!payloadB64 || !signatureB64)
|
|
66
|
+
return false;
|
|
67
|
+
const expectedSignatureB64 = sign(payloadB64);
|
|
68
|
+
const presented = Buffer.from(signatureB64, "base64url");
|
|
69
|
+
const expected = Buffer.from(expectedSignatureB64, "base64url");
|
|
70
|
+
// Length-check before timingSafeEqual: it throws on mismatched buffer
|
|
71
|
+
// lengths rather than returning false, and skipping this check would
|
|
72
|
+
// itself be a length side-channel.
|
|
73
|
+
if (presented.length !== expected.length || !timingSafeEqual(presented, expected)) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString());
|
|
77
|
+
if (typeof payload?.id !== "string" || typeof payload?.exp !== "number")
|
|
78
|
+
return false;
|
|
79
|
+
if (payload.id !== id)
|
|
80
|
+
return false;
|
|
81
|
+
if (nowMs >= payload.exp * 1000)
|
|
82
|
+
return false;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
import { type MediaStorage } from "./types.js";
|
|
3
|
+
export declare class LocalDiskStorage implements MediaStorage {
|
|
4
|
+
readonly kind: "local";
|
|
5
|
+
private readonly rootDir;
|
|
6
|
+
constructor(dir: string);
|
|
7
|
+
ensureReady(): Promise<void>;
|
|
8
|
+
put(key: string, data: Buffer, _mimeType: string): Promise<void>;
|
|
9
|
+
getStream(key: string): Promise<{
|
|
10
|
+
stream: Readable;
|
|
11
|
+
size?: number;
|
|
12
|
+
}>;
|
|
13
|
+
delete(key: string): Promise<void>;
|
|
14
|
+
exists(key: string): Promise<boolean>;
|
|
15
|
+
private pathFor;
|
|
16
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { mkdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
5
|
+
import { assertValidStorageKey, MediaNotFoundError } from "./types.js";
|
|
6
|
+
// Scaffold default (S186c §1) — files live under MEDIA_DIR, next to the
|
|
7
|
+
// site's velora.db, so backing up the project folder backs up its media
|
|
8
|
+
// too. No object storage required for a shared-host / single-VPS site.
|
|
9
|
+
export class LocalDiskStorage {
|
|
10
|
+
kind = "local";
|
|
11
|
+
rootDir;
|
|
12
|
+
constructor(dir) {
|
|
13
|
+
// Resolved against cwd, same convention as .env / MEDIA_DIR itself —
|
|
14
|
+
// a relative MEDIA_DIR means "next to wherever the process was
|
|
15
|
+
// started," matching how the rest of the app resolves relative paths.
|
|
16
|
+
this.rootDir = resolve(process.cwd(), dir);
|
|
17
|
+
}
|
|
18
|
+
async ensureReady() {
|
|
19
|
+
await mkdir(this.rootDir, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
async put(key, data, _mimeType) {
|
|
22
|
+
const finalPath = this.pathFor(key);
|
|
23
|
+
const dir = dirname(finalPath);
|
|
24
|
+
await mkdir(dir, { recursive: true });
|
|
25
|
+
// Temp-file-then-rename: a reader calling getStream/exists mid-write
|
|
26
|
+
// only ever sees either nothing or the complete file, never a torn
|
|
27
|
+
// one. Same-directory rename is atomic on both POSIX and NTFS.
|
|
28
|
+
const tempPath = join(dir, `.tmp-${randomUUID()}`);
|
|
29
|
+
try {
|
|
30
|
+
await writeFile(tempPath, data);
|
|
31
|
+
await rename(tempPath, finalPath);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
await rm(tempPath, { force: true });
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async getStream(key) {
|
|
39
|
+
const path = this.pathFor(key);
|
|
40
|
+
let stats;
|
|
41
|
+
try {
|
|
42
|
+
stats = await stat(path);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (isNotFound(error))
|
|
46
|
+
throw new MediaNotFoundError(key);
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
// A directory sitting at this path (e.g. the key names a prefix that
|
|
50
|
+
// legitimately holds other objects) is not a readable object —
|
|
51
|
+
// treated the same as "missing" from a caller's point of view.
|
|
52
|
+
if (!stats.isFile())
|
|
53
|
+
throw new MediaNotFoundError(key);
|
|
54
|
+
return { stream: createReadStream(path), size: stats.size };
|
|
55
|
+
}
|
|
56
|
+
async delete(key) {
|
|
57
|
+
try {
|
|
58
|
+
await unlink(this.pathFor(key));
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
// Idempotent, matching S3 DeleteObject's native "no error either
|
|
62
|
+
// way" behavior — see the MediaStorage interface doc comment.
|
|
63
|
+
if (!isNotFound(error))
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async exists(key) {
|
|
68
|
+
try {
|
|
69
|
+
const stats = await stat(this.pathFor(key));
|
|
70
|
+
// A directory is not "an object that exists" from a caller's point
|
|
71
|
+
// of view — same isFile() gate as getStream.
|
|
72
|
+
return stats.isFile();
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (isNotFound(error))
|
|
76
|
+
return false;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
pathFor(key) {
|
|
81
|
+
// Keys are always server-generated (`<uuid>/<sanitized-filename>`,
|
|
82
|
+
// see sanitizeForKey in media-service.ts), never a raw user-supplied
|
|
83
|
+
// path fragment — but this validates BEFORE resolve() ever runs, so a
|
|
84
|
+
// hostile/buggy key is rejected outright rather than relying on the
|
|
85
|
+
// containment check below to catch whatever `..` walking it produces.
|
|
86
|
+
assertValidStorageKey(key);
|
|
87
|
+
const path = resolve(this.rootDir, key);
|
|
88
|
+
// No equality allowance: a key that resolves to exactly rootDir
|
|
89
|
+
// itself (e.g. via a validated-but-empty tail) is still "outside" any
|
|
90
|
+
// single object this driver should ever address.
|
|
91
|
+
if (!path.startsWith(this.rootDir + sep)) {
|
|
92
|
+
throw new Error(`Refusing to resolve media key outside MEDIA_DIR: ${key}`);
|
|
93
|
+
}
|
|
94
|
+
return path;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function isNotFound(error) {
|
|
98
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
99
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
import { type MediaStorage } from "./types.js";
|
|
3
|
+
export interface S3StorageConfig {
|
|
4
|
+
endpoint: string;
|
|
5
|
+
region: string;
|
|
6
|
+
accessKeyId: string;
|
|
7
|
+
secretAccessKey: string;
|
|
8
|
+
bucket: string;
|
|
9
|
+
endpointWasDefaulted?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function s3ConfigFromEnv(env?: NodeJS.ProcessEnv): S3StorageConfig;
|
|
12
|
+
export declare class S3Storage implements MediaStorage {
|
|
13
|
+
private readonly config;
|
|
14
|
+
readonly kind: "s3";
|
|
15
|
+
private readonly client;
|
|
16
|
+
constructor(config: S3StorageConfig);
|
|
17
|
+
ensureReady(): Promise<void>;
|
|
18
|
+
put(key: string, data: Buffer, mimeType: string): Promise<void>;
|
|
19
|
+
getStream(key: string): Promise<{
|
|
20
|
+
stream: Readable;
|
|
21
|
+
size?: number;
|
|
22
|
+
}>;
|
|
23
|
+
delete(key: string): Promise<void>;
|
|
24
|
+
exists(key: string): Promise<boolean>;
|
|
25
|
+
}
|