@fonderie/media 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/brain/outcomes.md +42 -0
- package/brain/signatures.md +103 -0
- package/dist/index.cjs +335 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +190 -0
- package/dist/index.d.ts +190 -0
- package/dist/index.js +300 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +7 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/migrations/sql/001_media.sql +37 -0
- package/package.json +72 -0
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { IFonderieModule, IFonderieApp } from '@fonderie/core';
|
|
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
|
+
}
|
|
46
|
+
|
|
47
|
+
interface IMediaConfig {
|
|
48
|
+
/** Where bytes are stored. `DbBlobProvider` (zero infra) by default; swap for S3 at scale. */
|
|
49
|
+
provider: IStorageProvider;
|
|
50
|
+
/** Max decoded size per asset, in bytes. Default 1 MB. */
|
|
51
|
+
maxBytes?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Content types accepted on upload (matched against magic bytes, not the
|
|
54
|
+
* client's claim). Default: PNG / JPEG / WebP / GIF. SVG is never accepted —
|
|
55
|
+
* it's a stored-XSS vector.
|
|
56
|
+
*/
|
|
57
|
+
allowedTypes?: string[];
|
|
58
|
+
}
|
|
59
|
+
declare const DEFAULT_MAX_BYTES = 1000000;
|
|
60
|
+
declare const DEFAULT_ALLOWED_TYPES: string[];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Provider-abstracted asset storage. Register it like any other brick; it adds
|
|
64
|
+
* `POST /media`, `GET /media/:id` (public), and `DELETE /media/:id`, and stores
|
|
65
|
+
* bytes through the configured `IStorageProvider` (`DbBlobProvider` for zero
|
|
66
|
+
* infra, swappable for object storage). Depends on `@fonderie/auth` for the
|
|
67
|
+
* authenticated caller on upload/delete.
|
|
68
|
+
*/
|
|
69
|
+
declare class MediaModule implements IFonderieModule {
|
|
70
|
+
private readonly store;
|
|
71
|
+
private readonly config;
|
|
72
|
+
readonly name = "@fonderie/media";
|
|
73
|
+
readonly deps: string[];
|
|
74
|
+
constructor(store: IStoreAdapter, config: IMediaConfig);
|
|
75
|
+
install(app: IFonderieApp): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
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
|
+
/** A stored asset's metadata row (bytes live behind the storage provider). */
|
|
124
|
+
interface IMediaAsset {
|
|
125
|
+
id: string;
|
|
126
|
+
ownerType: string;
|
|
127
|
+
ownerId: string;
|
|
128
|
+
purpose: string;
|
|
129
|
+
contentType: string;
|
|
130
|
+
byteSize: number;
|
|
131
|
+
storageRef: string;
|
|
132
|
+
createdBy: string | null;
|
|
133
|
+
createdAt: Date;
|
|
134
|
+
}
|
|
135
|
+
/** What an upload records. `ownerId` defaults to the caller when omitted. */
|
|
136
|
+
interface ICreateAssetInput {
|
|
137
|
+
ownerType: string;
|
|
138
|
+
ownerId: string;
|
|
139
|
+
purpose: string;
|
|
140
|
+
contentType: string;
|
|
141
|
+
byteSize: number;
|
|
142
|
+
storageRef: string;
|
|
143
|
+
createdBy: string | null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Data access for `fonderie_media_assets` — the metadata around each stored blob. */
|
|
147
|
+
declare class MediaAssetModel {
|
|
148
|
+
private readonly store;
|
|
149
|
+
constructor(store: IStoreAdapter);
|
|
150
|
+
create(input: ICreateAssetInput): Promise<IMediaAsset>;
|
|
151
|
+
get(id: string): Promise<IMediaAsset | null>;
|
|
152
|
+
/** The most recent asset for an owner + purpose (e.g. a user's current avatar). */
|
|
153
|
+
latestFor(ownerType: string, ownerId: string, purpose: string): Promise<IMediaAsset | null>;
|
|
154
|
+
delete(id: string): Promise<void>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The wire shape for a stored asset. `url` is the monomorphic read contract:
|
|
159
|
+
* always a `/media/:id` path, whatever the backend — clients render it in an
|
|
160
|
+
* `<img>` and never care whether the bytes came from Postgres, disk, or S3.
|
|
161
|
+
*/
|
|
162
|
+
interface IMediaAssetDTO {
|
|
163
|
+
id: string;
|
|
164
|
+
url: string;
|
|
165
|
+
contentType: string;
|
|
166
|
+
byteSize: number;
|
|
167
|
+
ownerType: string;
|
|
168
|
+
ownerId: string;
|
|
169
|
+
purpose: string;
|
|
170
|
+
createdAt: string;
|
|
171
|
+
}
|
|
172
|
+
/** basePath is the router mount (e.g. '/v1'); '' yields a root-relative '/media/:id'. */
|
|
173
|
+
declare function toMediaAssetDTO(asset: IMediaAsset, basePath?: string): IMediaAssetDTO;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Decode a base64 payload to bytes. Accepts both a bare base64 string and a
|
|
177
|
+
* data URI (`data:image/png;base64,<...>`) — the frontend `FileReader` produces
|
|
178
|
+
* the latter, so callers don't have to strip it.
|
|
179
|
+
*/
|
|
180
|
+
declare function decodeBase64(input: string): Uint8Array;
|
|
181
|
+
/**
|
|
182
|
+
* Identify an image from its magic bytes — NOT from a client-claimed MIME type,
|
|
183
|
+
* which is trivially spoofed. Returns the canonical content type or `null` for
|
|
184
|
+
* anything unrecognised. SVG is deliberately not detected (it's XML, can carry
|
|
185
|
+
* scripts, and is a stored-XSS vector), so it falls through to `null` and is
|
|
186
|
+
* rejected upstream.
|
|
187
|
+
*/
|
|
188
|
+
declare function sniffImageType(bytes: Uint8Array): string | null;
|
|
189
|
+
|
|
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 };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { IFonderieModule, IFonderieApp } from '@fonderie/core';
|
|
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
|
+
}
|
|
46
|
+
|
|
47
|
+
interface IMediaConfig {
|
|
48
|
+
/** Where bytes are stored. `DbBlobProvider` (zero infra) by default; swap for S3 at scale. */
|
|
49
|
+
provider: IStorageProvider;
|
|
50
|
+
/** Max decoded size per asset, in bytes. Default 1 MB. */
|
|
51
|
+
maxBytes?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Content types accepted on upload (matched against magic bytes, not the
|
|
54
|
+
* client's claim). Default: PNG / JPEG / WebP / GIF. SVG is never accepted —
|
|
55
|
+
* it's a stored-XSS vector.
|
|
56
|
+
*/
|
|
57
|
+
allowedTypes?: string[];
|
|
58
|
+
}
|
|
59
|
+
declare const DEFAULT_MAX_BYTES = 1000000;
|
|
60
|
+
declare const DEFAULT_ALLOWED_TYPES: string[];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Provider-abstracted asset storage. Register it like any other brick; it adds
|
|
64
|
+
* `POST /media`, `GET /media/:id` (public), and `DELETE /media/:id`, and stores
|
|
65
|
+
* bytes through the configured `IStorageProvider` (`DbBlobProvider` for zero
|
|
66
|
+
* infra, swappable for object storage). Depends on `@fonderie/auth` for the
|
|
67
|
+
* authenticated caller on upload/delete.
|
|
68
|
+
*/
|
|
69
|
+
declare class MediaModule implements IFonderieModule {
|
|
70
|
+
private readonly store;
|
|
71
|
+
private readonly config;
|
|
72
|
+
readonly name = "@fonderie/media";
|
|
73
|
+
readonly deps: string[];
|
|
74
|
+
constructor(store: IStoreAdapter, config: IMediaConfig);
|
|
75
|
+
install(app: IFonderieApp): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
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
|
+
/** A stored asset's metadata row (bytes live behind the storage provider). */
|
|
124
|
+
interface IMediaAsset {
|
|
125
|
+
id: string;
|
|
126
|
+
ownerType: string;
|
|
127
|
+
ownerId: string;
|
|
128
|
+
purpose: string;
|
|
129
|
+
contentType: string;
|
|
130
|
+
byteSize: number;
|
|
131
|
+
storageRef: string;
|
|
132
|
+
createdBy: string | null;
|
|
133
|
+
createdAt: Date;
|
|
134
|
+
}
|
|
135
|
+
/** What an upload records. `ownerId` defaults to the caller when omitted. */
|
|
136
|
+
interface ICreateAssetInput {
|
|
137
|
+
ownerType: string;
|
|
138
|
+
ownerId: string;
|
|
139
|
+
purpose: string;
|
|
140
|
+
contentType: string;
|
|
141
|
+
byteSize: number;
|
|
142
|
+
storageRef: string;
|
|
143
|
+
createdBy: string | null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Data access for `fonderie_media_assets` — the metadata around each stored blob. */
|
|
147
|
+
declare class MediaAssetModel {
|
|
148
|
+
private readonly store;
|
|
149
|
+
constructor(store: IStoreAdapter);
|
|
150
|
+
create(input: ICreateAssetInput): Promise<IMediaAsset>;
|
|
151
|
+
get(id: string): Promise<IMediaAsset | null>;
|
|
152
|
+
/** The most recent asset for an owner + purpose (e.g. a user's current avatar). */
|
|
153
|
+
latestFor(ownerType: string, ownerId: string, purpose: string): Promise<IMediaAsset | null>;
|
|
154
|
+
delete(id: string): Promise<void>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The wire shape for a stored asset. `url` is the monomorphic read contract:
|
|
159
|
+
* always a `/media/:id` path, whatever the backend — clients render it in an
|
|
160
|
+
* `<img>` and never care whether the bytes came from Postgres, disk, or S3.
|
|
161
|
+
*/
|
|
162
|
+
interface IMediaAssetDTO {
|
|
163
|
+
id: string;
|
|
164
|
+
url: string;
|
|
165
|
+
contentType: string;
|
|
166
|
+
byteSize: number;
|
|
167
|
+
ownerType: string;
|
|
168
|
+
ownerId: string;
|
|
169
|
+
purpose: string;
|
|
170
|
+
createdAt: string;
|
|
171
|
+
}
|
|
172
|
+
/** basePath is the router mount (e.g. '/v1'); '' yields a root-relative '/media/:id'. */
|
|
173
|
+
declare function toMediaAssetDTO(asset: IMediaAsset, basePath?: string): IMediaAssetDTO;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Decode a base64 payload to bytes. Accepts both a bare base64 string and a
|
|
177
|
+
* data URI (`data:image/png;base64,<...>`) — the frontend `FileReader` produces
|
|
178
|
+
* the latter, so callers don't have to strip it.
|
|
179
|
+
*/
|
|
180
|
+
declare function decodeBase64(input: string): Uint8Array;
|
|
181
|
+
/**
|
|
182
|
+
* Identify an image from its magic bytes — NOT from a client-claimed MIME type,
|
|
183
|
+
* which is trivially spoofed. Returns the canonical content type or `null` for
|
|
184
|
+
* anything unrecognised. SVG is deliberately not detected (it's XML, can carry
|
|
185
|
+
* scripts, and is a stored-XSS vector), so it falls through to `null` and is
|
|
186
|
+
* rejected upstream.
|
|
187
|
+
*/
|
|
188
|
+
declare function sniffImageType(bytes: Uint8Array): string | null;
|
|
189
|
+
|
|
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 };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// src/routes.ts
|
|
2
|
+
import { HTTP, setApiResponse } from "@fonderie/core";
|
|
3
|
+
import { requireAuth } from "@fonderie/core/middlewares";
|
|
4
|
+
|
|
5
|
+
// src/config.ts
|
|
6
|
+
var DEFAULT_MAX_BYTES = 1e6;
|
|
7
|
+
var DEFAULT_ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
|
8
|
+
|
|
9
|
+
// src/models/asset.model.ts
|
|
10
|
+
var toAsset = (r) => ({
|
|
11
|
+
id: r.id,
|
|
12
|
+
ownerType: r.owner_type,
|
|
13
|
+
ownerId: r.owner_id,
|
|
14
|
+
purpose: r.purpose,
|
|
15
|
+
contentType: r.content_type,
|
|
16
|
+
byteSize: r.byte_size,
|
|
17
|
+
storageRef: r.storage_ref,
|
|
18
|
+
createdBy: r.created_by,
|
|
19
|
+
createdAt: r.created_at
|
|
20
|
+
});
|
|
21
|
+
var MediaAssetModel = class {
|
|
22
|
+
constructor(store) {
|
|
23
|
+
this.store = store;
|
|
24
|
+
}
|
|
25
|
+
store;
|
|
26
|
+
async create(input) {
|
|
27
|
+
const rows = await this.store.query(
|
|
28
|
+
`INSERT INTO fonderie_media_assets
|
|
29
|
+
(owner_type, owner_id, purpose, content_type, byte_size, storage_ref, created_by)
|
|
30
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
31
|
+
RETURNING *`,
|
|
32
|
+
[input.ownerType, input.ownerId, input.purpose, input.contentType, input.byteSize, input.storageRef, input.createdBy]
|
|
33
|
+
);
|
|
34
|
+
return toAsset(rows[0]);
|
|
35
|
+
}
|
|
36
|
+
async get(id) {
|
|
37
|
+
const rows = await this.store.query("SELECT * FROM fonderie_media_assets WHERE id = $1", [id]);
|
|
38
|
+
return rows[0] ? toAsset(rows[0]) : null;
|
|
39
|
+
}
|
|
40
|
+
/** The most recent asset for an owner + purpose (e.g. a user's current avatar). */
|
|
41
|
+
async latestFor(ownerType, ownerId, purpose) {
|
|
42
|
+
const rows = await this.store.query(
|
|
43
|
+
`SELECT * FROM fonderie_media_assets
|
|
44
|
+
WHERE owner_type = $1 AND owner_id = $2 AND purpose = $3
|
|
45
|
+
ORDER BY created_at DESC LIMIT 1`,
|
|
46
|
+
[ownerType, ownerId, purpose]
|
|
47
|
+
);
|
|
48
|
+
return rows[0] ? toAsset(rows[0]) : null;
|
|
49
|
+
}
|
|
50
|
+
async delete(id) {
|
|
51
|
+
await this.store.query("DELETE FROM fonderie_media_assets WHERE id = $1", [id]);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// src/dtos/media.ts
|
|
56
|
+
function toMediaAssetDTO(asset, basePath = "") {
|
|
57
|
+
return {
|
|
58
|
+
id: asset.id,
|
|
59
|
+
url: `${basePath}/media/${asset.id}`,
|
|
60
|
+
contentType: asset.contentType,
|
|
61
|
+
byteSize: asset.byteSize,
|
|
62
|
+
ownerType: asset.ownerType,
|
|
63
|
+
ownerId: asset.ownerId,
|
|
64
|
+
purpose: asset.purpose,
|
|
65
|
+
createdAt: asset.createdAt instanceof Date ? asset.createdAt.toISOString() : String(asset.createdAt)
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/services/image.ts
|
|
70
|
+
function decodeBase64(input) {
|
|
71
|
+
const comma = input.startsWith("data:") ? input.indexOf(",") : -1;
|
|
72
|
+
const b64 = comma >= 0 ? input.slice(comma + 1) : input;
|
|
73
|
+
return new Uint8Array(Buffer.from(b64, "base64"));
|
|
74
|
+
}
|
|
75
|
+
function sniffImageType(bytes) {
|
|
76
|
+
const b = bytes;
|
|
77
|
+
if (b.length >= 8 && b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71 && b[4] === 13 && b[5] === 10 && b[6] === 26 && b[7] === 10) {
|
|
78
|
+
return "image/png";
|
|
79
|
+
}
|
|
80
|
+
if (b.length >= 3 && b[0] === 255 && b[1] === 216 && b[2] === 255) {
|
|
81
|
+
return "image/jpeg";
|
|
82
|
+
}
|
|
83
|
+
if (b.length >= 6 && b[0] === 71 && b[1] === 73 && b[2] === 70 && b[3] === 56 && (b[4] === 55 || b[4] === 57) && b[5] === 97) {
|
|
84
|
+
return "image/gif";
|
|
85
|
+
}
|
|
86
|
+
if (b.length >= 12 && b[0] === 82 && b[1] === 73 && b[2] === 70 && b[3] === 70 && b[8] === 87 && b[9] === 69 && b[10] === 66 && b[11] === 80) {
|
|
87
|
+
return "image/webp";
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/routes.ts
|
|
93
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
94
|
+
function buildMediaRoutes(store, config) {
|
|
95
|
+
const assets = new MediaAssetModel(store);
|
|
96
|
+
const maxBytes = config.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
97
|
+
const allowed = config.allowedTypes ?? DEFAULT_ALLOWED_TYPES;
|
|
98
|
+
return [
|
|
99
|
+
// POST /media { dataBase64, ownerType?, ownerId?, purpose? } -> { asset }
|
|
100
|
+
// Accepts base64 (bare or a data URI), verifies it's a real image by its
|
|
101
|
+
// magic bytes (never the client's claim), caps the decoded size, stores
|
|
102
|
+
// the bytes via the provider, and records metadata. Returns a URL.
|
|
103
|
+
[
|
|
104
|
+
"POST",
|
|
105
|
+
"/media",
|
|
106
|
+
requireAuth,
|
|
107
|
+
async (ctx) => {
|
|
108
|
+
const userId = ctx.user.id;
|
|
109
|
+
const body = ctx.meta["body"] ?? {};
|
|
110
|
+
if (typeof body.dataBase64 !== "string" || body.dataBase64.length === 0) {
|
|
111
|
+
return setApiResponse(HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "dataBase64 (a base64 string) is required.");
|
|
112
|
+
}
|
|
113
|
+
let bytes;
|
|
114
|
+
try {
|
|
115
|
+
bytes = decodeBase64(body.dataBase64);
|
|
116
|
+
} catch {
|
|
117
|
+
return setApiResponse(HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "dataBase64 is not valid base64.");
|
|
118
|
+
}
|
|
119
|
+
if (bytes.byteLength === 0) {
|
|
120
|
+
return setApiResponse(HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "The image is empty.");
|
|
121
|
+
}
|
|
122
|
+
if (bytes.byteLength > maxBytes) {
|
|
123
|
+
return setApiResponse(HTTP.UNPROCESSABLE, "ASSET_TOO_LARGE", `Image exceeds the ${maxBytes}-byte limit.`);
|
|
124
|
+
}
|
|
125
|
+
const contentType = sniffImageType(bytes);
|
|
126
|
+
if (!contentType || !allowed.includes(contentType)) {
|
|
127
|
+
return setApiResponse(
|
|
128
|
+
HTTP.UNPROCESSABLE,
|
|
129
|
+
"ASSET_UNSUPPORTED",
|
|
130
|
+
`Unsupported image type. Allowed: ${allowed.join(", ")}.`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const ownerType = typeof body.ownerType === "string" ? body.ownerType : "user";
|
|
134
|
+
const ownerId = typeof body.ownerId === "string" ? body.ownerId : userId;
|
|
135
|
+
const purpose = typeof body.purpose === "string" ? body.purpose : "avatar";
|
|
136
|
+
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
|
+
});
|
|
146
|
+
const basePath = new URL(ctx.request.url).pathname.replace(/\/media$/, "");
|
|
147
|
+
return setApiResponse(HTTP.OK, "ASSET_CREATED", "Asset uploaded.", {
|
|
148
|
+
asset: toMediaAssetDTO(asset, basePath)
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
],
|
|
152
|
+
// GET /media/:id (PUBLIC — an <img src> can't send an Authorization
|
|
153
|
+
// header) -> the image bytes with cache headers, or a 302 to a
|
|
154
|
+
// provider-served URL. Assets are immutable, so the id is a stable ETag.
|
|
155
|
+
[
|
|
156
|
+
"GET",
|
|
157
|
+
"/media/:id",
|
|
158
|
+
async (ctx) => {
|
|
159
|
+
const id = ctx.meta.params?.["id"];
|
|
160
|
+
if (!id || !UUID_RE.test(id)) return new Response("Not found", { status: 404 });
|
|
161
|
+
const asset = await assets.get(id);
|
|
162
|
+
if (!asset) return new Response("Not found", { status: 404 });
|
|
163
|
+
const etag = `"${asset.id}"`;
|
|
164
|
+
if (ctx.request.headers.get("if-none-match") === etag) {
|
|
165
|
+
return new Response(null, { status: 304, headers: { ETag: etag } });
|
|
166
|
+
}
|
|
167
|
+
const fetched = await config.provider.get(asset.storageRef);
|
|
168
|
+
if (!fetched) return new Response("Not found", { status: 404 });
|
|
169
|
+
if (fetched.kind === "redirect") {
|
|
170
|
+
return new Response(null, { status: 302, headers: { Location: fetched.url } });
|
|
171
|
+
}
|
|
172
|
+
return new Response(new Uint8Array(fetched.bytes), {
|
|
173
|
+
status: 200,
|
|
174
|
+
headers: {
|
|
175
|
+
"Content-Type": asset.contentType,
|
|
176
|
+
"Content-Length": String(asset.byteSize),
|
|
177
|
+
"Cache-Control": "public, max-age=300",
|
|
178
|
+
ETag: etag
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
],
|
|
183
|
+
// DELETE /media/:id -> removes the asset; only the uploader may delete it.
|
|
184
|
+
[
|
|
185
|
+
"DELETE",
|
|
186
|
+
"/media/:id",
|
|
187
|
+
requireAuth,
|
|
188
|
+
async (ctx) => {
|
|
189
|
+
const id = ctx.meta.params?.["id"];
|
|
190
|
+
if (!id || !UUID_RE.test(id)) {
|
|
191
|
+
return setApiResponse(HTTP.NOT_FOUND, "ASSET_NOT_FOUND", "No such asset.");
|
|
192
|
+
}
|
|
193
|
+
const asset = await assets.get(id);
|
|
194
|
+
if (!asset) return setApiResponse(HTTP.NOT_FOUND, "ASSET_NOT_FOUND", "No such asset.");
|
|
195
|
+
if (asset.createdBy !== ctx.user.id) {
|
|
196
|
+
return setApiResponse(HTTP.FORBIDDEN, "FORBIDDEN", "You can only delete assets you uploaded.");
|
|
197
|
+
}
|
|
198
|
+
await config.provider.delete(asset.storageRef);
|
|
199
|
+
await assets.delete(id);
|
|
200
|
+
return setApiResponse(HTTP.OK, "ASSET_DELETED", "Asset deleted.", { id });
|
|
201
|
+
}
|
|
202
|
+
]
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/module.ts
|
|
207
|
+
var MediaModule = class {
|
|
208
|
+
constructor(store, config) {
|
|
209
|
+
this.store = store;
|
|
210
|
+
this.config = config;
|
|
211
|
+
}
|
|
212
|
+
store;
|
|
213
|
+
config;
|
|
214
|
+
name = "@fonderie/media";
|
|
215
|
+
deps = ["@fonderie/auth"];
|
|
216
|
+
install(app) {
|
|
217
|
+
for (const [method, path, ...handlers] of buildMediaRoutes(this.store, this.config)) {
|
|
218
|
+
app.addRoute(method, path, ...handlers);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
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
|
+
};
|
|
289
|
+
export {
|
|
290
|
+
DEFAULT_ALLOWED_TYPES,
|
|
291
|
+
DEFAULT_MAX_BYTES,
|
|
292
|
+
DbBlobProvider,
|
|
293
|
+
LocalFsProvider,
|
|
294
|
+
MediaAssetModel,
|
|
295
|
+
MediaModule,
|
|
296
|
+
decodeBase64,
|
|
297
|
+
sniffImageType,
|
|
298
|
+
toMediaAssetDTO
|
|
299
|
+
};
|
|
300
|
+
//# sourceMappingURL=index.js.map
|