@proteinjs/db-file 1.10.16 → 1.11.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/generated/index.d.ts.map +1 -1
  3. package/dist/generated/index.js +5 -1
  4. package/dist/generated/index.js.map +1 -1
  5. package/dist/src/routes/FileResponder.d.ts +25 -0
  6. package/dist/src/routes/FileResponder.d.ts.map +1 -1
  7. package/dist/src/routes/FileResponder.js +88 -29
  8. package/dist/src/routes/FileResponder.js.map +1 -1
  9. package/dist/src/routes/getFileBytes.d.ts +14 -0
  10. package/dist/src/routes/getFileBytes.d.ts.map +1 -0
  11. package/dist/src/routes/getFileBytes.js +90 -0
  12. package/dist/src/routes/getFileBytes.js.map +1 -0
  13. package/dist/src/routes/getFileRoute.d.ts +14 -0
  14. package/dist/src/routes/getFileRoute.d.ts.map +1 -1
  15. package/dist/src/routes/getFileRoute.js +15 -1
  16. package/dist/src/routes/getFileRoute.js.map +1 -1
  17. package/dist/src/routes/getFileVariantBytes.d.ts +11 -0
  18. package/dist/src/routes/getFileVariantBytes.d.ts.map +1 -0
  19. package/dist/src/routes/getFileVariantBytes.js +103 -0
  20. package/dist/src/routes/getFileVariantBytes.js.map +1 -0
  21. package/dist/src/routes/getFileVariantRoute.d.ts +5 -0
  22. package/dist/src/routes/getFileVariantRoute.d.ts.map +1 -1
  23. package/dist/src/routes/getFileVariantRoute.js +7 -1
  24. package/dist/src/routes/getFileVariantRoute.js.map +1 -1
  25. package/dist/test/GetFileBytesRoute.test.d.ts +2 -0
  26. package/dist/test/GetFileBytesRoute.test.d.ts.map +1 -0
  27. package/dist/test/GetFileBytesRoute.test.js +488 -0
  28. package/dist/test/GetFileBytesRoute.test.js.map +1 -0
  29. package/generated/index.ts +5 -1
  30. package/package.json +4 -4
  31. package/src/routes/FileResponder.ts +62 -18
  32. package/src/routes/getFileBytes.ts +38 -0
  33. package/src/routes/getFileRoute.ts +16 -0
  34. package/src/routes/getFileVariantBytes.ts +42 -0
  35. package/src/routes/getFileVariantRoute.ts +7 -0
  36. package/test/GetFileBytesRoute.test.ts +316 -0
