graphile-presigned-url-plugin 1.11.4 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/confirm-upload.d.ts +64 -0
- package/confirm-upload.js +70 -0
- package/default-bucket.d.ts +42 -0
- package/default-bucket.js +51 -0
- package/esm/confirm-upload.d.ts +64 -0
- package/esm/confirm-upload.js +66 -0
- package/esm/default-bucket.d.ts +42 -0
- package/esm/default-bucket.js +48 -0
- package/esm/file-ref-registry.d.ts +61 -0
- package/esm/file-ref-registry.js +115 -0
- package/esm/index.d.ts +9 -1
- package/esm/index.js +7 -1
- package/esm/managed-upload.d.ts +132 -0
- package/esm/managed-upload.js +262 -0
- package/esm/physical-bucket.d.ts +60 -0
- package/esm/physical-bucket.js +111 -0
- package/esm/plugin.js +64 -84
- package/esm/s3-signer.d.ts +29 -0
- package/esm/s3-signer.js +61 -1
- package/esm/types.d.ts +34 -2
- package/file-ref-registry.d.ts +61 -0
- package/file-ref-registry.js +121 -0
- package/index.d.ts +9 -1
- package/index.js +24 -1
- package/managed-upload.d.ts +132 -0
- package/managed-upload.js +268 -0
- package/package.json +4 -3
- package/physical-bucket.d.ts +60 -0
- package/physical-bucket.js +117 -0
- package/plugin.js +68 -88
- package/s3-signer.d.ts +29 -0
- package/s3-signer.js +62 -0
- package/types.d.ts +34 -2
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The byte-validating half of the presigned lane's confirmation.
|
|
3
|
+
*
|
|
4
|
+
* A presigned upload's bytes never pass through the server: the client PUTs them
|
|
5
|
+
* straight to S3, so at the moment the files row is written the only statements
|
|
6
|
+
* about the file are the client's own. `requested` is exactly that state — a
|
|
7
|
+
* claim — and the row must not reach `uploaded` until the bytes behind it have
|
|
8
|
+
* been looked at, because every downstream processing step (image versions,
|
|
9
|
+
* extraction, embeddings) fires on `uploaded` and would otherwise be handed
|
|
10
|
+
* whatever the client chose to upload.
|
|
11
|
+
*
|
|
12
|
+
* So confirmation answers three questions in order, and each has a distinct
|
|
13
|
+
* outcome on the row:
|
|
14
|
+
*
|
|
15
|
+
* 1. did the bytes arrive? no → `expired` (the client walked away)
|
|
16
|
+
* 2. are they the file they claim? no → `rejected` (and the object deleted)
|
|
17
|
+
* 3. otherwise → `uploaded`
|
|
18
|
+
*
|
|
19
|
+
* The bytes are read with a ranged GET of the leading bytes, not downloaded: a
|
|
20
|
+
* magic-byte signature is in the first few dozen bytes, so this costs the same
|
|
21
|
+
* for a 2GB video as for an icon.
|
|
22
|
+
*
|
|
23
|
+
* This module is the decision, not the transition. The worker that owns the
|
|
24
|
+
* `storage:confirm_upload` job applies it by calling the generated
|
|
25
|
+
* `<files>_confirm_uploaded` / `_reject_file` / `_expire_file` functions — the
|
|
26
|
+
* verdict is returned rather than executed so that the same rule can be applied
|
|
27
|
+
* by any transport, and tested without a database.
|
|
28
|
+
*/
|
|
29
|
+
import type { S3Config } from './types';
|
|
30
|
+
/**
|
|
31
|
+
* How many leading bytes to read. Signatures are far shorter than this; the
|
|
32
|
+
* margin covers formats whose signature sits at an offset (e.g. the `ftyp` box
|
|
33
|
+
* of an MP4) and gives charset detection enough text to work with.
|
|
34
|
+
*/
|
|
35
|
+
export declare const CONFIRM_PREFIX_BYTES = 4096;
|
|
36
|
+
export interface ConfirmUploadInput {
|
|
37
|
+
s3: S3Config;
|
|
38
|
+
/** The object key the presigned PUT was signed for. */
|
|
39
|
+
key: string;
|
|
40
|
+
/** The MIME type the client declared when the row was created. */
|
|
41
|
+
declaredMime: string;
|
|
42
|
+
/** The filename recorded on the files row, if any. */
|
|
43
|
+
filename?: string | null;
|
|
44
|
+
}
|
|
45
|
+
export type ConfirmUploadVerdict = {
|
|
46
|
+
outcome: 'uploaded';
|
|
47
|
+
detectedMime: string | null;
|
|
48
|
+
} | {
|
|
49
|
+
outcome: 'rejected';
|
|
50
|
+
reason: string;
|
|
51
|
+
detectedMime: string | null;
|
|
52
|
+
} | {
|
|
53
|
+
outcome: 'expired';
|
|
54
|
+
reason: string;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Decide what should happen to a `requested` files row, from its object's bytes.
|
|
58
|
+
*
|
|
59
|
+
* A missing object is `expired` rather than `rejected`: nothing was uploaded, so
|
|
60
|
+
* there is nothing to reject, and the row's own retry/expiry budget governs how
|
|
61
|
+
* long the client has left. An empty object, by contrast, *was* written and is
|
|
62
|
+
* not a file.
|
|
63
|
+
*/
|
|
64
|
+
export declare function confirmUploadedBytes(input: ConfirmUploadInput): Promise<ConfirmUploadVerdict>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The byte-validating half of the presigned lane's confirmation.
|
|
4
|
+
*
|
|
5
|
+
* A presigned upload's bytes never pass through the server: the client PUTs them
|
|
6
|
+
* straight to S3, so at the moment the files row is written the only statements
|
|
7
|
+
* about the file are the client's own. `requested` is exactly that state — a
|
|
8
|
+
* claim — and the row must not reach `uploaded` until the bytes behind it have
|
|
9
|
+
* been looked at, because every downstream processing step (image versions,
|
|
10
|
+
* extraction, embeddings) fires on `uploaded` and would otherwise be handed
|
|
11
|
+
* whatever the client chose to upload.
|
|
12
|
+
*
|
|
13
|
+
* So confirmation answers three questions in order, and each has a distinct
|
|
14
|
+
* outcome on the row:
|
|
15
|
+
*
|
|
16
|
+
* 1. did the bytes arrive? no → `expired` (the client walked away)
|
|
17
|
+
* 2. are they the file they claim? no → `rejected` (and the object deleted)
|
|
18
|
+
* 3. otherwise → `uploaded`
|
|
19
|
+
*
|
|
20
|
+
* The bytes are read with a ranged GET of the leading bytes, not downloaded: a
|
|
21
|
+
* magic-byte signature is in the first few dozen bytes, so this costs the same
|
|
22
|
+
* for a 2GB video as for an icon.
|
|
23
|
+
*
|
|
24
|
+
* This module is the decision, not the transition. The worker that owns the
|
|
25
|
+
* `storage:confirm_upload` job applies it by calling the generated
|
|
26
|
+
* `<files>_confirm_uploaded` / `_reject_file` / `_expire_file` functions — the
|
|
27
|
+
* verdict is returned rather than executed so that the same rule can be applied
|
|
28
|
+
* by any transport, and tested without a database.
|
|
29
|
+
*/
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.CONFIRM_PREFIX_BYTES = void 0;
|
|
32
|
+
exports.confirmUploadedBytes = confirmUploadedBytes;
|
|
33
|
+
const mime_bytes_1 = require("mime-bytes");
|
|
34
|
+
const mime_bytes_2 = require("mime-bytes");
|
|
35
|
+
const s3_signer_1 = require("./s3-signer");
|
|
36
|
+
/**
|
|
37
|
+
* How many leading bytes to read. Signatures are far shorter than this; the
|
|
38
|
+
* margin covers formats whose signature sits at an offset (e.g. the `ftyp` box
|
|
39
|
+
* of an MP4) and gives charset detection enough text to work with.
|
|
40
|
+
*/
|
|
41
|
+
exports.CONFIRM_PREFIX_BYTES = 4096;
|
|
42
|
+
/**
|
|
43
|
+
* Decide what should happen to a `requested` files row, from its object's bytes.
|
|
44
|
+
*
|
|
45
|
+
* A missing object is `expired` rather than `rejected`: nothing was uploaded, so
|
|
46
|
+
* there is nothing to reject, and the row's own retry/expiry budget governs how
|
|
47
|
+
* long the client has left. An empty object, by contrast, *was* written and is
|
|
48
|
+
* not a file.
|
|
49
|
+
*/
|
|
50
|
+
async function confirmUploadedBytes(input) {
|
|
51
|
+
const { s3, key, declaredMime, filename } = input;
|
|
52
|
+
const prefix = await (0, s3_signer_1.readObjectPrefix)(s3, key, exports.CONFIRM_PREFIX_BYTES);
|
|
53
|
+
if (prefix === null) {
|
|
54
|
+
return { outcome: 'expired', reason: `no object at key ${key}: the upload never arrived` };
|
|
55
|
+
}
|
|
56
|
+
if (prefix.length === 0) {
|
|
57
|
+
return {
|
|
58
|
+
outcome: 'rejected',
|
|
59
|
+
reason: `object at key ${key} is empty; an upload must carry at least one byte`,
|
|
60
|
+
detectedMime: null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const detected = await (0, mime_bytes_1.detectFromBuffer)(prefix);
|
|
64
|
+
const detectedMime = detected?.mimeType ?? null;
|
|
65
|
+
const agreement = (0, mime_bytes_2.checkTypeAgreement)({ filename, declaredMime, detectedMime });
|
|
66
|
+
if (!agreement.ok) {
|
|
67
|
+
return { outcome: 'rejected', reason: agreement.violation.message, detectedMime };
|
|
68
|
+
}
|
|
69
|
+
return { outcome: 'uploaded', detectedMime };
|
|
70
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side bucket resolution.
|
|
3
|
+
*
|
|
4
|
+
* Which bucket a write lands in belongs to the database, never to the client and
|
|
5
|
+
* never to the server's environment: a client-chosen key means a different
|
|
6
|
+
* bucket per tenant, and an env-level bucket name means storage that belongs to
|
|
7
|
+
* no tenant at all. `function_resolution.resolve_default_bucket` is the one
|
|
8
|
+
* place that answers it — a logical key when the field declares one, otherwise
|
|
9
|
+
* the reserved default tag for the requested access ('default' / 'default-public').
|
|
10
|
+
*
|
|
11
|
+
* Zero matches and several matches both raise inside SQL, so there is nothing to
|
|
12
|
+
* guess here: this module only carries the question in and the coordinate out.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The resolved bucket coordinate.
|
|
16
|
+
*
|
|
17
|
+
* `physicalName` is the recorded S3 bucket name, or null when the logical
|
|
18
|
+
* bucket has never been provisioned — the caller mints and records it then.
|
|
19
|
+
*/
|
|
20
|
+
export interface ResolvedBucketCoordinate {
|
|
21
|
+
bucketId: string;
|
|
22
|
+
resolvedKey: string;
|
|
23
|
+
bucketType: 'public' | 'private' | 'temp';
|
|
24
|
+
physicalName: string | null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the bucket a write should land in.
|
|
28
|
+
*
|
|
29
|
+
* @param scope - The storage module's scope ('app' for database-wide storage)
|
|
30
|
+
* @param entityId - The owning entity row for an entity-scoped module, else null
|
|
31
|
+
* @param publicAccess - Which reserved default tag to use when no key is named,
|
|
32
|
+
* and an assertion on the named bucket's type when one is
|
|
33
|
+
* @param bucketKey - The field's declared logical key, or null for the default
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveDefaultBucket(pgClient: {
|
|
36
|
+
query: (opts: {
|
|
37
|
+
text: string;
|
|
38
|
+
values?: unknown[];
|
|
39
|
+
}) => Promise<{
|
|
40
|
+
rows: unknown[];
|
|
41
|
+
}>;
|
|
42
|
+
}, databaseId: string, scope: string, entityId: string | null, publicAccess: boolean, bucketKey: string | null): Promise<ResolvedBucketCoordinate>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Server-side bucket resolution.
|
|
4
|
+
*
|
|
5
|
+
* Which bucket a write lands in belongs to the database, never to the client and
|
|
6
|
+
* never to the server's environment: a client-chosen key means a different
|
|
7
|
+
* bucket per tenant, and an env-level bucket name means storage that belongs to
|
|
8
|
+
* no tenant at all. `function_resolution.resolve_default_bucket` is the one
|
|
9
|
+
* place that answers it — a logical key when the field declares one, otherwise
|
|
10
|
+
* the reserved default tag for the requested access ('default' / 'default-public').
|
|
11
|
+
*
|
|
12
|
+
* Zero matches and several matches both raise inside SQL, so there is nothing to
|
|
13
|
+
* guess here: this module only carries the question in and the coordinate out.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.resolveDefaultBucket = resolveDefaultBucket;
|
|
17
|
+
const logger_1 = require("@pgpmjs/logger");
|
|
18
|
+
const log = new logger_1.Logger('graphile-presigned-url:default-bucket');
|
|
19
|
+
const RESOLVE_DEFAULT_BUCKET_QUERY = `
|
|
20
|
+
SELECT bucket_id, resolved_key, bucket_type, physical_name
|
|
21
|
+
FROM function_resolution.resolve_default_bucket($1, $2, $3, $4, $5)
|
|
22
|
+
`;
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the bucket a write should land in.
|
|
25
|
+
*
|
|
26
|
+
* @param scope - The storage module's scope ('app' for database-wide storage)
|
|
27
|
+
* @param entityId - The owning entity row for an entity-scoped module, else null
|
|
28
|
+
* @param publicAccess - Which reserved default tag to use when no key is named,
|
|
29
|
+
* and an assertion on the named bucket's type when one is
|
|
30
|
+
* @param bucketKey - The field's declared logical key, or null for the default
|
|
31
|
+
*/
|
|
32
|
+
async function resolveDefaultBucket(pgClient, databaseId, scope, entityId, publicAccess, bucketKey) {
|
|
33
|
+
const result = await pgClient.query({
|
|
34
|
+
text: RESOLVE_DEFAULT_BUCKET_QUERY,
|
|
35
|
+
values: [databaseId, scope, entityId, publicAccess, bucketKey],
|
|
36
|
+
});
|
|
37
|
+
const row = result.rows[0];
|
|
38
|
+
if (!row) {
|
|
39
|
+
// resolve_default_bucket raises on zero and on several matches, so an empty
|
|
40
|
+
// result means the function did not run as declared rather than "no bucket".
|
|
41
|
+
throw new Error(`STORAGE_DEFAULT_BUCKET_NO_ROW: resolve_default_bucket returned no row for ` +
|
|
42
|
+
`database=${databaseId} scope=${scope} public=${publicAccess} key=${bucketKey ?? '<default tag>'}`);
|
|
43
|
+
}
|
|
44
|
+
log.debug(`Resolved bucket ${row.resolved_key} (${row.bucket_type}) for database=${databaseId} scope=${scope}`);
|
|
45
|
+
return {
|
|
46
|
+
bucketId: row.bucket_id,
|
|
47
|
+
resolvedKey: row.resolved_key,
|
|
48
|
+
bucketType: row.bucket_type,
|
|
49
|
+
physicalName: row.physical_name,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The byte-validating half of the presigned lane's confirmation.
|
|
3
|
+
*
|
|
4
|
+
* A presigned upload's bytes never pass through the server: the client PUTs them
|
|
5
|
+
* straight to S3, so at the moment the files row is written the only statements
|
|
6
|
+
* about the file are the client's own. `requested` is exactly that state — a
|
|
7
|
+
* claim — and the row must not reach `uploaded` until the bytes behind it have
|
|
8
|
+
* been looked at, because every downstream processing step (image versions,
|
|
9
|
+
* extraction, embeddings) fires on `uploaded` and would otherwise be handed
|
|
10
|
+
* whatever the client chose to upload.
|
|
11
|
+
*
|
|
12
|
+
* So confirmation answers three questions in order, and each has a distinct
|
|
13
|
+
* outcome on the row:
|
|
14
|
+
*
|
|
15
|
+
* 1. did the bytes arrive? no → `expired` (the client walked away)
|
|
16
|
+
* 2. are they the file they claim? no → `rejected` (and the object deleted)
|
|
17
|
+
* 3. otherwise → `uploaded`
|
|
18
|
+
*
|
|
19
|
+
* The bytes are read with a ranged GET of the leading bytes, not downloaded: a
|
|
20
|
+
* magic-byte signature is in the first few dozen bytes, so this costs the same
|
|
21
|
+
* for a 2GB video as for an icon.
|
|
22
|
+
*
|
|
23
|
+
* This module is the decision, not the transition. The worker that owns the
|
|
24
|
+
* `storage:confirm_upload` job applies it by calling the generated
|
|
25
|
+
* `<files>_confirm_uploaded` / `_reject_file` / `_expire_file` functions — the
|
|
26
|
+
* verdict is returned rather than executed so that the same rule can be applied
|
|
27
|
+
* by any transport, and tested without a database.
|
|
28
|
+
*/
|
|
29
|
+
import type { S3Config } from './types';
|
|
30
|
+
/**
|
|
31
|
+
* How many leading bytes to read. Signatures are far shorter than this; the
|
|
32
|
+
* margin covers formats whose signature sits at an offset (e.g. the `ftyp` box
|
|
33
|
+
* of an MP4) and gives charset detection enough text to work with.
|
|
34
|
+
*/
|
|
35
|
+
export declare const CONFIRM_PREFIX_BYTES = 4096;
|
|
36
|
+
export interface ConfirmUploadInput {
|
|
37
|
+
s3: S3Config;
|
|
38
|
+
/** The object key the presigned PUT was signed for. */
|
|
39
|
+
key: string;
|
|
40
|
+
/** The MIME type the client declared when the row was created. */
|
|
41
|
+
declaredMime: string;
|
|
42
|
+
/** The filename recorded on the files row, if any. */
|
|
43
|
+
filename?: string | null;
|
|
44
|
+
}
|
|
45
|
+
export type ConfirmUploadVerdict = {
|
|
46
|
+
outcome: 'uploaded';
|
|
47
|
+
detectedMime: string | null;
|
|
48
|
+
} | {
|
|
49
|
+
outcome: 'rejected';
|
|
50
|
+
reason: string;
|
|
51
|
+
detectedMime: string | null;
|
|
52
|
+
} | {
|
|
53
|
+
outcome: 'expired';
|
|
54
|
+
reason: string;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Decide what should happen to a `requested` files row, from its object's bytes.
|
|
58
|
+
*
|
|
59
|
+
* A missing object is `expired` rather than `rejected`: nothing was uploaded, so
|
|
60
|
+
* there is nothing to reject, and the row's own retry/expiry budget governs how
|
|
61
|
+
* long the client has left. An empty object, by contrast, *was* written and is
|
|
62
|
+
* not a file.
|
|
63
|
+
*/
|
|
64
|
+
export declare function confirmUploadedBytes(input: ConfirmUploadInput): Promise<ConfirmUploadVerdict>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The byte-validating half of the presigned lane's confirmation.
|
|
3
|
+
*
|
|
4
|
+
* A presigned upload's bytes never pass through the server: the client PUTs them
|
|
5
|
+
* straight to S3, so at the moment the files row is written the only statements
|
|
6
|
+
* about the file are the client's own. `requested` is exactly that state — a
|
|
7
|
+
* claim — and the row must not reach `uploaded` until the bytes behind it have
|
|
8
|
+
* been looked at, because every downstream processing step (image versions,
|
|
9
|
+
* extraction, embeddings) fires on `uploaded` and would otherwise be handed
|
|
10
|
+
* whatever the client chose to upload.
|
|
11
|
+
*
|
|
12
|
+
* So confirmation answers three questions in order, and each has a distinct
|
|
13
|
+
* outcome on the row:
|
|
14
|
+
*
|
|
15
|
+
* 1. did the bytes arrive? no → `expired` (the client walked away)
|
|
16
|
+
* 2. are they the file they claim? no → `rejected` (and the object deleted)
|
|
17
|
+
* 3. otherwise → `uploaded`
|
|
18
|
+
*
|
|
19
|
+
* The bytes are read with a ranged GET of the leading bytes, not downloaded: a
|
|
20
|
+
* magic-byte signature is in the first few dozen bytes, so this costs the same
|
|
21
|
+
* for a 2GB video as for an icon.
|
|
22
|
+
*
|
|
23
|
+
* This module is the decision, not the transition. The worker that owns the
|
|
24
|
+
* `storage:confirm_upload` job applies it by calling the generated
|
|
25
|
+
* `<files>_confirm_uploaded` / `_reject_file` / `_expire_file` functions — the
|
|
26
|
+
* verdict is returned rather than executed so that the same rule can be applied
|
|
27
|
+
* by any transport, and tested without a database.
|
|
28
|
+
*/
|
|
29
|
+
import { detectFromBuffer } from 'mime-bytes';
|
|
30
|
+
import { checkTypeAgreement } from 'mime-bytes';
|
|
31
|
+
import { readObjectPrefix } from './s3-signer';
|
|
32
|
+
/**
|
|
33
|
+
* How many leading bytes to read. Signatures are far shorter than this; the
|
|
34
|
+
* margin covers formats whose signature sits at an offset (e.g. the `ftyp` box
|
|
35
|
+
* of an MP4) and gives charset detection enough text to work with.
|
|
36
|
+
*/
|
|
37
|
+
export const CONFIRM_PREFIX_BYTES = 4096;
|
|
38
|
+
/**
|
|
39
|
+
* Decide what should happen to a `requested` files row, from its object's bytes.
|
|
40
|
+
*
|
|
41
|
+
* A missing object is `expired` rather than `rejected`: nothing was uploaded, so
|
|
42
|
+
* there is nothing to reject, and the row's own retry/expiry budget governs how
|
|
43
|
+
* long the client has left. An empty object, by contrast, *was* written and is
|
|
44
|
+
* not a file.
|
|
45
|
+
*/
|
|
46
|
+
export async function confirmUploadedBytes(input) {
|
|
47
|
+
const { s3, key, declaredMime, filename } = input;
|
|
48
|
+
const prefix = await readObjectPrefix(s3, key, CONFIRM_PREFIX_BYTES);
|
|
49
|
+
if (prefix === null) {
|
|
50
|
+
return { outcome: 'expired', reason: `no object at key ${key}: the upload never arrived` };
|
|
51
|
+
}
|
|
52
|
+
if (prefix.length === 0) {
|
|
53
|
+
return {
|
|
54
|
+
outcome: 'rejected',
|
|
55
|
+
reason: `object at key ${key} is empty; an upload must carry at least one byte`,
|
|
56
|
+
detectedMime: null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const detected = await detectFromBuffer(prefix);
|
|
60
|
+
const detectedMime = detected?.mimeType ?? null;
|
|
61
|
+
const agreement = checkTypeAgreement({ filename, declaredMime, detectedMime });
|
|
62
|
+
if (!agreement.ok) {
|
|
63
|
+
return { outcome: 'rejected', reason: agreement.violation.message, detectedMime };
|
|
64
|
+
}
|
|
65
|
+
return { outcome: 'uploaded', detectedMime };
|
|
66
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side bucket resolution.
|
|
3
|
+
*
|
|
4
|
+
* Which bucket a write lands in belongs to the database, never to the client and
|
|
5
|
+
* never to the server's environment: a client-chosen key means a different
|
|
6
|
+
* bucket per tenant, and an env-level bucket name means storage that belongs to
|
|
7
|
+
* no tenant at all. `function_resolution.resolve_default_bucket` is the one
|
|
8
|
+
* place that answers it — a logical key when the field declares one, otherwise
|
|
9
|
+
* the reserved default tag for the requested access ('default' / 'default-public').
|
|
10
|
+
*
|
|
11
|
+
* Zero matches and several matches both raise inside SQL, so there is nothing to
|
|
12
|
+
* guess here: this module only carries the question in and the coordinate out.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The resolved bucket coordinate.
|
|
16
|
+
*
|
|
17
|
+
* `physicalName` is the recorded S3 bucket name, or null when the logical
|
|
18
|
+
* bucket has never been provisioned — the caller mints and records it then.
|
|
19
|
+
*/
|
|
20
|
+
export interface ResolvedBucketCoordinate {
|
|
21
|
+
bucketId: string;
|
|
22
|
+
resolvedKey: string;
|
|
23
|
+
bucketType: 'public' | 'private' | 'temp';
|
|
24
|
+
physicalName: string | null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the bucket a write should land in.
|
|
28
|
+
*
|
|
29
|
+
* @param scope - The storage module's scope ('app' for database-wide storage)
|
|
30
|
+
* @param entityId - The owning entity row for an entity-scoped module, else null
|
|
31
|
+
* @param publicAccess - Which reserved default tag to use when no key is named,
|
|
32
|
+
* and an assertion on the named bucket's type when one is
|
|
33
|
+
* @param bucketKey - The field's declared logical key, or null for the default
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveDefaultBucket(pgClient: {
|
|
36
|
+
query: (opts: {
|
|
37
|
+
text: string;
|
|
38
|
+
values?: unknown[];
|
|
39
|
+
}) => Promise<{
|
|
40
|
+
rows: unknown[];
|
|
41
|
+
}>;
|
|
42
|
+
}, databaseId: string, scope: string, entityId: string | null, publicAccess: boolean, bucketKey: string | null): Promise<ResolvedBucketCoordinate>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side bucket resolution.
|
|
3
|
+
*
|
|
4
|
+
* Which bucket a write lands in belongs to the database, never to the client and
|
|
5
|
+
* never to the server's environment: a client-chosen key means a different
|
|
6
|
+
* bucket per tenant, and an env-level bucket name means storage that belongs to
|
|
7
|
+
* no tenant at all. `function_resolution.resolve_default_bucket` is the one
|
|
8
|
+
* place that answers it — a logical key when the field declares one, otherwise
|
|
9
|
+
* the reserved default tag for the requested access ('default' / 'default-public').
|
|
10
|
+
*
|
|
11
|
+
* Zero matches and several matches both raise inside SQL, so there is nothing to
|
|
12
|
+
* guess here: this module only carries the question in and the coordinate out.
|
|
13
|
+
*/
|
|
14
|
+
import { Logger } from '@pgpmjs/logger';
|
|
15
|
+
const log = new Logger('graphile-presigned-url:default-bucket');
|
|
16
|
+
const RESOLVE_DEFAULT_BUCKET_QUERY = `
|
|
17
|
+
SELECT bucket_id, resolved_key, bucket_type, physical_name
|
|
18
|
+
FROM function_resolution.resolve_default_bucket($1, $2, $3, $4, $5)
|
|
19
|
+
`;
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the bucket a write should land in.
|
|
22
|
+
*
|
|
23
|
+
* @param scope - The storage module's scope ('app' for database-wide storage)
|
|
24
|
+
* @param entityId - The owning entity row for an entity-scoped module, else null
|
|
25
|
+
* @param publicAccess - Which reserved default tag to use when no key is named,
|
|
26
|
+
* and an assertion on the named bucket's type when one is
|
|
27
|
+
* @param bucketKey - The field's declared logical key, or null for the default
|
|
28
|
+
*/
|
|
29
|
+
export async function resolveDefaultBucket(pgClient, databaseId, scope, entityId, publicAccess, bucketKey) {
|
|
30
|
+
const result = await pgClient.query({
|
|
31
|
+
text: RESOLVE_DEFAULT_BUCKET_QUERY,
|
|
32
|
+
values: [databaseId, scope, entityId, publicAccess, bucketKey],
|
|
33
|
+
});
|
|
34
|
+
const row = result.rows[0];
|
|
35
|
+
if (!row) {
|
|
36
|
+
// resolve_default_bucket raises on zero and on several matches, so an empty
|
|
37
|
+
// result means the function did not run as declared rather than "no bucket".
|
|
38
|
+
throw new Error(`STORAGE_DEFAULT_BUCKET_NO_ROW: resolve_default_bucket returned no row for ` +
|
|
39
|
+
`database=${databaseId} scope=${scope} public=${publicAccess} key=${bucketKey ?? '<default tag>'}`);
|
|
40
|
+
}
|
|
41
|
+
log.debug(`Resolved bucket ${row.resolved_key} (${row.bucket_type}) for database=${databaseId} scope=${scope}`);
|
|
42
|
+
return {
|
|
43
|
+
bucketId: row.bucket_id,
|
|
44
|
+
resolvedKey: row.resolved_key,
|
|
45
|
+
bucketType: row.bucket_type,
|
|
46
|
+
physicalName: row.physical_name,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `file_ref_field` registry: which storage module and bucket a managed
|
|
3
|
+
* document column writes into.
|
|
4
|
+
*
|
|
5
|
+
* An `image`/`upload` column is a projection of a files row, and the decision of
|
|
6
|
+
* *where* those bytes live is a property of the field declaration, not of the
|
|
7
|
+
* request. The registry records that intent per (table, column) — a storage
|
|
8
|
+
* module plus either a logical bucket key, a tag selector, or nothing at all
|
|
9
|
+
* (meaning the reserved default tag for the declared publicness).
|
|
10
|
+
*
|
|
11
|
+
* This module answers one question — "what does a write to this column bind
|
|
12
|
+
* to?" — and answers it loudly: an unregistered column raises rather than
|
|
13
|
+
* falling back to a server-global bucket, because a silent fallback is how the
|
|
14
|
+
* unmanaged lane produced objects no tenant owned.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* A field's recorded storage intent.
|
|
18
|
+
*
|
|
19
|
+
* `bucketKey` and `bucketTags` are mutually exclusive by table constraint, and
|
|
20
|
+
* both may be absent — resolution then uses the reserved default tag for
|
|
21
|
+
* `isPublic`. Nothing here is a physical bucket name or id: the concrete bucket
|
|
22
|
+
* is resolved per written row, inside the tenant.
|
|
23
|
+
*/
|
|
24
|
+
export interface FileRefFieldBinding {
|
|
25
|
+
id: string;
|
|
26
|
+
storageModuleId: string;
|
|
27
|
+
bucketKey: string | null;
|
|
28
|
+
bucketTags: string[] | null;
|
|
29
|
+
isPublic: boolean | null;
|
|
30
|
+
enforceFk: boolean;
|
|
31
|
+
}
|
|
32
|
+
export declare class FileRefFieldNotRegisteredError extends Error {
|
|
33
|
+
readonly databaseId: string;
|
|
34
|
+
readonly schemaName: string;
|
|
35
|
+
readonly tableName: string;
|
|
36
|
+
readonly columnName: string;
|
|
37
|
+
constructor(databaseId: string, schemaName: string, tableName: string, columnName: string);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Look up the storage binding for a document column, or throw.
|
|
41
|
+
*
|
|
42
|
+
* The read runs on whichever client the caller passes. The registry is schema
|
|
43
|
+
* metadata rather than tenant rows, so callers resolve it in the system lane —
|
|
44
|
+
* the RLS that matters is on the files table the upload eventually writes.
|
|
45
|
+
*/
|
|
46
|
+
export declare function getFileRefFieldBinding(pgClient: {
|
|
47
|
+
query: (opts: {
|
|
48
|
+
text: string;
|
|
49
|
+
values?: unknown[];
|
|
50
|
+
}) => Promise<{
|
|
51
|
+
rows: unknown[];
|
|
52
|
+
}>;
|
|
53
|
+
}, databaseId: string, field: {
|
|
54
|
+
schemaName: string;
|
|
55
|
+
tableName: string;
|
|
56
|
+
columnName: string;
|
|
57
|
+
}): Promise<FileRefFieldBinding>;
|
|
58
|
+
/**
|
|
59
|
+
* Drop cached bindings. Used by tests and after a re-provision.
|
|
60
|
+
*/
|
|
61
|
+
export declare function clearFileRefFieldCache(): void;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `file_ref_field` registry: which storage module and bucket a managed
|
|
3
|
+
* document column writes into.
|
|
4
|
+
*
|
|
5
|
+
* An `image`/`upload` column is a projection of a files row, and the decision of
|
|
6
|
+
* *where* those bytes live is a property of the field declaration, not of the
|
|
7
|
+
* request. The registry records that intent per (table, column) — a storage
|
|
8
|
+
* module plus either a logical bucket key, a tag selector, or nothing at all
|
|
9
|
+
* (meaning the reserved default tag for the declared publicness).
|
|
10
|
+
*
|
|
11
|
+
* This module answers one question — "what does a write to this column bind
|
|
12
|
+
* to?" — and answers it loudly: an unregistered column raises rather than
|
|
13
|
+
* falling back to a server-global bucket, because a silent fallback is how the
|
|
14
|
+
* unmanaged lane produced objects no tenant owned.
|
|
15
|
+
*/
|
|
16
|
+
import { Logger } from '@pgpmjs/logger';
|
|
17
|
+
import { LRUCache } from 'lru-cache';
|
|
18
|
+
const log = new Logger('graphile-presigned-url:file-ref-registry');
|
|
19
|
+
const FIVE_MINUTES_MS = 1000 * 60 * 5;
|
|
20
|
+
const ONE_HOUR_MS = 1000 * 60 * 60;
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the registry row for a document column.
|
|
23
|
+
*
|
|
24
|
+
* Joined through metaschema rather than keyed by name, because the registry
|
|
25
|
+
* records field *ids*: the physical (schema, table, column) triple is what the
|
|
26
|
+
* GraphQL layer knows, and metaschema is the only thing that maps one to the
|
|
27
|
+
* other.
|
|
28
|
+
*/
|
|
29
|
+
const FILE_REF_FIELD_QUERY = `
|
|
30
|
+
SELECT
|
|
31
|
+
frf.id,
|
|
32
|
+
frf.storage_module_id,
|
|
33
|
+
frf.bucket_key,
|
|
34
|
+
frf.bucket_tags::text[] AS bucket_tags,
|
|
35
|
+
frf.is_public,
|
|
36
|
+
frf.enforce_fk
|
|
37
|
+
FROM metaschema_modules_public.file_ref_field frf
|
|
38
|
+
JOIN metaschema_public.field f ON f.id = frf.field_id
|
|
39
|
+
JOIN metaschema_public.table t ON t.id = frf.table_id
|
|
40
|
+
JOIN metaschema_public.schema s ON s.id = t.schema_id
|
|
41
|
+
WHERE frf.database_id = $1
|
|
42
|
+
AND s.schema_name = $2
|
|
43
|
+
AND t.name = $3
|
|
44
|
+
AND f.name = $4
|
|
45
|
+
LIMIT 1
|
|
46
|
+
`;
|
|
47
|
+
/**
|
|
48
|
+
* LRU cache of field bindings.
|
|
49
|
+
*
|
|
50
|
+
* A binding is schema, not data: it changes only when a database is
|
|
51
|
+
* re-provisioned, so it caches on the same terms as the storage module config
|
|
52
|
+
* next to it. Misses are never cached — an unregistered column is a hard error
|
|
53
|
+
* every time it is written, not a remembered "no".
|
|
54
|
+
*/
|
|
55
|
+
const bindingCache = new LRUCache({
|
|
56
|
+
max: 500,
|
|
57
|
+
ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
|
|
58
|
+
updateAgeOnGet: true,
|
|
59
|
+
});
|
|
60
|
+
export class FileRefFieldNotRegisteredError extends Error {
|
|
61
|
+
databaseId;
|
|
62
|
+
schemaName;
|
|
63
|
+
tableName;
|
|
64
|
+
columnName;
|
|
65
|
+
constructor(databaseId, schemaName, tableName, columnName) {
|
|
66
|
+
super(`FILE_REF_FIELD_NOT_REGISTERED: ${schemaName}.${tableName}.${columnName} ` +
|
|
67
|
+
`is not a registered file-reference field in database ${databaseId}. ` +
|
|
68
|
+
'A managed upload needs the declared storage module and bucket intent; ' +
|
|
69
|
+
'there is no server-global bucket to fall back to.');
|
|
70
|
+
this.databaseId = databaseId;
|
|
71
|
+
this.schemaName = schemaName;
|
|
72
|
+
this.tableName = tableName;
|
|
73
|
+
this.columnName = columnName;
|
|
74
|
+
this.name = 'FileRefFieldNotRegisteredError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Look up the storage binding for a document column, or throw.
|
|
79
|
+
*
|
|
80
|
+
* The read runs on whichever client the caller passes. The registry is schema
|
|
81
|
+
* metadata rather than tenant rows, so callers resolve it in the system lane —
|
|
82
|
+
* the RLS that matters is on the files table the upload eventually writes.
|
|
83
|
+
*/
|
|
84
|
+
export async function getFileRefFieldBinding(pgClient, databaseId, field) {
|
|
85
|
+
const cacheKey = `file-ref:${databaseId}:${field.schemaName}.${field.tableName}.${field.columnName}`;
|
|
86
|
+
const cached = bindingCache.get(cacheKey);
|
|
87
|
+
if (cached)
|
|
88
|
+
return cached;
|
|
89
|
+
const result = await pgClient.query({
|
|
90
|
+
text: FILE_REF_FIELD_QUERY,
|
|
91
|
+
values: [databaseId, field.schemaName, field.tableName, field.columnName],
|
|
92
|
+
});
|
|
93
|
+
if (result.rows.length === 0) {
|
|
94
|
+
throw new FileRefFieldNotRegisteredError(databaseId, field.schemaName, field.tableName, field.columnName);
|
|
95
|
+
}
|
|
96
|
+
const row = result.rows[0];
|
|
97
|
+
const binding = {
|
|
98
|
+
id: row.id,
|
|
99
|
+
storageModuleId: row.storage_module_id,
|
|
100
|
+
bucketKey: row.bucket_key,
|
|
101
|
+
bucketTags: row.bucket_tags,
|
|
102
|
+
isPublic: row.is_public,
|
|
103
|
+
enforceFk: row.enforce_fk,
|
|
104
|
+
};
|
|
105
|
+
bindingCache.set(cacheKey, binding);
|
|
106
|
+
log.debug(`Bound ${field.schemaName}.${field.tableName}.${field.columnName} to storage module ` +
|
|
107
|
+
`${binding.storageModuleId} (bucket_key=${binding.bucketKey ?? '<default tag>'})`);
|
|
108
|
+
return binding;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Drop cached bindings. Used by tests and after a re-provision.
|
|
112
|
+
*/
|
|
113
|
+
export function clearFileRefFieldCache() {
|
|
114
|
+
bindingCache.clear();
|
|
115
|
+
}
|
package/esm/index.d.ts
CHANGED
|
@@ -26,9 +26,17 @@
|
|
|
26
26
|
* };
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
+
export { CONFIRM_PREFIX_BYTES, confirmUploadedBytes, type ConfirmUploadInput, type ConfirmUploadVerdict, } from './confirm-upload';
|
|
30
|
+
export type { ResolvedBucketCoordinate } from './default-bucket';
|
|
31
|
+
export { resolveDefaultBucket } from './default-bucket';
|
|
29
32
|
export { createDownloadUrlPlugin } from './download-url-field';
|
|
33
|
+
export type { FileRefFieldBinding } from './file-ref-registry';
|
|
34
|
+
export { clearFileRefFieldCache, FileRefFieldNotRegisteredError, getFileRefFieldBinding } from './file-ref-registry';
|
|
35
|
+
export { assertUploadAllowedByBucket, buildFileProjection, type FileProjection, finalizeStagedUpload, type ManagedUploadTarget, resolveManagedUploadTarget, } from './managed-upload';
|
|
36
|
+
export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, resolveS3ForDatabase } from './physical-bucket';
|
|
30
37
|
export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
|
|
31
38
|
export { PresignedUrlPreset } from './preset';
|
|
32
|
-
export {
|
|
39
|
+
export { type WithPgClient, withRequestPgClient } from './request-pg-client';
|
|
40
|
+
export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
|
|
33
41
|
export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
|
|
34
42
|
export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
|
package/esm/index.js
CHANGED
|
@@ -26,8 +26,14 @@
|
|
|
26
26
|
* };
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
+
export { CONFIRM_PREFIX_BYTES, confirmUploadedBytes, } from './confirm-upload';
|
|
30
|
+
export { resolveDefaultBucket } from './default-bucket';
|
|
29
31
|
export { createDownloadUrlPlugin } from './download-url-field';
|
|
32
|
+
export { clearFileRefFieldCache, FileRefFieldNotRegisteredError, getFileRefFieldBinding } from './file-ref-registry';
|
|
33
|
+
export { assertUploadAllowedByBucket, buildFileProjection, finalizeStagedUpload, resolveManagedUploadTarget, } from './managed-upload';
|
|
34
|
+
export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, resolveS3ForDatabase } from './physical-bucket';
|
|
30
35
|
export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
|
|
31
36
|
export { PresignedUrlPreset } from './preset';
|
|
32
|
-
export {
|
|
37
|
+
export { withRequestPgClient } from './request-pg-client';
|
|
38
|
+
export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
|
|
33
39
|
export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
|