@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.
Files changed (79) hide show
  1. package/README.md +6 -0
  2. package/dist/admin/sync-admin-bundle.d.ts +9 -0
  3. package/dist/admin/sync-admin-bundle.js +120 -0
  4. package/dist/admin-bundle/.velora-version +1 -0
  5. 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
  6. 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
  7. package/dist/admin-bundle/assets/{content-trash.schema-DfXHkC-w.js → content-trash.schema-tvJ4Lbos.js} +5 -5
  8. package/dist/admin-bundle/assets/{hostInit--C4xK6nj.js → hostInit-B-6-34XY.js} +1 -1
  9. package/dist/admin-bundle/assets/{index-DhMY-0sC.js → index-CHCGuSbb.js} +1 -1
  10. package/dist/admin-bundle/assets/{index-BIAF0ShP.js → index-DYO7-IK8.js} +33 -33
  11. package/dist/admin-bundle/assets/{mf-entry-bootstrap-0-aef015cd.js → mf-entry-bootstrap-0-7b0ce2fe.js} +2 -2
  12. package/dist/admin-bundle/assets/{remoteEntry-CNyN68bc.js → remoteEntry-J2yJXsGB.js} +2 -2
  13. package/dist/admin-bundle/index.html +4 -4
  14. package/dist/app.d.ts +16 -1
  15. package/dist/app.js +63 -4
  16. package/dist/auth/api-keys.d.ts +1 -0
  17. package/dist/auth/api-keys.js +5 -1
  18. package/dist/auth/route-rate-limits.d.ts +4 -0
  19. package/dist/auth/route-rate-limits.js +13 -0
  20. package/dist/bin-sync-admin.d.ts +2 -0
  21. package/dist/bin-sync-admin.js +39 -0
  22. package/dist/bundled-templates/com.velora.blog/package.tar.gz +0 -0
  23. package/dist/config/public-url.d.ts +3 -0
  24. package/dist/config/public-url.js +45 -0
  25. package/dist/db/core-migrations/steps.js +95 -16
  26. package/dist/docs-bundle/pagefind/fragment/en_42f5356.pf_fragment +0 -0
  27. package/dist/docs-bundle/pagefind/fragment/en_6c5ee82.pf_fragment +0 -0
  28. package/dist/docs-bundle/pagefind/fragment/en_80941b8.pf_fragment +0 -0
  29. package/dist/docs-bundle/pagefind/fragment/en_a64440b.pf_fragment +0 -0
  30. package/dist/docs-bundle/pagefind/fragment/en_eb168aa.pf_fragment +0 -0
  31. package/dist/docs-bundle/pagefind/fragment/en_f053753.pf_fragment +0 -0
  32. package/dist/docs-bundle/pagefind/index/en_33ea61e.pf_index +0 -0
  33. package/dist/docs-bundle/pagefind/index/en_bb7c383.pf_index +0 -0
  34. package/dist/docs-bundle/pagefind/index/en_ff6eddf.pf_index +0 -0
  35. package/dist/docs-bundle/pagefind/pagefind-entry.json +1 -1
  36. package/dist/docs-bundle/pagefind/pagefind.en_6bbff64ea9.pf_meta +0 -0
  37. package/dist/docs-bundle/v1/index.html +4 -4
  38. package/dist/docs-bundle/v1/using-velora/getting-started/index.html +5 -3
  39. package/dist/docs-bundle/v1/using-velora/headless-api/index.html +10 -4
  40. package/dist/docs-bundle/v1/using-velora/index.html +6 -4
  41. package/dist/docs-bundle/v1/using-velora/installation/index.html +73 -15
  42. package/dist/docs-bundle/v1/using-velora/media/index.html +118 -34
  43. package/dist/graphql/schema.d.ts +1 -0
  44. package/dist/graphql/schema.js +5 -2
  45. package/dist/lib.d.ts +1 -0
  46. package/dist/lib.js +45 -11
  47. package/dist/media/media-service.d.ts +14 -15
  48. package/dist/media/media-service.js +121 -59
  49. package/dist/media/media-tokens.d.ts +6 -0
  50. package/dist/media/media-tokens.js +88 -0
  51. package/dist/media/storage/local.d.ts +16 -0
  52. package/dist/media/storage/local.js +99 -0
  53. package/dist/media/storage/s3.d.ts +25 -0
  54. package/dist/media/storage/s3.js +134 -0
  55. package/dist/media/storage/types.d.ts +21 -0
  56. package/dist/media/storage/types.js +44 -0
  57. package/dist/routes/admin-static.d.ts +2 -0
  58. package/dist/routes/admin-static.js +42 -4
  59. package/dist/routes/media-public.d.ts +6 -0
  60. package/dist/routes/media-public.js +265 -0
  61. package/dist/routes/media.d.ts +2 -1
  62. package/dist/routes/media.js +95 -5
  63. package/dist/routes/plugin-storage.js +5 -1
  64. package/dist/routes/public-api.js +5 -3
  65. package/dist/routes/published-item.d.ts +2 -1
  66. package/dist/routes/published-item.js +28 -5
  67. package/dist/scripts/copy-admin-bundle.js +9 -1
  68. package/package.json +12 -11
  69. package/prisma/schema.prisma +16 -3
  70. package/dist/docs-bundle/pagefind/fragment/en_1d9eb24.pf_fragment +0 -0
  71. package/dist/docs-bundle/pagefind/fragment/en_1ef320c.pf_fragment +0 -0
  72. package/dist/docs-bundle/pagefind/fragment/en_3099406.pf_fragment +0 -0
  73. package/dist/docs-bundle/pagefind/fragment/en_9aa652e.pf_fragment +0 -0
  74. package/dist/docs-bundle/pagefind/fragment/en_d855550.pf_fragment +0 -0
  75. package/dist/docs-bundle/pagefind/fragment/en_e6d88d8.pf_fragment +0 -0
  76. package/dist/docs-bundle/pagefind/index/en_5feeed7.pf_index +0 -0
  77. package/dist/docs-bundle/pagefind/index/en_c6981f9.pf_index +0 -0
  78. package/dist/docs-bundle/pagefind/index/en_ce4f6bf.pf_index +0 -0
  79. 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