@@ -0,0 +1,316 @@
1
+ import { SourceRepository } from '@proteinjs/reflection';
2
+ import { UserAuth, UserRepo, guestUser, User } from '@proteinjs/user';
3
+ import { File } from '../src/tables/FileTable';
4
+ import { FileStorage } from '../src/FileStorage';
5
+ import { FileStorageDriver } from '../src/FileStorageDriver';
6
+ import type { FileVariantKind } from '../src/FileVariantMaker';
7
+ import { getFile } from '../src/routes/getFile';
8
+ import { getFileBytes } from '../src/routes/getFileBytes';
9
+ import { getFileVariantBytes } from '../src/routes/getFileVariantBytes';
10
+ import { getFileRoute, getFileBytesRoute } from '../src/routes/getFileRoute';
11
+ import { getFileVariantRoute, getFileVariantBytesRoute } from '../src/routes/getFileVariantRoute';
12
+ import { FileTestEnvironment } from './FileTestEnvironment';
13
+
14
+ /** Every byte value once — a payload that corrupts under any text/base64 mix-up. */
15
+ const rawBytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
16
+ /** The copy for others — the original's first eight bytes behind a mark, byte-distinct from it. */
17
+ const COPY_MARK = Buffer.from('COPY-FOR-OTHERS');
18
+ const copyOf = (bytes: Buffer): Buffer => Buffer.concat([COPY_MARK, bytes.subarray(0, 8)]);
19
+ /** A variant — the kind's name over the original's first eight bytes. */
20
+ const variantOf = (kind: FileVariantKind, bytes: Buffer): Buffer =>
21
+ Buffer.concat([Buffer.from(`${kind}:`), bytes.subarray(0, 8)]);
22
+
23
+ /**
24
+ * A driver WITH an external URL space (the signed-URL serving case): `GET /file/:id` answers a 302 into
25
+ * it. The bytes doors must never redirect — they serve the bytes themselves, whatever the driver's URL
26
+ * space; so a signed URL minted by this driver is the proof that the door skipped it.
27
+ */
28
+ class TestSignedUrlFileStorageDriver implements FileStorageDriver {
29
+ readonly store = new Map<string, Buffer>();
30
+ signedUrlsMinted = 0;
31
+
32
+ async createFile(file: File, fileData: string): Promise<void> {
33
+ this.store.set(file.id, Buffer.from(fileData, 'base64'));
34
+ }
35
+
36
+ async getFileData(fileId: string): Promise<string> {
37
+ const data = this.store.get(fileId);
38
+ if (data === undefined) {
39
+ throw new Error(`No such object: ${fileId}`);
40
+ }
41
+ return data.toString('base64');
42
+ }
43
+
44
+ async updateFileData(fileId: string, data: string): Promise<void> {
45
+ this.store.set(fileId, Buffer.from(data, 'base64'));
46
+ }
47
+
48
+ async getSignedUrl(fileId: string): Promise<string> {
49
+ this.signedUrlsMinted++;
50
+ return `https://blobs.example/${fileId}?sig=test`;
51
+ }
52
+
53
+ async deleteFile(fileId: string): Promise<void> {
54
+ this.store.delete(fileId);
55
+ }
56
+ }
57
+
58
+ /** Minimal express Response recorder — the routes only touch these members. */
59
+ class ResponseRecorder {
60
+ statusCode?: number;
61
+ body?: unknown;
62
+ redirectUrl?: string;
63
+ headers: Record<string, string> = {};
64
+
65
+ status(code: number): ResponseRecorder {
66
+ this.statusCode = code;
67
+ return this;
68
+ }
69
+
70
+ send(body: unknown): ResponseRecorder {
71
+ this.body = body;
72
+ return this;
73
+ }
74
+
75
+ setHeader(name: string, value: string): void {
76
+ this.headers[name.toLowerCase()] = value;
77
+ }
78
+
79
+ redirect(status: number, url: string): void {
80
+ this.statusCode = status;
81
+ this.redirectUrl = url;
82
+ }
83
+ }
84
+
85
+ type RouteRequest = Parameters<typeof getFileBytes.onRequest>[0];
86
+ type RouteResponse = Parameters<typeof getFileBytes.onRequest>[1];
87
+
88
+ const invokeBytes = async (fileId: string, headers: Record<string, string> = {}): Promise<ResponseRecorder> => {
89
+ const response = new ResponseRecorder();
90
+ await getFileBytes.onRequest(
91
+ { params: { id: fileId }, headers } as unknown as RouteRequest,
92
+ response as unknown as RouteResponse
93
+ );
94
+ return response;
95
+ };
96
+
97
+ const invokeVariantBytes = async (
98
+ fileId: string,
99
+ kind: string,
100
+ headers: Record<string, string> = {}
101
+ ): Promise<ResponseRecorder> => {
102
+ const response = new ResponseRecorder();
103
+ await getFileVariantBytes.onRequest(
104
+ { params: { id: fileId, kind }, headers } as unknown as RouteRequest,
105
+ response as unknown as RouteResponse
106
+ );
107
+ return response;
108
+ };
109
+
110
+ const invokeFile = async (fileId: string): Promise<ResponseRecorder> => {
111
+ const response = new ResponseRecorder();
112
+ await getFile.onRequest(
113
+ { params: { id: fileId }, headers: {} } as unknown as RouteRequest,
114
+ response as unknown as RouteResponse
115
+ );
116
+ return response;
117
+ };
118
+
119
+ const testEnv = new FileTestEnvironment();
120
+ type UserAuthInternals = { userRepo?: unknown };
121
+ type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
122
+ const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
123
+ const driver = new TestSignedUrlFileStorageDriver();
124
+ let owner: User;
125
+ let recipient: User;
126
+ let stranger: User;
127
+ /** File ids the stub resolver reports as reachable for the current user (the shared-content leg). */
128
+ const reachableFileIds = new Set<string>();
129
+
130
+ beforeAll(async () => {
131
+ await testEnv.beforeAll();
132
+ // The routes gate on UserAuth.isLoggedIn(), which resolves its user repo from the source repository —
133
+ // seed it (tests don't load the generated source graph) and reset the static cache.
134
+ objectCache()['@proteinjs/user-auth/AuthenticatedUserRepo'] = [new UserRepo()];
135
+ (UserAuth as unknown as UserAuthInternals).userRepo = undefined;
136
+ objectCache()['@proteinjs/db-file/FileReachabilityResolver'] = [
137
+ { canReadViaReference: async (fileId: string) => reachableFileIds.has(fileId) },
138
+ ];
139
+ testEnv.setDriver(driver);
140
+ owner = await testEnv.createUser({ name: 'File owner', email: 'bytes-owner@test.local' });
141
+ recipient = await testEnv.createUser({ name: 'Share recipient', email: 'bytes-recipient@test.local' });
142
+ stranger = await testEnv.createUser({ name: 'Stranger', email: 'bytes-stranger@test.local' });
143
+ testEnv.actAs(owner);
144
+ });
145
+
146
+ afterAll(async () => {
147
+ (UserAuth as unknown as UserAuthInternals).userRepo = undefined;
148
+ delete objectCache()['@proteinjs/db-file/FileReachabilityResolver'];
149
+ await testEnv.afterAll();
150
+ });
151
+
152
+ beforeEach(() => {
153
+ reachableFileIds.clear();
154
+ objectCache()['@proteinjs/db-file/FileCopyForOthers'] = [];
155
+ objectCache()['@proteinjs/db-file/FileVariantMaker'] = [];
156
+ driver.signedUrlsMinted = 0;
157
+ testEnv.actAs(owner);
158
+ });
159
+
160
+ const createOwnerFile = async (name: string, type = 'image/png'): Promise<File> =>
161
+ await new FileStorage().createFile({ name, type, size: rawBytes.length } as File, rawBytes.toString('base64'));
162
+
163
+ describe('the route paths — the bytes door is the file URL plus /bytes, one rule for both doors', () => {
164
+ it('names the doors from the app URLs the surfaces already use', () => {
165
+ expect(getFileBytesRoute.path('f1')).toEqual(`${getFileRoute.path('f1')}/bytes`);
166
+ expect(getFileVariantBytesRoute.path('f1', 'preview')).toEqual(
167
+ `${getFileVariantRoute.path('f1', 'preview')}/bytes`
168
+ );
169
+ expect(getFileBytes.path).toEqual('/file/:id/bytes');
170
+ expect(getFileVariantBytes.path).toEqual('/file/:id/variant/:kind/bytes');
171
+ });
172
+ });
173
+
174
+ describe('GET /file/:id/bytes — the same-origin bytes door', () => {
175
+ it('serves the true bytes itself and never a redirect, though the driver has a URL space (the proxy branch)', async () => {
176
+ const file = await createOwnerFile('shot.png');
177
+
178
+ const asFile = await invokeFile(file.id);
179
+ expect(asFile.statusCode).toEqual(302);
180
+
181
+ const response = await invokeBytes(file.id);
182
+
183
+ expect(response.redirectUrl).toBeUndefined();
184
+ expect(response.statusCode).not.toEqual(302);
185
+ expect(response.headers['content-type']).toEqual('image/png');
186
+ expect(Buffer.isBuffer(response.body)).toBe(true);
187
+ expect(Buffer.compare(response.body as Buffer, rawBytes)).toBe(0);
188
+ // The signed URL the file route minted is the only one — the bytes door never asked for one.
189
+ expect(driver.signedUrlsMinted).toEqual(1);
190
+ });
191
+
192
+ it('says the browser may keep the bytes privately — never a shared cache (the bytes are per caller)', async () => {
193
+ const file = await createOwnerFile('cached.png');
194
+
195
+ const response = await invokeBytes(file.id);
196
+
197
+ expect(response.headers['cache-control']).toEqual('private, max-age=300');
198
+ expect(response.headers['cache-control']).not.toContain('public');
199
+ });
200
+
201
+ it('honors Range exactly as the proxy branch does (206 with the bytes asked)', async () => {
202
+ const file = await createOwnerFile('clip.webm', 'video/webm');
203
+
204
+ const response = await invokeBytes(file.id, { range: 'bytes=10-19' });
205
+
206
+ expect(response.statusCode).toEqual(206);
207
+ expect(response.headers['accept-ranges']).toEqual('bytes');
208
+ expect(response.headers['content-range']).toEqual('bytes 10-19/256');
209
+ expect(Buffer.compare(response.body as Buffer, rawBytes.subarray(10, 20))).toBe(0);
210
+ });
211
+
212
+ it('serves a shared-content recipient the COPY for others, as every door does — the same egress owner', async () => {
213
+ const file = await createOwnerFile('shared.jpg', 'image/jpeg');
214
+ reachableFileIds.add(file.id);
215
+ objectCache()['@proteinjs/db-file/FileCopyForOthers'] = [
216
+ { appliesTo: () => true, make: async (_file: File, bytes: Buffer) => copyOf(bytes) },
217
+ ];
218
+
219
+ testEnv.actAs(recipient);
220
+ const response = await invokeBytes(file.id);
221
+
222
+ expect(response.statusCode).not.toEqual(404);
223
+ expect(Buffer.compare(response.body as Buffer, copyOf(rawBytes))).toBe(0);
224
+ expect(Buffer.compare(response.body as Buffer, rawBytes)).not.toBe(0);
225
+
226
+ // The owner's own read through the same door is the original.
227
+ testEnv.actAs(owner);
228
+ const own = await invokeBytes(file.id);
229
+ expect(Buffer.compare(own.body as Buffer, rawBytes)).toBe(0);
230
+ });
231
+
232
+ it("THE ERROR SURFACE: a stranger holding only the id is answered 404 'File not found' — the row read decides, no new permission", async () => {
233
+ const file = await createOwnerFile('private.png');
234
+
235
+ testEnv.actAs(stranger);
236
+ const response = await invokeBytes(file.id);
237
+
238
+ expect(response.statusCode).toEqual(404);
239
+ expect(response.body).toEqual('File not found');
240
+ expect(response.redirectUrl).toBeUndefined();
241
+ });
242
+
243
+ it('404s on a missing id without touching the driver', async () => {
244
+ const response = await invokeBytes('00000000-0000-0000-0000-000000000000');
245
+
246
+ expect(response.statusCode).toEqual(404);
247
+ expect(response.body).toEqual('File not found');
248
+ expect(driver.signedUrlsMinted).toEqual(0);
249
+ });
250
+
251
+ it('401s when not logged in', async () => {
252
+ const file = await createOwnerFile('clip.png');
253
+ testEnv.actAs(guestUser as unknown as User);
254
+ try {
255
+ const response = await invokeBytes(file.id);
256
+
257
+ expect(response.statusCode).toEqual(401);
258
+ expect(response.body).toBeDefined();
259
+ expect(Buffer.isBuffer(response.body)).toBe(false);
260
+ } finally {
261
+ testEnv.actAs(owner);
262
+ }
263
+ });
264
+ });
265
+
266
+ describe('GET /file/:id/variant/:kind/bytes — the variant through the same door', () => {
267
+ it("serves the variant's bytes itself, never a redirect (the maker's bytes, not the original's)", async () => {
268
+ objectCache()['@proteinjs/db-file/FileVariantMaker'] = [
269
+ {
270
+ appliesTo: (file: File, kind: FileVariantKind) => file.type.startsWith('image/') && kind === 'preview',
271
+ make: async (_file: File, bytes: Buffer, kind: FileVariantKind) => ({
272
+ bytes: variantOf(kind, bytes),
273
+ type: 'image/webp',
274
+ width: 8,
275
+ height: 4,
276
+ }),
277
+ },
278
+ ];
279
+ const file = await createOwnerFile('photo.jpg', 'image/jpeg');
280
+
281
+ const response = await invokeVariantBytes(file.id, 'preview');
282
+
283
+ expect(response.redirectUrl).toBeUndefined();
284
+ expect(response.headers['content-type']).toEqual('image/webp');
285
+ expect(response.headers['cache-control']).toEqual('private, max-age=300');
286
+ expect(Buffer.compare(response.body as Buffer, variantOf('preview', rawBytes))).toBe(0);
287
+ });
288
+
289
+ it('serves the file itself when no variant applies (the consumer never branches)', async () => {
290
+ const file = await createOwnerFile('tiny.png');
291
+
292
+ const response = await invokeVariantBytes(file.id, 'preview');
293
+
294
+ expect(response.redirectUrl).toBeUndefined();
295
+ expect(Buffer.compare(response.body as Buffer, rawBytes)).toBe(0);
296
+ });
297
+
298
+ it('404s a kind the library does not know, as a file that is not there', async () => {
299
+ const file = await createOwnerFile('any.png');
300
+
301
+ const response = await invokeVariantBytes(file.id, 'poster');
302
+
303
+ expect(response.statusCode).toEqual(404);
304
+ expect(response.body).toEqual('File not found');
305
+ });
306
+
307
+ it("THE ERROR SURFACE: a stranger is answered 404 for a variant too — the original's read decides", async () => {
308
+ const file = await createOwnerFile('private.jpg', 'image/jpeg');
309
+
310
+ testEnv.actAs(stranger);
311
+ const response = await invokeVariantBytes(file.id, 'preview');
312
+
313
+ expect(response.statusCode).toEqual(404);
314
+ expect(response.body).toEqual('File not found');
315
+ });
316
+ });