@proteinjs/db-file 1.8.4 → 1.9.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/CHANGELOG.md +41 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +3 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/src/DbFileStorageDriver.d.ts +7 -0
- package/dist/src/DbFileStorageDriver.d.ts.map +1 -1
- package/dist/src/DbFileStorageDriver.js +69 -41
- package/dist/src/DbFileStorageDriver.js.map +1 -1
- package/dist/src/FileCopyForOthers.d.ts +52 -0
- package/dist/src/FileCopyForOthers.d.ts.map +1 -0
- package/dist/src/FileCopyForOthers.js +62 -0
- package/dist/src/FileCopyForOthers.js.map +1 -0
- package/dist/src/FileStorage.d.ts +158 -11
- package/dist/src/FileStorage.d.ts.map +1 -1
- package/dist/src/FileStorage.js +562 -41
- package/dist/src/FileStorage.js.map +1 -1
- package/dist/src/FileStorageDriver.d.ts +11 -0
- package/dist/src/FileStorageDriver.d.ts.map +1 -1
- package/dist/src/FileStorageError.d.ts +62 -0
- package/dist/src/FileStorageError.d.ts.map +1 -0
- package/dist/src/FileStorageError.js +118 -0
- package/dist/src/FileStorageError.js.map +1 -0
- package/dist/src/FileVariantMaker.d.ts +66 -0
- package/dist/src/FileVariantMaker.d.ts.map +1 -0
- package/dist/src/FileVariantMaker.js +62 -0
- package/dist/src/FileVariantMaker.js.map +1 -0
- package/dist/src/routes/FileResponder.d.ts +41 -0
- package/dist/src/routes/FileResponder.d.ts.map +1 -0
- package/dist/src/routes/FileResponder.js +135 -0
- package/dist/src/routes/FileResponder.js.map +1 -0
- package/dist/src/routes/getFile.d.ts +5 -13
- package/dist/src/routes/getFile.d.ts.map +1 -1
- package/dist/src/routes/getFile.js +20 -56
- package/dist/src/routes/getFile.js.map +1 -1
- package/dist/src/routes/getFileVariant.d.ts +20 -0
- package/dist/src/routes/getFileVariant.d.ts.map +1 -0
- package/dist/src/routes/getFileVariant.js +112 -0
- package/dist/src/routes/getFileVariant.js.map +1 -0
- package/dist/src/routes/getFileVariantRoute.d.ts +7 -0
- package/dist/src/routes/getFileVariantRoute.d.ts.map +1 -0
- package/dist/src/routes/getFileVariantRoute.js +9 -0
- package/dist/src/routes/getFileVariantRoute.js.map +1 -0
- package/dist/src/tables/FileTable.d.ts +70 -2
- package/dist/src/tables/FileTable.d.ts.map +1 -1
- package/dist/src/tables/FileTable.js +30 -0
- package/dist/src/tables/FileTable.js.map +1 -1
- package/dist/test/DbFileStorageDriverMissingBytes.test.d.ts +2 -0
- package/dist/test/DbFileStorageDriverMissingBytes.test.d.ts.map +1 -0
- package/dist/test/DbFileStorageDriverMissingBytes.test.js +184 -0
- package/dist/test/DbFileStorageDriverMissingBytes.test.js.map +1 -0
- package/dist/test/FileByteAccess.test.js +6 -3
- package/dist/test/FileByteAccess.test.js.map +1 -1
- package/dist/test/FileCopyForOthers.test.d.ts +2 -0
- package/dist/test/FileCopyForOthers.test.d.ts.map +1 -0
- package/dist/test/FileCopyForOthers.test.js +1143 -0
- package/dist/test/FileCopyForOthers.test.js.map +1 -0
- package/dist/test/FileDerivedProvenance.test.d.ts +2 -0
- package/dist/test/FileDerivedProvenance.test.d.ts.map +1 -0
- package/dist/test/FileDerivedProvenance.test.js +420 -0
- package/dist/test/FileDerivedProvenance.test.js.map +1 -0
- package/dist/test/FileMediaMetadata.test.js +60 -5
- package/dist/test/FileMediaMetadata.test.js.map +1 -1
- package/dist/test/FileStorageError.test.d.ts +2 -0
- package/dist/test/FileStorageError.test.d.ts.map +1 -0
- package/dist/test/FileStorageError.test.js +68 -0
- package/dist/test/FileStorageError.test.js.map +1 -0
- package/dist/test/FileTestEnvironment.d.ts.map +1 -1
- package/dist/test/FileTestEnvironment.js +3 -0
- package/dist/test/FileTestEnvironment.js.map +1 -1
- package/dist/test/FileVariants.test.d.ts +2 -0
- package/dist/test/FileVariants.test.d.ts.map +1 -0
- package/dist/test/FileVariants.test.js +1008 -0
- package/dist/test/FileVariants.test.js.map +1 -0
- package/dist/test/GetFileRoute.test.js +29 -0
- package/dist/test/GetFileRoute.test.js.map +1 -1
- package/generated/index.ts +3 -1
- package/index.ts +4 -0
- package/package.json +5 -5
- package/src/DbFileStorageDriver.ts +32 -21
- package/src/FileCopyForOthers.ts +71 -0
- package/src/FileStorage.ts +377 -19
- package/src/FileStorageDriver.ts +12 -0
- package/src/FileStorageError.ts +123 -0
- package/src/FileVariantMaker.ts +89 -0
- package/src/routes/FileResponder.ts +85 -0
- package/src/routes/getFile.ts +9 -44
- package/src/routes/getFileVariant.ts +53 -0
- package/src/routes/getFileVariantRoute.ts +7 -0
- package/src/tables/FileTable.ts +82 -1
- package/test/DbFileStorageDriverMissingBytes.test.ts +85 -0
- package/test/FileByteAccess.test.ts +7 -1
- package/test/FileCopyForOthers.test.ts +702 -0
- package/test/FileDerivedProvenance.test.ts +248 -0
- package/test/FileMediaMetadata.test.ts +44 -0
- package/test/FileStorageError.test.ts +79 -0
- package/test/FileTestEnvironment.ts +3 -0
- package/test/FileVariants.test.ts +604 -0
- package/test/GetFileRoute.test.ts +19 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a byte operation failed — the closed set a caller branches on.
|
|
3
|
+
*
|
|
4
|
+
* - `not-found`: the stored bytes for that file id are not there.
|
|
5
|
+
* - `precondition-failed`: the store refused a conditional write (the object changed underneath it).
|
|
6
|
+
* - `forbidden`: the store refused the driver's credentials or permissions.
|
|
7
|
+
* - `unavailable`: the store could not be reached or asked to be retried later.
|
|
8
|
+
* - `unknown`: anything else.
|
|
9
|
+
*/
|
|
10
|
+
export type FileStorageErrorCode = 'not-found' | 'precondition-failed' | 'forbidden' | 'unavailable' | 'unknown';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* THE error a {@link FileStorageDriver} throws — the only thing that leaves a driver when a byte
|
|
14
|
+
* operation fails.
|
|
15
|
+
*
|
|
16
|
+
* It carries three plain facts and nothing else: a {@link FileStorageErrorCode}, a one-line
|
|
17
|
+
* message, and the HTTP status where the store speaks HTTP. It NEVER carries the store client's own
|
|
18
|
+
* error object, nor anything hanging off one — a request config, a request, a response, headers, a
|
|
19
|
+
* signed URL. A store client's error routinely holds the request it made, and that request holds
|
|
20
|
+
* the driver's credentials (an `Authorization` header); an application that logs the error it
|
|
21
|
+
* caught would write a live credential into its logs. The driver is the only party that talks to
|
|
22
|
+
* the store, so the driver owns what its errors carry, and no caller has to know to strip one.
|
|
23
|
+
*
|
|
24
|
+
* Callers branch on `code` — through {@link FileStorageError.is} / {@link FileStorageError.isNotFound},
|
|
25
|
+
* which read the error's shape rather than its prototype, so they hold across duplicate copies of
|
|
26
|
+
* this package in one process.
|
|
27
|
+
*/
|
|
28
|
+
export class FileStorageError extends Error {
|
|
29
|
+
private static readonly NAME = 'FileStorageError';
|
|
30
|
+
private static readonly CODES: FileStorageErrorCode[] = [
|
|
31
|
+
'not-found',
|
|
32
|
+
'precondition-failed',
|
|
33
|
+
'forbidden',
|
|
34
|
+
'unavailable',
|
|
35
|
+
'unknown',
|
|
36
|
+
];
|
|
37
|
+
/** Free text that reaches a message is one line and bounded. */
|
|
38
|
+
private static readonly MAX_DETAIL_CHARS = 300;
|
|
39
|
+
|
|
40
|
+
readonly code: FileStorageErrorCode;
|
|
41
|
+
/** The store's HTTP status, where it has one. */
|
|
42
|
+
readonly status?: number;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param code what went wrong, for callers to branch on
|
|
46
|
+
* @param summary the driver's own words for the failed operation (e.g. `getFileData failed for file <id>`)
|
|
47
|
+
* @param options.status the store's HTTP status, where it has one
|
|
48
|
+
* @param options.detail the store's own message text — made plain (see {@link plain}) before it is kept
|
|
49
|
+
*/
|
|
50
|
+
constructor(code: FileStorageErrorCode, summary: string, options?: { status?: number; detail?: unknown }) {
|
|
51
|
+
super(FileStorageError.describe(code, summary, options));
|
|
52
|
+
this.name = FileStorageError.NAME;
|
|
53
|
+
Object.setPrototypeOf(this, FileStorageError.prototype);
|
|
54
|
+
this.code = code;
|
|
55
|
+
if (options?.status !== undefined) {
|
|
56
|
+
this.status = options.status;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Is this a driver's error? Read by shape, so it holds across duplicate copies of this package. */
|
|
61
|
+
static is(error: unknown): error is FileStorageError {
|
|
62
|
+
const candidate = error as { name?: unknown; code?: unknown } | null | undefined;
|
|
63
|
+
return (
|
|
64
|
+
candidate?.name === FileStorageError.NAME &&
|
|
65
|
+
FileStorageError.CODES.includes(candidate.code as FileStorageErrorCode)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The stored bytes for that file id are not there. */
|
|
70
|
+
static isNotFound(error: unknown): boolean {
|
|
71
|
+
return FileStorageError.is(error) && error.code === 'not-found';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The code an HTTP status means. Drivers whose store speaks HTTP share this table so one status
|
|
76
|
+
* never reads two ways.
|
|
77
|
+
*/
|
|
78
|
+
static codeForStatus(status: number | undefined): FileStorageErrorCode {
|
|
79
|
+
if (status === 404) {
|
|
80
|
+
return 'not-found';
|
|
81
|
+
}
|
|
82
|
+
if (status === 412) {
|
|
83
|
+
return 'precondition-failed';
|
|
84
|
+
}
|
|
85
|
+
if (status === 401 || status === 403) {
|
|
86
|
+
return 'forbidden';
|
|
87
|
+
}
|
|
88
|
+
if (status === 408 || status === 429 || (status !== undefined && status >= 500 && status <= 599)) {
|
|
89
|
+
return 'unavailable';
|
|
90
|
+
}
|
|
91
|
+
return 'unknown';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private static describe(
|
|
95
|
+
code: FileStorageErrorCode,
|
|
96
|
+
summary: string,
|
|
97
|
+
options?: { status?: number; detail?: unknown }
|
|
98
|
+
): string {
|
|
99
|
+
const status = options?.status !== undefined ? ` (HTTP ${options.status})` : '';
|
|
100
|
+
const detail = FileStorageError.plain(options?.detail);
|
|
101
|
+
return `${summary}: ${code}${status}${detail ? ` — ${detail}` : ''}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A store's message text made safe to keep: text only (an object is never read into it), one
|
|
106
|
+
* line, bounded, with every URL's query string dropped (a signed URL's signature lives there)
|
|
107
|
+
* and any bearer credential masked.
|
|
108
|
+
*/
|
|
109
|
+
private static plain(detail: unknown): string {
|
|
110
|
+
if (typeof detail !== 'string') {
|
|
111
|
+
return '';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const oneLine = detail
|
|
115
|
+
.replace(/\s+/g, ' ')
|
|
116
|
+
.replace(/(https?:\/\/[^\s?#]+)[?#]\S*/gi, '$1')
|
|
117
|
+
.replace(/\b(bearer|basic)\s+\S+/gi, '$1 [masked]')
|
|
118
|
+
.trim();
|
|
119
|
+
return oneLine.length > FileStorageError.MAX_DETAIL_CHARS
|
|
120
|
+
? `${oneLine.slice(0, FileStorageError.MAX_DETAIL_CHARS)}…`
|
|
121
|
+
: oneLine;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Loadable, SourceRepository } from '@proteinjs/reflection';
|
|
2
|
+
import { File } from './tables/FileTable';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The kinds of derived File a file can carry — each a seat on the row (`File.preview`,
|
|
6
|
+
* `File.stage`) with one lifecycle: made once, named on the row, dropped when the original's
|
|
7
|
+
* bytes change, deleted with the original, readable by whoever can read the original.
|
|
8
|
+
*/
|
|
9
|
+
export type FileVariantKind = 'preview' | 'stage';
|
|
10
|
+
|
|
11
|
+
export const FILE_VARIANT_KINDS: readonly FileVariantKind[] = ['preview', 'stage'];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What the maker hands back for a variant: the bytes, their type, and the facts a consumer renders
|
|
15
|
+
* from without loading them. Where the bytes came from is not the maker's to answer: the library
|
|
16
|
+
* writes the original's provenance on the variant's row (`FileTable.provenanceColumns`).
|
|
17
|
+
*/
|
|
18
|
+
export interface FileVariant {
|
|
19
|
+
bytes: Buffer;
|
|
20
|
+
type: string;
|
|
21
|
+
width?: number;
|
|
22
|
+
height?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* How a derived File of a File is made — ONE owner for "a variant of this file's bytes".
|
|
27
|
+
*
|
|
28
|
+
* The library owns the seat on the row, the lifecycle, the access rule and the provenance (the
|
|
29
|
+
* original's) for every variant (`FileStorage.deriveVariants` at ingest, `FileStorage.getVariant`
|
|
30
|
+
* on the read path); the one
|
|
31
|
+
* package that knows the file's format registers how the variant's bytes are made — a picture's
|
|
32
|
+
* 512 px thumbnail (`preview`) and its 1600 px rendition for the stage (`stage`) are two answers
|
|
33
|
+
* of the same maker, never two mechanisms. Dependency direction is the
|
|
34
|
+
* {@link FileReachabilityResolver} / {@link FileCopyForOthers} pattern: the interface here, the
|
|
35
|
+
* implementation above, discovered through the SourceRepository. With no maker registered no
|
|
36
|
+
* variant is ever made and every consumer draws the original.
|
|
37
|
+
*/
|
|
38
|
+
export interface FileVariantMaker extends Loadable {
|
|
39
|
+
/**
|
|
40
|
+
* Whether a variant of this kind is made of this file at all — decided from the row alone,
|
|
41
|
+
* never from its bytes (a document, a clip, a picture already small enough cost no read). True
|
|
42
|
+
* means {@link make} is called once with the original's bytes and its answer is stored.
|
|
43
|
+
*/
|
|
44
|
+
appliesTo(file: File, kind: FileVariantKind): boolean;
|
|
45
|
+
/** The variant, made from the original's bytes. Throws when it cannot be made — nothing is stored, the consumer draws the original. */
|
|
46
|
+
make(file: File, bytes: Buffer, kind: FileVariantKind): Promise<FileVariant>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const getFileVariantMaker = (): FileVariantMaker | undefined =>
|
|
50
|
+
SourceRepository.get().object<FileVariantMaker>('@proteinjs/db-file/FileVariantMaker');
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What a derivation becomes when the registered maker could not make the variant of these
|
|
54
|
+
* bytes: nothing is stored. Thrown by `FileStorage` in place of whatever the maker threw (the
|
|
55
|
+
* maker's own one-line reason kept as `reason`), so the ingest door can report it to the caller
|
|
56
|
+
* holding the bytes and the read path can tell it from a failure of the store or the database
|
|
57
|
+
* (and serve the file itself). Read by shape ({@link FileVariantNotMade.is}), like
|
|
58
|
+
* `FileCopyRefused`, so it holds across duplicate copies of this package.
|
|
59
|
+
*/
|
|
60
|
+
export class FileVariantNotMade extends Error {
|
|
61
|
+
private static readonly NAME = 'FileVariantNotMade';
|
|
62
|
+
/** Free text that reaches a message is one line and bounded. */
|
|
63
|
+
private static readonly MAX_REASON_CHARS = 300;
|
|
64
|
+
readonly fileId: string;
|
|
65
|
+
readonly kind: FileVariantKind;
|
|
66
|
+
readonly reason: string;
|
|
67
|
+
|
|
68
|
+
constructor(fileId: string, kind: FileVariantKind, cause: unknown) {
|
|
69
|
+
const reason = FileVariantNotMade.plain(cause);
|
|
70
|
+
super(`No ${kind} variant could be made of file ${fileId}${reason ? `: ${reason}` : ''}`);
|
|
71
|
+
this.name = FileVariantNotMade.NAME;
|
|
72
|
+
Object.setPrototypeOf(this, FileVariantNotMade.prototype);
|
|
73
|
+
this.fileId = fileId;
|
|
74
|
+
this.kind = kind;
|
|
75
|
+
this.reason = reason;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static is(error: unknown): error is FileVariantNotMade {
|
|
79
|
+
return (error as { name?: unknown } | null | undefined)?.name === FileVariantNotMade.NAME;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private static plain(cause: unknown): string {
|
|
83
|
+
const message = cause instanceof Error ? cause.message : typeof cause === 'string' ? cause : '';
|
|
84
|
+
const line = message.replace(/\s+/g, ' ').trim();
|
|
85
|
+
return line.length > FileVariantNotMade.MAX_REASON_CHARS
|
|
86
|
+
? `${line.slice(0, FileVariantNotMade.MAX_REASON_CHARS)}…`
|
|
87
|
+
: line;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { File } from '../tables/FileTable';
|
|
2
|
+
import { FileStorage } from '../FileStorage';
|
|
3
|
+
import { FileStorageError } from '../FileStorageError';
|
|
4
|
+
import { ServiceRefusal } from '@proteinjs/service';
|
|
5
|
+
import { resolveByteRange } from './byteRange';
|
|
6
|
+
|
|
7
|
+
/** The express members the file routes touch — typed narrowly so the routes and their tests agree. */
|
|
8
|
+
export type FileRequest = { params: Record<string, string>; headers?: { range?: string } };
|
|
9
|
+
export type FileResponse = {
|
|
10
|
+
status(code: number): { send(body: unknown): unknown };
|
|
11
|
+
send(body: unknown): unknown;
|
|
12
|
+
setHeader(name: string, value: string): void;
|
|
13
|
+
redirect(status: number, url: string): void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* THE ONE SERVING PATH for a file's bytes — `GET /file/:id` and `GET /file/:id/variant/:kind`
|
|
18
|
+
* answer through it, so a variant is served exactly as any File is. Two shapes:
|
|
19
|
+
*
|
|
20
|
+
* - **302 redirect** to a short-lived signed URL when the driver's store has an external URL
|
|
21
|
+
* space (GCS). The app URL stays the one stable reference every `<img>`/`<video>`/chip uses;
|
|
22
|
+
* the client follows the redirect and reads the store directly — native Range/206 for video
|
|
23
|
+
* seeking, real caching, bytes never transit the app server. The redirect itself is cached
|
|
24
|
+
* briefly (client-private, well under the signed TTL) so repeated loads reuse one URL.
|
|
25
|
+
* - **Proxy** for drivers with no external URL space (`DbFileStorageDriver`): the interface's
|
|
26
|
+
* base64 is decoded so every mime — binary or text — serves its true bytes, and HTTP Range
|
|
27
|
+
* is honored (206/416 via {@link resolveByteRange}) so a `<video>` can SEEK against a
|
|
28
|
+
* proxy-served blob — streaming parity with the signed-URL path instead of download-then-watch.
|
|
29
|
+
*
|
|
30
|
+
* WHICH bytes is `FileStorage`'s decision (`getSignedUrl` / `getFileData` substitute the copy
|
|
31
|
+
* for others for anyone but the owner); the headers are the row's (its name and type). The
|
|
32
|
+
* route decided existence and access before calling here (`FileStorage.getFile`).
|
|
33
|
+
*/
|
|
34
|
+
export class FileResponder {
|
|
35
|
+
/** Serve `file` (a row the caller may read) to `response`; every failure shape mapped to its status. */
|
|
36
|
+
static async serve(file: File, request: FileRequest, response: FileResponse): Promise<void> {
|
|
37
|
+
const fileStorage = new FileStorage();
|
|
38
|
+
try {
|
|
39
|
+
const signedUrl = await fileStorage.getSignedUrl(file.id);
|
|
40
|
+
if (signedUrl) {
|
|
41
|
+
response.setHeader('Cache-Control', 'private, max-age=300');
|
|
42
|
+
response.redirect(302, signedUrl);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const bytes = Buffer.from(await fileStorage.getFileData(file.id), 'base64');
|
|
47
|
+
const safeFilename = encodeURIComponent(file.name);
|
|
48
|
+
response.setHeader('Content-Disposition', `inline; filename="${safeFilename}"`);
|
|
49
|
+
response.setHeader('Content-Type', file.type);
|
|
50
|
+
response.setHeader('Accept-Ranges', 'bytes');
|
|
51
|
+
const range = resolveByteRange(request.headers?.range, bytes.length);
|
|
52
|
+
if (range === 'unsatisfiable') {
|
|
53
|
+
response.setHeader('Content-Range', `bytes */${bytes.length}`);
|
|
54
|
+
response.status(416).send('Range Not Satisfiable');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (range) {
|
|
58
|
+
response.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${bytes.length}`);
|
|
59
|
+
response.status(206).send(bytes.subarray(range.start, range.end + 1));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
response.send(bytes);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
FileResponder.fail(file.id, error, response);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The failure shapes a file read has: bytes missing behind a row (404), a refusal (its status, 404), anything else (500, logged). */
|
|
69
|
+
static fail(fileId: string, error: unknown, response: FileResponse): void {
|
|
70
|
+
if (FileStorageError.isNotFound(error)) {
|
|
71
|
+
// The row is there and its bytes are not: the same answer as a row that is not there.
|
|
72
|
+
response.status(404).send('File not found');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (ServiceRefusal.is(error)) {
|
|
76
|
+
// A deliberate refusal, not a failure — a file no copy can be made of is not there for anyone
|
|
77
|
+
// but its owner (`FileStorage` answers every door the same 404 and keeps the maker's reason in
|
|
78
|
+
// its own WARN). The same words as a row the caller cannot read; quiet, and never a 500.
|
|
79
|
+
response.status(error.status).send('File not found');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
console.error(`Error fetching file (${fileId}):`, error);
|
|
83
|
+
response.status(500).send('Internal Server Error');
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/routes/getFile.ts
CHANGED
|
@@ -1,23 +1,15 @@
|
|
|
1
1
|
import { Route } from '@proteinjs/server-api';
|
|
2
2
|
import { getFileStorage } from '../FileStorage';
|
|
3
3
|
import { UserAuth } from '@proteinjs/user';
|
|
4
|
-
import {
|
|
4
|
+
import { FileResponder } from './FileResponder';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
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
|
|
9
|
-
* (`FileReachabilityResolver`: the caller may read a file when they may read a
|
|
10
|
-
* references it, e.g. a shared thought's media node). Then
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* space (GCS). The app URL stays the one stable reference every `<img>`/`<video>`/chip uses;
|
|
14
|
-
* the client follows the redirect and reads the store directly — native Range/206 for video
|
|
15
|
-
* seeking, real caching, bytes never transit the app server. The redirect itself is cached
|
|
16
|
-
* briefly (client-private, well under the signed TTL) so repeated loads reuse one URL.
|
|
17
|
-
* - **Proxy** for drivers with no external URL space (`DbFileStorageDriver`): the interface's
|
|
18
|
-
* base64 is decoded so every mime — binary or text — serves its true bytes, and HTTP Range
|
|
19
|
-
* is honored (206/416 via {@link resolveByteRange}) so a `<video>` can SEEK against a
|
|
20
|
-
* proxy-served blob — streaming parity with the signed-URL path instead of download-then-watch.
|
|
8
|
+
* access decision — the caller's SCOPED read, else a variant's original, else the shared-content
|
|
9
|
+
* reachability leg (`FileReachabilityResolver`: the caller may read a file when they may read a
|
|
10
|
+
* row that references it, e.g. a shared thought's media node). Then {@link FileResponder} — the
|
|
11
|
+
* one serving path (a 302 to a signed URL, or the proxy with Range) every file route answers
|
|
12
|
+
* through.
|
|
21
13
|
*/
|
|
22
14
|
export const getFile: Route = {
|
|
23
15
|
path: '/file/:id',
|
|
@@ -29,43 +21,16 @@ export const getFile: Route = {
|
|
|
29
21
|
}
|
|
30
22
|
|
|
31
23
|
const fileId = request.params.id;
|
|
32
|
-
const fileStorage = getFileStorage();
|
|
33
24
|
try {
|
|
34
25
|
// The file row decides existence (a scoped-or-reachable read, so it is also the access check).
|
|
35
|
-
const file = await
|
|
26
|
+
const file = await getFileStorage().getFile(fileId);
|
|
36
27
|
if (!file) {
|
|
37
28
|
response.status(404).send('File not found');
|
|
38
29
|
return;
|
|
39
30
|
}
|
|
40
|
-
|
|
41
|
-
const signedUrl = await fileStorage.getSignedUrl(fileId);
|
|
42
|
-
if (signedUrl) {
|
|
43
|
-
response.setHeader('Cache-Control', 'private, max-age=300');
|
|
44
|
-
response.redirect(302, signedUrl);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const fileDataBase64 = await fileStorage.getFileData(fileId);
|
|
49
|
-
const bytes = Buffer.from(fileDataBase64, 'base64');
|
|
50
|
-
const safeFilename = encodeURIComponent(file.name);
|
|
51
|
-
response.setHeader('Content-Disposition', `inline; filename="${safeFilename}"`);
|
|
52
|
-
response.setHeader('Content-Type', file.type);
|
|
53
|
-
response.setHeader('Accept-Ranges', 'bytes');
|
|
54
|
-
const range = resolveByteRange(request.headers?.range, bytes.length);
|
|
55
|
-
if (range === 'unsatisfiable') {
|
|
56
|
-
response.setHeader('Content-Range', `bytes */${bytes.length}`);
|
|
57
|
-
response.status(416).send('Range Not Satisfiable');
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
if (range) {
|
|
61
|
-
response.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${bytes.length}`);
|
|
62
|
-
response.status(206).send(bytes.subarray(range.start, range.end + 1));
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
response.send(bytes);
|
|
31
|
+
await FileResponder.serve(file, request, response);
|
|
66
32
|
} catch (error) {
|
|
67
|
-
|
|
68
|
-
response.status(500).send('Internal Server Error');
|
|
33
|
+
FileResponder.fail(fileId, error, response);
|
|
69
34
|
}
|
|
70
35
|
},
|
|
71
36
|
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Route } from '@proteinjs/server-api';
|
|
2
|
+
import { UserAuth } from '@proteinjs/user';
|
|
3
|
+
import { FileStorage } from '../FileStorage';
|
|
4
|
+
import { FILE_VARIANT_KINDS, FileVariantKind } from '../FileVariantMaker';
|
|
5
|
+
import { FileResponder } from './FileResponder';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Serves a file's VARIANT of a kind (`preview`, `stage`) — the one URL a surface that draws a
|
|
9
|
+
* variant asks by (a plain `<img src>`, an off-DOM warm-up), whether or not the file has it yet:
|
|
10
|
+
*
|
|
11
|
+
* - the variant the row names is served as any File is ({@link FileResponder} — a 302 to ITS
|
|
12
|
+
* signed URL or the proxy, ITS headers; a non-owner is served ITS copy for others);
|
|
13
|
+
* - a file WITHOUT the variant derives it on this first request, once, through the registered
|
|
14
|
+
* `FileVariantMaker` (`FileStorage.getVariant` — a file stored before the kind existed, or by a
|
|
15
|
+
* producer that did not derive it; the row's word is decided in one transaction, so a second
|
|
16
|
+
* request never re-derives);
|
|
17
|
+
* - a file the maker does not apply to (a clip, a picture already small), or no maker at all, is
|
|
18
|
+
* served AS ITSELF — the consumer always gets a picture from this URL and never branches.
|
|
19
|
+
*
|
|
20
|
+
* Auth as `/file/:id`: logged-in, then the original's `getFile` read is the access decision (a
|
|
21
|
+
* variant is readable by whoever can read its original). A kind the library does not know is a
|
|
22
|
+
* 404, as a file that is not there.
|
|
23
|
+
*/
|
|
24
|
+
export const getFileVariant: Route = {
|
|
25
|
+
path: '/file/:id/variant/:kind',
|
|
26
|
+
method: 'get',
|
|
27
|
+
onRequest: async (request, response): Promise<void> => {
|
|
28
|
+
if (!UserAuth.isLoggedIn()) {
|
|
29
|
+
response.status(401).send('User not logged in');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const fileId = request.params.id;
|
|
34
|
+
const kind = request.params.kind as FileVariantKind;
|
|
35
|
+
if (!FILE_VARIANT_KINDS.includes(kind)) {
|
|
36
|
+
response.status(404).send('File not found');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const fileStorage = new FileStorage();
|
|
41
|
+
// The variant first (its own access read of the original inside); the file itself only when
|
|
42
|
+
// none applies — so the common request costs one row read before the bytes.
|
|
43
|
+
const served = (await fileStorage.getVariant(fileId, kind)) ?? (await fileStorage.getFile(fileId));
|
|
44
|
+
if (!served) {
|
|
45
|
+
response.status(404).send('File not found');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
await FileResponder.serve(served, request, response);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
FileResponder.fail(fileId, error, response);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FileVariantKind } from '../FileVariantMaker';
|
|
2
|
+
|
|
3
|
+
/** The URL a surface asks a file's variant of a kind by — served by `GET /file/:id/variant/:kind` (the variant, or the file itself when none applies). */
|
|
4
|
+
export const getFileVariantRoute = {
|
|
5
|
+
path: (fileId: string, kind: FileVariantKind) => `/file/${fileId}/variant/${kind}`,
|
|
6
|
+
method: 'get',
|
|
7
|
+
};
|
package/src/tables/FileTable.ts
CHANGED
|
@@ -11,8 +11,43 @@ export interface File extends ScopedRecord {
|
|
|
11
11
|
* Optional preview — another `File` (stored the same way, in GCS) used as a preview/thumbnail of
|
|
12
12
|
* this file's content, e.g. a recording GIF's preview frame. A reference, not an inline blob, so
|
|
13
13
|
* the bytes stay out of the DB row (consistent with the storage model); deleted with this file.
|
|
14
|
+
*
|
|
15
|
+
* A VARIANT — one of the derived Files of this file the {@link FileVariantMaker} seam makes
|
|
16
|
+
* (`preview`, `stage`): the same lifecycle for each — named here, made once (at ingest with the
|
|
17
|
+
* bytes in hand, or lazily on the first request through `FileStorage.getVariant`), dropped when
|
|
18
|
+
* this file's bytes change (`FileStorage.updateFileData`), deleted with this file (cascade),
|
|
19
|
+
* readable by whoever can read this file (`variantOf`), and carrying this file's provenance
|
|
20
|
+
* ({@link FileTable.provenanceColumns}) — a rendition's provenance is its original's.
|
|
14
21
|
*/
|
|
15
|
-
preview?: Reference<File
|
|
22
|
+
preview?: Reference<File> | null;
|
|
23
|
+
/**
|
|
24
|
+
* The stage variant — a second derived `File` sized for the surface that shows the picture large
|
|
25
|
+
* but not full-screen (a 1600 px longest-edge rendition where the preview is a 512 px thumbnail):
|
|
26
|
+
* the same mechanism as `preview` in every respect (see there). Absent on files made before the
|
|
27
|
+
* seam or of a kind the maker does not apply to (a video, an animated picture); the first request
|
|
28
|
+
* derives it once. A viewer that wants every pixel keeps drawing the original.
|
|
29
|
+
*/
|
|
30
|
+
stage?: Reference<File> | null;
|
|
31
|
+
/**
|
|
32
|
+
* For a variant (`preview`/`stage`) made by the seam: the File it was derived from. The
|
|
33
|
+
* variant's reachability is its original's — a reader who can read the original (their own
|
|
34
|
+
* scope, or a row the shared-content leg vouches for) can read the variant by its own id, even
|
|
35
|
+
* when no content row names the variant (a variant derived after the content was placed). Never
|
|
36
|
+
* set on a `copyForOthers` copy, whose own id is reachable by nobody but the owner. No cascade
|
|
37
|
+
* in either direction: the original's own reference (`preview`/`stage`) deletes the variant.
|
|
38
|
+
*/
|
|
39
|
+
variantOf?: Reference<File> | null;
|
|
40
|
+
/**
|
|
41
|
+
* The copy of this file served to anyone who is not its owner — another `File` (the same
|
|
42
|
+
* store, the owner's scope), made ONCE by the registered {@link FileCopyForOthers} the first
|
|
43
|
+
* time a non-owner reads the bytes, named here so every later read serves it without remaking
|
|
44
|
+
* it. `null`/absent means no copy has been made yet: the next non-owner read makes one when the
|
|
45
|
+
* maker applies to this file, and serves the original when it does not. Dropped when the
|
|
46
|
+
* original's bytes change (`FileStorage.updateFileData`) and deleted with this file (cascade).
|
|
47
|
+
* The owner is never served it — their own scoped read serves the original. Carries this
|
|
48
|
+
* file's provenance ({@link FileTable.provenanceColumns}), as every derived File does.
|
|
49
|
+
*/
|
|
50
|
+
copyForOthers?: Reference<File> | null;
|
|
16
51
|
/**
|
|
17
52
|
* Media metadata — generic file facts (an image/video's pixel dimensions, a video/audio
|
|
18
53
|
* duration) every consumer needs to render without loading bytes, e.g. reserving a media box's
|
|
@@ -49,9 +84,47 @@ export interface File extends ScopedRecord {
|
|
|
49
84
|
* an integrity fact. Absent for files written before the column existed.
|
|
50
85
|
*/
|
|
51
86
|
contentHash?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Producer attribution — HOW these bytes came to exist (e.g. a user upload, a browser
|
|
89
|
+
* capture, model generation, a rendered mockup). Same layer ruling as `width`/`height`:
|
|
90
|
+
* provenance is a generic file fact consumers need without a join — e.g. evidence surfaces
|
|
91
|
+
* that must refuse non-capture media as proof. Set by ingest paths that know their producer;
|
|
92
|
+
* absent for everything else. Values are the producing domain's vocabulary, not enumerated
|
|
93
|
+
* here.
|
|
94
|
+
*/
|
|
95
|
+
origin?: string;
|
|
96
|
+
/**
|
|
97
|
+
* The id of the model that made the bytes, when a model did — `origin` names the KIND of
|
|
98
|
+
* producer, this names the one. Absent for every other producer. Read by a consumer that
|
|
99
|
+
* labels a made file with what made it, without a join.
|
|
100
|
+
*/
|
|
101
|
+
originModel?: string;
|
|
52
102
|
}
|
|
53
103
|
|
|
54
104
|
export class FileTable extends Table<File> {
|
|
105
|
+
/**
|
|
106
|
+
* The columns that are a file's PROVENANCE — where its bytes came from and under what terms: the
|
|
107
|
+
* producer (`origin`, `originModel`), the web source (`sourceUrl`, `sourcePageUrl`,
|
|
108
|
+
* `retrievedAt`) and the rights record (`license`, `licenseUrl`, `attribution`). ONE list, the
|
|
109
|
+
* table's own: every derived File the library makes of a file — a variant (`preview`, `stage`)
|
|
110
|
+
* and the copy for others — carries these from the row it was made from (`FileStorage` writes
|
|
111
|
+
* them at insert), so a consumer reads a rendition's provenance off the row it draws, without a
|
|
112
|
+
* join, and a row with none derives rows with none. A column that describes THE ROW'S OWN bytes
|
|
113
|
+
* (`size`, `contentHash`, the media dimensions) is not provenance and is never copied — the
|
|
114
|
+
* maker answers those. A new provenance column is added HERE with its column; nothing else
|
|
115
|
+
* names the list.
|
|
116
|
+
*/
|
|
117
|
+
static readonly provenanceColumns: ReadonlyArray<keyof File> = [
|
|
118
|
+
'origin',
|
|
119
|
+
'originModel',
|
|
120
|
+
'sourceUrl',
|
|
121
|
+
'sourcePageUrl',
|
|
122
|
+
'retrievedAt',
|
|
123
|
+
'license',
|
|
124
|
+
'licenseUrl',
|
|
125
|
+
'attribution',
|
|
126
|
+
];
|
|
127
|
+
|
|
55
128
|
public name = FILE_TABLE_NAME;
|
|
56
129
|
public auth: Table<File>['auth'] = {
|
|
57
130
|
db: {
|
|
@@ -67,6 +140,12 @@ export class FileTable extends Table<File> {
|
|
|
67
140
|
size: new IntegerColumn('size'),
|
|
68
141
|
// Self-reference (the preview is itself a File). cascadeDelete: removing a file removes its preview.
|
|
69
142
|
preview: new ReferenceColumn<File>('preview', FILE_TABLE_NAME, true),
|
|
143
|
+
// Self-reference (the stage variant is itself a File). cascadeDelete: removing a file removes its stage variant.
|
|
144
|
+
stage: new ReferenceColumn<File>('stage', FILE_TABLE_NAME, true),
|
|
145
|
+
// Self-reference (a variant names its original). No cascade: the original's own reference deletes the variant.
|
|
146
|
+
variantOf: new ReferenceColumn<File>('variant_of', FILE_TABLE_NAME, false),
|
|
147
|
+
// Self-reference (the copy is itself a File). cascadeDelete: removing a file removes the copy others were served.
|
|
148
|
+
copyForOthers: new ReferenceColumn<File>('copy_for_others', FILE_TABLE_NAME, true),
|
|
70
149
|
width: new IntegerColumn('width'),
|
|
71
150
|
height: new IntegerColumn('height'),
|
|
72
151
|
durationMs: new IntegerColumn('duration_ms'),
|
|
@@ -80,6 +159,8 @@ export class FileTable extends Table<File> {
|
|
|
80
159
|
licenseUrl: new StringColumn('license_url', {}, 'MAX'),
|
|
81
160
|
attribution: new StringColumn('attribution', {}, 'MAX'),
|
|
82
161
|
contentHash: new StringColumn('content_hash', {}, 64),
|
|
162
|
+
origin: new StringColumn('origin', undefined, 50),
|
|
163
|
+
originModel: new StringColumn('origin_model', undefined, 200),
|
|
83
164
|
});
|
|
84
165
|
// Dedup lookup path: find the caller's existing copy of these bytes (content_hash is only
|
|
85
166
|
// ever queried per-user — createScopedIndex prefixes the scope column).
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { QueryBuilderFactory, getDbAsSystem } from '@proteinjs/db';
|
|
2
|
+
import { User } from '@proteinjs/user';
|
|
3
|
+
import { File } from '../src/tables/FileTable';
|
|
4
|
+
import { tables } from '../src/tables/tables';
|
|
5
|
+
import { FileStorage } from '../src/FileStorage';
|
|
6
|
+
import { FileStorageError } from '../src/FileStorageError';
|
|
7
|
+
import { DbFileStorageDriver } from '../src/DbFileStorageDriver';
|
|
8
|
+
import { FileTestEnvironment } from './FileTestEnvironment';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The driver contract has one answer for bytes that are not there: the `not-found` code. An
|
|
12
|
+
* object store says it for a missing object; this driver's store is the chunk table, where
|
|
13
|
+
* "nothing stored" and "an empty file" both used to read as zero chunk rows and came back as `''`
|
|
14
|
+
* — so a caller could not tell a lost file from an empty one, and code that branched on the
|
|
15
|
+
* object store's error never ran against this driver.
|
|
16
|
+
*/
|
|
17
|
+
const testEnv = new FileTestEnvironment();
|
|
18
|
+
const driver = new DbFileStorageDriver();
|
|
19
|
+
let owner: User;
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
await testEnv.beforeAll();
|
|
23
|
+
testEnv.setDriver(driver);
|
|
24
|
+
owner = await testEnv.createUser({ name: 'File owner', email: 'missing-bytes-owner@test.local' });
|
|
25
|
+
testEnv.actAs(owner);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterAll(async () => {
|
|
29
|
+
await testEnv.afterAll();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const chunkRows = async (fileId: string) =>
|
|
33
|
+
await getDbAsSystem().query(
|
|
34
|
+
tables.FileData,
|
|
35
|
+
new QueryBuilderFactory().getQueryBuilder(tables.FileData, { file: fileId })
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
describe('DbFileStorageDriver reports bytes that are not there as not-found', () => {
|
|
39
|
+
it('a file row whose bytes are gone is not-found, not an empty string', async () => {
|
|
40
|
+
const bytes = Buffer.from('the bytes of a picture');
|
|
41
|
+
const file = await new FileStorage().createFile(
|
|
42
|
+
{ name: 'holiday.jpg', type: 'image/jpeg', size: bytes.length } as File,
|
|
43
|
+
bytes.toString('base64')
|
|
44
|
+
);
|
|
45
|
+
await driver.deleteFile(file.id);
|
|
46
|
+
|
|
47
|
+
const failure = await driver.getFileData(file.id).catch((error) => error);
|
|
48
|
+
|
|
49
|
+
expect(FileStorageError.isNotFound(failure)).toBe(true);
|
|
50
|
+
expect(failure.message).toEqual(`getFileData failed for file ${file.id}: not-found`);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('an id that names no file at all is not-found', async () => {
|
|
54
|
+
const failure = await driver.getFileData('no-such-file-id').catch((error) => error);
|
|
55
|
+
|
|
56
|
+
expect(FileStorageError.isNotFound(failure)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('an empty file is stored, and reads back as empty', async () => {
|
|
60
|
+
const file = await new FileStorage().createFile({ name: 'empty.txt', type: 'text/plain', size: 0 } as File, '');
|
|
61
|
+
|
|
62
|
+
expect(await chunkRows(file.id)).toHaveLength(1);
|
|
63
|
+
expect(await driver.getFileData(file.id)).toEqual('');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('a file overwritten with nothing stays stored, whatever its row says about its size', async () => {
|
|
67
|
+
const bytes = Buffer.from('first');
|
|
68
|
+
const file = await new FileStorage().createFile(
|
|
69
|
+
{ name: 'note.txt', type: 'text/plain', size: bytes.length } as File,
|
|
70
|
+
bytes.toString('base64')
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
await driver.updateFileData(file.id, '');
|
|
74
|
+
|
|
75
|
+
expect(await driver.getFileData(file.id)).toEqual('');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('an empty file written before empty files had a chunk row still reads back as empty', async () => {
|
|
79
|
+
const file = await new FileStorage().createFile({ name: 'old-empty.txt', type: 'text/plain', size: 0 } as File, '');
|
|
80
|
+
await driver.deleteFile(file.id);
|
|
81
|
+
expect(await chunkRows(file.id)).toHaveLength(0);
|
|
82
|
+
|
|
83
|
+
expect(await driver.getFileData(file.id)).toEqual('');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SourceRepository } from '@proteinjs/reflection';
|
|
2
2
|
import { User } from '@proteinjs/user';
|
|
3
|
+
import { ServiceRefusal } from '@proteinjs/service';
|
|
3
4
|
import { File } from '../src/tables/FileTable';
|
|
4
5
|
import { FileStorage } from '../src/FileStorage';
|
|
5
6
|
import { DbFileStorageDriver } from '../src/DbFileStorageDriver';
|
|
@@ -95,7 +96,12 @@ describe('byte reads gate on the file-row read (the getFile decision)', () => {
|
|
|
95
96
|
const file = await createOwnerFile('private-bytes.bin');
|
|
96
97
|
|
|
97
98
|
testEnv.actAs(stranger);
|
|
98
|
-
await
|
|
99
|
+
const denial = await new FileStorage().getFileData(file.id).then(
|
|
100
|
+
() => undefined,
|
|
101
|
+
(error: unknown) => error
|
|
102
|
+
);
|
|
103
|
+
// The one answer a byte door gives: a 404 refusal, never the id (a file not there reads the same).
|
|
104
|
+
expect(ServiceRefusal.is(denial) && [denial.status, denial.message]).toEqual([404, 'File not found']);
|
|
99
105
|
});
|
|
100
106
|
|
|
101
107
|
it('a shared-content recipient (resolver-vouched) reads the bytes byte-identical', async () => {
|