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
package/esm/plugin.js
CHANGED
|
@@ -19,9 +19,13 @@
|
|
|
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';
|
|
23
|
+
import { resolveDefaultBucket } from './default-bucket';
|
|
24
|
+
import { buildFileProjection } from './managed-upload';
|
|
25
|
+
import { provisionAndRecordPhysicalBucket, resolveS3ForDatabase } from './physical-bucket';
|
|
22
26
|
import { withRequestPgClient } from './request-pg-client';
|
|
23
27
|
import { deleteS3Object, generatePresignedPutUrl } from './s3-signer';
|
|
24
|
-
import { getBucketConfig,
|
|
28
|
+
import { getBucketConfig, loadAllStorageModules, resolveStorageConfigFromCodec, storedPhysicalName } from './storage-module-cache';
|
|
25
29
|
const log = new Logger('graphile-presigned-url:plugin');
|
|
26
30
|
// --- Protocol-level constants (not configurable) ---
|
|
27
31
|
const MAX_CONTENT_HASH_LENGTH = 128;
|
|
@@ -67,87 +71,20 @@ async function resolveDatabaseId(pgClient) {
|
|
|
67
71
|
});
|
|
68
72
|
return result.rows[0]?.id ?? null;
|
|
69
73
|
}
|
|
70
|
-
function resolveS3(options) {
|
|
71
|
-
if (typeof options.s3 === 'function') {
|
|
72
|
-
const resolved = options.s3();
|
|
73
|
-
options.s3 = resolved;
|
|
74
|
-
return resolved;
|
|
75
|
-
}
|
|
76
|
-
return options.s3;
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Mint the physical S3 bucket name for a logical bucket's first provision.
|
|
80
|
-
*
|
|
81
|
-
* This is a naming *policy*, consulted exactly once per bucket — before the
|
|
82
|
-
* physical bucket exists. Once provisioned, the recorded `physical_name` on
|
|
83
|
-
* the row is authoritative and this function must not be consulted again.
|
|
84
|
-
*/
|
|
85
|
-
function mintPhysicalBucketName(options, databaseId, bucketKey) {
|
|
86
|
-
if (options.resolveBucketName) {
|
|
87
|
-
return options.resolveBucketName(databaseId, bucketKey);
|
|
88
|
-
}
|
|
89
|
-
// Single-bucket deployment: the globally configured bucket is the physical bucket.
|
|
90
|
-
return resolveS3(options).bucket;
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Build the S3 config for a *known* physical bucket. `physicalName` is
|
|
94
|
-
* required — callers must resolve the coordinate (stored row value, or a
|
|
95
|
-
* freshly provisioned name) before getting here. No name is ever recomputed.
|
|
96
|
-
*/
|
|
97
|
-
function resolveS3ForDatabase(options, storageConfig, physicalName) {
|
|
98
|
-
const globalS3 = resolveS3(options);
|
|
99
|
-
const publicUrlPrefix = storageConfig.publicUrlPrefix != null
|
|
100
|
-
? storageConfig.publicUrlPrefix
|
|
101
|
-
: globalS3.publicUrlPrefix;
|
|
102
|
-
if (physicalName === globalS3.bucket && publicUrlPrefix === globalS3.publicUrlPrefix) {
|
|
103
|
-
return globalS3;
|
|
104
|
-
}
|
|
105
|
-
return {
|
|
106
|
-
...globalS3,
|
|
107
|
-
bucket: physicalName,
|
|
108
|
-
...(publicUrlPrefix != null ? { publicUrlPrefix } : {}),
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
74
|
/**
|
|
112
|
-
*
|
|
113
|
-
* bucket, and record the exact name on the source row. Returns the recorded
|
|
114
|
-
* physical name.
|
|
115
|
-
*
|
|
116
|
-
* Only called when the row has no `physical_name` yet. Afterwards the stored
|
|
117
|
-
* value is the durable coordinate: route resolution and every later read use
|
|
118
|
-
* it verbatim; nothing is recomputed.
|
|
75
|
+
* Resolve the bucket an upload mutation writes into.
|
|
119
76
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* `jwt_private.current_database_id()` and would otherwise raise
|
|
125
|
-
* DATABASE_CLAIM_REQUIRED; `withRequestPgClient` applies that claim inside the
|
|
126
|
-
* write's transaction without switching off the privileged role.
|
|
127
|
-
* `bucket` (the cached config) is mutated in place so subsequent reads observe
|
|
128
|
-
* the recorded name without a DB round-trip.
|
|
77
|
+
* A named `bucketKey` is the caller's override and is read directly, as before.
|
|
78
|
+
* An omitted one asks the database for the tenant's reserved default tag for the
|
|
79
|
+
* requested access, so a missing or ambiguous default raises in SQL rather than
|
|
80
|
+
* falling back to a server-global bucket name here.
|
|
129
81
|
*/
|
|
130
|
-
async function
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
log.info(`Lazy-provisioning S3 bucket "${s3BucketName}" for database ${databaseId}`);
|
|
134
|
-
await options.ensureBucketProvisioned(s3BucketName, bucket.type, databaseId, allowedOrigins);
|
|
135
|
-
markS3BucketProvisioned(s3BucketName);
|
|
136
|
-
log.info(`Lazy-provisioned S3 bucket "${s3BucketName}" successfully`);
|
|
82
|
+
async function resolveUploadBucket(pgClient, storageConfig, databaseId, bucketKey, ownerId, isPublic) {
|
|
83
|
+
if (bucketKey) {
|
|
84
|
+
return getBucketConfig(pgClient, storageConfig, databaseId, bucketKey, ownerId || undefined);
|
|
137
85
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
// The catalog-sync trigger on this UPDATE needs `jwt.claims.database_id`, so the
|
|
141
|
-
// write runs under the resolved database claim (privileged role preserved).
|
|
142
|
-
await withRequestPgClient(withPgClient, { 'jwt.claims.database_id': databaseId }, (client) => client.query({
|
|
143
|
-
text: `UPDATE ${storageConfig.bucketsQualifiedName}
|
|
144
|
-
SET physical_name = $1
|
|
145
|
-
WHERE id = $2 AND physical_name IS NULL`,
|
|
146
|
-
values: [s3BucketName, bucket.id],
|
|
147
|
-
}));
|
|
148
|
-
bucket.physical_name = s3BucketName;
|
|
149
|
-
log.info(`Recorded physical_name="${s3BucketName}" on bucket ${bucket.id}`);
|
|
150
|
-
return s3BucketName;
|
|
86
|
+
const coordinate = await resolveDefaultBucket(pgClient, databaseId, storageConfig.scope, ownerId, isPublic, null);
|
|
87
|
+
return getBucketConfig(pgClient, storageConfig, databaseId, coordinate.resolvedKey, ownerId || undefined);
|
|
151
88
|
}
|
|
152
89
|
// --- Plugin factory ---
|
|
153
90
|
export function createPresignedUrlPlugin(options) {
|
|
@@ -166,6 +103,14 @@ export function createPresignedUrlPlugin(options) {
|
|
|
166
103
|
if (!isRootMutation)
|
|
167
104
|
return fields;
|
|
168
105
|
const { graphql: { GraphQLString, GraphQLNonNull, GraphQLInt, GraphQLBoolean, GraphQLObjectType, GraphQLInputObjectType, GraphQLList, }, } = build;
|
|
106
|
+
// The projection document is jsonb-shaped. PostGraphile registers a JSON
|
|
107
|
+
// scalar whenever the schema has a jsonb column, which any storage-equipped
|
|
108
|
+
// database does; if it is absent the payload simply omits the field rather
|
|
109
|
+
// than failing schema build over a field nothing can have asked for yet.
|
|
110
|
+
const jsonType = build.getTypeByName('JSON') ?? null;
|
|
111
|
+
if (!jsonType) {
|
|
112
|
+
log.warn('No JSON scalar in this schema; upload payloads will omit the `file` projection');
|
|
113
|
+
}
|
|
169
114
|
const bucketCodecs = Object.values(build.input.pgRegistry.pgCodecs).filter((codec) => codec.attributes && codec.extensions?.tags?.storageBuckets);
|
|
170
115
|
if (bucketCodecs.length === 0)
|
|
171
116
|
return fields;
|
|
@@ -199,7 +144,8 @@ export function createPresignedUrlPlugin(options) {
|
|
|
199
144
|
const InputType = new GraphQLInputObjectType({
|
|
200
145
|
name: `Upload${filesTypeName}Input`,
|
|
201
146
|
fields: {
|
|
202
|
-
bucketKey: { type:
|
|
147
|
+
bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
|
|
148
|
+
isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted: the public one (true) or the private one (default false). Ignored when bucketKey is given.' },
|
|
203
149
|
...(hasOwnerId
|
|
204
150
|
? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
205
151
|
: {}),
|
|
@@ -219,6 +165,16 @@ export function createPresignedUrlPlugin(options) {
|
|
|
219
165
|
deduplicated: { type: new GraphQLNonNull(GraphQLBoolean), description: 'Whether this file was deduplicated (content already exists)' },
|
|
220
166
|
expiresAt: { type: GraphQLString, description: 'Presigned URL expiry time (null if deduplicated)' },
|
|
221
167
|
previousVersionId: { type: GraphQLString, description: 'ID of the previous version (when using custom keys)' },
|
|
168
|
+
...(jsonType
|
|
169
|
+
? {
|
|
170
|
+
file: {
|
|
171
|
+
type: jsonType,
|
|
172
|
+
description: 'The projection document for the created file: {id, key, bucket_id, mime, size, filename, url?}. ' +
|
|
173
|
+
'Store this verbatim in an image/upload column — its `id` is what keeps the object from being ' +
|
|
174
|
+
'garbage collected while the column still references it.',
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
: {}),
|
|
222
178
|
},
|
|
223
179
|
});
|
|
224
180
|
const capturedFilesCodec = filesCodec;
|
|
@@ -232,6 +188,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
232
188
|
plan(_$mutation, fieldArgs) {
|
|
233
189
|
const $input = fieldArgs.getRaw('input');
|
|
234
190
|
const $bucketKey = access($input, 'bucketKey');
|
|
191
|
+
const $isPublic = access($input, 'isPublic');
|
|
235
192
|
const $contentHash = access($input, 'contentHash');
|
|
236
193
|
const $contentType = access($input, 'contentType');
|
|
237
194
|
const $size = access($input, 'size');
|
|
@@ -242,6 +199,7 @@ export function createPresignedUrlPlugin(options) {
|
|
|
242
199
|
const $pgSettings = grafastContext().get('pgSettings');
|
|
243
200
|
const $combined = object({
|
|
244
201
|
bucketKey: $bucketKey,
|
|
202
|
+
isPublic: $isPublic,
|
|
245
203
|
ownerId: $ownerId,
|
|
246
204
|
contentHash: $contentHash,
|
|
247
205
|
contentType: $contentType,
|
|
@@ -264,8 +222,8 @@ export function createPresignedUrlPlugin(options) {
|
|
|
264
222
|
const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
|
|
265
223
|
if (!storageConfig)
|
|
266
224
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
267
|
-
// Bucket
|
|
268
|
-
const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) =>
|
|
225
|
+
// Bucket resolution + read under the request role (RLS-gated visibility).
|
|
226
|
+
const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveUploadBucket(pgClient, storageConfig, databaseId, vals.bucketKey ?? null, vals.ownerId ?? null, vals.isPublic === true));
|
|
269
227
|
if (!bucket)
|
|
270
228
|
throw new Error('BUCKET_NOT_FOUND');
|
|
271
229
|
// First provision mints + records the coordinate; afterwards the
|
|
@@ -305,12 +263,14 @@ export function createPresignedUrlPlugin(options) {
|
|
|
305
263
|
deduplicated: { type: new GraphQLNonNull(GraphQLBoolean) },
|
|
306
264
|
expiresAt: { type: GraphQLString },
|
|
307
265
|
previousVersionId: { type: GraphQLString },
|
|
266
|
+
...(jsonType ? { file: { type: jsonType, description: 'The projection document for the created file.' } } : {}),
|
|
308
267
|
},
|
|
309
268
|
});
|
|
310
269
|
const BulkInputType = new GraphQLInputObjectType({
|
|
311
270
|
name: `Upload${filesTypeName}BulkInput`,
|
|
312
271
|
fields: {
|
|
313
|
-
bucketKey: { type:
|
|
272
|
+
bucketKey: { type: GraphQLString, description: 'Bucket key (e.g., "public", "private"). Omit to use the database\'s default bucket for the requested access.' },
|
|
273
|
+
isPublic: { type: GraphQLBoolean, description: 'Which default bucket to resolve when bucketKey is omitted. Ignored when bucketKey is given.' },
|
|
314
274
|
...(hasOwnerId
|
|
315
275
|
? { ownerId: { type: new GraphQLNonNull(ownerIdGqlType || GraphQLString), description: 'Owner entity ID (required for entity-scoped buckets)' } }
|
|
316
276
|
: {}),
|
|
@@ -334,12 +294,14 @@ export function createPresignedUrlPlugin(options) {
|
|
|
334
294
|
plan(_$mutation, fieldArgs) {
|
|
335
295
|
const $input = fieldArgs.getRaw('input');
|
|
336
296
|
const $bucketKey = access($input, 'bucketKey');
|
|
297
|
+
const $isPublic = access($input, 'isPublic');
|
|
337
298
|
const $ownerId = hasOwnerId ? access($input, 'ownerId') : lambda(null, () => null);
|
|
338
299
|
const $files = access($input, 'files');
|
|
339
300
|
const $withPgClient = grafastContext().get('withPgClient');
|
|
340
301
|
const $pgSettings = grafastContext().get('pgSettings');
|
|
341
302
|
const $combined = object({
|
|
342
303
|
bucketKey: $bucketKey,
|
|
304
|
+
isPublic: $isPublic,
|
|
343
305
|
ownerId: $ownerId,
|
|
344
306
|
files: $files,
|
|
345
307
|
withPgClient: $withPgClient,
|
|
@@ -358,8 +320,8 @@ export function createPresignedUrlPlugin(options) {
|
|
|
358
320
|
const storageConfig = resolveStorageConfigFromCodec(capturedFilesCodec, allConfigs);
|
|
359
321
|
if (!storageConfig)
|
|
360
322
|
throw new Error('STORAGE_MODULE_NOT_FOUND');
|
|
361
|
-
// Bucket
|
|
362
|
-
const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) =>
|
|
323
|
+
// Bucket resolution + read under the request role (RLS-gated visibility).
|
|
324
|
+
const bucket = await withRequestPgClient(vals.withPgClient, vals.pgSettings, (pgClient) => resolveUploadBucket(pgClient, storageConfig, databaseId, vals.bucketKey ?? null, vals.ownerId ?? null, vals.isPublic === true));
|
|
363
325
|
if (!bucket)
|
|
364
326
|
throw new Error('BUCKET_NOT_FOUND');
|
|
365
327
|
// Enforce bulk upload limits
|
|
@@ -544,6 +506,16 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
544
506
|
throw new Error('INVALID_FILENAME');
|
|
545
507
|
}
|
|
546
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
|
+
}
|
|
547
519
|
// Validate content type against bucket's allowed_mime_types
|
|
548
520
|
if (bucket.allowed_mime_types && bucket.allowed_mime_types.length > 0) {
|
|
549
521
|
const allowed = bucket.allowed_mime_types;
|
|
@@ -564,6 +536,11 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
564
536
|
if (bucket.max_file_size && size > bucket.max_file_size) {
|
|
565
537
|
throw new Error(`FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes`);
|
|
566
538
|
}
|
|
539
|
+
// The projection document the caller stores in an image/upload column. Built
|
|
540
|
+
// from the same values the files row carries, so the column and the row cannot
|
|
541
|
+
// disagree, and it names the files row by id — which is what stops GC from
|
|
542
|
+
// collecting an object a document still points at.
|
|
543
|
+
const projectFile = (fileId, key) => buildFileProjection({ id: fileId, key, bucketId: bucket.id, mime: contentType, size, filename }, bucket, s3ForDb);
|
|
567
544
|
// Determine S3 key
|
|
568
545
|
let s3Key;
|
|
569
546
|
let isCustomKey = false;
|
|
@@ -604,6 +581,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
604
581
|
deduplicated: true,
|
|
605
582
|
expiresAt: null,
|
|
606
583
|
previousVersionId: null,
|
|
584
|
+
file: projectFile(existing.id, s3Key),
|
|
607
585
|
};
|
|
608
586
|
}
|
|
609
587
|
previousVersionId = existing.id;
|
|
@@ -629,6 +607,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
629
607
|
deduplicated: true,
|
|
630
608
|
expiresAt: null,
|
|
631
609
|
previousVersionId: null,
|
|
610
|
+
file: projectFile(existingFile.id, s3Key),
|
|
632
611
|
};
|
|
633
612
|
}
|
|
634
613
|
}
|
|
@@ -669,6 +648,7 @@ async function processSingleFile(options, txClient, storageConfig, databaseId, b
|
|
|
669
648
|
deduplicated: false,
|
|
670
649
|
expiresAt,
|
|
671
650
|
previousVersionId,
|
|
651
|
+
file: projectFile(fileId, s3Key),
|
|
672
652
|
};
|
|
673
653
|
}
|
|
674
654
|
export const PresignedUrlPlugin = createPresignedUrlPlugin;
|
package/esm/s3-signer.d.ts
CHANGED
|
@@ -36,6 +36,35 @@ export declare function generatePresignedGetUrl(s3Config: S3Config, key: string,
|
|
|
36
36
|
* @param key - S3 object key to delete
|
|
37
37
|
*/
|
|
38
38
|
export declare function deleteS3Object(s3Config: S3Config, key: string): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Copy an object within the same physical bucket, preserving its content type.
|
|
41
|
+
*
|
|
42
|
+
* Used to promote a staged upload to its content-addressed key: the bytes are
|
|
43
|
+
* hashed as they stream in, so the final key is only known once the stream ends.
|
|
44
|
+
* Server-side copy keeps that promotion off the application's wire.
|
|
45
|
+
*
|
|
46
|
+
* @param s3Config - S3 client and bucket configuration
|
|
47
|
+
* @param sourceKey - The staged key the bytes were written to
|
|
48
|
+
* @param destinationKey - The final key (the content hash)
|
|
49
|
+
* @param contentType - MIME type to record on the destination object
|
|
50
|
+
*/
|
|
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>;
|
|
39
68
|
/**
|
|
40
69
|
* Check if an object exists in S3 and optionally verify its content-type.
|
|
41
70
|
*
|
package/esm/s3-signer.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, } from '@aws-sdk/client-s3';
|
|
1
|
+
import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, } from '@aws-sdk/client-s3';
|
|
2
2
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
3
3
|
import { Logger } from '@pgpmjs/logger';
|
|
4
4
|
const log = new Logger('graphile-presigned-url:s3');
|
|
@@ -68,6 +68,66 @@ export async function deleteS3Object(s3Config, key) {
|
|
|
68
68
|
}));
|
|
69
69
|
log.debug(`Deleted S3 object: bucket=${s3Config.bucket}, key=${key}`);
|
|
70
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Copy an object within the same physical bucket, preserving its content type.
|
|
73
|
+
*
|
|
74
|
+
* Used to promote a staged upload to its content-addressed key: the bytes are
|
|
75
|
+
* hashed as they stream in, so the final key is only known once the stream ends.
|
|
76
|
+
* Server-side copy keeps that promotion off the application's wire.
|
|
77
|
+
*
|
|
78
|
+
* @param s3Config - S3 client and bucket configuration
|
|
79
|
+
* @param sourceKey - The staged key the bytes were written to
|
|
80
|
+
* @param destinationKey - The final key (the content hash)
|
|
81
|
+
* @param contentType - MIME type to record on the destination object
|
|
82
|
+
*/
|
|
83
|
+
export async function copyS3Object(s3Config, sourceKey, destinationKey, contentType) {
|
|
84
|
+
await s3Config.client.send(new CopyObjectCommand({
|
|
85
|
+
Bucket: s3Config.bucket,
|
|
86
|
+
Key: destinationKey,
|
|
87
|
+
CopySource: `${s3Config.bucket}/${sourceKey}`,
|
|
88
|
+
ContentType: contentType,
|
|
89
|
+
MetadataDirective: 'REPLACE',
|
|
90
|
+
}));
|
|
91
|
+
log.debug(`Copied S3 object: bucket=${s3Config.bucket}, ${sourceKey} → ${destinationKey}`);
|
|
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
|
+
}
|
|
71
131
|
/**
|
|
72
132
|
* Check if an object exists in S3 and optionally verify its content-type.
|
|
73
133
|
*
|
package/esm/types.d.ts
CHANGED
|
@@ -70,8 +70,19 @@ export interface StorageModuleConfig {
|
|
|
70
70
|
* Input for the requestUploadUrl mutation.
|
|
71
71
|
*/
|
|
72
72
|
export interface RequestUploadUrlInput {
|
|
73
|
-
/**
|
|
74
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Logical bucket key (e.g., "public", "private").
|
|
75
|
+
*
|
|
76
|
+
* Optional: when omitted the database resolves its own default bucket for the
|
|
77
|
+
* requested access (see `isPublic`), so a client never has to know a tenant's
|
|
78
|
+
* bucket naming to upload.
|
|
79
|
+
*/
|
|
80
|
+
bucketKey?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Which default bucket to resolve when `bucketKey` is omitted: the public one
|
|
83
|
+
* (true) or the private one (default false). Ignored when `bucketKey` is given.
|
|
84
|
+
*/
|
|
85
|
+
isPublic?: boolean;
|
|
75
86
|
/**
|
|
76
87
|
* Owner entity ID for entity-scoped uploads.
|
|
77
88
|
* Omit for app-level (database-wide) storage.
|
|
@@ -111,6 +122,27 @@ export interface RequestUploadUrlPayload {
|
|
|
111
122
|
expiresAt: string | null;
|
|
112
123
|
/** ID of the previous version (set when re-uploading to an existing custom key) */
|
|
113
124
|
previousVersionId: string | null;
|
|
125
|
+
/**
|
|
126
|
+
* The projection document to store in an `image`/`upload` column.
|
|
127
|
+
*
|
|
128
|
+
* Its `id` is the files row, which is what makes the column a reference the
|
|
129
|
+
* server can count — storage GC will not collect an object while a registered
|
|
130
|
+
* document column still names its file.
|
|
131
|
+
*/
|
|
132
|
+
file: FileProjection;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The document a managed `image`/`upload` column stores. See `./managed-upload`.
|
|
136
|
+
*/
|
|
137
|
+
export interface FileProjection {
|
|
138
|
+
id: string;
|
|
139
|
+
key: string;
|
|
140
|
+
bucket_id: string;
|
|
141
|
+
mime: string;
|
|
142
|
+
size: number;
|
|
143
|
+
filename?: string;
|
|
144
|
+
/** @deprecated Read the files row's `downloadUrl` via `id` instead. */
|
|
145
|
+
url?: string;
|
|
114
146
|
}
|
|
115
147
|
/**
|
|
116
148
|
* S3 configuration for the presigned URL plugin.
|
|
@@ -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,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The `file_ref_field` registry: which storage module and bucket a managed
|
|
4
|
+
* document column writes into.
|
|
5
|
+
*
|
|
6
|
+
* An `image`/`upload` column is a projection of a files row, and the decision of
|
|
7
|
+
* *where* those bytes live is a property of the field declaration, not of the
|
|
8
|
+
* request. The registry records that intent per (table, column) — a storage
|
|
9
|
+
* module plus either a logical bucket key, a tag selector, or nothing at all
|
|
10
|
+
* (meaning the reserved default tag for the declared publicness).
|
|
11
|
+
*
|
|
12
|
+
* This module answers one question — "what does a write to this column bind
|
|
13
|
+
* to?" — and answers it loudly: an unregistered column raises rather than
|
|
14
|
+
* falling back to a server-global bucket, because a silent fallback is how the
|
|
15
|
+
* unmanaged lane produced objects no tenant owned.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.FileRefFieldNotRegisteredError = void 0;
|
|
19
|
+
exports.getFileRefFieldBinding = getFileRefFieldBinding;
|
|
20
|
+
exports.clearFileRefFieldCache = clearFileRefFieldCache;
|
|
21
|
+
const logger_1 = require("@pgpmjs/logger");
|
|
22
|
+
const lru_cache_1 = require("lru-cache");
|
|
23
|
+
const log = new logger_1.Logger('graphile-presigned-url:file-ref-registry');
|
|
24
|
+
const FIVE_MINUTES_MS = 1000 * 60 * 5;
|
|
25
|
+
const ONE_HOUR_MS = 1000 * 60 * 60;
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the registry row for a document column.
|
|
28
|
+
*
|
|
29
|
+
* Joined through metaschema rather than keyed by name, because the registry
|
|
30
|
+
* records field *ids*: the physical (schema, table, column) triple is what the
|
|
31
|
+
* GraphQL layer knows, and metaschema is the only thing that maps one to the
|
|
32
|
+
* other.
|
|
33
|
+
*/
|
|
34
|
+
const FILE_REF_FIELD_QUERY = `
|
|
35
|
+
SELECT
|
|
36
|
+
frf.id,
|
|
37
|
+
frf.storage_module_id,
|
|
38
|
+
frf.bucket_key,
|
|
39
|
+
frf.bucket_tags::text[] AS bucket_tags,
|
|
40
|
+
frf.is_public,
|
|
41
|
+
frf.enforce_fk
|
|
42
|
+
FROM metaschema_modules_public.file_ref_field frf
|
|
43
|
+
JOIN metaschema_public.field f ON f.id = frf.field_id
|
|
44
|
+
JOIN metaschema_public.table t ON t.id = frf.table_id
|
|
45
|
+
JOIN metaschema_public.schema s ON s.id = t.schema_id
|
|
46
|
+
WHERE frf.database_id = $1
|
|
47
|
+
AND s.schema_name = $2
|
|
48
|
+
AND t.name = $3
|
|
49
|
+
AND f.name = $4
|
|
50
|
+
LIMIT 1
|
|
51
|
+
`;
|
|
52
|
+
/**
|
|
53
|
+
* LRU cache of field bindings.
|
|
54
|
+
*
|
|
55
|
+
* A binding is schema, not data: it changes only when a database is
|
|
56
|
+
* re-provisioned, so it caches on the same terms as the storage module config
|
|
57
|
+
* next to it. Misses are never cached — an unregistered column is a hard error
|
|
58
|
+
* every time it is written, not a remembered "no".
|
|
59
|
+
*/
|
|
60
|
+
const bindingCache = new lru_cache_1.LRUCache({
|
|
61
|
+
max: 500,
|
|
62
|
+
ttl: process.env.NODE_ENV === 'development' ? FIVE_MINUTES_MS : ONE_HOUR_MS,
|
|
63
|
+
updateAgeOnGet: true,
|
|
64
|
+
});
|
|
65
|
+
class FileRefFieldNotRegisteredError extends Error {
|
|
66
|
+
databaseId;
|
|
67
|
+
schemaName;
|
|
68
|
+
tableName;
|
|
69
|
+
columnName;
|
|
70
|
+
constructor(databaseId, schemaName, tableName, columnName) {
|
|
71
|
+
super(`FILE_REF_FIELD_NOT_REGISTERED: ${schemaName}.${tableName}.${columnName} ` +
|
|
72
|
+
`is not a registered file-reference field in database ${databaseId}. ` +
|
|
73
|
+
'A managed upload needs the declared storage module and bucket intent; ' +
|
|
74
|
+
'there is no server-global bucket to fall back to.');
|
|
75
|
+
this.databaseId = databaseId;
|
|
76
|
+
this.schemaName = schemaName;
|
|
77
|
+
this.tableName = tableName;
|
|
78
|
+
this.columnName = columnName;
|
|
79
|
+
this.name = 'FileRefFieldNotRegisteredError';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
exports.FileRefFieldNotRegisteredError = FileRefFieldNotRegisteredError;
|
|
83
|
+
/**
|
|
84
|
+
* Look up the storage binding for a document column, or throw.
|
|
85
|
+
*
|
|
86
|
+
* The read runs on whichever client the caller passes. The registry is schema
|
|
87
|
+
* metadata rather than tenant rows, so callers resolve it in the system lane —
|
|
88
|
+
* the RLS that matters is on the files table the upload eventually writes.
|
|
89
|
+
*/
|
|
90
|
+
async function getFileRefFieldBinding(pgClient, databaseId, field) {
|
|
91
|
+
const cacheKey = `file-ref:${databaseId}:${field.schemaName}.${field.tableName}.${field.columnName}`;
|
|
92
|
+
const cached = bindingCache.get(cacheKey);
|
|
93
|
+
if (cached)
|
|
94
|
+
return cached;
|
|
95
|
+
const result = await pgClient.query({
|
|
96
|
+
text: FILE_REF_FIELD_QUERY,
|
|
97
|
+
values: [databaseId, field.schemaName, field.tableName, field.columnName],
|
|
98
|
+
});
|
|
99
|
+
if (result.rows.length === 0) {
|
|
100
|
+
throw new FileRefFieldNotRegisteredError(databaseId, field.schemaName, field.tableName, field.columnName);
|
|
101
|
+
}
|
|
102
|
+
const row = result.rows[0];
|
|
103
|
+
const binding = {
|
|
104
|
+
id: row.id,
|
|
105
|
+
storageModuleId: row.storage_module_id,
|
|
106
|
+
bucketKey: row.bucket_key,
|
|
107
|
+
bucketTags: row.bucket_tags,
|
|
108
|
+
isPublic: row.is_public,
|
|
109
|
+
enforceFk: row.enforce_fk,
|
|
110
|
+
};
|
|
111
|
+
bindingCache.set(cacheKey, binding);
|
|
112
|
+
log.debug(`Bound ${field.schemaName}.${field.tableName}.${field.columnName} to storage module ` +
|
|
113
|
+
`${binding.storageModuleId} (bucket_key=${binding.bucketKey ?? '<default tag>'})`);
|
|
114
|
+
return binding;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Drop cached bindings. Used by tests and after a re-provision.
|
|
118
|
+
*/
|
|
119
|
+
function clearFileRefFieldCache() {
|
|
120
|
+
bindingCache.clear();
|
|
121
|
+
}
|
package/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';
|