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.
@@ -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;
@@ -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.12.0",
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": "44f56ee80a348f649a22e5bd32b35f2822726509"
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
  *