@fonderie/media 0.1.0 → 0.2.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 CHANGED
@@ -31,23 +31,33 @@ app.register(new MediaModule(store, {
31
31
  }));
32
32
  ```
33
33
 
34
- Run its migration alongside the others (it owns `fonderie_*` tables):
34
+ Run **both** migrations media's (`fonderie_media_assets` metadata) and
35
+ `@fonderie/storage`'s (`fonderie_storage_blobs`, where `DbBlobProvider` writes).
36
+ Forgetting storage's makes DbBlob uploads fail at runtime:
35
37
 
36
38
  ```ts
37
- import { getMigrationsPath } from '@fonderie/media/migrations';
38
- await new InternalMigrationRunner(store, getMigrationsPath()).run();
39
+ import { getMigrationsPath as mediaMigrations } from '@fonderie/media/migrations';
40
+ import { getMigrationsPath as storageMigrations } from '@fonderie/storage/migrations';
41
+ await new InternalMigrationRunner(store, storageMigrations()).run();
42
+ await new InternalMigrationRunner(store, mediaMigrations()).run();
39
43
  ```
40
44
 
41
- ## Storage providers
45
+ ## Storage & access
42
46
 
43
- `IStorageProvider` is the seam — `put` / `get` / `delete`, with `get` returning
44
- either bytes (app serves them) or a redirect URL (backend serves them, e.g. an
45
- S3 signed URL). Two zero-infra backends ship built in:
47
+ Byte storage lives in **`@fonderie/storage`** (media re-exports the zero-infra
48
+ providers for convenience):
46
49
 
47
- - **`DbBlobProvider`** — bytes in Postgres (`fonderie_media_blobs`). One process,
48
- one database, atomic backups. The default.
49
- - **`LocalFsProvider`** — bytes on the server filesystem.
50
+ - **`DbBlobProvider`** — bytes in Postgres. Zero infra; the default.
51
+ - **`LocalFsProvider`** bytes on disk.
52
+ - **`S3Provider`** (S3 / MinIO / R2) `import { S3Provider } from '@fonderie/storage/s3'`.
50
53
 
51
- Implement the interface to add object storage (`S3Provider`, etc.) without
52
- touching product code — the same pattern `@fonderie/billing` uses for payment
53
- providers.
54
+ Swapping backends is one config line; the `/media/:id` URL contract is unchanged.
55
+
56
+ **Access model:** `GET /media/:id` is **public** — an `<img src>` can't carry a
57
+ Bearer token, and ids are unguessable UUIDs (capability URLs). Right for
58
+ avatars/logos; it is **not** an access-controlled store for private or sensitive
59
+ files — serve those through your own authenticated route.
60
+
61
+ **Owner authorization:** uploads default to self-owned user assets only
62
+ (`ownerType: 'user'`, `ownerId` = the caller). To allow workspace logos, customer
63
+ photos, etc., pass `authorizeOwner(ctx, owner)` in the module config.
package/brain/outcomes.md CHANGED
@@ -23,20 +23,12 @@ created_by UUID
23
23
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
24
24
  ```
25
25
 
26
- ### `fonderie_media_blobs`
27
-
28
- ```sql
29
- id UUID PRIMARY KEY DEFAULT gen_random_uuid()
30
- bytes BYTEA NOT NULL
31
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
32
- ```
33
-
34
26
  Raw SQL ships in `node_modules/@fonderie/media/dist/migrations/sql/` — read it there if you must; never download tarballs.
35
27
 
36
28
  ## HTTP routes registered
37
29
 
38
30
  | Method | Path | Middleware chain (auth / validation / handler) |
39
31
  |---|---|---|
40
- | POST | `/media` | `requireAuth → async (ctx) => { const userId = ctx.user!.id; const body = (ctx.meta['body'] ?? {}) as { dataBase64?: unknown; ownerType?: unknown; ownerId?: unknown; purpose?: unknown; }; if (typeof body.dataBase64 !== 'string' || body.dataBase64.length === 0) { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'dataBase64 (a base64 string) is required.'); } let bytes: Uint8Array; try { bytes = decodeBase64(body.dataBase64); } catch { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'dataBase64 is not valid base64.'); } if (bytes.byteLength === 0) { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'The image is empty.'); } if (bytes.byteLength > maxBytes) { return setApiResponse(HTTP.UNPROCESSABLE, 'ASSET_TOO_LARGE', `Image exceeds the ${maxBytes}-byte limit.`); } const contentType = sniffImageType(bytes); if (!contentType || !allowed.includes(contentType)) { return setApiResponse( HTTP.UNPROCESSABLE, 'ASSET_UNSUPPORTED', `Unsupported image type. Allowed: ${allowed.join(', ')}.`, ); } const ownerType = typeof body.ownerType === 'string' ? body.ownerType : 'user'; const ownerId = typeof body.ownerId === 'string' ? body.ownerId : userId; const purpose = typeof body.purpose === 'string' ? body.purpose : 'avatar'; const { ref } = await config.provider.put({ bytes, contentType }); const asset = await assets.create({ ownerType, ownerId, purpose, contentType, byteSize: bytes.byteLength, storageRef: ref, createdBy: userId, }); // Build the URL at whatever prefix this route is mounted under // (e.g. '/v1/media/:id'), derived from the request path. const basePath = new URL(ctx.request.url).pathname.replace(/\/media$/, ''); return setApiResponse(HTTP.OK, 'ASSET_CREATED', 'Asset uploaded.', { asset: toMediaAssetDTO(asset, basePath), }); }` |
32
+ | POST | `/media` | `requireAuth → async (ctx) => { const userId = ctx.user!.id; const body = (ctx.meta['body'] ?? {}) as { dataBase64?: unknown; ownerType?: unknown; ownerId?: unknown; purpose?: unknown; }; if (typeof body.dataBase64 !== 'string' || body.dataBase64.length === 0) { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'dataBase64 (a base64 string) is required.'); } // Reject oversized uploads BEFORE decoding — base64 inflates ~4/3, so a // string longer than maxBytes*1.4 cannot fit the cap. Bounds the decode // allocation instead of materializing a huge buffer only to reject it. // (A request-body-size limit at the adapter is the complementary guard.) if (body.dataBase64.length > Math.ceil(maxBytes * 1.4)) { return setApiResponse(HTTP.UNPROCESSABLE, 'ASSET_TOO_LARGE', `Image exceeds the ${maxBytes}-byte limit.`); } let bytes: Uint8Array; try { bytes = decodeBase64(body.dataBase64); } catch { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'dataBase64 is not valid base64.'); } if (bytes.byteLength === 0) { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'The image is empty.'); } if (bytes.byteLength > maxBytes) { return setApiResponse(HTTP.UNPROCESSABLE, 'ASSET_TOO_LARGE', `Image exceeds the ${maxBytes}-byte limit.`); } const contentType = sniffImageType(bytes); if (!contentType || !allowed.includes(contentType)) { return setApiResponse( HTTP.UNPROCESSABLE, 'ASSET_UNSUPPORTED', `Unsupported image type. Allowed: ${allowed.join(', ')}.`, ); } const ownerType = typeof body.ownerType === 'string' ? body.ownerType : 'user'; const ownerId = typeof body.ownerId === 'string' ? body.ownerId : userId; const purpose = typeof body.purpose === 'string' ? body.purpose : 'avatar'; // Authorize the target owner. Default policy: self-owned user assets // only; a consumer opts into other owners (workspace logos, customer // photos) via config.authorizeOwner. const authorized = config.authorizeOwner ? await config.authorizeOwner(ctx, { ownerType, ownerId }) : ownerType === 'user' && ownerId === userId; if (!authorized) { return setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Not allowed to upload for that owner.'); } const { ref } = await config.provider.put({ bytes, contentType }); let asset: Awaited<ReturnType<typeof assets.create>>; try { asset = await assets.create({ ownerType, ownerId, purpose, contentType, byteSize: bytes.byteLength, storageRef: ref, createdBy: userId, }); } catch (err) { // Metadata insert failed — don't orphan the bytes we just stored. await config.provider.delete(ref).catch(() => {}); throw err; } // Build the URL at whatever prefix this route is mounted under // (e.g. '/v1/media/:id'), derived from the request path. const basePath = new URL(ctx.request.url).pathname.replace(/\/media$/, ''); return setApiResponse(HTTP.OK, 'ASSET_CREATED', 'Asset uploaded.', { asset: toMediaAssetDTO(asset, basePath), }); }` |
41
33
  | DELETE | `/media/:id` | `requireAuth → async (ctx) => { const id = ctx.meta.params?.['id']; if (!id || !UUID_RE.test(id)) { return setApiResponse(HTTP.NOT_FOUND, 'ASSET_NOT_FOUND', 'No such asset.'); } const asset = await assets.get(id); if (!asset) return setApiResponse(HTTP.NOT_FOUND, 'ASSET_NOT_FOUND', 'No such asset.'); if (asset.createdBy !== ctx.user!.id) { return setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'You can only delete assets you uploaded.'); } await config.provider.delete(asset.storageRef); await assets.delete(id); return setApiResponse(HTTP.OK, 'ASSET_DELETED', 'Asset deleted.', { id }); }` |
42
34
  | GET | `/media/:id` | `async (ctx) => { const id = ctx.meta.params?.['id']; if (!id || !UUID_RE.test(id)) return new Response('Not found', { status: 404 }); const asset = await assets.get(id); if (!asset) return new Response('Not found', { status: 404 }); const etag = `"${asset.id}"`; if (ctx.request.headers.get('if-none-match') === etag) { return new Response(null, { status: 304, headers: { ETag: etag } }); } const fetched = await config.provider.get(asset.storageRef); if (!fetched) return new Response('Not found', { status: 404 }); if (fetched.kind === 'redirect') { return new Response(null, { status: 302, headers: { Location: fetched.url } }); } // Fresh Uint8Array (ArrayBuffer-backed) so it satisfies BodyInit; a // pg Buffer is typed Uint8Array<ArrayBufferLike>, which the lib rejects. return new Response(new Uint8Array(fetched.bytes), { status: 200, headers: { 'Content-Type': asset.contentType, 'Content-Length': String(asset.byteSize), 'Cache-Control': 'public, max-age=300', ETag: etag, }, }); }` |
@@ -20,6 +20,10 @@ interface IMediaConfig {
20
20
  provider: IStorageProvider;
21
21
  maxBytes?: number;
22
22
  allowedTypes?: string[];
23
+ authorizeOwner?(ctx: IFonderieContext, owner: {
24
+ ownerType: string;
25
+ ownerId: string;
26
+ }): boolean | Promise<boolean>;
23
27
  }
24
28
 
25
29
  new DbBlobProvider(store: IStoreAdapter): DbBlobProvider
package/dist/index.cjs CHANGED
@@ -22,8 +22,8 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  DEFAULT_ALLOWED_TYPES: () => DEFAULT_ALLOWED_TYPES,
24
24
  DEFAULT_MAX_BYTES: () => DEFAULT_MAX_BYTES,
25
- DbBlobProvider: () => DbBlobProvider,
26
- LocalFsProvider: () => LocalFsProvider,
25
+ DbBlobProvider: () => import_storage.DbBlobProvider,
26
+ LocalFsProvider: () => import_storage.LocalFsProvider,
27
27
  MediaAssetModel: () => MediaAssetModel,
28
28
  MediaModule: () => MediaModule,
29
29
  decodeBase64: () => decodeBase64,
