graphile-presigned-url-plugin 1.12.0 → 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/esm/confirm-upload.d.ts +64 -0
- package/esm/confirm-upload.js +66 -0
- package/esm/index.d.ts +2 -1
- package/esm/index.js +2 -1
- package/esm/plugin.js +11 -0
- package/esm/s3-signer.d.ts +16 -0
- package/esm/s3-signer.js +38 -0
- package/index.d.ts +2 -1
- package/index.js +5 -1
- package/package.json +4 -3
- package/plugin.js +11 -0
- package/s3-signer.d.ts +16 -0
- package/s3-signer.js +39 -0
|
@@ -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,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
|
+
}
|
package/esm/index.d.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* };
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
+
export { CONFIRM_PREFIX_BYTES, confirmUploadedBytes, type ConfirmUploadInput, type ConfirmUploadVerdict, } from './confirm-upload';
|
|
29
30
|
export type { ResolvedBucketCoordinate } from './default-bucket';
|
|
30
31
|
export { resolveDefaultBucket } from './default-bucket';
|
|
31
32
|
export { createDownloadUrlPlugin } from './download-url-field';
|
|
@@ -36,6 +37,6 @@ export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, re
|
|
|
36
37
|
export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
|
|
37
38
|
export { PresignedUrlPreset } from './preset';
|
|
38
39
|
export { type WithPgClient, withRequestPgClient } from './request-pg-client';
|
|
39
|
-
export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
|
|
40
|
+
export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
|
|
40
41
|
export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
|
|
41
42
|
export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
|
package/esm/index.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* };
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
+
export { CONFIRM_PREFIX_BYTES, confirmUploadedBytes, } from './confirm-upload';
|
|
29
30
|
export { resolveDefaultBucket } from './default-bucket';
|
|
30
31
|
export { createDownloadUrlPlugin } from './download-url-field';
|
|
31
32
|
export { clearFileRefFieldCache, FileRefFieldNotRegisteredError, getFileRefFieldBinding } from './file-ref-registry';
|
|
@@ -34,5 +35,5 @@ export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, re
|
|
|
34
35
|
export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
|
|
35
36
|
export { PresignedUrlPreset } from './preset';
|
|
36
37
|
export { withRequestPgClient } from './request-pg-client';
|
|
37
|
-
export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
|
|
38
|
+
export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
|
|
38
39
|
export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
|
package/esm/plugin.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import 'graphile-build';
|
|
20
20
|
import { Logger } from '@pgpmjs/logger';
|
|
21
21
|
import { access, context as grafastContext, lambda, object } from 'grafast';
|
|
22
|
+
import { checkTypeAgreement } from 'mime-bytes';
|
|
22
23
|
import { resolveDefaultBucket } from './default-bucket';
|
|
23
24
|
import { buildFileProjection } from './managed-upload';
|
|
24
25
|
import { provisionAndRecordPhysicalBucket, resolveS3ForDatabase } from './physical-bucket';
|
|
@@ -505,6 +506,16 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
505
506
|
throw new Error('INVALID_FILENAME');
|
|
506
507
|
}
|
|
507
508
|
}
|
|
509
|
+
// The bytes are not here to be examined — the client PUTs them straight to S3 —
|
|
510
|
+
// so this checks the two claims that *are* here against each other. It is the
|
|
511
|
+
// cheap half of the rule: an upload declaring `image/jpeg` under the name
|
|
512
|
+
// `payload.html` is refused before a row exists, without reading a byte. The
|
|
513
|
+
// bytes themselves are checked on confirmation, before the row leaves
|
|
514
|
+
// `requested`.
|
|
515
|
+
const agreement = checkTypeAgreement({ filename, declaredMime: contentType });
|
|
516
|
+
if (!agreement.ok) {
|
|
517
|
+
throw new Error(`UPLOAD_TYPE_MISMATCH: ${agreement.violation.message}`);
|
|
518
|
+
}
|
|
508
519
|
// Validate content type against bucket's allowed_mime_types
|
|
509
520
|
if (bucket.allowed_mime_types && bucket.allowed_mime_types.length > 0) {
|
|
510
521
|
const allowed = bucket.allowed_mime_types;
|
package/esm/s3-signer.d.ts
CHANGED
|
@@ -49,6 +49,22 @@ export declare function deleteS3Object(s3Config: S3Config, key: string): Promise
|
|
|
49
49
|
* @param contentType - MIME type to record on the destination object
|
|
50
50
|
*/
|
|
51
51
|
export declare function copyS3Object(s3Config: S3Config, sourceKey: string, destinationKey: string, contentType: string): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Read the leading bytes of an object.
|
|
54
|
+
*
|
|
55
|
+
* A ranged GET, because the only reason to touch bytes the client uploaded
|
|
56
|
+
* directly is to see what they actually are: a magic-byte signature lives in the
|
|
57
|
+
* first few dozen bytes, so validating a 2GB video costs the same as validating
|
|
58
|
+
* an icon.
|
|
59
|
+
*
|
|
60
|
+
* Returns null when the object is not there — the presigned lane's ordinary
|
|
61
|
+
* "client never PUT it" case, which is an expiry rather than a failure.
|
|
62
|
+
*
|
|
63
|
+
* @param s3Config - S3 client and bucket configuration
|
|
64
|
+
* @param key - S3 object key
|
|
65
|
+
* @param byteCount - How many leading bytes to read
|
|
66
|
+
*/
|
|
67
|
+
export declare function readObjectPrefix(s3Config: S3Config, key: string, byteCount: number): Promise<Buffer | null>;
|
|
52
68
|
/**
|
|
53
69
|
* Check if an object exists in S3 and optionally verify its content-type.
|
|
54
70
|
*
|
package/esm/s3-signer.js
CHANGED
|
@@ -90,6 +90,44 @@ export async function copyS3Object(s3Config, sourceKey, destinationKey, contentT
|
|
|
90
90
|
}));
|
|
91
91
|
log.debug(`Copied S3 object: bucket=${s3Config.bucket}, ${sourceKey} → ${destinationKey}`);
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Read the leading bytes of an object.
|
|
95
|
+
*
|
|
96
|
+
* A ranged GET, because the only reason to touch bytes the client uploaded
|
|
97
|
+
* directly is to see what they actually are: a magic-byte signature lives in the
|
|
98
|
+
* first few dozen bytes, so validating a 2GB video costs the same as validating
|
|
99
|
+
* an icon.
|
|
100
|
+
*
|
|
101
|
+
* Returns null when the object is not there — the presigned lane's ordinary
|
|
102
|
+
* "client never PUT it" case, which is an expiry rather than a failure.
|
|
103
|
+
*
|
|
104
|
+
* @param s3Config - S3 client and bucket configuration
|
|
105
|
+
* @param key - S3 object key
|
|
106
|
+
* @param byteCount - How many leading bytes to read
|
|
107
|
+
*/
|
|
108
|
+
export async function readObjectPrefix(s3Config, key, byteCount) {
|
|
109
|
+
try {
|
|
110
|
+
const response = await s3Config.client.send(new GetObjectCommand({
|
|
111
|
+
Bucket: s3Config.bucket,
|
|
112
|
+
Key: key,
|
|
113
|
+
Range: `bytes=0-${byteCount - 1}`,
|
|
114
|
+
}));
|
|
115
|
+
const body = response.Body;
|
|
116
|
+
if (!body)
|
|
117
|
+
return Buffer.alloc(0);
|
|
118
|
+
const chunks = [];
|
|
119
|
+
for await (const chunk of body) {
|
|
120
|
+
chunks.push(Buffer.from(chunk));
|
|
121
|
+
}
|
|
122
|
+
return Buffer.concat(chunks);
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
if (e.name === 'NoSuchKey' || e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
throw e;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
93
131
|
/**
|
|
94
132
|
* Check if an object exists in S3 and optionally verify its content-type.
|
|
95
133
|
*
|
package/index.d.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* };
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
|
+
export { CONFIRM_PREFIX_BYTES, confirmUploadedBytes, type ConfirmUploadInput, type ConfirmUploadVerdict, } from './confirm-upload';
|
|
29
30
|
export type { ResolvedBucketCoordinate } from './default-bucket';
|
|
30
31
|
export { resolveDefaultBucket } from './default-bucket';
|
|
31
32
|
export { createDownloadUrlPlugin } from './download-url-field';
|
|
@@ -36,6 +37,6 @@ export { mintPhysicalBucketName, provisionAndRecordPhysicalBucket, resolveS3, re
|
|
|
36
37
|
export { createPresignedUrlPlugin, PresignedUrlPlugin } from './plugin';
|
|
37
38
|
export { PresignedUrlPreset } from './preset';
|
|
38
39
|
export { type WithPgClient, withRequestPgClient } from './request-pg-client';
|
|
39
|
-
export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject } from './s3-signer';
|
|
40
|
+
export { copyS3Object, deleteS3Object, generatePresignedGetUrl, generatePresignedPutUrl, headObject, readObjectPrefix } from './s3-signer';
|
|
40
41
|
export { clearBucketCache, clearStorageModuleCache, getBucketConfig, getStorageModuleConfig, getStorageModuleConfigForOwner, isS3BucketProvisioned, loadAllStorageModules, markS3BucketProvisioned, resolveStorageConfigFromCodec, resolveStorageModuleByFileId } from './storage-module-cache';
|
|
41
42
|
export type { BucketConfig, BucketNameResolver, EnsureBucketProvisioned, PresignedUrlPluginOptions, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, StorageModuleConfig, } from './types';
|
package/index.js
CHANGED
|
@@ -28,7 +28,10 @@
|
|
|
28
28
|
* ```
|
|
29
29
|
*/
|
|
30
30
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
-
exports.resolveStorageModuleByFileId = exports.resolveStorageConfigFromCodec = exports.markS3BucketProvisioned = exports.loadAllStorageModules = exports.isS3BucketProvisioned = exports.getStorageModuleConfigForOwner = exports.getStorageModuleConfig = exports.getBucketConfig = exports.clearStorageModuleCache = exports.clearBucketCache = exports.headObject = exports.generatePresignedPutUrl = exports.generatePresignedGetUrl = exports.deleteS3Object = exports.copyS3Object = exports.withRequestPgClient = exports.PresignedUrlPreset = exports.PresignedUrlPlugin = exports.createPresignedUrlPlugin = exports.resolveS3ForDatabase = exports.resolveS3 = exports.provisionAndRecordPhysicalBucket = exports.mintPhysicalBucketName = exports.resolveManagedUploadTarget = exports.finalizeStagedUpload = exports.buildFileProjection = exports.assertUploadAllowedByBucket = exports.getFileRefFieldBinding = exports.FileRefFieldNotRegisteredError = exports.clearFileRefFieldCache = exports.createDownloadUrlPlugin = exports.resolveDefaultBucket = void 0;
|
|
31
|
+
exports.resolveStorageModuleByFileId = exports.resolveStorageConfigFromCodec = exports.markS3BucketProvisioned = exports.loadAllStorageModules = exports.isS3BucketProvisioned = exports.getStorageModuleConfigForOwner = exports.getStorageModuleConfig = exports.getBucketConfig = exports.clearStorageModuleCache = exports.clearBucketCache = exports.readObjectPrefix = exports.headObject = exports.generatePresignedPutUrl = exports.generatePresignedGetUrl = exports.deleteS3Object = exports.copyS3Object = exports.withRequestPgClient = exports.PresignedUrlPreset = exports.PresignedUrlPlugin = exports.createPresignedUrlPlugin = exports.resolveS3ForDatabase = exports.resolveS3 = exports.provisionAndRecordPhysicalBucket = exports.mintPhysicalBucketName = exports.resolveManagedUploadTarget = exports.finalizeStagedUpload = exports.buildFileProjection = exports.assertUploadAllowedByBucket = exports.getFileRefFieldBinding = exports.FileRefFieldNotRegisteredError = exports.clearFileRefFieldCache = exports.createDownloadUrlPlugin = exports.resolveDefaultBucket = exports.confirmUploadedBytes = exports.CONFIRM_PREFIX_BYTES = void 0;
|
|
32
|
+
var confirm_upload_1 = require("./confirm-upload");
|
|
33
|
+
Object.defineProperty(exports, "CONFIRM_PREFIX_BYTES", { enumerable: true, get: function () { return confirm_upload_1.CONFIRM_PREFIX_BYTES; } });
|
|
34
|
+
Object.defineProperty(exports, "confirmUploadedBytes", { enumerable: true, get: function () { return confirm_upload_1.confirmUploadedBytes; } });
|
|
32
35
|
var default_bucket_1 = require("./default-bucket");
|
|
33
36
|
Object.defineProperty(exports, "resolveDefaultBucket", { enumerable: true, get: function () { return default_bucket_1.resolveDefaultBucket; } });
|
|
34
37
|
var download_url_field_1 = require("./download-url-field");
|
|
@@ -60,6 +63,7 @@ Object.defineProperty(exports, "deleteS3Object", { enumerable: true, get: functi
|
|
|
60
63
|
Object.defineProperty(exports, "generatePresignedGetUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedGetUrl; } });
|
|
61
64
|
Object.defineProperty(exports, "generatePresignedPutUrl", { enumerable: true, get: function () { return s3_signer_1.generatePresignedPutUrl; } });
|
|
62
65
|
Object.defineProperty(exports, "headObject", { enumerable: true, get: function () { return s3_signer_1.headObject; } });
|
|
66
|
+
Object.defineProperty(exports, "readObjectPrefix", { enumerable: true, get: function () { return s3_signer_1.readObjectPrefix; } });
|
|
63
67
|
var storage_module_cache_1 = require("./storage-module-cache");
|
|
64
68
|
Object.defineProperty(exports, "clearBucketCache", { enumerable: true, get: function () { return storage_module_cache_1.clearBucketCache; } });
|
|
65
69
|
Object.defineProperty(exports, "clearStorageModuleCache", { enumerable: true, get: function () { return storage_module_cache_1.clearStorageModuleCache; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-presigned-url-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl mutation and downloadUrl computed field",
|
|
5
5
|
"author": "Constructive <developers@constructive.io>",
|
|
6
6
|
"homepage": "https://github.com/constructive-io/constructive",
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"@aws-sdk/s3-request-presigner": "^3.1052.0",
|
|
45
45
|
"@pgpmjs/logger": "^2.24.1",
|
|
46
46
|
"@pgsql/quotes": "^18.2.4",
|
|
47
|
-
"lru-cache": "^11.2.7"
|
|
47
|
+
"lru-cache": "^11.2.7",
|
|
48
|
+
"mime-bytes": "^0.31.0"
|
|
48
49
|
},
|
|
49
50
|
"peerDependencies": {
|
|
50
51
|
"grafast": "^1.1.1",
|
|
@@ -60,5 +61,5 @@
|
|
|
60
61
|
"@types/node": "^22.19.11",
|
|
61
62
|
"makage": "^0.3.0"
|
|
62
63
|
},
|
|
63
|
-
"gitHead": "
|
|
64
|
+
"gitHead": "d0d796b7eb0d16c8c19405a49c0ca62ce3df117b"
|
|
64
65
|
}
|
package/plugin.js
CHANGED
|
@@ -23,6 +23,7 @@ exports.createPresignedUrlPlugin = createPresignedUrlPlugin;
|
|
|
23
23
|
require("graphile-build");
|
|
24
24
|
const logger_1 = require("@pgpmjs/logger");
|
|
25
25
|
const grafast_1 = require("grafast");
|
|
26
|
+
const mime_bytes_1 = require("mime-bytes");
|
|
26
27
|
const default_bucket_1 = require("./default-bucket");
|
|
27
28
|
const managed_upload_1 = require("./managed-upload");
|
|
28
29
|
const physical_bucket_1 = require("./physical-bucket");
|
|
@@ -509,6 +510,16 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
509
510
|
throw new Error('INVALID_FILENAME');
|
|
510
511
|
}
|
|
511
512
|
}
|
|
513
|
+
// The bytes are not here to be examined — the client PUTs them straight to S3 —
|
|
514
|
+
// so this checks the two claims that *are* here against each other. It is the
|
|
515
|
+
// cheap half of the rule: an upload declaring `image/jpeg` under the name
|
|
516
|
+
// `payload.html` is refused before a row exists, without reading a byte. The
|
|
517
|
+
// bytes themselves are checked on confirmation, before the row leaves
|
|
518
|
+
// `requested`.
|
|
519
|
+
const agreement = (0, mime_bytes_1.checkTypeAgreement)({ filename, declaredMime: contentType });
|
|
520
|
+
if (!agreement.ok) {
|
|
521
|
+
throw new Error(`UPLOAD_TYPE_MISMATCH: ${agreement.violation.message}`);
|
|
522
|
+
}
|
|
512
523
|
// Validate content type against bucket's allowed_mime_types
|
|
513
524
|
if (bucket.allowed_mime_types && bucket.allowed_mime_types.length > 0) {
|
|
514
525
|
const allowed = bucket.allowed_mime_types;
|
package/s3-signer.d.ts
CHANGED
|
@@ -49,6 +49,22 @@ export declare function deleteS3Object(s3Config: S3Config, key: string): Promise
|
|
|
49
49
|
* @param contentType - MIME type to record on the destination object
|
|
50
50
|
*/
|
|
51
51
|
export declare function copyS3Object(s3Config: S3Config, sourceKey: string, destinationKey: string, contentType: string): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Read the leading bytes of an object.
|
|
54
|
+
*
|
|
55
|
+
* A ranged GET, because the only reason to touch bytes the client uploaded
|
|
56
|
+
* directly is to see what they actually are: a magic-byte signature lives in the
|
|
57
|
+
* first few dozen bytes, so validating a 2GB video costs the same as validating
|
|
58
|
+
* an icon.
|
|
59
|
+
*
|
|
60
|
+
* Returns null when the object is not there — the presigned lane's ordinary
|
|
61
|
+
* "client never PUT it" case, which is an expiry rather than a failure.
|
|
62
|
+
*
|
|
63
|
+
* @param s3Config - S3 client and bucket configuration
|
|
64
|
+
* @param key - S3 object key
|
|
65
|
+
* @param byteCount - How many leading bytes to read
|
|
66
|
+
*/
|
|
67
|
+
export declare function readObjectPrefix(s3Config: S3Config, key: string, byteCount: number): Promise<Buffer | null>;
|
|
52
68
|
/**
|
|
53
69
|
* Check if an object exists in S3 and optionally verify its content-type.
|
|
54
70
|
*
|
package/s3-signer.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.generatePresignedPutUrl = generatePresignedPutUrl;
|
|
|
4
4
|
exports.generatePresignedGetUrl = generatePresignedGetUrl;
|
|
5
5
|
exports.deleteS3Object = deleteS3Object;
|
|
6
6
|
exports.copyS3Object = copyS3Object;
|
|
7
|
+
exports.readObjectPrefix = readObjectPrefix;
|
|
7
8
|
exports.headObject = headObject;
|
|
8
9
|
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
9
10
|
const s3_request_presigner_1 = require("@aws-sdk/s3-request-presigner");
|
|
@@ -97,6 +98,44 @@ async function copyS3Object(s3Config, sourceKey, destinationKey, contentType) {
|
|
|
97
98
|
}));
|
|
98
99
|
log.debug(`Copied S3 object: bucket=${s3Config.bucket}, ${sourceKey} → ${destinationKey}`);
|
|
99
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Read the leading bytes of an object.
|
|
103
|
+
*
|
|
104
|
+
* A ranged GET, because the only reason to touch bytes the client uploaded
|
|
105
|
+
* directly is to see what they actually are: a magic-byte signature lives in the
|
|
106
|
+
* first few dozen bytes, so validating a 2GB video costs the same as validating
|
|
107
|
+
* an icon.
|
|
108
|
+
*
|
|
109
|
+
* Returns null when the object is not there — the presigned lane's ordinary
|
|
110
|
+
* "client never PUT it" case, which is an expiry rather than a failure.
|
|
111
|
+
*
|
|
112
|
+
* @param s3Config - S3 client and bucket configuration
|
|
113
|
+
* @param key - S3 object key
|
|
114
|
+
* @param byteCount - How many leading bytes to read
|
|
115
|
+
*/
|
|
116
|
+
async function readObjectPrefix(s3Config, key, byteCount) {
|
|
117
|
+
try {
|
|
118
|
+
const response = await s3Config.client.send(new client_s3_1.GetObjectCommand({
|
|
119
|
+
Bucket: s3Config.bucket,
|
|
120
|
+
Key: key,
|
|
121
|
+
Range: `bytes=0-${byteCount - 1}`,
|
|
122
|
+
}));
|
|
123
|
+
const body = response.Body;
|
|
124
|
+
if (!body)
|
|
125
|
+
return Buffer.alloc(0);
|
|
126
|
+
const chunks = [];
|
|
127
|
+
for await (const chunk of body) {
|
|
128
|
+
chunks.push(Buffer.from(chunk));
|
|
129
|
+
}
|
|
130
|
+
return Buffer.concat(chunks);
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
if (e.name === 'NoSuchKey' || e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
throw e;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
100
139
|
/**
|
|
101
140
|
* Check if an object exists in S3 and optionally verify its content-type.
|
|
102
141
|
*
|