- export interface MediaStorageConfig {
5
- endpoint: string;
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 config;
15
+ private readonly storage;
21
16
  private readonly dialect;
22
- private readonly s3;
23
- constructor(prisma: PrismaClient, config: MediaStorageConfig, dialect: Dialect);
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, expiresInSeconds?: number): Promise<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
- private canonicalUrl;
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 { CreateBucketCommand, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3";
3
- import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
4
- // Defaults match the minio service in docker-compose.yml (same treatment as
5
- // POSTGRES_TEST_URL), so local dev works with zero required config. Point
6
- // these at real S3/R2 via .env see .env.example.
7
- export function mediaStorageConfigFromEnv() {
8
- return {
9
- endpoint: process.env.S3_ENDPOINT ?? "http://localhost:9000",
10
- region: process.env.S3_REGION ?? "us-east-1",
11
- accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "velora",
12
- secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "velora-secret",
13
- bucket: process.env.S3_BUCKET ?? "velora-media",
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
- config;
42
+ storage;
19
43
  dialect;
20
- s3;
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.config = config;
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. Only creates the
45
- // bucket when it doesn't exist yet.
46
- async ensureBucket() {
47
- try {
48
- await this.s3.send(new HeadBucketCommand({ Bucket: this.config.bucket }));
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.s3.send(new PutObjectCommand({
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
- url: this.canonicalUrl(storageKey),
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.s3
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
- // Time-limited presigned GETworks against private buckets, which is
106
- // why downloads go through this rather than the stored canonical url.
107
- async getDownloadUrl(id, expiresInSeconds = 900) {
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 getSignedUrl(this.s3, new GetObjectCommand({ Bucket: this.config.bucket, Key: row.storageKey }), { expiresIn: expiresInSeconds });
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.s3.send(new DeleteObjectCommand({ Bucket: this.config.bucket, Key: row.storageKey }));
152
+ await this.storage.delete(row.storageKey);
120
153
  await this.prisma.mediaFile.delete({ where: { id } });
121
154
  }
122
- canonicalUrl(storageKey) {
123
- const encodedKey = storageKey.split("/").map(encodeURIComponent).join("/");
124
- return `${this.config.endpoint.replace(/\/$/, "")}/${this.config.bucket}/${encodedKey}`;
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
- function sanitizeForKey(filename) {
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
- url: row.url,
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
+ }