@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.
Files changed (102) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/generated/index.d.ts.map +1 -1
  3. package/dist/generated/index.js +3 -1
  4. package/dist/generated/index.js.map +1 -1
  5. package/dist/index.d.ts +4 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +4 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/src/DbFileStorageDriver.d.ts +7 -0
  10. package/dist/src/DbFileStorageDriver.d.ts.map +1 -1
  11. package/dist/src/DbFileStorageDriver.js +69 -41
  12. package/dist/src/DbFileStorageDriver.js.map +1 -1
  13. package/dist/src/FileCopyForOthers.d.ts +52 -0
  14. package/dist/src/FileCopyForOthers.d.ts.map +1 -0
  15. package/dist/src/FileCopyForOthers.js +62 -0
  16. package/dist/src/FileCopyForOthers.js.map +1 -0
  17. package/dist/src/FileStorage.d.ts +158 -11
  18. package/dist/src/FileStorage.d.ts.map +1 -1
  19. package/dist/src/FileStorage.js +562 -41
  20. package/dist/src/FileStorage.js.map +1 -1
  21. package/dist/src/FileStorageDriver.d.ts +11 -0
  22. package/dist/src/FileStorageDriver.d.ts.map +1 -1
  23. package/dist/src/FileStorageError.d.ts +62 -0
  24. package/dist/src/FileStorageError.d.ts.map +1 -0
  25. package/dist/src/FileStorageError.js +118 -0
  26. package/dist/src/FileStorageError.js.map +1 -0
  27. package/dist/src/FileVariantMaker.d.ts +66 -0
  28. package/dist/src/FileVariantMaker.d.ts.map +1 -0
  29. package/dist/src/FileVariantMaker.js +62 -0
  30. package/dist/src/FileVariantMaker.js.map +1 -0
  31. package/dist/src/routes/FileResponder.d.ts +41 -0
  32. package/dist/src/routes/FileResponder.d.ts.map +1 -0
  33. package/dist/src/routes/FileResponder.js +135 -0
  34. package/dist/src/routes/FileResponder.js.map +1 -0
  35. package/dist/src/routes/getFile.d.ts +5 -13
  36. package/dist/src/routes/getFile.d.ts.map +1 -1
  37. package/dist/src/routes/getFile.js +20 -56
  38. package/dist/src/routes/getFile.js.map +1 -1
  39. package/dist/src/routes/getFileVariant.d.ts +20 -0
  40. package/dist/src/routes/getFileVariant.d.ts.map +1 -0
  41. package/dist/src/routes/getFileVariant.js +112 -0
  42. package/dist/src/routes/getFileVariant.js.map +1 -0
  43. package/dist/src/routes/getFileVariantRoute.d.ts +7 -0
  44. package/dist/src/routes/getFileVariantRoute.d.ts.map +1 -0
  45. package/dist/src/routes/getFileVariantRoute.js +9 -0
  46. package/dist/src/routes/getFileVariantRoute.js.map +1 -0
  47. package/dist/src/tables/FileTable.d.ts +70 -2
  48. package/dist/src/tables/FileTable.d.ts.map +1 -1
  49. package/dist/src/tables/FileTable.js +30 -0
  50. package/dist/src/tables/FileTable.js.map +1 -1
  51. package/dist/test/DbFileStorageDriverMissingBytes.test.d.ts +2 -0
  52. package/dist/test/DbFileStorageDriverMissingBytes.test.d.ts.map +1 -0
  53. package/dist/test/DbFileStorageDriverMissingBytes.test.js +184 -0
  54. package/dist/test/DbFileStorageDriverMissingBytes.test.js.map +1 -0
  55. package/dist/test/FileByteAccess.test.js +6 -3
  56. package/dist/test/FileByteAccess.test.js.map +1 -1
  57. package/dist/test/FileCopyForOthers.test.d.ts +2 -0
  58. package/dist/test/FileCopyForOthers.test.d.ts.map +1 -0
  59. package/dist/test/FileCopyForOthers.test.js +1143 -0
  60. package/dist/test/FileCopyForOthers.test.js.map +1 -0
  61. package/dist/test/FileDerivedProvenance.test.d.ts +2 -0
  62. package/dist/test/FileDerivedProvenance.test.d.ts.map +1 -0
  63. package/dist/test/FileDerivedProvenance.test.js +420 -0
  64. package/dist/test/FileDerivedProvenance.test.js.map +1 -0
  65. package/dist/test/FileMediaMetadata.test.js +60 -5
  66. package/dist/test/FileMediaMetadata.test.js.map +1 -1
  67. package/dist/test/FileStorageError.test.d.ts +2 -0
  68. package/dist/test/FileStorageError.test.d.ts.map +1 -0
  69. package/dist/test/FileStorageError.test.js +68 -0
  70. package/dist/test/FileStorageError.test.js.map +1 -0
  71. package/dist/test/FileTestEnvironment.d.ts.map +1 -1
  72. package/dist/test/FileTestEnvironment.js +3 -0
  73. package/dist/test/FileTestEnvironment.js.map +1 -1
  74. package/dist/test/FileVariants.test.d.ts +2 -0
  75. package/dist/test/FileVariants.test.d.ts.map +1 -0
  76. package/dist/test/FileVariants.test.js +1008 -0
  77. package/dist/test/FileVariants.test.js.map +1 -0
  78. package/dist/test/GetFileRoute.test.js +29 -0
  79. package/dist/test/GetFileRoute.test.js.map +1 -1
  80. package/generated/index.ts +3 -1
  81. package/index.ts +4 -0
  82. package/package.json +5 -5
  83. package/src/DbFileStorageDriver.ts +32 -21
  84. package/src/FileCopyForOthers.ts +71 -0
  85. package/src/FileStorage.ts +377 -19
  86. package/src/FileStorageDriver.ts +12 -0
  87. package/src/FileStorageError.ts +123 -0
  88. package/src/FileVariantMaker.ts +89 -0
  89. package/src/routes/FileResponder.ts +85 -0
  90. package/src/routes/getFile.ts +9 -44
  91. package/src/routes/getFileVariant.ts +53 -0
  92. package/src/routes/getFileVariantRoute.ts +7 -0
  93. package/src/tables/FileTable.ts +82 -1
  94. package/test/DbFileStorageDriverMissingBytes.test.ts +85 -0
  95. package/test/FileByteAccess.test.ts +7 -1
  96. package/test/FileCopyForOthers.test.ts +702 -0
  97. package/test/FileDerivedProvenance.test.ts +248 -0
  98. package/test/FileMediaMetadata.test.ts +44 -0
  99. package/test/FileStorageError.test.ts +79 -0
  100. package/test/FileTestEnvironment.ts +3 -0
  101. package/test/FileVariants.test.ts +604 -0
  102. 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
+ }
@@ -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 { resolveByteRange } from './byteRange';
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 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:
11
- *
12
- * - **302 redirect** to a short-lived signed URL when the driver's store has an external URL
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 fileStorage.getFile(fileId);
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
- console.error(`Error fetching file (${fileId}):`, error);
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
+ };
@@ -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 expect(new FileStorage().getFileData(file.id)).rejects.toThrow(`File not found: ${file.id}`);
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 () => {