@proteinjs/db-file 1.7.3 → 1.7.4
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/CHANGELOG.md +11 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/src/FileReachabilityResolver.d.ts +27 -0
- package/dist/src/FileReachabilityResolver.d.ts.map +1 -0
- package/dist/src/FileReachabilityResolver.js +9 -0
- package/dist/src/FileReachabilityResolver.js.map +1 -0
- package/dist/src/FileStorage.d.ts +11 -3
- package/dist/src/FileStorage.d.ts.map +1 -1
- package/dist/src/FileStorage.js +36 -8
- package/dist/src/FileStorage.js.map +1 -1
- package/dist/src/routes/getFile.d.ts +4 -1
- package/dist/src/routes/getFile.d.ts.map +1 -1
- package/dist/src/routes/getFile.js +4 -1
- package/dist/src/routes/getFile.js.map +1 -1
- package/dist/test/FileSharedReachability.test.d.ts +2 -0
- package/dist/test/FileSharedReachability.test.d.ts.map +1 -0
- package/dist/test/FileSharedReachability.test.js +358 -0
- package/dist/test/FileSharedReachability.test.js.map +1 -0
- package/dist/test/FileTestEnvironment.d.ts.map +1 -1
- package/dist/test/FileTestEnvironment.js +4 -0
- package/dist/test/FileTestEnvironment.js.map +1 -1
- package/generated/index.ts +1 -1
- package/index.ts +1 -0
- package/package.json +2 -2
- package/src/FileReachabilityResolver.ts +29 -0
- package/src/FileStorage.ts +25 -4
- package/src/routes/getFile.ts +5 -2
- package/test/FileSharedReachability.test.ts +229 -0
- package/test/FileTestEnvironment.ts +4 -0
package/src/FileStorage.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { ScopedRecord, getScopedDb } from '@proteinjs/user';
|
|
1
|
+
import { ScopedRecord, getScopedDb, getScopedDbAsSystem } from '@proteinjs/user';
|
|
2
2
|
import { File } from './tables/FileTable';
|
|
3
3
|
import { tables } from './tables/tables';
|
|
4
4
|
import { FileStorageService, getFileStorageService } from './services/FileStorageService';
|
|
5
5
|
import { FileStorageDriver } from './FileStorageDriver';
|
|
6
|
+
import { getFileReachabilityResolvers } from './FileReachabilityResolver';
|
|
6
7
|
import { Loadable, SourceRepository } from '@proteinjs/reflection';
|
|
7
8
|
import { Logger } from '@proteinjs/logger';
|
|
8
9
|
import { DbFileStorageDriver } from './DbFileStorageDriver';
|
|
@@ -73,13 +74,32 @@ export class FileStorage implements FileStorageService {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
|
-
* Retrieves the metadata of a given file
|
|
77
|
+
* Retrieves the metadata of a given file — THE file-read access decision every serving path
|
|
78
|
+
* derives from (`/file/:id`, signed-URL minting, the browser service).
|
|
79
|
+
*
|
|
80
|
+
* Two legs: the caller's own file resolves by the SCOPED read; when that misses, the
|
|
81
|
+
* shared-content leg asks the registered {@link FileReachabilityResolver}s whether the caller
|
|
82
|
+
* can read a row that REFERENCES the file (a shared thought's media node) — content access
|
|
83
|
+
* confers file access, through the content's own grant-filtered read, never by widening file
|
|
84
|
+
* scope. No resolver vouching means the miss stands. Reads only: writes/deletes stay scoped.
|
|
77
85
|
* @param fileId - The `id` of the file.
|
|
78
|
-
* @returns The file metadata.
|
|
86
|
+
* @returns The file metadata, or `undefined` when the caller can neither own nor reach it.
|
|
79
87
|
*/
|
|
80
88
|
async getFile(fileId: string): Promise<File> {
|
|
81
89
|
const db = getScopedDb();
|
|
82
90
|
const file = await db.get(tables.File, { id: fileId });
|
|
91
|
+
if (file) {
|
|
92
|
+
return file;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const resolver of getFileReachabilityResolvers()) {
|
|
96
|
+
if (await resolver.canReadViaReference(fileId)) {
|
|
97
|
+
// Reachability established through the caller's grant-filtered content read — the row
|
|
98
|
+
// itself lives in the owner's scope, so it is served via system read.
|
|
99
|
+
return await getScopedDbAsSystem().get(tables.File, { id: fileId });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
83
103
|
return file;
|
|
84
104
|
}
|
|
85
105
|
|
|
@@ -95,7 +115,8 @@ export class FileStorage implements FileStorageService {
|
|
|
95
115
|
/**
|
|
96
116
|
* Mint a short-lived, read-only URL for the file's bytes (server-only; not part of the
|
|
97
117
|
* browser-facing `FileStorageService`). A signed URL is a bearer capability, so minting
|
|
98
|
-
* verifies the caller can read the file row (
|
|
118
|
+
* verifies the caller can read the file row (the two-legged {@link getFile} read — own scope
|
|
119
|
+
* or shared-content reachability) before signing.
|
|
99
120
|
* @param fileId - The `id` of the file.
|
|
100
121
|
* @param options.ttlMs - How long the URL stays valid; the driver applies its default when omitted.
|
|
101
122
|
* @returns The signed URL, or `undefined` when the driver has no external URL space
|
package/src/routes/getFile.ts
CHANGED
|
@@ -4,7 +4,10 @@ import { UserAuth } from '@proteinjs/user';
|
|
|
4
4
|
import { resolveByteRange } from './byteRange';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Serves a file's bytes. Auth first
|
|
7
|
+
* Serves a file's bytes. Auth first: logged-in, then the `FileStorage.getFile` row read as the
|
|
8
|
+
* access decision — the caller's SCOPED read, else the shared-content reachability leg
|
|
9
|
+
* (`FileReachabilityResolver`: the caller may read a file when they may read a row that
|
|
10
|
+
* references it, e.g. a shared thought's media node). Then one of two serving paths:
|
|
8
11
|
*
|
|
9
12
|
* - **302 redirect** to a short-lived signed URL when the driver's store has an external URL
|
|
10
13
|
* space (GCS). The app URL stays the one stable reference every `<img>`/`<video>`/chip uses;
|
|
@@ -28,7 +31,7 @@ export const getFile: Route = {
|
|
|
28
31
|
const fileId = request.params.id;
|
|
29
32
|
const fileStorage = getFileStorage();
|
|
30
33
|
try {
|
|
31
|
-
// The file row decides existence (a scoped read, so it is also the access check).
|
|
34
|
+
// The file row decides existence (a scoped-or-reachable read, so it is also the access check).
|
|
32
35
|
const file = await fileStorage.getFile(fileId);
|
|
33
36
|
if (!file) {
|
|
34
37
|
response.status(404).send('File not found');
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { SourceRepository } from '@proteinjs/reflection';
|
|
2
|
+
import { UserAuth, UserRepo, 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 { getFile } from '../src/routes/getFile';
|
|
7
|
+
import { FileTestEnvironment } from './FileTestEnvironment';
|
|
8
|
+
|
|
9
|
+
/** Every byte value once — a payload that corrupts under any text/base64 mix-up. */
|
|
10
|
+
const rawBytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The shared-content file leg (SHARING_EXPANSION §0 found defect / §1 fix shape): `/file/:id`
|
|
14
|
+
* used the caller's SCOPED row read as its whole access check, so a file embedded in content
|
|
15
|
+
* shared WITH the caller — a shared thought's media — 404'd for every share recipient. The fix
|
|
16
|
+
* is the FileReachabilityResolver seam: after the scoped read misses, content packages answer
|
|
17
|
+
* "can the current user read a row that references this file?" through their own grant-filtered
|
|
18
|
+
* reads, and only then is the row served via system read.
|
|
19
|
+
*
|
|
20
|
+
* This suite proves the SEAM CONTRACT at the route with a stub resolver (content packages sit
|
|
21
|
+
* above db-file — the real thought-media resolver is integration-tested in thought-server):
|
|
22
|
+
* a reachable file serves byte-identical to a non-owner, an unreachable one still 404s (the fix
|
|
23
|
+
* must not over-open), the owner's scoped leg never consults resolvers, and writes stay
|
|
24
|
+
* owner-scoped.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** In-memory proxy-shape driver (no signed URLs) — the DbFileStorageDriver serving shape. */
|
|
28
|
+
class ProxyDriver implements FileStorageDriver {
|
|
29
|
+
readonly store = new Map<string, Buffer>();
|
|
30
|
+
|
|
31
|
+
async createFile(file: File, fileData: string): Promise<void> {
|
|
32
|
+
this.store.set(file.id, Buffer.from(fileData, 'base64'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async getFileData(fileId: string): Promise<string> {
|
|
36
|
+
const data = this.store.get(fileId);
|
|
37
|
+
if (data === undefined) {
|
|
38
|
+
throw new Error(`No such object: ${fileId}`);
|
|
39
|
+
}
|
|
40
|
+
return data.toString('base64');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async updateFileData(fileId: string, data: string): Promise<void> {
|
|
44
|
+
this.store.set(fileId, Buffer.from(data, 'base64'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async deleteFile(fileId: string): Promise<void> {
|
|
48
|
+
this.store.delete(fileId);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Proxy driver plus a counting signed-URL mint — the GCS serving shape, minting observable. */
|
|
53
|
+
class SignedUrlDriver extends ProxyDriver {
|
|
54
|
+
mintCount = 0;
|
|
55
|
+
|
|
56
|
+
async getSignedUrl(fileId: string): Promise<string> {
|
|
57
|
+
this.mintCount++;
|
|
58
|
+
return `https://signed.test/blob/${fileId}?sig=test`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Minimal express Response recorder — the route only touches these members. */
|
|
63
|
+
class ResponseRecorder {
|
|
64
|
+
statusCode?: number;
|
|
65
|
+
body?: unknown;
|
|
66
|
+
redirectUrl?: string;
|
|
67
|
+
headers: Record<string, string> = {};
|
|
68
|
+
|
|
69
|
+
status(code: number): ResponseRecorder {
|
|
70
|
+
this.statusCode = code;
|
|
71
|
+
return this;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
send(body: unknown): ResponseRecorder {
|
|
75
|
+
this.body = body;
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
setHeader(name: string, value: string): void {
|
|
80
|
+
this.headers[name.toLowerCase()] = value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
redirect(status: number, url: string): void {
|
|
84
|
+
this.statusCode = status;
|
|
85
|
+
this.redirectUrl = url;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type RouteRequest = Parameters<typeof getFile.onRequest>[0];
|
|
90
|
+
type RouteResponse = Parameters<typeof getFile.onRequest>[1];
|
|
91
|
+
|
|
92
|
+
const invokeRoute = async (fileId: string): Promise<ResponseRecorder> => {
|
|
93
|
+
const response = new ResponseRecorder();
|
|
94
|
+
await getFile.onRequest(
|
|
95
|
+
{ params: { id: fileId }, headers: {} } as unknown as RouteRequest,
|
|
96
|
+
response as unknown as RouteResponse
|
|
97
|
+
);
|
|
98
|
+
return response;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const testEnv = new FileTestEnvironment();
|
|
102
|
+
type UserAuthInternals = { userRepo?: unknown };
|
|
103
|
+
type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
|
|
104
|
+
const objectCache = () => (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
|
|
105
|
+
|
|
106
|
+
let owner: User;
|
|
107
|
+
let recipient: User;
|
|
108
|
+
/** File ids the stub resolver reports as reachable for the current user. */
|
|
109
|
+
const reachableFileIds = new Set<string>();
|
|
110
|
+
/** How many times the seam was consulted — the owner's scoped leg must never pay for it. */
|
|
111
|
+
let resolverConsultations = 0;
|
|
112
|
+
|
|
113
|
+
beforeAll(async () => {
|
|
114
|
+
await testEnv.beforeAll();
|
|
115
|
+
// The route gates on UserAuth.isLoggedIn(), which resolves its user repo from the source
|
|
116
|
+
// repository — seed it (tests don't load the generated source graph) and reset the static cache.
|
|
117
|
+
objectCache()['@proteinjs/user-auth/AuthenticatedUserRepo'] = [new UserRepo()];
|
|
118
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = undefined;
|
|
119
|
+
// The seam under test, stubbed (the real thought-media resolver lives above this package).
|
|
120
|
+
objectCache()['@proteinjs/db-file/FileReachabilityResolver'] = [
|
|
121
|
+
{
|
|
122
|
+
canReadViaReference: async (fileId: string) => {
|
|
123
|
+
resolverConsultations++;
|
|
124
|
+
return reachableFileIds.has(fileId);
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
owner = await testEnv.createUser({ name: 'File owner', email: 'reachability-owner@test.local' });
|
|
129
|
+
recipient = await testEnv.createUser({ name: 'Share recipient', email: 'reachability-recipient@test.local' });
|
|
130
|
+
testEnv.actAs(owner);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
afterAll(async () => {
|
|
134
|
+
delete objectCache()['@proteinjs/db-file/FileReachabilityResolver'];
|
|
135
|
+
(UserAuth as unknown as UserAuthInternals).userRepo = undefined;
|
|
136
|
+
await testEnv.afterAll();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
beforeEach(() => {
|
|
140
|
+
reachableFileIds.clear();
|
|
141
|
+
resolverConsultations = 0;
|
|
142
|
+
testEnv.actAs(owner);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const createOwnerFile = async (name: string, type: string): Promise<File> =>
|
|
146
|
+
await new FileStorage().createFile({ name, type, size: rawBytes.length } as File, rawBytes.toString('base64'));
|
|
147
|
+
|
|
148
|
+
describe('shared-content reachability — proxy serving', () => {
|
|
149
|
+
const driver = new ProxyDriver();
|
|
150
|
+
|
|
151
|
+
beforeAll(() => {
|
|
152
|
+
testEnv.setDriver(driver);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("THE DEFECT: a non-owner whom a resolver vouches for is served the owner's file byte-identical", async () => {
|
|
156
|
+
const file = await createOwnerFile('shared-shot.png', 'image/png');
|
|
157
|
+
reachableFileIds.add(file.id);
|
|
158
|
+
|
|
159
|
+
testEnv.actAs(recipient);
|
|
160
|
+
const response = await invokeRoute(file.id);
|
|
161
|
+
|
|
162
|
+
expect(response.statusCode).not.toEqual(404);
|
|
163
|
+
expect(response.headers['content-type']).toEqual('image/png');
|
|
164
|
+
expect(Buffer.isBuffer(response.body)).toBe(true);
|
|
165
|
+
expect(Buffer.compare(response.body as Buffer, rawBytes)).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('THE OTHER DIRECTION: a non-owner with no reachability still 404s — the fix must not over-open', async () => {
|
|
169
|
+
const file = await createOwnerFile('private-shot.png', 'image/png');
|
|
170
|
+
|
|
171
|
+
testEnv.actAs(recipient);
|
|
172
|
+
const response = await invokeRoute(file.id);
|
|
173
|
+
|
|
174
|
+
expect(response.statusCode).toEqual(404);
|
|
175
|
+
expect(response.body).toEqual('File not found');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('the owner is served by their scoped read alone — resolvers are never consulted', async () => {
|
|
179
|
+
const file = await createOwnerFile('own-shot.png', 'image/png');
|
|
180
|
+
|
|
181
|
+
const response = await invokeRoute(file.id);
|
|
182
|
+
|
|
183
|
+
expect(Buffer.compare(response.body as Buffer, rawBytes)).toBe(0);
|
|
184
|
+
expect(resolverConsultations).toEqual(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("reachability widens READS only: a vouched-for non-owner's delete leaves the owner's file intact", async () => {
|
|
188
|
+
const file = await createOwnerFile('undeletable.png', 'image/png');
|
|
189
|
+
reachableFileIds.add(file.id);
|
|
190
|
+
|
|
191
|
+
testEnv.actAs(recipient);
|
|
192
|
+
await new FileStorage().deleteFile(file.id);
|
|
193
|
+
|
|
194
|
+
testEnv.actAs(owner);
|
|
195
|
+
const response = await invokeRoute(file.id);
|
|
196
|
+
expect(Buffer.compare(response.body as Buffer, rawBytes)).toBe(0);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('shared-content reachability — signed-URL serving', () => {
|
|
201
|
+
const driver = new SignedUrlDriver();
|
|
202
|
+
|
|
203
|
+
beforeAll(() => {
|
|
204
|
+
testEnv.setDriver(driver);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('302-redirects a vouched-for non-owner to a signed URL (the GCS prod shape)', async () => {
|
|
208
|
+
const file = await createOwnerFile('shared-clip.mp4', 'video/mp4');
|
|
209
|
+
reachableFileIds.add(file.id);
|
|
210
|
+
|
|
211
|
+
testEnv.actAs(recipient);
|
|
212
|
+
const response = await invokeRoute(file.id);
|
|
213
|
+
|
|
214
|
+
expect(response.statusCode).toEqual(302);
|
|
215
|
+
expect(response.redirectUrl).toEqual(`https://signed.test/blob/${file.id}?sig=test`);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('404s an unreachable non-owner WITHOUT minting a signed URL (a signed URL is a bearer capability)', async () => {
|
|
219
|
+
const file = await createOwnerFile('private-clip.mp4', 'video/mp4');
|
|
220
|
+
const mintsBefore = driver.mintCount;
|
|
221
|
+
|
|
222
|
+
testEnv.actAs(recipient);
|
|
223
|
+
const response = await invokeRoute(file.id);
|
|
224
|
+
|
|
225
|
+
expect(response.statusCode).toEqual(404);
|
|
226
|
+
expect(response.redirectUrl).toBeUndefined();
|
|
227
|
+
expect(driver.mintCount).toEqual(mintsBefore);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -58,6 +58,10 @@ export class FileTestEnvironment {
|
|
|
58
58
|
objectCache['@proteinjs/db/Table'] = [...Object.values(userTables), ...Object.values(fileTables)] as unknown[];
|
|
59
59
|
// The byte-deletion seam under test: prod loads it from the generated source graph.
|
|
60
60
|
objectCache['@proteinjs/db/TableWatcher'] = [new FileStorageTableWatcher()];
|
|
61
|
+
// The shared-content reachability seam getFile consults on a scoped-read miss (prod loads
|
|
62
|
+
// implementations from the generated source graph; a graph-less harness lookup THROWS).
|
|
63
|
+
// Default: no registered resolvers. Suites exercising the seam overwrite this entry.
|
|
64
|
+
objectCache['@proteinjs/db-file/FileReachabilityResolver'] = [];
|
|
61
65
|
Session.setData({
|
|
62
66
|
sessionId: 'test-session',
|
|
63
67
|
user: 'guest',
|