@@ -144,6 +144,9 @@ function buildMediaRoutes(store, config) {
144
144
  if (typeof body.dataBase64 !== "string" || body.dataBase64.length === 0) {
145
145
  return (0, import_core.setApiResponse)(import_core.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "dataBase64 (a base64 string) is required.");
146
146
  }
147
+ if (body.dataBase64.length > Math.ceil(maxBytes * 1.4)) {
148
+ return (0, import_core.setApiResponse)(import_core.HTTP.UNPROCESSABLE, "ASSET_TOO_LARGE", `Image exceeds the ${maxBytes}-byte limit.`);
149
+ }
147
150
  let bytes;
148
151
  try {
149
152
  bytes = decodeBase64(body.dataBase64);
@@ -167,16 +170,27 @@ function buildMediaRoutes(store, config) {
167
170
  const ownerType = typeof body.ownerType === "string" ? body.ownerType : "user";
168
171
  const ownerId = typeof body.ownerId === "string" ? body.ownerId : userId;
169
172
  const purpose = typeof body.purpose === "string" ? body.purpose : "avatar";
173
+ const authorized = config.authorizeOwner ? await config.authorizeOwner(ctx, { ownerType, ownerId }) : ownerType === "user" && ownerId === userId;
174
+ if (!authorized) {
175
+ return (0, import_core.setApiResponse)(import_core.HTTP.FORBIDDEN, "FORBIDDEN", "Not allowed to upload for that owner.");
176
+ }
170
177
  const { ref } = await config.provider.put({ bytes, contentType });
171
- const asset = await assets.create({
172
- ownerType,
173
- ownerId,
174
- purpose,
175
- contentType,
176
- byteSize: bytes.byteLength,
177
- storageRef: ref,
178
- createdBy: userId
179
- });
178
+ let asset;
179
+ try {
180
+ asset = await assets.create({
181
+ ownerType,
182
+ ownerId,
183
+ purpose,
184
+ contentType,
185
+ byteSize: bytes.byteLength,
186
+ storageRef: ref,
187
+ createdBy: userId
188
+ });
189
+ } catch (err) {
190
+ await config.provider.delete(ref).catch(() => {
191
+ });
192
+ throw err;
193
+ }
180
194
  const basePath = new URL(ctx.request.url).pathname.replace(/\/media$/, "");
181
195
  return (0, import_core.setApiResponse)(import_core.HTTP.OK, "ASSET_CREATED", "Asset uploaded.", {
182
196
  asset: toMediaAssetDTO(asset, basePath)
@@ -254,72 +268,8 @@ var MediaModule = class {
254
268
  }
255
269
  };
256
270
 
257
- // src/providers/db-blob.ts
258
- var DbBlobProvider = class {
259
- constructor(store) {
260
- this.store = store;
261
- }
262
- store;
263
- name = "db-blob";
264
- async put({ bytes }) {
265
- const rows = await this.store.query(
266
- "INSERT INTO fonderie_media_blobs (bytes) VALUES ($1) RETURNING id",
267
- [Buffer.from(bytes)]
268
- );
269
- return { ref: rows[0].id };
270
- }
271
- async get(ref) {
272
- const rows = await this.store.query(
273
- "SELECT bytes FROM fonderie_media_blobs WHERE id = $1",
274
- [ref]
275
- );
276
- const row = rows[0];
277
- return row ? { kind: "bytes", bytes: row.bytes } : null;
278
- }
279
- async delete(ref) {
280
- await this.store.query("DELETE FROM fonderie_media_blobs WHERE id = $1", [ref]);
281
- }
282
- };
283
-
284
- // src/providers/local-fs.ts
285
- var import_promises = require("fs/promises");
286
- var import_node_crypto = require("crypto");
287
- var import_node_path = require("path");
288
- var LocalFsProvider = class {
289
- name = "local-fs";
290
- dir;
291
- ready = null;
292
- constructor(dir) {
293
- this.dir = (0, import_node_path.resolve)(dir);
294
- }
295
- ensureDir() {
296
- if (!this.ready) this.ready = (0, import_promises.mkdir)(this.dir, { recursive: true }).then(() => void 0);
297
- return this.ready;
298
- }
299
- // Reject any ref that isn't a bare id, so a ref can never escape `dir`
300
- // (path traversal). Ids we mint are UUIDs.
301
- pathFor(ref) {
302
- if (!/^[A-Za-z0-9_-]+$/.test(ref)) throw new Error("invalid media ref");
303
- return (0, import_node_path.join)(this.dir, ref);
304
- }
305
- async put({ bytes }) {
306
- await this.ensureDir();
307
- const ref = (0, import_node_crypto.randomUUID)();
308
- await (0, import_promises.writeFile)(this.pathFor(ref), bytes);
309
- return { ref };
310
- }
311
- async get(ref) {
312
- try {
313
- const bytes = await (0, import_promises.readFile)(this.pathFor(ref));
314
- return { kind: "bytes", bytes };
315
- } catch {
316
- return null;
317
- }
318
- }
319
- async delete(ref) {
320
- await (0, import_promises.rm)(this.pathFor(ref), { force: true });
321
- }
322
- };
271
+ // src/index.ts
272
+ var import_storage = require("@fonderie/storage");
323
273
  // Annotate the CommonJS export names for ESM import in node:
324
274
  0 && (module.exports = {
325
275
  DEFAULT_ALLOWED_TYPES,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/routes.ts","../src/config.ts","../src/models/asset.model.ts","../src/dtos/media.ts","../src/services/image.ts","../src/module.ts","../src/providers/db-blob.ts","../src/providers/local-fs.ts"],"sourcesContent":["export { MediaModule } from './module';\nexport { DEFAULT_ALLOWED_TYPES, DEFAULT_MAX_BYTES } from './config';\nexport type { IMediaConfig } from './config';\n\n// Storage providers — DbBlobProvider (zero infra) ships built in; implement\n// IStorageProvider to add object storage without touching product code.\nexport { DbBlobProvider, LocalFsProvider } from './providers';\nexport type { IStorageProvider, IFetched, IStoredRef } from './providers';\n\n// For server-side resolution (e.g. wiring a user's avatar URL) and custom flows.\nexport { MediaAssetModel } from './models/asset.model';\nexport { toMediaAssetDTO } from './dtos/media';\nexport type { IMediaAssetDTO } from './dtos/media';\nexport type { IMediaAsset, ICreateAssetInput } from './types';\nexport { decodeBase64, sniffImageType } from './services/image';\n","import type { Middleware } from '@fonderie/core';\nimport { HTTP, setApiResponse } from '@fonderie/core';\nimport { requireAuth } from '@fonderie/core/middlewares';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { DEFAULT_ALLOWED_TYPES, DEFAULT_MAX_BYTES, type IMediaConfig } from './config';\nimport { MediaAssetModel } from './models/asset.model';\nimport { toMediaAssetDTO } from './dtos/media';\nimport { decodeBase64, sniffImageType } from './services/image';\n\ntype Route = [string, string, ...Middleware[]];\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\nexport function buildMediaRoutes(store: IStoreAdapter, config: IMediaConfig): Route[] {\n\tconst assets = new MediaAssetModel(store);\n\tconst maxBytes = config.maxBytes ?? DEFAULT_MAX_BYTES;\n\tconst allowed = config.allowedTypes ?? DEFAULT_ALLOWED_TYPES;\n\n\treturn [\n\t\t// POST /media { dataBase64, ownerType?, ownerId?, purpose? } -> { asset }\n\t\t// Accepts base64 (bare or a data URI), verifies it's a real image by its\n\t\t// magic bytes (never the client's claim), caps the decoded size, stores\n\t\t// the bytes via the provider, and records metadata. Returns a URL.\n\t\t[\n\t\t\t'POST',\n\t\t\t'/media',\n\t\t\trequireAuth,\n\t\t\tasync (ctx) => {\n\t\t\t\tconst userId = ctx.user!.id;\n\t\t\t\tconst body = (ctx.meta['body'] ?? {}) as {\n\t\t\t\t\tdataBase64?: unknown;\n\t\t\t\t\townerType?: unknown;\n\t\t\t\t\townerId?: unknown;\n\t\t\t\t\tpurpose?: unknown;\n\t\t\t\t};\n\n\t\t\t\tif (typeof body.dataBase64 !== 'string' || body.dataBase64.length === 0) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'dataBase64 (a base64 string) is required.');\n\t\t\t\t}\n\n\t\t\t\tlet bytes: Uint8Array;\n\t\t\t\ttry {\n\t\t\t\t\tbytes = decodeBase64(body.dataBase64);\n\t\t\t\t} catch {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'dataBase64 is not valid base64.');\n\t\t\t\t}\n\t\t\t\tif (bytes.byteLength === 0) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'The image is empty.');\n\t\t\t\t}\n\t\t\t\tif (bytes.byteLength > maxBytes) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'ASSET_TOO_LARGE', `Image exceeds the ${maxBytes}-byte limit.`);\n\t\t\t\t}\n\n\t\t\t\tconst contentType = sniffImageType(bytes);\n\t\t\t\tif (!contentType || !allowed.includes(contentType)) {\n\t\t\t\t\treturn setApiResponse(\n\t\t\t\t\t\tHTTP.UNPROCESSABLE,\n\t\t\t\t\t\t'ASSET_UNSUPPORTED',\n\t\t\t\t\t\t`Unsupported image type. Allowed: ${allowed.join(', ')}.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\tconst ownerType = typeof body.ownerType === 'string' ? body.ownerType : 'user';\n\t\t\t\tconst ownerId = typeof body.ownerId === 'string' ? body.ownerId : userId;\n\t\t\t\tconst purpose = typeof body.purpose === 'string' ? body.purpose : 'avatar';\n\n\t\t\t\tconst { ref } = await config.provider.put({ bytes, contentType });\n\t\t\t\tconst asset = await assets.create({\n\t\t\t\t\townerType,\n\t\t\t\t\townerId,\n\t\t\t\t\tpurpose,\n\t\t\t\t\tcontentType,\n\t\t\t\t\tbyteSize: bytes.byteLength,\n\t\t\t\t\tstorageRef: ref,\n\t\t\t\t\tcreatedBy: userId,\n\t\t\t\t});\n\n\t\t\t\t// Build the URL at whatever prefix this route is mounted under\n\t\t\t\t// (e.g. '/v1/media/:id'), derived from the request path.\n\t\t\t\tconst basePath = new URL(ctx.request.url).pathname.replace(/\\/media$/, '');\n\t\t\t\treturn setApiResponse(HTTP.OK, 'ASSET_CREATED', 'Asset uploaded.', {\n\t\t\t\t\tasset: toMediaAssetDTO(asset, basePath),\n\t\t\t\t});\n\t\t\t},\n\t\t],\n\n\t\t// GET /media/:id (PUBLIC — an <img src> can't send an Authorization\n\t\t// header) -> the image bytes with cache headers, or a 302 to a\n\t\t// provider-served URL. Assets are immutable, so the id is a stable ETag.\n\t\t[\n\t\t\t'GET',\n\t\t\t'/media/:id',\n\t\t\tasync (ctx) => {\n\t\t\t\tconst id = ctx.meta.params?.['id'];\n\t\t\t\tif (!id || !UUID_RE.test(id)) return new Response('Not found', { status: 404 });\n\n\t\t\t\tconst asset = await assets.get(id);\n\t\t\t\tif (!asset) return new Response('Not found', { status: 404 });\n\n\t\t\t\tconst etag = `\"${asset.id}\"`;\n\t\t\t\tif (ctx.request.headers.get('if-none-match') === etag) {\n\t\t\t\t\treturn new Response(null, { status: 304, headers: { ETag: etag } });\n\t\t\t\t}\n\n\t\t\t\tconst fetched = await config.provider.get(asset.storageRef);\n\t\t\t\tif (!fetched) return new Response('Not found', { status: 404 });\n\t\t\t\tif (fetched.kind === 'redirect') {\n\t\t\t\t\treturn new Response(null, { status: 302, headers: { Location: fetched.url } });\n\t\t\t\t}\n\t\t\t\t// Fresh Uint8Array (ArrayBuffer-backed) so it satisfies BodyInit; a\n\t\t\t\t// pg Buffer is typed Uint8Array<ArrayBufferLike>, which the lib rejects.\n\t\t\t\treturn new Response(new Uint8Array(fetched.bytes), {\n\t\t\t\t\tstatus: 200,\n\t\t\t\t\theaders: {\n\t\t\t\t\t\t'Content-Type': asset.contentType,\n\t\t\t\t\t\t'Content-Length': String(asset.byteSize),\n\t\t\t\t\t\t'Cache-Control': 'public, max-age=300',\n\t\t\t\t\t\tETag: etag,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t},\n\t\t],\n\n\t\t// DELETE /media/:id -> removes the asset; only the uploader may delete it.\n\t\t[\n\t\t\t'DELETE',\n\t\t\t'/media/:id',\n\t\t\trequireAuth,\n\t\t\tasync (ctx) => {\n\t\t\t\tconst id = ctx.meta.params?.['id'];\n\t\t\t\tif (!id || !UUID_RE.test(id)) {\n\t\t\t\t\treturn setApiResponse(HTTP.NOT_FOUND, 'ASSET_NOT_FOUND', 'No such asset.');\n\t\t\t\t}\n\t\t\t\tconst asset = await assets.get(id);\n\t\t\t\tif (!asset) return setApiResponse(HTTP.NOT_FOUND, 'ASSET_NOT_FOUND', 'No such asset.');\n\t\t\t\tif (asset.createdBy !== ctx.user!.id) {\n\t\t\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'You can only delete assets you uploaded.');\n\t\t\t\t}\n\t\t\t\tawait config.provider.delete(asset.storageRef);\n\t\t\t\tawait assets.delete(id);\n\t\t\t\treturn setApiResponse(HTTP.OK, 'ASSET_DELETED', 'Asset deleted.', { id });\n\t\t\t},\n\t\t],\n\t];\n}\n","import type { IStorageProvider } from './providers/types';\n\nexport interface IMediaConfig {\n\t/** Where bytes are stored. `DbBlobProvider` (zero infra) by default; swap for S3 at scale. */\n\tprovider: IStorageProvider;\n\t/** Max decoded size per asset, in bytes. Default 1 MB. */\n\tmaxBytes?: number;\n\t/**\n\t * Content types accepted on upload (matched against magic bytes, not the\n\t * client's claim). Default: PNG / JPEG / WebP / GIF. SVG is never accepted —\n\t * it's a stored-XSS vector.\n\t */\n\tallowedTypes?: string[];\n}\n\nexport const DEFAULT_MAX_BYTES = 1_000_000;\nexport const DEFAULT_ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { ICreateAssetInput, IMediaAsset } from '../types';\n\ninterface AssetRow {\n\tid: string;\n\towner_type: string;\n\towner_id: string;\n\tpurpose: string;\n\tcontent_type: string;\n\tbyte_size: number;\n\tstorage_ref: string;\n\tcreated_by: string | null;\n\tcreated_at: Date;\n}\n\nconst toAsset = (r: AssetRow): IMediaAsset => ({\n\tid: r.id,\n\townerType: r.owner_type,\n\townerId: r.owner_id,\n\tpurpose: r.purpose,\n\tcontentType: r.content_type,\n\tbyteSize: r.byte_size,\n\tstorageRef: r.storage_ref,\n\tcreatedBy: r.created_by,\n\tcreatedAt: r.created_at,\n});\n\n/** Data access for `fonderie_media_assets` — the metadata around each stored blob. */\nexport class MediaAssetModel {\n\tconstructor(private readonly store: IStoreAdapter) {}\n\n\tasync create(input: ICreateAssetInput): Promise<IMediaAsset> {\n\t\tconst rows = await this.store.query<AssetRow>(\n\t\t\t`INSERT INTO fonderie_media_assets\n\t\t\t (owner_type, owner_id, purpose, content_type, byte_size, storage_ref, created_by)\n\t\t\t VALUES ($1, $2, $3, $4, $5, $6, $7)\n\t\t\t RETURNING *`,\n\t\t\t[input.ownerType, input.ownerId, input.purpose, input.contentType, input.byteSize, input.storageRef, input.createdBy],\n\t\t);\n\t\treturn toAsset(rows[0]!);\n\t}\n\n\tasync get(id: string): Promise<IMediaAsset | null> {\n\t\tconst rows = await this.store.query<AssetRow>('SELECT * FROM fonderie_media_assets WHERE id = $1', [id]);\n\t\treturn rows[0] ? toAsset(rows[0]) : null;\n\t}\n\n\t/** The most recent asset for an owner + purpose (e.g. a user's current avatar). */\n\tasync latestFor(ownerType: string, ownerId: string, purpose: string): Promise<IMediaAsset | null> {\n\t\tconst rows = await this.store.query<AssetRow>(\n\t\t\t`SELECT * FROM fonderie_media_assets\n\t\t\t WHERE owner_type = $1 AND owner_id = $2 AND purpose = $3\n\t\t\t ORDER BY created_at DESC LIMIT 1`,\n\t\t\t[ownerType, ownerId, purpose],\n\t\t);\n\t\treturn rows[0] ? toAsset(rows[0]) : null;\n\t}\n\n\tasync delete(id: string): Promise<void> {\n\t\tawait this.store.query('DELETE FROM fonderie_media_assets WHERE id = $1', [id]);\n\t}\n}\n","import type { IMediaAsset } from '../types';\n\n/**\n * The wire shape for a stored asset. `url` is the monomorphic read contract:\n * always a `/media/:id` path, whatever the backend — clients render it in an\n * `<img>` and never care whether the bytes came from Postgres, disk, or S3.\n */\nexport interface IMediaAssetDTO {\n\tid: string;\n\turl: string;\n\tcontentType: string;\n\tbyteSize: number;\n\townerType: string;\n\townerId: string;\n\tpurpose: string;\n\tcreatedAt: string;\n}\n\n/** basePath is the router mount (e.g. '/v1'); '' yields a root-relative '/media/:id'. */\nexport function toMediaAssetDTO(asset: IMediaAsset, basePath = ''): IMediaAssetDTO {\n\treturn {\n\t\tid: asset.id,\n\t\turl: `${basePath}/media/${asset.id}`,\n\t\tcontentType: asset.contentType,\n\t\tbyteSize: asset.byteSize,\n\t\townerType: asset.ownerType,\n\t\townerId: asset.ownerId,\n\t\tpurpose: asset.purpose,\n\t\tcreatedAt: asset.createdAt instanceof Date ? asset.createdAt.toISOString() : String(asset.createdAt),\n\t};\n}\n","/**\n * Decode a base64 payload to bytes. Accepts both a bare base64 string and a\n * data URI (`data:image/png;base64,<...>`) — the frontend `FileReader` produces\n * the latter, so callers don't have to strip it.\n */\nexport function decodeBase64(input: string): Uint8Array {\n\tconst comma = input.startsWith('data:') ? input.indexOf(',') : -1;\n\tconst b64 = comma >= 0 ? input.slice(comma + 1) : input;\n\treturn new Uint8Array(Buffer.from(b64, 'base64'));\n}\n\n/**\n * Identify an image from its magic bytes — NOT from a client-claimed MIME type,\n * which is trivially spoofed. Returns the canonical content type or `null` for\n * anything unrecognised. SVG is deliberately not detected (it's XML, can carry\n * scripts, and is a stored-XSS vector), so it falls through to `null` and is\n * rejected upstream.\n */\nexport function sniffImageType(bytes: Uint8Array): string | null {\n\tconst b = bytes;\n\t// PNG: 89 50 4E 47 0D 0A 1A 0A\n\tif (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) {\n\t\treturn 'image/png';\n\t}\n\t// JPEG: FF D8 FF\n\tif (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {\n\t\treturn 'image/jpeg';\n\t}\n\t// GIF: \"GIF87a\" / \"GIF89a\"\n\tif (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38 && (b[4] === 0x37 || b[4] === 0x39) && b[5] === 0x61) {\n\t\treturn 'image/gif';\n\t}\n\t// WEBP: \"RIFF\" .... \"WEBP\" (bytes 0-3 and 8-11)\n\tif (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) {\n\t\treturn 'image/webp';\n\t}\n\treturn null;\n}\n","import type { IFonderieApp, IFonderieModule } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMediaConfig } from './config';\nimport { buildMediaRoutes } from './routes';\n\n/**\n * Provider-abstracted asset storage. Register it like any other brick; it adds\n * `POST /media`, `GET /media/:id` (public), and `DELETE /media/:id`, and stores\n * bytes through the configured `IStorageProvider` (`DbBlobProvider` for zero\n * infra, swappable for object storage). Depends on `@fonderie/auth` for the\n * authenticated caller on upload/delete.\n */\nexport class MediaModule implements IFonderieModule {\n\treadonly name = '@fonderie/media';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(\n\t\tprivate readonly store: IStoreAdapter,\n\t\tprivate readonly config: IMediaConfig,\n\t) {}\n\n\tinstall(app: IFonderieApp): void {\n\t\tfor (const [method, path, ...handlers] of buildMediaRoutes(this.store, this.config)) {\n\t\t\tapp.addRoute(method, path, ...handlers);\n\t\t}\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IFetched, IStorageProvider, IStoredRef } from './types';\n\n/**\n * Zero-infra provider: bytes live in Postgres (`fonderie_media_blobs`, created by\n * this package's migration). Great for getting started and self-hosting — the\n * whole app is one Node process + one database, and a `pg_dump` captures the\n * images atomically with their metadata. Swap to `S3Provider` when bandwidth or\n * table size make object storage worth the extra moving part; no app code\n * changes, only the `MediaModule` config line.\n */\nexport class DbBlobProvider implements IStorageProvider {\n\treadonly name = 'db-blob';\n\n\tconstructor(private readonly store: IStoreAdapter) {}\n\n\tasync put({ bytes }: { bytes: Uint8Array; contentType: string }): Promise<IStoredRef> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t'INSERT INTO fonderie_media_blobs (bytes) VALUES ($1) RETURNING id',\n\t\t\t[Buffer.from(bytes)],\n\t\t);\n\t\treturn { ref: rows[0]!.id };\n\t}\n\n\tasync get(ref: string): Promise<IFetched | null> {\n\t\tconst rows = await this.store.query<{ bytes: Buffer }>(\n\t\t\t'SELECT bytes FROM fonderie_media_blobs WHERE id = $1',\n\t\t\t[ref],\n\t\t);\n\t\tconst row = rows[0];\n\t\treturn row ? { kind: 'bytes', bytes: row.bytes } : null;\n\t}\n\n\tasync delete(ref: string): Promise<void> {\n\t\tawait this.store.query('DELETE FROM fonderie_media_blobs WHERE id = $1', [ref]);\n\t}\n}\n","import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';\nimport { randomUUID } from 'node:crypto';\nimport { join, resolve } from 'node:path';\n\nimport type { IFetched, IStorageProvider, IStoredRef } from './types';\n\n/**\n * Zero-infra provider: bytes live on the server's filesystem under `dir`. Useful\n * for a single-box deployment that wants images off the database without\n * standing up object storage. The `ref` is an opaque filename; the asset's\n * content type is tracked in `fonderie_media_assets`, so nothing about the\n * bytes-on-disk needs to encode it.\n *\n * (Serves inline through the app like `DbBlobProvider`. It has no CDN in front,\n * so at real scale prefer `S3Provider` — same interface, one config line.)\n */\nexport class LocalFsProvider implements IStorageProvider {\n\treadonly name = 'local-fs';\n\tprivate readonly dir: string;\n\tprivate ready: Promise<void> | null = null;\n\n\tconstructor(dir: string) {\n\t\tthis.dir = resolve(dir);\n\t}\n\n\tprivate ensureDir(): Promise<void> {\n\t\tif (!this.ready) this.ready = mkdir(this.dir, { recursive: true }).then(() => undefined);\n\t\treturn this.ready;\n\t}\n\n\t// Reject any ref that isn't a bare id, so a ref can never escape `dir`\n\t// (path traversal). Ids we mint are UUIDs.\n\tprivate pathFor(ref: string): string {\n\t\tif (!/^[A-Za-z0-9_-]+$/.test(ref)) throw new Error('invalid media ref');\n\t\treturn join(this.dir, ref);\n\t}\n\n\tasync put({ bytes }: { bytes: Uint8Array; contentType: string }): Promise<IStoredRef> {\n\t\tawait this.ensureDir();\n\t\tconst ref = randomUUID();\n\t\tawait writeFile(this.pathFor(ref), bytes);\n\t\treturn { ref };\n\t}\n\n\tasync get(ref: string): Promise<IFetched | null> {\n\t\ttry {\n\t\t\tconst bytes = await readFile(this.pathFor(ref));\n\t\t\treturn { kind: 'bytes', bytes };\n\t\t} catch {\n\t\t\treturn null; // ENOENT (or an invalid ref) → treated as not found\n\t\t}\n\t}\n\n\tasync delete(ref: string): Promise<void> {\n\t\tawait rm(this.pathFor(ref), { force: true }); // force → no throw when already gone\n\t}\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/routes.ts","../src/config.ts","../src/models/asset.model.ts","../src/dtos/media.ts","../src/services/image.ts","../src/module.ts"],"sourcesContent":["export { MediaModule } from './module';\nexport { DEFAULT_ALLOWED_TYPES, DEFAULT_MAX_BYTES } from './config';\nexport type { IMediaConfig } from './config';\n\n// Storage lives in @fonderie/storage now; re-exported here for convenience so\n// existing consumers can keep importing the zero-infra providers from media.\n// S3Provider (the object-storage backend) is at '@fonderie/storage/s3'.\nexport { DbBlobProvider, LocalFsProvider } from '@fonderie/storage';\nexport type { IStorageProvider, IFetched, IStoredRef } from '@fonderie/storage';\n\n// For server-side resolution (e.g. wiring a user's avatar URL) and custom flows.\nexport { MediaAssetModel } from './models/asset.model';\nexport { toMediaAssetDTO } from './dtos/media';\nexport type { IMediaAssetDTO } from './dtos/media';\nexport type { IMediaAsset, ICreateAssetInput } from './types';\nexport { decodeBase64, sniffImageType } from './services/image';\n","import type { Middleware } from '@fonderie/core';\nimport { HTTP, setApiResponse } from '@fonderie/core';\nimport { requireAuth } from '@fonderie/core/middlewares';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { DEFAULT_ALLOWED_TYPES, DEFAULT_MAX_BYTES, type IMediaConfig } from './config';\nimport { MediaAssetModel } from './models/asset.model';\nimport { toMediaAssetDTO } from './dtos/media';\nimport { decodeBase64, sniffImageType } from './services/image';\n\ntype Route = [string, string, ...Middleware[]];\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\nexport function buildMediaRoutes(store: IStoreAdapter, config: IMediaConfig): Route[] {\n\tconst assets = new MediaAssetModel(store);\n\tconst maxBytes = config.maxBytes ?? DEFAULT_MAX_BYTES;\n\tconst allowed = config.allowedTypes ?? DEFAULT_ALLOWED_TYPES;\n\n\treturn [\n\t\t// POST /media { dataBase64, ownerType?, ownerId?, purpose? } -> { asset }\n\t\t// Accepts base64 (bare or a data URI), verifies it's a real image by its\n\t\t// magic bytes (never the client's claim), caps the decoded size, stores\n\t\t// the bytes via the provider, and records metadata. Returns a URL.\n\t\t[\n\t\t\t'POST',\n\t\t\t'/media',\n\t\t\trequireAuth,\n\t\t\tasync (ctx) => {\n\t\t\t\tconst userId = ctx.user!.id;\n\t\t\t\tconst body = (ctx.meta['body'] ?? {}) as {\n\t\t\t\t\tdataBase64?: unknown;\n\t\t\t\t\townerType?: unknown;\n\t\t\t\t\townerId?: unknown;\n\t\t\t\t\tpurpose?: unknown;\n\t\t\t\t};\n\n\t\t\t\tif (typeof body.dataBase64 !== 'string' || body.dataBase64.length === 0) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'dataBase64 (a base64 string) is required.');\n\t\t\t\t}\n\t\t\t\t// Reject oversized uploads BEFORE decoding — base64 inflates ~4/3, so a\n\t\t\t\t// string longer than maxBytes*1.4 cannot fit the cap. Bounds the decode\n\t\t\t\t// allocation instead of materializing a huge buffer only to reject it.\n\t\t\t\t// (A request-body-size limit at the adapter is the complementary guard.)\n\t\t\t\tif (body.dataBase64.length > Math.ceil(maxBytes * 1.4)) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'ASSET_TOO_LARGE', `Image exceeds the ${maxBytes}-byte limit.`);\n\t\t\t\t}\n\n\t\t\t\tlet bytes: Uint8Array;\n\t\t\t\ttry {\n\t\t\t\t\tbytes = decodeBase64(body.dataBase64);\n\t\t\t\t} catch {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'dataBase64 is not valid base64.');\n\t\t\t\t}\n\t\t\t\tif (bytes.byteLength === 0) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'The image is empty.');\n\t\t\t\t}\n\t\t\t\tif (bytes.byteLength > maxBytes) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'ASSET_TOO_LARGE', `Image exceeds the ${maxBytes}-byte limit.`);\n\t\t\t\t}\n\n\t\t\t\tconst contentType = sniffImageType(bytes);\n\t\t\t\tif (!contentType || !allowed.includes(contentType)) {\n\t\t\t\t\treturn setApiResponse(\n\t\t\t\t\t\tHTTP.UNPROCESSABLE,\n\t\t\t\t\t\t'ASSET_UNSUPPORTED',\n\t\t\t\t\t\t`Unsupported image type. Allowed: ${allowed.join(', ')}.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\tconst ownerType = typeof body.ownerType === 'string' ? body.ownerType : 'user';\n\t\t\t\tconst ownerId = typeof body.ownerId === 'string' ? body.ownerId : userId;\n\t\t\t\tconst purpose = typeof body.purpose === 'string' ? body.purpose : 'avatar';\n\n\t\t\t\t// Authorize the target owner. Default policy: self-owned user assets\n\t\t\t\t// only; a consumer opts into other owners (workspace logos, customer\n\t\t\t\t// photos) via config.authorizeOwner.\n\t\t\t\tconst authorized = config.authorizeOwner\n\t\t\t\t\t? await config.authorizeOwner(ctx, { ownerType, ownerId })\n\t\t\t\t\t: ownerType === 'user' && ownerId === userId;\n\t\t\t\tif (!authorized) {\n\t\t\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Not allowed to upload for that owner.');\n\t\t\t\t}\n\n\t\t\t\tconst { ref } = await config.provider.put({ bytes, contentType });\n\t\t\t\tlet asset: Awaited<ReturnType<typeof assets.create>>;\n\t\t\t\ttry {\n\t\t\t\t\tasset = await assets.create({\n\t\t\t\t\t\townerType,\n\t\t\t\t\t\townerId,\n\t\t\t\t\t\tpurpose,\n\t\t\t\t\t\tcontentType,\n\t\t\t\t\t\tbyteSize: bytes.byteLength,\n\t\t\t\t\t\tstorageRef: ref,\n\t\t\t\t\t\tcreatedBy: userId,\n\t\t\t\t\t});\n\t\t\t\t} catch (err) {\n\t\t\t\t\t// Metadata insert failed — don't orphan the bytes we just stored.\n\t\t\t\t\tawait config.provider.delete(ref).catch(() => {});\n\t\t\t\t\tthrow err;\n\t\t\t\t}\n\n\t\t\t\t// Build the URL at whatever prefix this route is mounted under\n\t\t\t\t// (e.g. '/v1/media/:id'), derived from the request path.\n\t\t\t\tconst basePath = new URL(ctx.request.url).pathname.replace(/\\/media$/, '');\n\t\t\t\treturn setApiResponse(HTTP.OK, 'ASSET_CREATED', 'Asset uploaded.', {\n\t\t\t\t\tasset: toMediaAssetDTO(asset, basePath),\n\t\t\t\t});\n\t\t\t},\n\t\t],\n\n\t\t// GET /media/:id (PUBLIC — an <img src> can't send an Authorization\n\t\t// header) -> the image bytes with cache headers, or a 302 to a\n\t\t// provider-served URL. Assets are immutable, so the id is a stable ETag.\n\t\t[\n\t\t\t'GET',\n\t\t\t'/media/:id',\n\t\t\tasync (ctx) => {\n\t\t\t\tconst id = ctx.meta.params?.['id'];\n\t\t\t\tif (!id || !UUID_RE.test(id)) return new Response('Not found', { status: 404 });\n\n\t\t\t\tconst asset = await assets.get(id);\n\t\t\t\tif (!asset) return new Response('Not found', { status: 404 });\n\n\t\t\t\tconst etag = `\"${asset.id}\"`;\n\t\t\t\tif (ctx.request.headers.get('if-none-match') === etag) {\n\t\t\t\t\treturn new Response(null, { status: 304, headers: { ETag: etag } });\n\t\t\t\t}\n\n\t\t\t\tconst fetched = await config.provider.get(asset.storageRef);\n\t\t\t\tif (!fetched) return new Response('Not found', { status: 404 });\n\t\t\t\tif (fetched.kind === 'redirect') {\n\t\t\t\t\treturn new Response(null, { status: 302, headers: { Location: fetched.url } });\n\t\t\t\t}\n\t\t\t\t// Fresh Uint8Array (ArrayBuffer-backed) so it satisfies BodyInit; a\n\t\t\t\t// pg Buffer is typed Uint8Array<ArrayBufferLike>, which the lib rejects.\n\t\t\t\treturn new Response(new Uint8Array(fetched.bytes), {\n\t\t\t\t\tstatus: 200,\n\t\t\t\t\theaders: {\n\t\t\t\t\t\t'Content-Type': asset.contentType,\n\t\t\t\t\t\t'Content-Length': String(asset.byteSize),\n\t\t\t\t\t\t'Cache-Control': 'public, max-age=300',\n\t\t\t\t\t\tETag: etag,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t},\n\t\t],\n\n\t\t// DELETE /media/:id -> removes the asset; only the uploader may delete it.\n\t\t[\n\t\t\t'DELETE',\n\t\t\t'/media/:id',\n\t\t\trequireAuth,\n\t\t\tasync (ctx) => {\n\t\t\t\tconst id = ctx.meta.params?.['id'];\n\t\t\t\tif (!id || !UUID_RE.test(id)) {\n\t\t\t\t\treturn setApiResponse(HTTP.NOT_FOUND, 'ASSET_NOT_FOUND', 'No such asset.');\n\t\t\t\t}\n\t\t\t\tconst asset = await assets.get(id);\n\t\t\t\tif (!asset) return setApiResponse(HTTP.NOT_FOUND, 'ASSET_NOT_FOUND', 'No such asset.');\n\t\t\t\tif (asset.createdBy !== ctx.user!.id) {\n\t\t\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'You can only delete assets you uploaded.');\n\t\t\t\t}\n\t\t\t\tawait config.provider.delete(asset.storageRef);\n\t\t\t\tawait assets.delete(id);\n\t\t\t\treturn setApiResponse(HTTP.OK, 'ASSET_DELETED', 'Asset deleted.', { id });\n\t\t\t},\n\t\t],\n\t];\n}\n","import type { IFonderieContext } from '@fonderie/core';\nimport type { IStorageProvider } from '@fonderie/storage';\n\nexport interface IMediaConfig {\n\t/** Where bytes are stored. `DbBlobProvider` (zero infra) by default; swap for S3 at scale. */\n\tprovider: IStorageProvider;\n\t/** Max decoded size per asset, in bytes. Default 1 MB. */\n\tmaxBytes?: number;\n\t/**\n\t * Content types accepted on upload (matched against magic bytes, not the\n\t * client's claim). Default: PNG / JPEG / WebP / GIF. SVG is never accepted —\n\t * it's a stored-XSS vector.\n\t */\n\tallowedTypes?: string[];\n\t/**\n\t * Authorize an upload's target owner. Return false to reject with 403. When\n\t * omitted, the default policy allows only **self-owned user assets**\n\t * (`ownerType: 'user'`, `ownerId` = the authenticated caller). Provide this\n\t * to permit other owners — e.g. a workspace logo the caller may administer, a\n\t * customer photo in the caller's workspace.\n\t */\n\tauthorizeOwner?(\n\t\tctx: IFonderieContext,\n\t\towner: { ownerType: string; ownerId: string },\n\t): boolean | Promise<boolean>;\n}\n\nexport const DEFAULT_MAX_BYTES = 1_000_000;\nexport const DEFAULT_ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { ICreateAssetInput, IMediaAsset } from '../types';\n\ninterface AssetRow {\n\tid: string;\n\towner_type: string;\n\towner_id: string;\n\tpurpose: string;\n\tcontent_type: string;\n\tbyte_size: number;\n\tstorage_ref: string;\n\tcreated_by: string | null;\n\tcreated_at: Date;\n}\n\nconst toAsset = (r: AssetRow): IMediaAsset => ({\n\tid: r.id,\n\townerType: r.owner_type,\n\townerId: r.owner_id,\n\tpurpose: r.purpose,\n\tcontentType: r.content_type,\n\tbyteSize: r.byte_size,\n\tstorageRef: r.storage_ref,\n\tcreatedBy: r.created_by,\n\tcreatedAt: r.created_at,\n});\n\n/** Data access for `fonderie_media_assets` — the metadata around each stored blob. */\nexport class MediaAssetModel {\n\tconstructor(private readonly store: IStoreAdapter) {}\n\n\tasync create(input: ICreateAssetInput): Promise<IMediaAsset> {\n\t\tconst rows = await this.store.query<AssetRow>(\n\t\t\t`INSERT INTO fonderie_media_assets\n\t\t\t (owner_type, owner_id, purpose, content_type, byte_size, storage_ref, created_by)\n\t\t\t VALUES ($1, $2, $3, $4, $5, $6, $7)\n\t\t\t RETURNING *`,\n\t\t\t[input.ownerType, input.ownerId, input.purpose, input.contentType, input.byteSize, input.storageRef, input.createdBy],\n\t\t);\n\t\treturn toAsset(rows[0]!);\n\t}\n\n\tasync get(id: string): Promise<IMediaAsset | null> {\n\t\tconst rows = await this.store.query<AssetRow>('SELECT * FROM fonderie_media_assets WHERE id = $1', [id]);\n\t\treturn rows[0] ? toAsset(rows[0]) : null;\n\t}\n\n\t/** The most recent asset for an owner + purpose (e.g. a user's current avatar). */\n\tasync latestFor(ownerType: string, ownerId: string, purpose: string): Promise<IMediaAsset | null> {\n\t\tconst rows = await this.store.query<AssetRow>(\n\t\t\t`SELECT * FROM fonderie_media_assets\n\t\t\t WHERE owner_type = $1 AND owner_id = $2 AND purpose = $3\n\t\t\t ORDER BY created_at DESC LIMIT 1`,\n\t\t\t[ownerType, ownerId, purpose],\n\t\t);\n\t\treturn rows[0] ? toAsset(rows[0]) : null;\n\t}\n\n\tasync delete(id: string): Promise<void> {\n\t\tawait this.store.query('DELETE FROM fonderie_media_assets WHERE id = $1', [id]);\n\t}\n}\n","import type { IMediaAsset } from '../types';\n\n/**\n * The wire shape for a stored asset. `url` is the monomorphic read contract:\n * always a `/media/:id` path, whatever the backend — clients render it in an\n * `<img>` and never care whether the bytes came from Postgres, disk, or S3.\n */\nexport interface IMediaAssetDTO {\n\tid: string;\n\turl: string;\n\tcontentType: string;\n\tbyteSize: number;\n\townerType: string;\n\townerId: string;\n\tpurpose: string;\n\tcreatedAt: string;\n}\n\n/** basePath is the router mount (e.g. '/v1'); '' yields a root-relative '/media/:id'. */\nexport function toMediaAssetDTO(asset: IMediaAsset, basePath = ''): IMediaAssetDTO {\n\treturn {\n\t\tid: asset.id,\n\t\turl: `${basePath}/media/${asset.id}`,\n\t\tcontentType: asset.contentType,\n\t\tbyteSize: asset.byteSize,\n\t\townerType: asset.ownerType,\n\t\townerId: asset.ownerId,\n\t\tpurpose: asset.purpose,\n\t\tcreatedAt: asset.createdAt instanceof Date ? asset.createdAt.toISOString() : String(asset.createdAt),\n\t};\n}\n","/**\n * Decode a base64 payload to bytes. Accepts both a bare base64 string and a\n * data URI (`data:image/png;base64,<...>`) — the frontend `FileReader` produces\n * the latter, so callers don't have to strip it.\n */\nexport function decodeBase64(input: string): Uint8Array {\n\tconst comma = input.startsWith('data:') ? input.indexOf(',') : -1;\n\tconst b64 = comma >= 0 ? input.slice(comma + 1) : input;\n\treturn new Uint8Array(Buffer.from(b64, 'base64'));\n}\n\n/**\n * Identify an image from its magic bytes — NOT from a client-claimed MIME type,\n * which is trivially spoofed. Returns the canonical content type or `null` for\n * anything unrecognised. SVG is deliberately not detected (it's XML, can carry\n * scripts, and is a stored-XSS vector), so it falls through to `null` and is\n * rejected upstream.\n */\nexport function sniffImageType(bytes: Uint8Array): string | null {\n\tconst b = bytes;\n\t// PNG: 89 50 4E 47 0D 0A 1A 0A\n\tif (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) {\n\t\treturn 'image/png';\n\t}\n\t// JPEG: FF D8 FF\n\tif (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {\n\t\treturn 'image/jpeg';\n\t}\n\t// GIF: \"GIF87a\" / \"GIF89a\"\n\tif (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38 && (b[4] === 0x37 || b[4] === 0x39) && b[5] === 0x61) {\n\t\treturn 'image/gif';\n\t}\n\t// WEBP: \"RIFF\" .... \"WEBP\" (bytes 0-3 and 8-11)\n\tif (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) {\n\t\treturn 'image/webp';\n\t}\n\treturn null;\n}\n","import type { IFonderieApp, IFonderieModule } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMediaConfig } from './config';\nimport { buildMediaRoutes } from './routes';\n\n/**\n * Provider-abstracted asset storage. Register it like any other brick; it adds\n * `POST /media`, `GET /media/:id` (public), and `DELETE /media/:id`, and stores\n * bytes through the configured `IStorageProvider` (`DbBlobProvider` for zero\n * infra, swappable for object storage). Depends on `@fonderie/auth` for the\n * authenticated caller on upload/delete.\n */\nexport class MediaModule implements IFonderieModule {\n\treadonly name = '@fonderie/media';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(\n\t\tprivate readonly store: IStoreAdapter,\n\t\tprivate readonly config: IMediaConfig,\n\t) {}\n\n\tinstall(app: IFonderieApp): void {\n\t\tfor (const [method, path, ...handlers] of buildMediaRoutes(this.store, this.config)) {\n\t\t\tapp.addRoute(method, path, ...handlers);\n\t\t}\n\t}\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.cts CHANGED
@@ -1,48 +1,7 @@
1
- import { IFonderieModule, IFonderieApp } from '@fonderie/core';
1
+ import { IFonderieContext, IFonderieModule, IFonderieApp } from '@fonderie/core';
2
2
  import { IStoreAdapter } from '@fonderie/store';
3
-
4
- /**
5
- * The storage seam every backend implements — the same pattern @fonderie/billing
6
- * uses for payment providers. Product code depends on this interface, never on a
7
- * concrete backend, so `DbBlobProvider` (zero infra) → `S3Provider` (object
8
- * storage) is a one-line swap in `MediaModule`'s config.
9
- *
10
- * Kept to a least-common-denominator on purpose: `put` / `get` / `delete` and
11
- * nothing backend-specific. The one real divergence between backends — "can you
12
- * hand the client a URL, or must the app stream the bytes?" — is modelled by the
13
- * discriminated result of `get`, so an S3 provider can 302 to a signed URL while
14
- * a DB/filesystem provider serves inline, and the controller never branches on
15
- * which backend is wired.
16
- */
17
- /** A backend-opaque handle to a stored object; the module persists it verbatim. */
18
- interface IStoredRef {
19
- ref: string;
20
- }
21
- /**
22
- * The outcome of resolving a ref. `bytes` → the app serves them (content type
23
- * comes from the asset record, not here). `redirect` → the app 302s to a URL the
24
- * backend can serve directly (e.g. an S3 signed URL). `null` → not found.
25
- */
26
- type IFetched = {
27
- kind: 'bytes';
28
- bytes: Uint8Array;
29
- } | {
30
- kind: 'redirect';
31
- url: string;
32
- };
33
- interface IStorageProvider {
34
- /** Stable id for logs/readiness (e.g. 'db-blob', 'local-fs', 's3'). */
35
- readonly name: string;
36
- /** Persist bytes; `contentType` is passed for backends that store it natively (S3). */
37
- put(input: {
38
- bytes: Uint8Array;
39
- contentType: string;
40
- }): Promise<IStoredRef>;
41
- /** Resolve a ref to servable bytes or a redirect URL, or null if it's gone. */
42
- get(ref: string): Promise<IFetched | null>;
43
- /** Remove the stored object. Idempotent — deleting a missing ref must not throw. */
44
- delete(ref: string): Promise<void>;
45
- }
3
+ import { IStorageProvider } from '@fonderie/storage';
4
+ export { DbBlobProvider, IFetched, IStorageProvider, IStoredRef, LocalFsProvider } from '@fonderie/storage';
46
5
 
47
6
  interface IMediaConfig {
48
7
  /** Where bytes are stored. `DbBlobProvider` (zero infra) by default; swap for S3 at scale. */
@@ -55,6 +14,17 @@ interface IMediaConfig {
55
14
  * it's a stored-XSS vector.
56
15
  */
57
16
  allowedTypes?: string[];
17
+ /**
18
+ * Authorize an upload's target owner. Return false to reject with 403. When
19
+ * omitted, the default policy allows only **self-owned user assets**
20
+ * (`ownerType: 'user'`, `ownerId` = the authenticated caller). Provide this
21
+ * to permit other owners — e.g. a workspace logo the caller may administer, a
22
+ * customer photo in the caller's workspace.
23
+ */
24
+ authorizeOwner?(ctx: IFonderieContext, owner: {
25
+ ownerType: string;
26
+ ownerId: string;
27
+ }): boolean | Promise<boolean>;
58
28
  }
59
29
  declare const DEFAULT_MAX_BYTES = 1000000;
60
30
  declare const DEFAULT_ALLOWED_TYPES: string[];
@@ -75,51 +45,6 @@ declare class MediaModule implements IFonderieModule {
75
45
  install(app: IFonderieApp): void;
76
46
  }
77
47
 
78
- /**
79
- * Zero-infra provider: bytes live in Postgres (`fonderie_media_blobs`, created by
80
- * this package's migration). Great for getting started and self-hosting — the
81
- * whole app is one Node process + one database, and a `pg_dump` captures the
82
- * images atomically with their metadata. Swap to `S3Provider` when bandwidth or
83
- * table size make object storage worth the extra moving part; no app code
84
- * changes, only the `MediaModule` config line.
85
- */
86
- declare class DbBlobProvider implements IStorageProvider {
87
- private readonly store;
88
- readonly name = "db-blob";
89
- constructor(store: IStoreAdapter);
90
- put({ bytes }: {
91
- bytes: Uint8Array;
92
- contentType: string;
93
- }): Promise<IStoredRef>;
94
- get(ref: string): Promise<IFetched | null>;
95
- delete(ref: string): Promise<void>;
96
- }
97
-
98
- /**
99
- * Zero-infra provider: bytes live on the server's filesystem under `dir`. Useful
100
- * for a single-box deployment that wants images off the database without
101
- * standing up object storage. The `ref` is an opaque filename; the asset's
102
- * content type is tracked in `fonderie_media_assets`, so nothing about the
103
- * bytes-on-disk needs to encode it.
104
- *
105
- * (Serves inline through the app like `DbBlobProvider`. It has no CDN in front,
106
- * so at real scale prefer `S3Provider` — same interface, one config line.)
107
- */
108
- declare class LocalFsProvider implements IStorageProvider {
109
- readonly name = "local-fs";
110
- private readonly dir;
111
- private ready;
112
- constructor(dir: string);
113
- private ensureDir;
114
- private pathFor;
115
- put({ bytes }: {
116
- bytes: Uint8Array;
117
- contentType: string;
118
- }): Promise<IStoredRef>;
119
- get(ref: string): Promise<IFetched | null>;
120
- delete(ref: string): Promise<void>;
121
- }
122
-
123
48
  /** A stored asset's metadata row (bytes live behind the storage provider). */
124
49
  interface IMediaAsset {
125
50
  id: string;
@@ -187,4 +112,4 @@ declare function decodeBase64(input: string): Uint8Array;
187
112
  */
188
113
  declare function sniffImageType(bytes: Uint8Array): string | null;
189
114
 
190
- export { DEFAULT_ALLOWED_TYPES, DEFAULT_MAX_BYTES, DbBlobProvider, type ICreateAssetInput, type IFetched, type IMediaAsset, type IMediaAssetDTO, type IMediaConfig, type IStorageProvider, type IStoredRef, LocalFsProvider, MediaAssetModel, MediaModule, decodeBase64, sniffImageType, toMediaAssetDTO };
115
+ export { DEFAULT_ALLOWED_TYPES, DEFAULT_MAX_BYTES, type ICreateAssetInput, type IMediaAsset, type IMediaAssetDTO, type IMediaConfig, MediaAssetModel, MediaModule, decodeBase64, sniffImageType, toMediaAssetDTO };
package/dist/index.d.ts CHANGED
@@ -1,48 +1,7 @@
1
- import { IFonderieModule, IFonderieApp } from '@fonderie/core';
1
+ import { IFonderieContext, IFonderieModule, IFonderieApp } from '@fonderie/core';
2
2
  import { IStoreAdapter } from '@fonderie/store';
3
-
4
- /**
5
- * The storage seam every backend implements — the same pattern @fonderie/billing
6
- * uses for payment providers. Product code depends on this interface, never on a
7
- * concrete backend, so `DbBlobProvider` (zero infra) → `S3Provider` (object
8
- * storage) is a one-line swap in `MediaModule`'s config.
9
- *
10
- * Kept to a least-common-denominator on purpose: `put` / `get` / `delete` and
11
- * nothing backend-specific. The one real divergence between backends — "can you
12
- * hand the client a URL, or must the app stream the bytes?" — is modelled by the
13
- * discriminated result of `get`, so an S3 provider can 302 to a signed URL while
14
- * a DB/filesystem provider serves inline, and the controller never branches on
15
- * which backend is wired.
16
- */
17
- /** A backend-opaque handle to a stored object; the module persists it verbatim. */
18
- interface IStoredRef {
19
- ref: string;
20
- }
21
- /**
22
- * The outcome of resolving a ref. `bytes` → the app serves them (content type
23
- * comes from the asset record, not here). `redirect` → the app 302s to a URL the
24
- * backend can serve directly (e.g. an S3 signed URL). `null` → not found.
25
- */
26
- type IFetched = {
27
- kind: 'bytes';
28
- bytes: Uint8Array;
29
- } | {
30
- kind: 'redirect';
31
- url: string;
32
- };
33
- interface IStorageProvider {
34
- /** Stable id for logs/readiness (e.g. 'db-blob', 'local-fs', 's3'). */
35
- readonly name: string;
36
- /** Persist bytes; `contentType` is passed for backends that store it natively (S3). */
37
- put(input: {
38
- bytes: Uint8Array;
39
- contentType: string;
40
- }): Promise<IStoredRef>;
41
- /** Resolve a ref to servable bytes or a redirect URL, or null if it's gone. */
42
- get(ref: string): Promise<IFetched | null>;
43
- /** Remove the stored object. Idempotent — deleting a missing ref must not throw. */
44
- delete(ref: string): Promise<void>;
45
- }
3
+ import { IStorageProvider } from '@fonderie/storage';
4
+ export { DbBlobProvider, IFetched, IStorageProvider, IStoredRef, LocalFsProvider } from '@fonderie/storage';
46
5
 
47
6
  interface IMediaConfig {
48
7
  /** Where bytes are stored. `DbBlobProvider` (zero infra) by default; swap for S3 at scale. */
@@ -55,6 +14,17 @@ interface IMediaConfig {
55
14
  * it's a stored-XSS vector.
56
15
  */
57
16
  allowedTypes?: string[];
17
+ /**
18
+ * Authorize an upload's target owner. Return false to reject with 403. When
19
+ * omitted, the default policy allows only **self-owned user assets**
20
+ * (`ownerType: 'user'`, `ownerId` = the authenticated caller). Provide this
21
+ * to permit other owners — e.g. a workspace logo the caller may administer, a
22
+ * customer photo in the caller's workspace.
23
+ */
24
+ authorizeOwner?(ctx: IFonderieContext, owner: {
25
+ ownerType: string;
26
+ ownerId: string;
27
+ }): boolean | Promise<boolean>;
58
28
  }
59
29
  declare const DEFAULT_MAX_BYTES = 1000000;
60
30
  declare const DEFAULT_ALLOWED_TYPES: string[];
@@ -75,51 +45,6 @@ declare class MediaModule implements IFonderieModule {
75
45
  install(app: IFonderieApp): void;
76
46
  }
77
47
 
78
- /**
79
- * Zero-infra provider: bytes live in Postgres (`fonderie_media_blobs`, created by
80
- * this package's migration). Great for getting started and self-hosting — the
81
- * whole app is one Node process + one database, and a `pg_dump` captures the
82
- * images atomically with their metadata. Swap to `S3Provider` when bandwidth or
83
- * table size make object storage worth the extra moving part; no app code
84
- * changes, only the `MediaModule` config line.
85
- */
86
- declare class DbBlobProvider implements IStorageProvider {
87
- private readonly store;
88
- readonly name = "db-blob";
89
- constructor(store: IStoreAdapter);
90
- put({ bytes }: {
91
- bytes: Uint8Array;
92
- contentType: string;
93
- }): Promise<IStoredRef>;
94
- get(ref: string): Promise<IFetched | null>;
95
- delete(ref: string): Promise<void>;
96
- }
97
-
98
- /**
99
- * Zero-infra provider: bytes live on the server's filesystem under `dir`. Useful
100
- * for a single-box deployment that wants images off the database without
101
- * standing up object storage. The `ref` is an opaque filename; the asset's
102
- * content type is tracked in `fonderie_media_assets`, so nothing about the
103
- * bytes-on-disk needs to encode it.
104
- *
105
- * (Serves inline through the app like `DbBlobProvider`. It has no CDN in front,
106
- * so at real scale prefer `S3Provider` — same interface, one config line.)
107
- */
108
- declare class LocalFsProvider implements IStorageProvider {
109
- readonly name = "local-fs";
110
- private readonly dir;
111
- private ready;
112
- constructor(dir: string);
113
- private ensureDir;
114
- private pathFor;
115
- put({ bytes }: {
116
- bytes: Uint8Array;
117
- contentType: string;
118
- }): Promise<IStoredRef>;
119
- get(ref: string): Promise<IFetched | null>;
120
- delete(ref: string): Promise<void>;
121
- }
122
-
123
48
  /** A stored asset's metadata row (bytes live behind the storage provider). */
124
49
  interface IMediaAsset {
125
50
  id: string;
@@ -187,4 +112,4 @@ declare function decodeBase64(input: string): Uint8Array;
187
112
  */
188
113
  declare function sniffImageType(bytes: Uint8Array): string | null;
189
114
 
190
- export { DEFAULT_ALLOWED_TYPES, DEFAULT_MAX_BYTES, DbBlobProvider, type ICreateAssetInput, type IFetched, type IMediaAsset, type IMediaAssetDTO, type IMediaConfig, type IStorageProvider, type IStoredRef, LocalFsProvider, MediaAssetModel, MediaModule, decodeBase64, sniffImageType, toMediaAssetDTO };
115
+ export { DEFAULT_ALLOWED_TYPES, DEFAULT_MAX_BYTES, type ICreateAssetInput, type IMediaAsset, type IMediaAssetDTO, type IMediaConfig, MediaAssetModel, MediaModule, decodeBase64, sniffImageType, toMediaAssetDTO };
package/dist/index.js CHANGED
@@ -110,6 +110,9 @@ function buildMediaRoutes(store, config) {
110
110
  if (typeof body.dataBase64 !== "string" || body.dataBase64.length === 0) {
111
111
  return setApiResponse(HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "dataBase64 (a base64 string) is required.");
112
112
  }
113
+ if (body.dataBase64.length > Math.ceil(maxBytes * 1.4)) {
114
+ return setApiResponse(HTTP.UNPROCESSABLE, "ASSET_TOO_LARGE", `Image exceeds the ${maxBytes}-byte limit.`);
115
+ }
113
116
  let bytes;
114
117
  try {
115
118
  bytes = decodeBase64(body.dataBase64);
@@ -133,16 +136,27 @@ function buildMediaRoutes(store, config) {
133
136
  const ownerType = typeof body.ownerType === "string" ? body.ownerType : "user";
134
137
  const ownerId = typeof body.ownerId === "string" ? body.ownerId : userId;
135
138
  const purpose = typeof body.purpose === "string" ? body.purpose : "avatar";
139
+ const authorized = config.authorizeOwner ? await config.authorizeOwner(ctx, { ownerType, ownerId }) : ownerType === "user" && ownerId === userId;
140
+ if (!authorized) {
141
+ return setApiResponse(HTTP.FORBIDDEN, "FORBIDDEN", "Not allowed to upload for that owner.");
142
+ }
136
143
  const { ref } = await config.provider.put({ bytes, contentType });
137
- const asset = await assets.create({
138
- ownerType,
139
- ownerId,
140
- purpose,
141
- contentType,
142
- byteSize: bytes.byteLength,
143
- storageRef: ref,
144
- createdBy: userId
145
- });
144
+ let asset;
145
+ try {
146
+ asset = await assets.create({
147
+ ownerType,
148
+ ownerId,
149
+ purpose,
150
+ contentType,
151
+ byteSize: bytes.byteLength,
152
+ storageRef: ref,
153
+ createdBy: userId
154
+ });
155
+ } catch (err) {
156
+ await config.provider.delete(ref).catch(() => {
157
+ });
158
+ throw err;
159
+ }
146
160
  const basePath = new URL(ctx.request.url).pathname.replace(/\/media$/, "");
147
161
  return setApiResponse(HTTP.OK, "ASSET_CREATED", "Asset uploaded.", {
148
162
  asset: toMediaAssetDTO(asset, basePath)
@@ -220,72 +234,8 @@ var MediaModule = class {
220
234
  }
221
235
  };
222
236
 
223
- // src/providers/db-blob.ts
224
- var DbBlobProvider = class {
225
- constructor(store) {
226
- this.store = store;
227
- }
228
- store;
229
- name = "db-blob";
230
- async put({ bytes }) {
231
- const rows = await this.store.query(
232
- "INSERT INTO fonderie_media_blobs (bytes) VALUES ($1) RETURNING id",
233
- [Buffer.from(bytes)]
234
- );
235
- return { ref: rows[0].id };
236
- }
237
- async get(ref) {
238
- const rows = await this.store.query(
239
- "SELECT bytes FROM fonderie_media_blobs WHERE id = $1",
240
- [ref]
241
- );
242
- const row = rows[0];
243
- return row ? { kind: "bytes", bytes: row.bytes } : null;
244
- }
245
- async delete(ref) {
246
- await this.store.query("DELETE FROM fonderie_media_blobs WHERE id = $1", [ref]);
247
- }
248
- };
249
-
250
- // src/providers/local-fs.ts
251
- import { mkdir, readFile, rm, writeFile } from "fs/promises";
252
- import { randomUUID } from "crypto";
253
- import { join, resolve } from "path";
254
- var LocalFsProvider = class {
255
- name = "local-fs";
256
- dir;
257
- ready = null;
258
- constructor(dir) {
259
- this.dir = resolve(dir);
260
- }
261
- ensureDir() {
262
- if (!this.ready) this.ready = mkdir(this.dir, { recursive: true }).then(() => void 0);
263
- return this.ready;
264
- }
265
- // Reject any ref that isn't a bare id, so a ref can never escape `dir`
266
- // (path traversal). Ids we mint are UUIDs.
267
- pathFor(ref) {
268
- if (!/^[A-Za-z0-9_-]+$/.test(ref)) throw new Error("invalid media ref");
269
- return join(this.dir, ref);
270
- }
271
- async put({ bytes }) {
272
- await this.ensureDir();
273
- const ref = randomUUID();
274
- await writeFile(this.pathFor(ref), bytes);
275
- return { ref };
276
- }
277
- async get(ref) {
278
- try {
279
- const bytes = await readFile(this.pathFor(ref));
280
- return { kind: "bytes", bytes };
281
- } catch {
282
- return null;
283
- }
284
- }
285
- async delete(ref) {
286
- await rm(this.pathFor(ref), { force: true });
287
- }
288
- };
237
+ // src/index.ts
238
+ import { DbBlobProvider, LocalFsProvider } from "@fonderie/storage";
289
239
  export {
290
240
  DEFAULT_ALLOWED_TYPES,
291
241
  DEFAULT_MAX_BYTES,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/routes.ts","../src/config.ts","../src/models/asset.model.ts","../src/dtos/media.ts","../src/services/image.ts","../src/module.ts","../src/providers/db-blob.ts","../src/providers/local-fs.ts"],"sourcesContent":["import type { Middleware } from '@fonderie/core';\nimport { HTTP, setApiResponse } from '@fonderie/core';\nimport { requireAuth } from '@fonderie/core/middlewares';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { DEFAULT_ALLOWED_TYPES, DEFAULT_MAX_BYTES, type IMediaConfig } from './config';\nimport { MediaAssetModel } from './models/asset.model';\nimport { toMediaAssetDTO } from './dtos/media';\nimport { decodeBase64, sniffImageType } from './services/image';\n\ntype Route = [string, string, ...Middleware[]];\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\nexport function buildMediaRoutes(store: IStoreAdapter, config: IMediaConfig): Route[] {\n\tconst assets = new MediaAssetModel(store);\n\tconst maxBytes = config.maxBytes ?? DEFAULT_MAX_BYTES;\n\tconst allowed = config.allowedTypes ?? DEFAULT_ALLOWED_TYPES;\n\n\treturn [\n\t\t// POST /media { dataBase64, ownerType?, ownerId?, purpose? } -> { asset }\n\t\t// Accepts base64 (bare or a data URI), verifies it's a real image by its\n\t\t// magic bytes (never the client's claim), caps the decoded size, stores\n\t\t// the bytes via the provider, and records metadata. Returns a URL.\n\t\t[\n\t\t\t'POST',\n\t\t\t'/media',\n\t\t\trequireAuth,\n\t\t\tasync (ctx) => {\n\t\t\t\tconst userId = ctx.user!.id;\n\t\t\t\tconst body = (ctx.meta['body'] ?? {}) as {\n\t\t\t\t\tdataBase64?: unknown;\n\t\t\t\t\townerType?: unknown;\n\t\t\t\t\townerId?: unknown;\n\t\t\t\t\tpurpose?: unknown;\n\t\t\t\t};\n\n\t\t\t\tif (typeof body.dataBase64 !== 'string' || body.dataBase64.length === 0) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'dataBase64 (a base64 string) is required.');\n\t\t\t\t}\n\n\t\t\t\tlet bytes: Uint8Array;\n\t\t\t\ttry {\n\t\t\t\t\tbytes = decodeBase64(body.dataBase64);\n\t\t\t\t} catch {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'dataBase64 is not valid base64.');\n\t\t\t\t}\n\t\t\t\tif (bytes.byteLength === 0) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'The image is empty.');\n\t\t\t\t}\n\t\t\t\tif (bytes.byteLength > maxBytes) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'ASSET_TOO_LARGE', `Image exceeds the ${maxBytes}-byte limit.`);\n\t\t\t\t}\n\n\t\t\t\tconst contentType = sniffImageType(bytes);\n\t\t\t\tif (!contentType || !allowed.includes(contentType)) {\n\t\t\t\t\treturn setApiResponse(\n\t\t\t\t\t\tHTTP.UNPROCESSABLE,\n\t\t\t\t\t\t'ASSET_UNSUPPORTED',\n\t\t\t\t\t\t`Unsupported image type. Allowed: ${allowed.join(', ')}.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\tconst ownerType = typeof body.ownerType === 'string' ? body.ownerType : 'user';\n\t\t\t\tconst ownerId = typeof body.ownerId === 'string' ? body.ownerId : userId;\n\t\t\t\tconst purpose = typeof body.purpose === 'string' ? body.purpose : 'avatar';\n\n\t\t\t\tconst { ref } = await config.provider.put({ bytes, contentType });\n\t\t\t\tconst asset = await assets.create({\n\t\t\t\t\townerType,\n\t\t\t\t\townerId,\n\t\t\t\t\tpurpose,\n\t\t\t\t\tcontentType,\n\t\t\t\t\tbyteSize: bytes.byteLength,\n\t\t\t\t\tstorageRef: ref,\n\t\t\t\t\tcreatedBy: userId,\n\t\t\t\t});\n\n\t\t\t\t// Build the URL at whatever prefix this route is mounted under\n\t\t\t\t// (e.g. '/v1/media/:id'), derived from the request path.\n\t\t\t\tconst basePath = new URL(ctx.request.url).pathname.replace(/\\/media$/, '');\n\t\t\t\treturn setApiResponse(HTTP.OK, 'ASSET_CREATED', 'Asset uploaded.', {\n\t\t\t\t\tasset: toMediaAssetDTO(asset, basePath),\n\t\t\t\t});\n\t\t\t},\n\t\t],\n\n\t\t// GET /media/:id (PUBLIC — an <img src> can't send an Authorization\n\t\t// header) -> the image bytes with cache headers, or a 302 to a\n\t\t// provider-served URL. Assets are immutable, so the id is a stable ETag.\n\t\t[\n\t\t\t'GET',\n\t\t\t'/media/:id',\n\t\t\tasync (ctx) => {\n\t\t\t\tconst id = ctx.meta.params?.['id'];\n\t\t\t\tif (!id || !UUID_RE.test(id)) return new Response('Not found', { status: 404 });\n\n\t\t\t\tconst asset = await assets.get(id);\n\t\t\t\tif (!asset) return new Response('Not found', { status: 404 });\n\n\t\t\t\tconst etag = `\"${asset.id}\"`;\n\t\t\t\tif (ctx.request.headers.get('if-none-match') === etag) {\n\t\t\t\t\treturn new Response(null, { status: 304, headers: { ETag: etag } });\n\t\t\t\t}\n\n\t\t\t\tconst fetched = await config.provider.get(asset.storageRef);\n\t\t\t\tif (!fetched) return new Response('Not found', { status: 404 });\n\t\t\t\tif (fetched.kind === 'redirect') {\n\t\t\t\t\treturn new Response(null, { status: 302, headers: { Location: fetched.url } });\n\t\t\t\t}\n\t\t\t\t// Fresh Uint8Array (ArrayBuffer-backed) so it satisfies BodyInit; a\n\t\t\t\t// pg Buffer is typed Uint8Array<ArrayBufferLike>, which the lib rejects.\n\t\t\t\treturn new Response(new Uint8Array(fetched.bytes), {\n\t\t\t\t\tstatus: 200,\n\t\t\t\t\theaders: {\n\t\t\t\t\t\t'Content-Type': asset.contentType,\n\t\t\t\t\t\t'Content-Length': String(asset.byteSize),\n\t\t\t\t\t\t'Cache-Control': 'public, max-age=300',\n\t\t\t\t\t\tETag: etag,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t},\n\t\t],\n\n\t\t// DELETE /media/:id -> removes the asset; only the uploader may delete it.\n\t\t[\n\t\t\t'DELETE',\n\t\t\t'/media/:id',\n\t\t\trequireAuth,\n\t\t\tasync (ctx) => {\n\t\t\t\tconst id = ctx.meta.params?.['id'];\n\t\t\t\tif (!id || !UUID_RE.test(id)) {\n\t\t\t\t\treturn setApiResponse(HTTP.NOT_FOUND, 'ASSET_NOT_FOUND', 'No such asset.');\n\t\t\t\t}\n\t\t\t\tconst asset = await assets.get(id);\n\t\t\t\tif (!asset) return setApiResponse(HTTP.NOT_FOUND, 'ASSET_NOT_FOUND', 'No such asset.');\n\t\t\t\tif (asset.createdBy !== ctx.user!.id) {\n\t\t\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'You can only delete assets you uploaded.');\n\t\t\t\t}\n\t\t\t\tawait config.provider.delete(asset.storageRef);\n\t\t\t\tawait assets.delete(id);\n\t\t\t\treturn setApiResponse(HTTP.OK, 'ASSET_DELETED', 'Asset deleted.', { id });\n\t\t\t},\n\t\t],\n\t];\n}\n","import type { IStorageProvider } from './providers/types';\n\nexport interface IMediaConfig {\n\t/** Where bytes are stored. `DbBlobProvider` (zero infra) by default; swap for S3 at scale. */\n\tprovider: IStorageProvider;\n\t/** Max decoded size per asset, in bytes. Default 1 MB. */\n\tmaxBytes?: number;\n\t/**\n\t * Content types accepted on upload (matched against magic bytes, not the\n\t * client's claim). Default: PNG / JPEG / WebP / GIF. SVG is never accepted —\n\t * it's a stored-XSS vector.\n\t */\n\tallowedTypes?: string[];\n}\n\nexport const DEFAULT_MAX_BYTES = 1_000_000;\nexport const DEFAULT_ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { ICreateAssetInput, IMediaAsset } from '../types';\n\ninterface AssetRow {\n\tid: string;\n\towner_type: string;\n\towner_id: string;\n\tpurpose: string;\n\tcontent_type: string;\n\tbyte_size: number;\n\tstorage_ref: string;\n\tcreated_by: string | null;\n\tcreated_at: Date;\n}\n\nconst toAsset = (r: AssetRow): IMediaAsset => ({\n\tid: r.id,\n\townerType: r.owner_type,\n\townerId: r.owner_id,\n\tpurpose: r.purpose,\n\tcontentType: r.content_type,\n\tbyteSize: r.byte_size,\n\tstorageRef: r.storage_ref,\n\tcreatedBy: r.created_by,\n\tcreatedAt: r.created_at,\n});\n\n/** Data access for `fonderie_media_assets` — the metadata around each stored blob. */\nexport class MediaAssetModel {\n\tconstructor(private readonly store: IStoreAdapter) {}\n\n\tasync create(input: ICreateAssetInput): Promise<IMediaAsset> {\n\t\tconst rows = await this.store.query<AssetRow>(\n\t\t\t`INSERT INTO fonderie_media_assets\n\t\t\t (owner_type, owner_id, purpose, content_type, byte_size, storage_ref, created_by)\n\t\t\t VALUES ($1, $2, $3, $4, $5, $6, $7)\n\t\t\t RETURNING *`,\n\t\t\t[input.ownerType, input.ownerId, input.purpose, input.contentType, input.byteSize, input.storageRef, input.createdBy],\n\t\t);\n\t\treturn toAsset(rows[0]!);\n\t}\n\n\tasync get(id: string): Promise<IMediaAsset | null> {\n\t\tconst rows = await this.store.query<AssetRow>('SELECT * FROM fonderie_media_assets WHERE id = $1', [id]);\n\t\treturn rows[0] ? toAsset(rows[0]) : null;\n\t}\n\n\t/** The most recent asset for an owner + purpose (e.g. a user's current avatar). */\n\tasync latestFor(ownerType: string, ownerId: string, purpose: string): Promise<IMediaAsset | null> {\n\t\tconst rows = await this.store.query<AssetRow>(\n\t\t\t`SELECT * FROM fonderie_media_assets\n\t\t\t WHERE owner_type = $1 AND owner_id = $2 AND purpose = $3\n\t\t\t ORDER BY created_at DESC LIMIT 1`,\n\t\t\t[ownerType, ownerId, purpose],\n\t\t);\n\t\treturn rows[0] ? toAsset(rows[0]) : null;\n\t}\n\n\tasync delete(id: string): Promise<void> {\n\t\tawait this.store.query('DELETE FROM fonderie_media_assets WHERE id = $1', [id]);\n\t}\n}\n","import type { IMediaAsset } from '../types';\n\n/**\n * The wire shape for a stored asset. `url` is the monomorphic read contract:\n * always a `/media/:id` path, whatever the backend — clients render it in an\n * `<img>` and never care whether the bytes came from Postgres, disk, or S3.\n */\nexport interface IMediaAssetDTO {\n\tid: string;\n\turl: string;\n\tcontentType: string;\n\tbyteSize: number;\n\townerType: string;\n\townerId: string;\n\tpurpose: string;\n\tcreatedAt: string;\n}\n\n/** basePath is the router mount (e.g. '/v1'); '' yields a root-relative '/media/:id'. */\nexport function toMediaAssetDTO(asset: IMediaAsset, basePath = ''): IMediaAssetDTO {\n\treturn {\n\t\tid: asset.id,\n\t\turl: `${basePath}/media/${asset.id}`,\n\t\tcontentType: asset.contentType,\n\t\tbyteSize: asset.byteSize,\n\t\townerType: asset.ownerType,\n\t\townerId: asset.ownerId,\n\t\tpurpose: asset.purpose,\n\t\tcreatedAt: asset.createdAt instanceof Date ? asset.createdAt.toISOString() : String(asset.createdAt),\n\t};\n}\n","/**\n * Decode a base64 payload to bytes. Accepts both a bare base64 string and a\n * data URI (`data:image/png;base64,<...>`) — the frontend `FileReader` produces\n * the latter, so callers don't have to strip it.\n */\nexport function decodeBase64(input: string): Uint8Array {\n\tconst comma = input.startsWith('data:') ? input.indexOf(',') : -1;\n\tconst b64 = comma >= 0 ? input.slice(comma + 1) : input;\n\treturn new Uint8Array(Buffer.from(b64, 'base64'));\n}\n\n/**\n * Identify an image from its magic bytes — NOT from a client-claimed MIME type,\n * which is trivially spoofed. Returns the canonical content type or `null` for\n * anything unrecognised. SVG is deliberately not detected (it's XML, can carry\n * scripts, and is a stored-XSS vector), so it falls through to `null` and is\n * rejected upstream.\n */\nexport function sniffImageType(bytes: Uint8Array): string | null {\n\tconst b = bytes;\n\t// PNG: 89 50 4E 47 0D 0A 1A 0A\n\tif (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) {\n\t\treturn 'image/png';\n\t}\n\t// JPEG: FF D8 FF\n\tif (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {\n\t\treturn 'image/jpeg';\n\t}\n\t// GIF: \"GIF87a\" / \"GIF89a\"\n\tif (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38 && (b[4] === 0x37 || b[4] === 0x39) && b[5] === 0x61) {\n\t\treturn 'image/gif';\n\t}\n\t// WEBP: \"RIFF\" .... \"WEBP\" (bytes 0-3 and 8-11)\n\tif (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) {\n\t\treturn 'image/webp';\n\t}\n\treturn null;\n}\n","import type { IFonderieApp, IFonderieModule } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMediaConfig } from './config';\nimport { buildMediaRoutes } from './routes';\n\n/**\n * Provider-abstracted asset storage. Register it like any other brick; it adds\n * `POST /media`, `GET /media/:id` (public), and `DELETE /media/:id`, and stores\n * bytes through the configured `IStorageProvider` (`DbBlobProvider` for zero\n * infra, swappable for object storage). Depends on `@fonderie/auth` for the\n * authenticated caller on upload/delete.\n */\nexport class MediaModule implements IFonderieModule {\n\treadonly name = '@fonderie/media';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(\n\t\tprivate readonly store: IStoreAdapter,\n\t\tprivate readonly config: IMediaConfig,\n\t) {}\n\n\tinstall(app: IFonderieApp): void {\n\t\tfor (const [method, path, ...handlers] of buildMediaRoutes(this.store, this.config)) {\n\t\t\tapp.addRoute(method, path, ...handlers);\n\t\t}\n\t}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IFetched, IStorageProvider, IStoredRef } from './types';\n\n/**\n * Zero-infra provider: bytes live in Postgres (`fonderie_media_blobs`, created by\n * this package's migration). Great for getting started and self-hosting — the\n * whole app is one Node process + one database, and a `pg_dump` captures the\n * images atomically with their metadata. Swap to `S3Provider` when bandwidth or\n * table size make object storage worth the extra moving part; no app code\n * changes, only the `MediaModule` config line.\n */\nexport class DbBlobProvider implements IStorageProvider {\n\treadonly name = 'db-blob';\n\n\tconstructor(private readonly store: IStoreAdapter) {}\n\n\tasync put({ bytes }: { bytes: Uint8Array; contentType: string }): Promise<IStoredRef> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t'INSERT INTO fonderie_media_blobs (bytes) VALUES ($1) RETURNING id',\n\t\t\t[Buffer.from(bytes)],\n\t\t);\n\t\treturn { ref: rows[0]!.id };\n\t}\n\n\tasync get(ref: string): Promise<IFetched | null> {\n\t\tconst rows = await this.store.query<{ bytes: Buffer }>(\n\t\t\t'SELECT bytes FROM fonderie_media_blobs WHERE id = $1',\n\t\t\t[ref],\n\t\t);\n\t\tconst row = rows[0];\n\t\treturn row ? { kind: 'bytes', bytes: row.bytes } : null;\n\t}\n\n\tasync delete(ref: string): Promise<void> {\n\t\tawait this.store.query('DELETE FROM fonderie_media_blobs WHERE id = $1', [ref]);\n\t}\n}\n","import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';\nimport { randomUUID } from 'node:crypto';\nimport { join, resolve } from 'node:path';\n\nimport type { IFetched, IStorageProvider, IStoredRef } from './types';\n\n/**\n * Zero-infra provider: bytes live on the server's filesystem under `dir`. Useful\n * for a single-box deployment that wants images off the database without\n * standing up object storage. The `ref` is an opaque filename; the asset's\n * content type is tracked in `fonderie_media_assets`, so nothing about the\n * bytes-on-disk needs to encode it.\n *\n * (Serves inline through the app like `DbBlobProvider`. It has no CDN in front,\n * so at real scale prefer `S3Provider` — same interface, one config line.)\n */\nexport class LocalFsProvider implements IStorageProvider {\n\treadonly name = 'local-fs';\n\tprivate readonly dir: string;\n\tprivate ready: Promise<void> | null = null;\n\n\tconstructor(dir: string) {\n\t\tthis.dir = resolve(dir);\n\t}\n\n\tprivate ensureDir(): Promise<void> {\n\t\tif (!this.ready) this.ready = mkdir(this.dir, { recursive: true }).then(() => undefined);\n\t\treturn this.ready;\n\t}\n\n\t// Reject any ref that isn't a bare id, so a ref can never escape `dir`\n\t// (path traversal). Ids we mint are UUIDs.\n\tprivate pathFor(ref: string): string {\n\t\tif (!/^[A-Za-z0-9_-]+$/.test(ref)) throw new Error('invalid media ref');\n\t\treturn join(this.dir, ref);\n\t}\n\n\tasync put({ bytes }: { bytes: Uint8Array; contentType: string }): Promise<IStoredRef> {\n\t\tawait this.ensureDir();\n\t\tconst ref = randomUUID();\n\t\tawait writeFile(this.pathFor(ref), bytes);\n\t\treturn { ref };\n\t}\n\n\tasync get(ref: string): Promise<IFetched | null> {\n\t\ttry {\n\t\t\tconst bytes = await readFile(this.pathFor(ref));\n\t\t\treturn { kind: 'bytes', bytes };\n\t\t} catch {\n\t\t\treturn null; // ENOENT (or an invalid ref) → treated as not found\n\t\t}\n\t}\n\n\tasync delete(ref: string): Promise<void> {\n\t\tawait rm(this.pathFor(ref), { force: true }); // force → no throw when already gone\n\t}\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/routes.ts","../src/config.ts","../src/models/asset.model.ts","../src/dtos/media.ts","../src/services/image.ts","../src/module.ts","../src/index.ts"],"sourcesContent":["import type { Middleware } from '@fonderie/core';\nimport { HTTP, setApiResponse } from '@fonderie/core';\nimport { requireAuth } from '@fonderie/core/middlewares';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { DEFAULT_ALLOWED_TYPES, DEFAULT_MAX_BYTES, type IMediaConfig } from './config';\nimport { MediaAssetModel } from './models/asset.model';\nimport { toMediaAssetDTO } from './dtos/media';\nimport { decodeBase64, sniffImageType } from './services/image';\n\ntype Route = [string, string, ...Middleware[]];\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\nexport function buildMediaRoutes(store: IStoreAdapter, config: IMediaConfig): Route[] {\n\tconst assets = new MediaAssetModel(store);\n\tconst maxBytes = config.maxBytes ?? DEFAULT_MAX_BYTES;\n\tconst allowed = config.allowedTypes ?? DEFAULT_ALLOWED_TYPES;\n\n\treturn [\n\t\t// POST /media { dataBase64, ownerType?, ownerId?, purpose? } -> { asset }\n\t\t// Accepts base64 (bare or a data URI), verifies it's a real image by its\n\t\t// magic bytes (never the client's claim), caps the decoded size, stores\n\t\t// the bytes via the provider, and records metadata. Returns a URL.\n\t\t[\n\t\t\t'POST',\n\t\t\t'/media',\n\t\t\trequireAuth,\n\t\t\tasync (ctx) => {\n\t\t\t\tconst userId = ctx.user!.id;\n\t\t\t\tconst body = (ctx.meta['body'] ?? {}) as {\n\t\t\t\t\tdataBase64?: unknown;\n\t\t\t\t\townerType?: unknown;\n\t\t\t\t\townerId?: unknown;\n\t\t\t\t\tpurpose?: unknown;\n\t\t\t\t};\n\n\t\t\t\tif (typeof body.dataBase64 !== 'string' || body.dataBase64.length === 0) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'dataBase64 (a base64 string) is required.');\n\t\t\t\t}\n\t\t\t\t// Reject oversized uploads BEFORE decoding — base64 inflates ~4/3, so a\n\t\t\t\t// string longer than maxBytes*1.4 cannot fit the cap. Bounds the decode\n\t\t\t\t// allocation instead of materializing a huge buffer only to reject it.\n\t\t\t\t// (A request-body-size limit at the adapter is the complementary guard.)\n\t\t\t\tif (body.dataBase64.length > Math.ceil(maxBytes * 1.4)) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'ASSET_TOO_LARGE', `Image exceeds the ${maxBytes}-byte limit.`);\n\t\t\t\t}\n\n\t\t\t\tlet bytes: Uint8Array;\n\t\t\t\ttry {\n\t\t\t\t\tbytes = decodeBase64(body.dataBase64);\n\t\t\t\t} catch {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'dataBase64 is not valid base64.');\n\t\t\t\t}\n\t\t\t\tif (bytes.byteLength === 0) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'INVALID_PARAMETER', 'The image is empty.');\n\t\t\t\t}\n\t\t\t\tif (bytes.byteLength > maxBytes) {\n\t\t\t\t\treturn setApiResponse(HTTP.UNPROCESSABLE, 'ASSET_TOO_LARGE', `Image exceeds the ${maxBytes}-byte limit.`);\n\t\t\t\t}\n\n\t\t\t\tconst contentType = sniffImageType(bytes);\n\t\t\t\tif (!contentType || !allowed.includes(contentType)) {\n\t\t\t\t\treturn setApiResponse(\n\t\t\t\t\t\tHTTP.UNPROCESSABLE,\n\t\t\t\t\t\t'ASSET_UNSUPPORTED',\n\t\t\t\t\t\t`Unsupported image type. Allowed: ${allowed.join(', ')}.`,\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\tconst ownerType = typeof body.ownerType === 'string' ? body.ownerType : 'user';\n\t\t\t\tconst ownerId = typeof body.ownerId === 'string' ? body.ownerId : userId;\n\t\t\t\tconst purpose = typeof body.purpose === 'string' ? body.purpose : 'avatar';\n\n\t\t\t\t// Authorize the target owner. Default policy: self-owned user assets\n\t\t\t\t// only; a consumer opts into other owners (workspace logos, customer\n\t\t\t\t// photos) via config.authorizeOwner.\n\t\t\t\tconst authorized = config.authorizeOwner\n\t\t\t\t\t? await config.authorizeOwner(ctx, { ownerType, ownerId })\n\t\t\t\t\t: ownerType === 'user' && ownerId === userId;\n\t\t\t\tif (!authorized) {\n\t\t\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Not allowed to upload for that owner.');\n\t\t\t\t}\n\n\t\t\t\tconst { ref } = await config.provider.put({ bytes, contentType });\n\t\t\t\tlet asset: Awaited<ReturnType<typeof assets.create>>;\n\t\t\t\ttry {\n\t\t\t\t\tasset = await assets.create({\n\t\t\t\t\t\townerType,\n\t\t\t\t\t\townerId,\n\t\t\t\t\t\tpurpose,\n\t\t\t\t\t\tcontentType,\n\t\t\t\t\t\tbyteSize: bytes.byteLength,\n\t\t\t\t\t\tstorageRef: ref,\n\t\t\t\t\t\tcreatedBy: userId,\n\t\t\t\t\t});\n\t\t\t\t} catch (err) {\n\t\t\t\t\t// Metadata insert failed — don't orphan the bytes we just stored.\n\t\t\t\t\tawait config.provider.delete(ref).catch(() => {});\n\t\t\t\t\tthrow err;\n\t\t\t\t}\n\n\t\t\t\t// Build the URL at whatever prefix this route is mounted under\n\t\t\t\t// (e.g. '/v1/media/:id'), derived from the request path.\n\t\t\t\tconst basePath = new URL(ctx.request.url).pathname.replace(/\\/media$/, '');\n\t\t\t\treturn setApiResponse(HTTP.OK, 'ASSET_CREATED', 'Asset uploaded.', {\n\t\t\t\t\tasset: toMediaAssetDTO(asset, basePath),\n\t\t\t\t});\n\t\t\t},\n\t\t],\n\n\t\t// GET /media/:id (PUBLIC — an <img src> can't send an Authorization\n\t\t// header) -> the image bytes with cache headers, or a 302 to a\n\t\t// provider-served URL. Assets are immutable, so the id is a stable ETag.\n\t\t[\n\t\t\t'GET',\n\t\t\t'/media/:id',\n\t\t\tasync (ctx) => {\n\t\t\t\tconst id = ctx.meta.params?.['id'];\n\t\t\t\tif (!id || !UUID_RE.test(id)) return new Response('Not found', { status: 404 });\n\n\t\t\t\tconst asset = await assets.get(id);\n\t\t\t\tif (!asset) return new Response('Not found', { status: 404 });\n\n\t\t\t\tconst etag = `\"${asset.id}\"`;\n\t\t\t\tif (ctx.request.headers.get('if-none-match') === etag) {\n\t\t\t\t\treturn new Response(null, { status: 304, headers: { ETag: etag } });\n\t\t\t\t}\n\n\t\t\t\tconst fetched = await config.provider.get(asset.storageRef);\n\t\t\t\tif (!fetched) return new Response('Not found', { status: 404 });\n\t\t\t\tif (fetched.kind === 'redirect') {\n\t\t\t\t\treturn new Response(null, { status: 302, headers: { Location: fetched.url } });\n\t\t\t\t}\n\t\t\t\t// Fresh Uint8Array (ArrayBuffer-backed) so it satisfies BodyInit; a\n\t\t\t\t// pg Buffer is typed Uint8Array<ArrayBufferLike>, which the lib rejects.\n\t\t\t\treturn new Response(new Uint8Array(fetched.bytes), {\n\t\t\t\t\tstatus: 200,\n\t\t\t\t\theaders: {\n\t\t\t\t\t\t'Content-Type': asset.contentType,\n\t\t\t\t\t\t'Content-Length': String(asset.byteSize),\n\t\t\t\t\t\t'Cache-Control': 'public, max-age=300',\n\t\t\t\t\t\tETag: etag,\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t},\n\t\t],\n\n\t\t// DELETE /media/:id -> removes the asset; only the uploader may delete it.\n\t\t[\n\t\t\t'DELETE',\n\t\t\t'/media/:id',\n\t\t\trequireAuth,\n\t\t\tasync (ctx) => {\n\t\t\t\tconst id = ctx.meta.params?.['id'];\n\t\t\t\tif (!id || !UUID_RE.test(id)) {\n\t\t\t\t\treturn setApiResponse(HTTP.NOT_FOUND, 'ASSET_NOT_FOUND', 'No such asset.');\n\t\t\t\t}\n\t\t\t\tconst asset = await assets.get(id);\n\t\t\t\tif (!asset) return setApiResponse(HTTP.NOT_FOUND, 'ASSET_NOT_FOUND', 'No such asset.');\n\t\t\t\tif (asset.createdBy !== ctx.user!.id) {\n\t\t\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'You can only delete assets you uploaded.');\n\t\t\t\t}\n\t\t\t\tawait config.provider.delete(asset.storageRef);\n\t\t\t\tawait assets.delete(id);\n\t\t\t\treturn setApiResponse(HTTP.OK, 'ASSET_DELETED', 'Asset deleted.', { id });\n\t\t\t},\n\t\t],\n\t];\n}\n","import type { IFonderieContext } from '@fonderie/core';\nimport type { IStorageProvider } from '@fonderie/storage';\n\nexport interface IMediaConfig {\n\t/** Where bytes are stored. `DbBlobProvider` (zero infra) by default; swap for S3 at scale. */\n\tprovider: IStorageProvider;\n\t/** Max decoded size per asset, in bytes. Default 1 MB. */\n\tmaxBytes?: number;\n\t/**\n\t * Content types accepted on upload (matched against magic bytes, not the\n\t * client's claim). Default: PNG / JPEG / WebP / GIF. SVG is never accepted —\n\t * it's a stored-XSS vector.\n\t */\n\tallowedTypes?: string[];\n\t/**\n\t * Authorize an upload's target owner. Return false to reject with 403. When\n\t * omitted, the default policy allows only **self-owned user assets**\n\t * (`ownerType: 'user'`, `ownerId` = the authenticated caller). Provide this\n\t * to permit other owners — e.g. a workspace logo the caller may administer, a\n\t * customer photo in the caller's workspace.\n\t */\n\tauthorizeOwner?(\n\t\tctx: IFonderieContext,\n\t\towner: { ownerType: string; ownerId: string },\n\t): boolean | Promise<boolean>;\n}\n\nexport const DEFAULT_MAX_BYTES = 1_000_000;\nexport const DEFAULT_ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { ICreateAssetInput, IMediaAsset } from '../types';\n\ninterface AssetRow {\n\tid: string;\n\towner_type: string;\n\towner_id: string;\n\tpurpose: string;\n\tcontent_type: string;\n\tbyte_size: number;\n\tstorage_ref: string;\n\tcreated_by: string | null;\n\tcreated_at: Date;\n}\n\nconst toAsset = (r: AssetRow): IMediaAsset => ({\n\tid: r.id,\n\townerType: r.owner_type,\n\townerId: r.owner_id,\n\tpurpose: r.purpose,\n\tcontentType: r.content_type,\n\tbyteSize: r.byte_size,\n\tstorageRef: r.storage_ref,\n\tcreatedBy: r.created_by,\n\tcreatedAt: r.created_at,\n});\n\n/** Data access for `fonderie_media_assets` — the metadata around each stored blob. */\nexport class MediaAssetModel {\n\tconstructor(private readonly store: IStoreAdapter) {}\n\n\tasync create(input: ICreateAssetInput): Promise<IMediaAsset> {\n\t\tconst rows = await this.store.query<AssetRow>(\n\t\t\t`INSERT INTO fonderie_media_assets\n\t\t\t (owner_type, owner_id, purpose, content_type, byte_size, storage_ref, created_by)\n\t\t\t VALUES ($1, $2, $3, $4, $5, $6, $7)\n\t\t\t RETURNING *`,\n\t\t\t[input.ownerType, input.ownerId, input.purpose, input.contentType, input.byteSize, input.storageRef, input.createdBy],\n\t\t);\n\t\treturn toAsset(rows[0]!);\n\t}\n\n\tasync get(id: string): Promise<IMediaAsset | null> {\n\t\tconst rows = await this.store.query<AssetRow>('SELECT * FROM fonderie_media_assets WHERE id = $1', [id]);\n\t\treturn rows[0] ? toAsset(rows[0]) : null;\n\t}\n\n\t/** The most recent asset for an owner + purpose (e.g. a user's current avatar). */\n\tasync latestFor(ownerType: string, ownerId: string, purpose: string): Promise<IMediaAsset | null> {\n\t\tconst rows = await this.store.query<AssetRow>(\n\t\t\t`SELECT * FROM fonderie_media_assets\n\t\t\t WHERE owner_type = $1 AND owner_id = $2 AND purpose = $3\n\t\t\t ORDER BY created_at DESC LIMIT 1`,\n\t\t\t[ownerType, ownerId, purpose],\n\t\t);\n\t\treturn rows[0] ? toAsset(rows[0]) : null;\n\t}\n\n\tasync delete(id: string): Promise<void> {\n\t\tawait this.store.query('DELETE FROM fonderie_media_assets WHERE id = $1', [id]);\n\t}\n}\n","import type { IMediaAsset } from '../types';\n\n/**\n * The wire shape for a stored asset. `url` is the monomorphic read contract:\n * always a `/media/:id` path, whatever the backend — clients render it in an\n * `<img>` and never care whether the bytes came from Postgres, disk, or S3.\n */\nexport interface IMediaAssetDTO {\n\tid: string;\n\turl: string;\n\tcontentType: string;\n\tbyteSize: number;\n\townerType: string;\n\townerId: string;\n\tpurpose: string;\n\tcreatedAt: string;\n}\n\n/** basePath is the router mount (e.g. '/v1'); '' yields a root-relative '/media/:id'. */\nexport function toMediaAssetDTO(asset: IMediaAsset, basePath = ''): IMediaAssetDTO {\n\treturn {\n\t\tid: asset.id,\n\t\turl: `${basePath}/media/${asset.id}`,\n\t\tcontentType: asset.contentType,\n\t\tbyteSize: asset.byteSize,\n\t\townerType: asset.ownerType,\n\t\townerId: asset.ownerId,\n\t\tpurpose: asset.purpose,\n\t\tcreatedAt: asset.createdAt instanceof Date ? asset.createdAt.toISOString() : String(asset.createdAt),\n\t};\n}\n","/**\n * Decode a base64 payload to bytes. Accepts both a bare base64 string and a\n * data URI (`data:image/png;base64,<...>`) — the frontend `FileReader` produces\n * the latter, so callers don't have to strip it.\n */\nexport function decodeBase64(input: string): Uint8Array {\n\tconst comma = input.startsWith('data:') ? input.indexOf(',') : -1;\n\tconst b64 = comma >= 0 ? input.slice(comma + 1) : input;\n\treturn new Uint8Array(Buffer.from(b64, 'base64'));\n}\n\n/**\n * Identify an image from its magic bytes — NOT from a client-claimed MIME type,\n * which is trivially spoofed. Returns the canonical content type or `null` for\n * anything unrecognised. SVG is deliberately not detected (it's XML, can carry\n * scripts, and is a stored-XSS vector), so it falls through to `null` and is\n * rejected upstream.\n */\nexport function sniffImageType(bytes: Uint8Array): string | null {\n\tconst b = bytes;\n\t// PNG: 89 50 4E 47 0D 0A 1A 0A\n\tif (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) {\n\t\treturn 'image/png';\n\t}\n\t// JPEG: FF D8 FF\n\tif (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {\n\t\treturn 'image/jpeg';\n\t}\n\t// GIF: \"GIF87a\" / \"GIF89a\"\n\tif (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38 && (b[4] === 0x37 || b[4] === 0x39) && b[5] === 0x61) {\n\t\treturn 'image/gif';\n\t}\n\t// WEBP: \"RIFF\" .... \"WEBP\" (bytes 0-3 and 8-11)\n\tif (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) {\n\t\treturn 'image/webp';\n\t}\n\treturn null;\n}\n","import type { IFonderieApp, IFonderieModule } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMediaConfig } from './config';\nimport { buildMediaRoutes } from './routes';\n\n/**\n * Provider-abstracted asset storage. Register it like any other brick; it adds\n * `POST /media`, `GET /media/:id` (public), and `DELETE /media/:id`, and stores\n * bytes through the configured `IStorageProvider` (`DbBlobProvider` for zero\n * infra, swappable for object storage). Depends on `@fonderie/auth` for the\n * authenticated caller on upload/delete.\n */\nexport class MediaModule implements IFonderieModule {\n\treadonly name = '@fonderie/media';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(\n\t\tprivate readonly store: IStoreAdapter,\n\t\tprivate readonly config: IMediaConfig,\n\t) {}\n\n\tinstall(app: IFonderieApp): void {\n\t\tfor (const [method, path, ...handlers] of buildMediaRoutes(this.store, this.config)) {\n\t\t\tapp.addRoute(method, path, ...handlers);\n\t\t}\n\t}\n}\n","export { MediaModule } from './module';\nexport { DEFAULT_ALLOWED_TYPES, DEFAULT_MAX_BYTES } from './config';\nexport type { IMediaConfig } from './config';\n\n// Storage lives in @fonderie/storage now; re-exported here for convenience so\n// existing consumers can keep importing the zero-infra providers from media.\n// S3Provider (the object-storage backend) is at '@fonderie/storage/s3'.\nexport { DbBlobProvider, LocalFsProvider } from '@fonderie/storage';\nexport type { IStorageProvider, IFetched, IStoredRef } from '@fonderie/storage';\n\n// For server-side resolution (e.g. wiring a user's avatar URL) and custom flows.\nexport { MediaAssetModel } from './models/asset.model';\nexport { toMediaAssetDTO } from './dtos/media';\nexport type { IMediaAssetDTO } from './dtos/media';\nexport type { IMediaAsset, ICreateAssetInput } from './types';\nexport { decodeBase64, sniffImageType } from './services/image';\n"],"mappings":"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","names":[]}
@@ -0,0 +1,12 @@
1
+ -- ----------------------------------------------------------------------------
2
+ -- 002_drop_legacy_blobs
3
+ -- ----------------------------------------------------------------------------
4
+ -- Byte storage moved to @fonderie/storage (fonderie_storage_blobs) when the
5
+ -- providers were extracted from this package. media now stores bytes through an
6
+ -- injected IStorageProvider, so the old fonderie_media_blobs table (created by
7
+ -- 001_media) is superseded. Safe to drop — media@0.1.0 shipped without a wired
8
+ -- consumer, so nothing referenced it. Run @fonderie/storage's migration for the
9
+ -- replacement table.
10
+ -- ----------------------------------------------------------------------------
11
+
12
+ DROP TABLE IF EXISTS fonderie_media_blobs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonderie/media",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Provider-abstracted asset storage — upload, store, and serve user-owned images (avatars, logos) with a DB-blob backend by default and object storage as a one-line swap.",
5
5
  "keywords": [
6
6
  "fonderiejs",
@@ -41,11 +41,13 @@
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@fonderie/core": "^0.8.0",
44
- "@fonderie/store": "^0.2.0"
44
+ "@fonderie/store": "^0.2.0",
45
+ "@fonderie/storage": "^0.1.0"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@fonderie/core": "../core",
48
49
  "@fonderie/store": "../store",
50
+ "@fonderie/storage": "../storage",
49
51
  "@types/node": "^26.4.1",
50
52
  "tsup": "^8.5.1",
51
53
  "tsx": "^4.23.13",