graphile-presigned-url-plugin 0.7.0 → 0.8.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/README.md +0 -2
- package/download-url-field.js +1 -7
- package/esm/download-url-field.js +1 -7
- package/esm/index.d.ts +2 -3
- package/esm/index.js +1 -2
- package/esm/plugin.d.ts +2 -6
- package/esm/plugin.js +10 -130
- package/esm/preset.d.ts +2 -2
- package/esm/preset.js +2 -2
- package/esm/s3-signer.d.ts +1 -2
- package/esm/s3-signer.js +1 -2
- package/esm/storage-module-cache.d.ts +0 -2
- package/esm/storage-module-cache.js +3 -14
- package/esm/types.d.ts +0 -24
- package/index.d.ts +2 -3
- package/index.js +1 -2
- package/package.json +3 -3
- package/plugin.d.ts +2 -6
- package/plugin.js +8 -128
- package/preset.d.ts +2 -2
- package/preset.js +2 -2
- package/s3-signer.d.ts +1 -2
- package/s3-signer.js +1 -2
- package/storage-module-cache.d.ts +0 -2
- package/storage-module-cache.js +3 -14
- package/types.d.ts +0 -24
package/README.md
CHANGED
|
@@ -17,11 +17,9 @@ Presigned URL upload plugin for PostGraphile v5.
|
|
|
17
17
|
## Features
|
|
18
18
|
|
|
19
19
|
- `requestUploadUrl` mutation — generates presigned PUT URLs for direct client-to-S3 upload
|
|
20
|
-
- `confirmUpload` mutation — verifies upload and transitions file status to 'ready'
|
|
21
20
|
- `downloadUrl` computed field — presigned GET URLs for private files, public URLs for public files
|
|
22
21
|
- Content-hash based S3 keys (SHA-256) with automatic deduplication
|
|
23
22
|
- Per-bucket MIME type and file size validation
|
|
24
|
-
- Upload request tracking for audit and rate limiting
|
|
25
23
|
|
|
26
24
|
## Usage
|
|
27
25
|
|
package/download-url-field.js
CHANGED
|
@@ -96,7 +96,6 @@ function createDownloadUrlPlugin(options) {
|
|
|
96
96
|
const $key = $parent.get('key');
|
|
97
97
|
const $isPublic = $parent.get('is_public');
|
|
98
98
|
const $filename = $parent.get('filename');
|
|
99
|
-
const $status = $parent.get('status');
|
|
100
99
|
// Access GraphQL context for per-database config resolution
|
|
101
100
|
const $withPgClient = (0, grafast_1.context)().get('withPgClient');
|
|
102
101
|
const $pgSettings = (0, grafast_1.context)().get('pgSettings');
|
|
@@ -104,17 +103,12 @@ function createDownloadUrlPlugin(options) {
|
|
|
104
103
|
key: $key,
|
|
105
104
|
isPublic: $isPublic,
|
|
106
105
|
filename: $filename,
|
|
107
|
-
status: $status,
|
|
108
106
|
withPgClient: $withPgClient,
|
|
109
107
|
pgSettings: $pgSettings,
|
|
110
108
|
});
|
|
111
|
-
return (0, grafast_1.lambda)($combined, async ({ key, isPublic, filename,
|
|
109
|
+
return (0, grafast_1.lambda)($combined, async ({ key, isPublic, filename, withPgClient, pgSettings }) => {
|
|
112
110
|
if (!key)
|
|
113
111
|
return null;
|
|
114
|
-
// Only provide download URLs for ready/processed files
|
|
115
|
-
if (status !== 'ready' && status !== 'processed') {
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
118
112
|
// Resolve per-database config (bucket, publicUrlPrefix, expiry)
|
|
119
113
|
let s3ForDb = resolveS3(options); // fallback to global
|
|
120
114
|
let downloadUrlExpirySeconds = 3600; // fallback default
|
|
@@ -93,7 +93,6 @@ export function createDownloadUrlPlugin(options) {
|
|
|
93
93
|
const $key = $parent.get('key');
|
|
94
94
|
const $isPublic = $parent.get('is_public');
|
|
95
95
|
const $filename = $parent.get('filename');
|
|
96
|
-
const $status = $parent.get('status');
|
|
97
96
|
// Access GraphQL context for per-database config resolution
|
|
98
97
|
const $withPgClient = grafastContext().get('withPgClient');
|
|
99
98
|
const $pgSettings = grafastContext().get('pgSettings');
|
|
@@ -101,17 +100,12 @@ export function createDownloadUrlPlugin(options) {
|
|
|
101
100
|
key: $key,
|
|
102
101
|
isPublic: $isPublic,
|
|
103
102
|
filename: $filename,
|
|
104
|
-
status: $status,
|
|
105
103
|
withPgClient: $withPgClient,
|
|
106
104
|
pgSettings: $pgSettings,
|
|
107
105
|
});
|
|
108
|
-
return lambda($combined, async ({ key, isPublic, filename,
|
|
106
|
+
return lambda($combined, async ({ key, isPublic, filename, withPgClient, pgSettings }) => {
|
|
109
107
|
if (!key)
|
|
110
108
|
return null;
|
|
111
|
-
// Only provide download URLs for ready/processed files
|
|
112
|
-
if (status !== 'ready' && status !== 'processed') {
|
|
113
|
-
return null;
|
|
114
|
-
}
|
|
115
109
|
// Resolve per-database config (bucket, publicUrlPrefix, expiry)
|
|
116
110
|
let s3ForDb = resolveS3(options); // fallback to global
|
|
117
111
|
let downloadUrlExpirySeconds = 3600; // fallback default
|
package/esm/index.d.ts
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Presigned URL Plugin for PostGraphile v5
|
|
3
3
|
*
|
|
4
4
|
* Provides presigned URL upload capabilities for PostGraphile v5:
|
|
5
|
-
* - requestUploadUrl mutation (presigned PUT URL generation)
|
|
6
|
-
* - confirmUpload mutation (upload verification + status transition)
|
|
5
|
+
* - requestUploadUrl mutation (presigned PUT URL generation + dedup)
|
|
7
6
|
* - downloadUrl computed field (presigned GET URL / public URL)
|
|
8
7
|
*
|
|
9
8
|
* @example
|
|
@@ -31,4 +30,4 @@ export { createDownloadUrlPlugin } from './download-url-field';
|
|
|
31
30
|
export { PresignedUrlPreset } from './preset';
|
|
32
31
|
export { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, resolveStorageModuleByFileId, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
|
|
33
32
|
export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
|
|
34
|
-
export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload,
|
|
33
|
+
export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, BucketNameResolver, EnsureBucketProvisioned, } from './types';
|
package/esm/index.js
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Presigned URL Plugin for PostGraphile v5
|
|
3
3
|
*
|
|
4
4
|
* Provides presigned URL upload capabilities for PostGraphile v5:
|
|
5
|
-
* - requestUploadUrl mutation (presigned PUT URL generation)
|
|
6
|
-
* - confirmUpload mutation (upload verification + status transition)
|
|
5
|
+
* - requestUploadUrl mutation (presigned PUT URL generation + dedup)
|
|
7
6
|
* - downloadUrl computed field (presigned GET URL / public URL)
|
|
8
7
|
*
|
|
9
8
|
* @example
|
package/esm/plugin.d.ts
CHANGED
|
@@ -5,13 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 1. `requestUploadUrl` mutation — generates a presigned PUT URL for direct
|
|
7
7
|
* client-to-S3 upload. Checks bucket access via RLS, deduplicates by
|
|
8
|
-
* content hash
|
|
8
|
+
* content hash via UNIQUE(bucket_id, key) constraint.
|
|
9
9
|
*
|
|
10
|
-
* 2. `
|
|
11
|
-
* the object exists with correct content-type, transitions file status
|
|
12
|
-
* from 'pending' to 'ready'.
|
|
13
|
-
*
|
|
14
|
-
* 3. `downloadUrl` computed field on File types — generates presigned GET URLs
|
|
10
|
+
* 2. `downloadUrl` computed field on File types — generates presigned GET URLs
|
|
15
11
|
* for private files, returns public URL prefix + key for public files.
|
|
16
12
|
*
|
|
17
13
|
* Uses the extendSchema + grafast plan pattern (same as PublicKeySignature).
|
package/esm/plugin.js
CHANGED
|
@@ -5,13 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 1. `requestUploadUrl` mutation — generates a presigned PUT URL for direct
|
|
7
7
|
* client-to-S3 upload. Checks bucket access via RLS, deduplicates by
|
|
8
|
-
* content hash
|
|
8
|
+
* content hash via UNIQUE(bucket_id, key) constraint.
|
|
9
9
|
*
|
|
10
|
-
* 2. `
|
|
11
|
-
* the object exists with correct content-type, transitions file status
|
|
12
|
-
* from 'pending' to 'ready'.
|
|
13
|
-
*
|
|
14
|
-
* 3. `downloadUrl` computed field on File types — generates presigned GET URLs
|
|
10
|
+
* 2. `downloadUrl` computed field on File types — generates presigned GET URLs
|
|
15
11
|
* for private files, returns public URL prefix + key for public files.
|
|
16
12
|
*
|
|
17
13
|
* Uses the extendSchema + grafast plan pattern (same as PublicKeySignature).
|
|
@@ -19,8 +15,8 @@
|
|
|
19
15
|
import { context as grafastContext, lambda, object } from 'grafast';
|
|
20
16
|
import { extendSchema, gql } from 'graphile-utils';
|
|
21
17
|
import { Logger } from '@pgpmjs/logger';
|
|
22
|
-
import { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig,
|
|
23
|
-
import { generatePresignedPutUrl
|
|
18
|
+
import { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
|
|
19
|
+
import { generatePresignedPutUrl } from './s3-signer';
|
|
24
20
|
const log = new Logger('graphile-presigned-url:plugin');
|
|
25
21
|
// --- Protocol-level constants (not configurable) ---
|
|
26
22
|
const MAX_CONTENT_HASH_LENGTH = 128;
|
|
@@ -145,22 +141,6 @@ export function createPresignedUrlPlugin(options) {
|
|
|
145
141
|
deduplicated: Boolean!
|
|
146
142
|
"""Presigned URL expiry time (null if deduplicated)"""
|
|
147
143
|
expiresAt: Datetime
|
|
148
|
-
"""File status — 'pending' for fresh uploads, 'ready' or 'processed' for deduplicated files. Clients can use this to know immediately whether the file is usable."""
|
|
149
|
-
status: String!
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
input ConfirmUploadInput {
|
|
153
|
-
"""The file ID returned by requestUploadUrl"""
|
|
154
|
-
fileId: UUID!
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
type ConfirmUploadPayload {
|
|
158
|
-
"""The confirmed file ID"""
|
|
159
|
-
fileId: UUID!
|
|
160
|
-
"""New file status"""
|
|
161
|
-
status: String!
|
|
162
|
-
"""Whether confirmation succeeded"""
|
|
163
|
-
success: Boolean!
|
|
164
144
|
}
|
|
165
145
|
|
|
166
146
|
extend type Mutation {
|
|
@@ -173,15 +153,6 @@ export function createPresignedUrlPlugin(options) {
|
|
|
173
153
|
requestUploadUrl(
|
|
174
154
|
input: RequestUploadUrlInput!
|
|
175
155
|
): RequestUploadUrlPayload
|
|
176
|
-
|
|
177
|
-
"""
|
|
178
|
-
Confirm that a file has been uploaded to S3.
|
|
179
|
-
Verifies the object exists in S3, checks content-type,
|
|
180
|
-
and transitions the file status from 'pending' to 'ready'.
|
|
181
|
-
"""
|
|
182
|
-
confirmUpload(
|
|
183
|
-
input: ConfirmUploadInput!
|
|
184
|
-
): ConfirmUploadPayload
|
|
185
156
|
}
|
|
186
157
|
`,
|
|
187
158
|
plans: {
|
|
@@ -263,45 +234,36 @@ export function createPresignedUrlPlugin(options) {
|
|
|
263
234
|
const s3Key = buildS3Key(contentHash);
|
|
264
235
|
// --- Dedup check: look for existing file with same key (content hash) in this bucket ---
|
|
265
236
|
const dedupResult = await txClient.query({
|
|
266
|
-
text: `SELECT id
|
|
237
|
+
text: `SELECT id
|
|
267
238
|
FROM ${storageConfig.filesQualifiedName}
|
|
268
239
|
WHERE key = $1
|
|
269
240
|
AND bucket_id = $2
|
|
270
|
-
AND status IN ('ready', 'processed')
|
|
271
241
|
LIMIT 1`,
|
|
272
242
|
values: [s3Key, bucket.id],
|
|
273
243
|
});
|
|
274
244
|
if (dedupResult.rows.length > 0) {
|
|
275
245
|
const existingFile = dedupResult.rows[0];
|
|
276
246
|
log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`);
|
|
277
|
-
// Track the dedup request
|
|
278
|
-
await txClient.query({
|
|
279
|
-
text: `INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
|
|
280
|
-
(file_id, bucket_id, key, content_type, content_hash, status, expires_at)
|
|
281
|
-
VALUES ($1, $2, $3, $4, $5, 'confirmed', NOW())`,
|
|
282
|
-
values: [existingFile.id, bucket.id, s3Key, contentType, contentHash],
|
|
283
|
-
});
|
|
284
247
|
return {
|
|
285
248
|
uploadUrl: null,
|
|
286
249
|
fileId: existingFile.id,
|
|
287
250
|
key: s3Key,
|
|
288
251
|
deduplicated: true,
|
|
289
252
|
expiresAt: null,
|
|
290
|
-
status: existingFile.status,
|
|
291
253
|
};
|
|
292
254
|
}
|
|
293
|
-
// --- Create file record
|
|
255
|
+
// --- Create file record ---
|
|
294
256
|
// For app-level storage (no owner_id column), omit owner_id from the INSERT.
|
|
295
257
|
const hasOwnerColumn = storageConfig.membershipType !== null;
|
|
296
258
|
const fileResult = await txClient.query({
|
|
297
259
|
text: hasOwnerColumn
|
|
298
260
|
? `INSERT INTO ${storageConfig.filesQualifiedName}
|
|
299
|
-
(bucket_id, key, mime_type, size, filename, owner_id, is_public
|
|
300
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7
|
|
261
|
+
(bucket_id, key, mime_type, size, filename, owner_id, is_public)
|
|
262
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
301
263
|
RETURNING id`
|
|
302
264
|
: `INSERT INTO ${storageConfig.filesQualifiedName}
|
|
303
|
-
(bucket_id, key, mime_type, size, filename, is_public
|
|
304
|
-
VALUES ($1, $2, $3, $4, $5, $6
|
|
265
|
+
(bucket_id, key, mime_type, size, filename, is_public)
|
|
266
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
305
267
|
RETURNING id`,
|
|
306
268
|
values: hasOwnerColumn
|
|
307
269
|
? [
|
|
@@ -329,94 +291,12 @@ export function createPresignedUrlPlugin(options) {
|
|
|
329
291
|
// --- Generate presigned PUT URL (per-database bucket) ---
|
|
330
292
|
const uploadUrl = await generatePresignedPutUrl(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
|
|
331
293
|
const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
|
|
332
|
-
// --- Track the upload request ---
|
|
333
|
-
await txClient.query({
|
|
334
|
-
text: `INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
|
|
335
|
-
(file_id, bucket_id, key, content_type, content_hash, status, expires_at)
|
|
336
|
-
VALUES ($1, $2, $3, $4, $5, 'issued', $6)`,
|
|
337
|
-
values: [fileId, bucket.id, s3Key, contentType, contentHash, expiresAt],
|
|
338
|
-
});
|
|
339
294
|
return {
|
|
340
295
|
uploadUrl,
|
|
341
296
|
fileId,
|
|
342
297
|
key: s3Key,
|
|
343
298
|
deduplicated: false,
|
|
344
299
|
expiresAt,
|
|
345
|
-
status: 'pending',
|
|
346
|
-
};
|
|
347
|
-
});
|
|
348
|
-
});
|
|
349
|
-
});
|
|
350
|
-
},
|
|
351
|
-
confirmUpload(_$mutation, fieldArgs) {
|
|
352
|
-
const $input = fieldArgs.getRaw('input');
|
|
353
|
-
const $withPgClient = grafastContext().get('withPgClient');
|
|
354
|
-
const $pgSettings = grafastContext().get('pgSettings');
|
|
355
|
-
const $combined = object({
|
|
356
|
-
input: $input,
|
|
357
|
-
withPgClient: $withPgClient,
|
|
358
|
-
pgSettings: $pgSettings,
|
|
359
|
-
});
|
|
360
|
-
return lambda($combined, async ({ input, withPgClient, pgSettings }) => {
|
|
361
|
-
const { fileId } = input;
|
|
362
|
-
if (!fileId || typeof fileId !== 'string') {
|
|
363
|
-
throw new Error('INVALID_FILE_ID');
|
|
364
|
-
}
|
|
365
|
-
return withPgClient(pgSettings, async (pgClient) => {
|
|
366
|
-
return pgClient.withTransaction(async (txClient) => {
|
|
367
|
-
// --- Resolve storage module by file ID (probes all file tables) ---
|
|
368
|
-
const databaseId = await resolveDatabaseId(txClient);
|
|
369
|
-
if (!databaseId) {
|
|
370
|
-
throw new Error('DATABASE_NOT_FOUND');
|
|
371
|
-
}
|
|
372
|
-
const resolved = await resolveStorageModuleByFileId(txClient, databaseId, fileId);
|
|
373
|
-
if (!resolved) {
|
|
374
|
-
throw new Error('FILE_NOT_FOUND');
|
|
375
|
-
}
|
|
376
|
-
const { storageConfig, file } = resolved;
|
|
377
|
-
if (file.status !== 'pending') {
|
|
378
|
-
// File is already confirmed or processed — idempotent success
|
|
379
|
-
return {
|
|
380
|
-
fileId: file.id,
|
|
381
|
-
status: file.status,
|
|
382
|
-
success: true,
|
|
383
|
-
};
|
|
384
|
-
}
|
|
385
|
-
// --- Verify file exists in S3 (per-database bucket) ---
|
|
386
|
-
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
387
|
-
const s3Head = await headObject(s3ForDb, file.key, file.mime_type);
|
|
388
|
-
if (!s3Head) {
|
|
389
|
-
throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
|
|
390
|
-
}
|
|
391
|
-
// --- Content-type verification ---
|
|
392
|
-
if (s3Head.contentType && s3Head.contentType !== file.mime_type) {
|
|
393
|
-
// Mark upload_request as rejected
|
|
394
|
-
await txClient.query({
|
|
395
|
-
text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
|
|
396
|
-
SET status = 'rejected'
|
|
397
|
-
WHERE file_id = $1 AND status = 'issued'`,
|
|
398
|
-
values: [fileId],
|
|
399
|
-
});
|
|
400
|
-
throw new Error(`CONTENT_TYPE_MISMATCH: expected ${file.mime_type}, got ${s3Head.contentType}`);
|
|
401
|
-
}
|
|
402
|
-
// --- Transition file to 'ready' ---
|
|
403
|
-
await txClient.query({
|
|
404
|
-
text: `UPDATE ${storageConfig.filesQualifiedName}
|
|
405
|
-
SET status = 'ready'
|
|
406
|
-
WHERE id = $1`,
|
|
407
|
-
values: [fileId],
|
|
408
|
-
});
|
|
409
|
-
// --- Update upload_request to 'confirmed' ---
|
|
410
|
-
await txClient.query({
|
|
411
|
-
text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
|
|
412
|
-
SET status = 'confirmed', confirmed_at = NOW()
|
|
413
|
-
WHERE file_id = $1 AND status = 'issued'`,
|
|
414
|
-
values: [fileId],
|
|
415
|
-
});
|
|
416
|
-
return {
|
|
417
|
-
fileId: file.id,
|
|
418
|
-
status: 'ready',
|
|
419
|
-
success: true,
|
|
420
300
|
};
|
|
421
301
|
});
|
|
422
302
|
});
|
package/esm/preset.d.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* PostGraphile v5 Presigned URL Preset
|
|
3
3
|
*
|
|
4
4
|
* Provides a convenient preset for including presigned URL upload support
|
|
5
|
-
* in PostGraphile. Combines the main mutation plugin (requestUploadUrl
|
|
6
|
-
*
|
|
5
|
+
* in PostGraphile. Combines the main mutation plugin (requestUploadUrl)
|
|
6
|
+
* with the downloadUrl computed field plugin.
|
|
7
7
|
*/
|
|
8
8
|
import type { GraphileConfig } from 'graphile-config';
|
|
9
9
|
import type { PresignedUrlPluginOptions } from './types';
|
package/esm/preset.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* PostGraphile v5 Presigned URL Preset
|
|
3
3
|
*
|
|
4
4
|
* Provides a convenient preset for including presigned URL upload support
|
|
5
|
-
* in PostGraphile. Combines the main mutation plugin (requestUploadUrl
|
|
6
|
-
*
|
|
5
|
+
* in PostGraphile. Combines the main mutation plugin (requestUploadUrl)
|
|
6
|
+
* with the downloadUrl computed field plugin.
|
|
7
7
|
*/
|
|
8
8
|
import { createPresignedUrlPlugin } from './plugin';
|
|
9
9
|
import { createDownloadUrlPlugin } from './download-url-field';
|
package/esm/s3-signer.d.ts
CHANGED
|
@@ -30,8 +30,7 @@ export declare function generatePresignedGetUrl(s3Config: S3Config, key: string,
|
|
|
30
30
|
/**
|
|
31
31
|
* Check if an object exists in S3 and optionally verify its content-type.
|
|
32
32
|
*
|
|
33
|
-
*
|
|
34
|
-
* and that the content-type matches what was declared.
|
|
33
|
+
* Checks whether an object exists in S3 and retrieves its content-type.
|
|
35
34
|
*
|
|
36
35
|
* @param s3Config - S3 client and bucket configuration
|
|
37
36
|
* @param key - S3 object key
|
package/esm/s3-signer.js
CHANGED
|
@@ -56,8 +56,7 @@ export async function generatePresignedGetUrl(s3Config, key, expiresIn = 3600, f
|
|
|
56
56
|
/**
|
|
57
57
|
* Check if an object exists in S3 and optionally verify its content-type.
|
|
58
58
|
*
|
|
59
|
-
*
|
|
60
|
-
* and that the content-type matches what was declared.
|
|
59
|
+
* Checks whether an object exists in S3 and retrieves its content-type.
|
|
61
60
|
*
|
|
62
61
|
* @param s3Config - S3 client and bucket configuration
|
|
63
62
|
* @param key - S3 object key
|
|
@@ -43,7 +43,6 @@ export declare function getStorageModuleConfigForOwner(pgClient: {
|
|
|
43
43
|
/**
|
|
44
44
|
* Resolve the storage module that owns a specific file by probing all file tables.
|
|
45
45
|
*
|
|
46
|
-
* Used by confirmUpload when only a fileId (UUID) is available.
|
|
47
46
|
* Since UUIDs are globally unique, exactly one table will contain the file.
|
|
48
47
|
*
|
|
49
48
|
* @param pgClient - A pg client from the Graphile context
|
|
@@ -64,7 +63,6 @@ export declare function resolveStorageModuleByFileId(pgClient: {
|
|
|
64
63
|
id: string;
|
|
65
64
|
key: string;
|
|
66
65
|
mime_type: string;
|
|
67
|
-
status: string;
|
|
68
66
|
bucket_id: string;
|
|
69
67
|
};
|
|
70
68
|
} | null>;
|
|
@@ -14,8 +14,8 @@ const ONE_HOUR_MS = 1000 * 60 * 60;
|
|
|
14
14
|
* LRU cache for per-database StorageModuleConfig.
|
|
15
15
|
*
|
|
16
16
|
* Each PostGraphile instance serves a single database, but the presigned URL
|
|
17
|
-
* plugin needs to know the generated table names (buckets, files
|
|
18
|
-
*
|
|
17
|
+
* plugin needs to know the generated table names (buckets, files)
|
|
18
|
+
* and their schemas. This cache avoids re-querying metaschema
|
|
19
19
|
* on every request.
|
|
20
20
|
*
|
|
21
21
|
* Pattern: same as graphile-cache's LRU with TTL-based eviction.
|
|
@@ -42,8 +42,6 @@ const APP_STORAGE_MODULE_QUERY = `
|
|
|
42
42
|
bt.name AS buckets_table,
|
|
43
43
|
fs.schema_name AS files_schema,
|
|
44
44
|
ft.name AS files_table,
|
|
45
|
-
urs.schema_name AS upload_requests_schema,
|
|
46
|
-
urt.name AS upload_requests_table,
|
|
47
45
|
sm.endpoint,
|
|
48
46
|
sm.public_url_prefix,
|
|
49
47
|
sm.provider,
|
|
@@ -60,8 +58,6 @@ const APP_STORAGE_MODULE_QUERY = `
|
|
|
60
58
|
JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
|
|
61
59
|
JOIN metaschema_public.table ft ON ft.id = sm.files_table_id
|
|
62
60
|
JOIN metaschema_public.schema fs ON fs.id = ft.schema_id
|
|
63
|
-
JOIN metaschema_public.table urt ON urt.id = sm.upload_requests_table_id
|
|
64
|
-
JOIN metaschema_public.schema urs ON urs.id = urt.schema_id
|
|
65
61
|
WHERE sm.database_id = $1
|
|
66
62
|
AND sm.membership_type IS NULL
|
|
67
63
|
LIMIT 1
|
|
@@ -81,8 +77,6 @@ const ALL_STORAGE_MODULES_QUERY = `
|
|
|
81
77
|
bt.name AS buckets_table,
|
|
82
78
|
fs.schema_name AS files_schema,
|
|
83
79
|
ft.name AS files_table,
|
|
84
|
-
urs.schema_name AS upload_requests_schema,
|
|
85
|
-
urt.name AS upload_requests_table,
|
|
86
80
|
sm.endpoint,
|
|
87
81
|
sm.public_url_prefix,
|
|
88
82
|
sm.provider,
|
|
@@ -99,8 +93,6 @@ const ALL_STORAGE_MODULES_QUERY = `
|
|
|
99
93
|
JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
|
|
100
94
|
JOIN metaschema_public.table ft ON ft.id = sm.files_table_id
|
|
101
95
|
JOIN metaschema_public.schema fs ON fs.id = ft.schema_id
|
|
102
|
-
JOIN metaschema_public.table urt ON urt.id = sm.upload_requests_table_id
|
|
103
|
-
JOIN metaschema_public.schema urs ON urs.id = urt.schema_id
|
|
104
96
|
LEFT JOIN metaschema_public.table et ON et.id = sm.entity_table_id
|
|
105
97
|
LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id
|
|
106
98
|
WHERE sm.database_id = $1
|
|
@@ -114,11 +106,9 @@ function buildConfig(row) {
|
|
|
114
106
|
id: row.id,
|
|
115
107
|
bucketsQualifiedName: QuoteUtils.quoteQualifiedIdentifier(row.buckets_schema, row.buckets_table),
|
|
116
108
|
filesQualifiedName: QuoteUtils.quoteQualifiedIdentifier(row.files_schema, row.files_table),
|
|
117
|
-
uploadRequestsQualifiedName: QuoteUtils.quoteQualifiedIdentifier(row.upload_requests_schema, row.upload_requests_table),
|
|
118
109
|
schemaName: row.buckets_schema,
|
|
119
110
|
bucketsTableName: row.buckets_table,
|
|
120
111
|
filesTableName: row.files_table,
|
|
121
|
-
uploadRequestsTableName: row.upload_requests_table,
|
|
122
112
|
membershipType: row.membership_type,
|
|
123
113
|
entityTableId: row.entity_table_id,
|
|
124
114
|
entityQualifiedName: row.entity_schema && row.entity_table
|
|
@@ -229,7 +219,6 @@ export async function getStorageModuleConfigForOwner(pgClient, databaseId, owner
|
|
|
229
219
|
/**
|
|
230
220
|
* Resolve the storage module that owns a specific file by probing all file tables.
|
|
231
221
|
*
|
|
232
|
-
* Used by confirmUpload when only a fileId (UUID) is available.
|
|
233
222
|
* Since UUIDs are globally unique, exactly one table will contain the file.
|
|
234
223
|
*
|
|
235
224
|
* @param pgClient - A pg client from the Graphile context
|
|
@@ -244,7 +233,7 @@ export async function resolveStorageModuleByFileId(pgClient, databaseId, fileId)
|
|
|
244
233
|
// Probe each module's files table for the fileId
|
|
245
234
|
for (const config of allConfigs) {
|
|
246
235
|
const fileResult = await pgClient.query({
|
|
247
|
-
text: `SELECT id, key, mime_type,
|
|
236
|
+
text: `SELECT id, key, mime_type, bucket_id
|
|
248
237
|
FROM ${config.filesQualifiedName}
|
|
249
238
|
WHERE id = $1
|
|
250
239
|
LIMIT 1`,
|
package/esm/types.d.ts
CHANGED
|
@@ -21,16 +21,12 @@ export interface StorageModuleConfig {
|
|
|
21
21
|
bucketsQualifiedName: string;
|
|
22
22
|
/** Resolved schema.table for files */
|
|
23
23
|
filesQualifiedName: string;
|
|
24
|
-
/** Resolved schema.table for upload_requests */
|
|
25
|
-
uploadRequestsQualifiedName: string;
|
|
26
24
|
/** Schema name (e.g., "app_public") */
|
|
27
25
|
schemaName: string;
|
|
28
26
|
/** Buckets table name */
|
|
29
27
|
bucketsTableName: string;
|
|
30
28
|
/** Files table name */
|
|
31
29
|
filesTableName: string;
|
|
32
|
-
/** Upload requests table name */
|
|
33
|
-
uploadRequestsTableName: string;
|
|
34
30
|
/** Membership type (NULL for app-level, non-NULL for entity-scoped) */
|
|
35
31
|
membershipType: number | null;
|
|
36
32
|
/** Entity table ID for entity-scoped storage (NULL for app-level) */
|
|
@@ -92,26 +88,6 @@ export interface RequestUploadUrlPayload {
|
|
|
92
88
|
deduplicated: boolean;
|
|
93
89
|
/** Presigned URL expiry time (null if deduplicated) */
|
|
94
90
|
expiresAt: string | null;
|
|
95
|
-
/** File status — 'pending' for fresh uploads, 'ready' or 'processed' for deduplicated files */
|
|
96
|
-
status: string;
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Input for the confirmUpload mutation.
|
|
100
|
-
*/
|
|
101
|
-
export interface ConfirmUploadInput {
|
|
102
|
-
/** The file ID returned by requestUploadUrl */
|
|
103
|
-
fileId: string;
|
|
104
|
-
}
|
|
105
|
-
/**
|
|
106
|
-
* Result of the confirmUpload mutation.
|
|
107
|
-
*/
|
|
108
|
-
export interface ConfirmUploadPayload {
|
|
109
|
-
/** The confirmed file ID */
|
|
110
|
-
fileId: string;
|
|
111
|
-
/** New file status (should be 'ready') */
|
|
112
|
-
status: string;
|
|
113
|
-
/** Whether confirmation succeeded */
|
|
114
|
-
success: boolean;
|
|
115
91
|
}
|
|
116
92
|
/**
|
|
117
93
|
* S3 configuration for the presigned URL plugin.
|
package/index.d.ts
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Presigned URL Plugin for PostGraphile v5
|
|
3
3
|
*
|
|
4
4
|
* Provides presigned URL upload capabilities for PostGraphile v5:
|
|
5
|
-
* - requestUploadUrl mutation (presigned PUT URL generation)
|
|
6
|
-
* - confirmUpload mutation (upload verification + status transition)
|
|
5
|
+
* - requestUploadUrl mutation (presigned PUT URL generation + dedup)
|
|
7
6
|
* - downloadUrl computed field (presigned GET URL / public URL)
|
|
8
7
|
*
|
|
9
8
|
* @example
|
|
@@ -31,4 +30,4 @@ export { createDownloadUrlPlugin } from './download-url-field';
|
|
|
31
30
|
export { PresignedUrlPreset } from './preset';
|
|
32
31
|
export { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, resolveStorageModuleByFileId, clearStorageModuleCache, clearBucketCache, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
|
|
33
32
|
export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
|
|
34
|
-
export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload,
|
|
33
|
+
export type { BucketConfig, StorageModuleConfig, RequestUploadUrlInput, RequestUploadUrlPayload, S3Config, S3ConfigOrGetter, PresignedUrlPluginOptions, BucketNameResolver, EnsureBucketProvisioned, } from './types';
|
package/index.js
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
* Presigned URL Plugin for PostGraphile v5
|
|
4
4
|
*
|
|
5
5
|
* Provides presigned URL upload capabilities for PostGraphile v5:
|
|
6
|
-
* - requestUploadUrl mutation (presigned PUT URL generation)
|
|
7
|
-
* - confirmUpload mutation (upload verification + status transition)
|
|
6
|
+
* - requestUploadUrl mutation (presigned PUT URL generation + dedup)
|
|
8
7
|
* - downloadUrl computed field (presigned GET URL / public URL)
|
|
9
8
|
*
|
|
10
9
|
* @example
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "graphile-presigned-url-plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|
|
7
7
|
"license": "MIT",
|
|
@@ -60,5 +60,5 @@
|
|
|
60
60
|
"@types/node": "^22.19.11",
|
|
61
61
|
"makage": "^0.1.10"
|
|
62
62
|
},
|
|
63
|
-
"gitHead": "
|
|
63
|
+
"gitHead": "0238640b70fed4b203eb84f48315c2bd807923b9"
|
|
64
64
|
}
|
package/plugin.d.ts
CHANGED
|
@@ -5,13 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 1. `requestUploadUrl` mutation — generates a presigned PUT URL for direct
|
|
7
7
|
* client-to-S3 upload. Checks bucket access via RLS, deduplicates by
|
|
8
|
-
* content hash
|
|
8
|
+
* content hash via UNIQUE(bucket_id, key) constraint.
|
|
9
9
|
*
|
|
10
|
-
* 2. `
|
|
11
|
-
* the object exists with correct content-type, transitions file status
|
|
12
|
-
* from 'pending' to 'ready'.
|
|
13
|
-
*
|
|
14
|
-
* 3. `downloadUrl` computed field on File types — generates presigned GET URLs
|
|
10
|
+
* 2. `downloadUrl` computed field on File types — generates presigned GET URLs
|
|
15
11
|
* for private files, returns public URL prefix + key for public files.
|
|
16
12
|
*
|
|
17
13
|
* Uses the extendSchema + grafast plan pattern (same as PublicKeySignature).
|
package/plugin.js
CHANGED
|
@@ -6,13 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* 1. `requestUploadUrl` mutation — generates a presigned PUT URL for direct
|
|
8
8
|
* client-to-S3 upload. Checks bucket access via RLS, deduplicates by
|
|
9
|
-
* content hash
|
|
9
|
+
* content hash via UNIQUE(bucket_id, key) constraint.
|
|
10
10
|
*
|
|
11
|
-
* 2. `
|
|
12
|
-
* the object exists with correct content-type, transitions file status
|
|
13
|
-
* from 'pending' to 'ready'.
|
|
14
|
-
*
|
|
15
|
-
* 3. `downloadUrl` computed field on File types — generates presigned GET URLs
|
|
11
|
+
* 2. `downloadUrl` computed field on File types — generates presigned GET URLs
|
|
16
12
|
* for private files, returns public URL prefix + key for public files.
|
|
17
13
|
*
|
|
18
14
|
* Uses the extendSchema + grafast plan pattern (same as PublicKeySignature).
|
|
@@ -149,22 +145,6 @@ function createPresignedUrlPlugin(options) {
|
|
|
149
145
|
deduplicated: Boolean!
|
|
150
146
|
"""Presigned URL expiry time (null if deduplicated)"""
|
|
151
147
|
expiresAt: Datetime
|
|
152
|
-
"""File status — 'pending' for fresh uploads, 'ready' or 'processed' for deduplicated files. Clients can use this to know immediately whether the file is usable."""
|
|
153
|
-
status: String!
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
input ConfirmUploadInput {
|
|
157
|
-
"""The file ID returned by requestUploadUrl"""
|
|
158
|
-
fileId: UUID!
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
type ConfirmUploadPayload {
|
|
162
|
-
"""The confirmed file ID"""
|
|
163
|
-
fileId: UUID!
|
|
164
|
-
"""New file status"""
|
|
165
|
-
status: String!
|
|
166
|
-
"""Whether confirmation succeeded"""
|
|
167
|
-
success: Boolean!
|
|
168
148
|
}
|
|
169
149
|
|
|
170
150
|
extend type Mutation {
|
|
@@ -177,15 +157,6 @@ function createPresignedUrlPlugin(options) {
|
|
|
177
157
|
requestUploadUrl(
|
|
178
158
|
input: RequestUploadUrlInput!
|
|
179
159
|
): RequestUploadUrlPayload
|
|
180
|
-
|
|
181
|
-
"""
|
|
182
|
-
Confirm that a file has been uploaded to S3.
|
|
183
|
-
Verifies the object exists in S3, checks content-type,
|
|
184
|
-
and transitions the file status from 'pending' to 'ready'.
|
|
185
|
-
"""
|
|
186
|
-
confirmUpload(
|
|
187
|
-
input: ConfirmUploadInput!
|
|
188
|
-
): ConfirmUploadPayload
|
|
189
160
|
}
|
|
190
161
|
`,
|
|
191
162
|
plans: {
|
|
@@ -267,45 +238,36 @@ function createPresignedUrlPlugin(options) {
|
|
|
267
238
|
const s3Key = buildS3Key(contentHash);
|
|
268
239
|
// --- Dedup check: look for existing file with same key (content hash) in this bucket ---
|
|
269
240
|
const dedupResult = await txClient.query({
|
|
270
|
-
text: `SELECT id
|
|
241
|
+
text: `SELECT id
|
|
271
242
|
FROM ${storageConfig.filesQualifiedName}
|
|
272
243
|
WHERE key = $1
|
|
273
244
|
AND bucket_id = $2
|
|
274
|
-
AND status IN ('ready', 'processed')
|
|
275
245
|
LIMIT 1`,
|
|
276
246
|
values: [s3Key, bucket.id],
|
|
277
247
|
});
|
|
278
248
|
if (dedupResult.rows.length > 0) {
|
|
279
249
|
const existingFile = dedupResult.rows[0];
|
|
280
250
|
log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`);
|
|
281
|
-
// Track the dedup request
|
|
282
|
-
await txClient.query({
|
|
283
|
-
text: `INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
|
|
284
|
-
(file_id, bucket_id, key, content_type, content_hash, status, expires_at)
|
|
285
|
-
VALUES ($1, $2, $3, $4, $5, 'confirmed', NOW())`,
|
|
286
|
-
values: [existingFile.id, bucket.id, s3Key, contentType, contentHash],
|
|
287
|
-
});
|
|
288
251
|
return {
|
|
289
252
|
uploadUrl: null,
|
|
290
253
|
fileId: existingFile.id,
|
|
291
254
|
key: s3Key,
|
|
292
255
|
deduplicated: true,
|
|
293
256
|
expiresAt: null,
|
|
294
|
-
status: existingFile.status,
|
|
295
257
|
};
|
|
296
258
|
}
|
|
297
|
-
// --- Create file record
|
|
259
|
+
// --- Create file record ---
|
|
298
260
|
// For app-level storage (no owner_id column), omit owner_id from the INSERT.
|
|
299
261
|
const hasOwnerColumn = storageConfig.membershipType !== null;
|
|
300
262
|
const fileResult = await txClient.query({
|
|
301
263
|
text: hasOwnerColumn
|
|
302
264
|
? `INSERT INTO ${storageConfig.filesQualifiedName}
|
|
303
|
-
(bucket_id, key, mime_type, size, filename, owner_id, is_public
|
|
304
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7
|
|
265
|
+
(bucket_id, key, mime_type, size, filename, owner_id, is_public)
|
|
266
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
305
267
|
RETURNING id`
|
|
306
268
|
: `INSERT INTO ${storageConfig.filesQualifiedName}
|
|
307
|
-
(bucket_id, key, mime_type, size, filename, is_public
|
|
308
|
-
VALUES ($1, $2, $3, $4, $5, $6
|
|
269
|
+
(bucket_id, key, mime_type, size, filename, is_public)
|
|
270
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
309
271
|
RETURNING id`,
|
|
310
272
|
values: hasOwnerColumn
|
|
311
273
|
? [
|
|
@@ -333,94 +295,12 @@ function createPresignedUrlPlugin(options) {
|
|
|
333
295
|
// --- Generate presigned PUT URL (per-database bucket) ---
|
|
334
296
|
const uploadUrl = await (0, s3_signer_1.generatePresignedPutUrl)(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
|
|
335
297
|
const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
|
|
336
|
-
// --- Track the upload request ---
|
|
337
|
-
await txClient.query({
|
|
338
|
-
text: `INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
|
|
339
|
-
(file_id, bucket_id, key, content_type, content_hash, status, expires_at)
|
|
340
|
-
VALUES ($1, $2, $3, $4, $5, 'issued', $6)`,
|
|
341
|
-
values: [fileId, bucket.id, s3Key, contentType, contentHash, expiresAt],
|
|
342
|
-
});
|
|
343
298
|
return {
|
|
344
299
|
uploadUrl,
|
|
345
300
|
fileId,
|
|
346
301
|
key: s3Key,
|
|
347
302
|
deduplicated: false,
|
|
348
303
|
expiresAt,
|
|
349
|
-
status: 'pending',
|
|
350
|
-
};
|
|
351
|
-
});
|
|
352
|
-
});
|
|
353
|
-
});
|
|
354
|
-
},
|
|
355
|
-
confirmUpload(_$mutation, fieldArgs) {
|
|
356
|
-
const $input = fieldArgs.getRaw('input');
|
|
357
|
-
const $withPgClient = (0, grafast_1.context)().get('withPgClient');
|
|
358
|
-
const $pgSettings = (0, grafast_1.context)().get('pgSettings');
|
|
359
|
-
const $combined = (0, grafast_1.object)({
|
|
360
|
-
input: $input,
|
|
361
|
-
withPgClient: $withPgClient,
|
|
362
|
-
pgSettings: $pgSettings,
|
|
363
|
-
});
|
|
364
|
-
return (0, grafast_1.lambda)($combined, async ({ input, withPgClient, pgSettings }) => {
|
|
365
|
-
const { fileId } = input;
|
|
366
|
-
if (!fileId || typeof fileId !== 'string') {
|
|
367
|
-
throw new Error('INVALID_FILE_ID');
|
|
368
|
-
}
|
|
369
|
-
return withPgClient(pgSettings, async (pgClient) => {
|
|
370
|
-
return pgClient.withTransaction(async (txClient) => {
|
|
371
|
-
// --- Resolve storage module by file ID (probes all file tables) ---
|
|
372
|
-
const databaseId = await resolveDatabaseId(txClient);
|
|
373
|
-
if (!databaseId) {
|
|
374
|
-
throw new Error('DATABASE_NOT_FOUND');
|
|
375
|
-
}
|
|
376
|
-
const resolved = await (0, storage_module_cache_1.resolveStorageModuleByFileId)(txClient, databaseId, fileId);
|
|
377
|
-
if (!resolved) {
|
|
378
|
-
throw new Error('FILE_NOT_FOUND');
|
|
379
|
-
}
|
|
380
|
-
const { storageConfig, file } = resolved;
|
|
381
|
-
if (file.status !== 'pending') {
|
|
382
|
-
// File is already confirmed or processed — idempotent success
|
|
383
|
-
return {
|
|
384
|
-
fileId: file.id,
|
|
385
|
-
status: file.status,
|
|
386
|
-
success: true,
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
// --- Verify file exists in S3 (per-database bucket) ---
|
|
390
|
-
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
391
|
-
const s3Head = await (0, s3_signer_1.headObject)(s3ForDb, file.key, file.mime_type);
|
|
392
|
-
if (!s3Head) {
|
|
393
|
-
throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
|
|
394
|
-
}
|
|
395
|
-
// --- Content-type verification ---
|
|
396
|
-
if (s3Head.contentType && s3Head.contentType !== file.mime_type) {
|
|
397
|
-
// Mark upload_request as rejected
|
|
398
|
-
await txClient.query({
|
|
399
|
-
text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
|
|
400
|
-
SET status = 'rejected'
|
|
401
|
-
WHERE file_id = $1 AND status = 'issued'`,
|
|
402
|
-
values: [fileId],
|
|
403
|
-
});
|
|
404
|
-
throw new Error(`CONTENT_TYPE_MISMATCH: expected ${file.mime_type}, got ${s3Head.contentType}`);
|
|
405
|
-
}
|
|
406
|
-
// --- Transition file to 'ready' ---
|
|
407
|
-
await txClient.query({
|
|
408
|
-
text: `UPDATE ${storageConfig.filesQualifiedName}
|
|
409
|
-
SET status = 'ready'
|
|
410
|
-
WHERE id = $1`,
|
|
411
|
-
values: [fileId],
|
|
412
|
-
});
|
|
413
|
-
// --- Update upload_request to 'confirmed' ---
|
|
414
|
-
await txClient.query({
|
|
415
|
-
text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
|
|
416
|
-
SET status = 'confirmed', confirmed_at = NOW()
|
|
417
|
-
WHERE file_id = $1 AND status = 'issued'`,
|
|
418
|
-
values: [fileId],
|
|
419
|
-
});
|
|
420
|
-
return {
|
|
421
|
-
fileId: file.id,
|
|
422
|
-
status: 'ready',
|
|
423
|
-
success: true,
|
|
424
304
|
};
|
|
425
305
|
});
|
|
426
306
|
});
|
package/preset.d.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* PostGraphile v5 Presigned URL Preset
|
|
3
3
|
*
|
|
4
4
|
* Provides a convenient preset for including presigned URL upload support
|
|
5
|
-
* in PostGraphile. Combines the main mutation plugin (requestUploadUrl
|
|
6
|
-
*
|
|
5
|
+
* in PostGraphile. Combines the main mutation plugin (requestUploadUrl)
|
|
6
|
+
* with the downloadUrl computed field plugin.
|
|
7
7
|
*/
|
|
8
8
|
import type { GraphileConfig } from 'graphile-config';
|
|
9
9
|
import type { PresignedUrlPluginOptions } from './types';
|
package/preset.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* PostGraphile v5 Presigned URL Preset
|
|
4
4
|
*
|
|
5
5
|
* Provides a convenient preset for including presigned URL upload support
|
|
6
|
-
* in PostGraphile. Combines the main mutation plugin (requestUploadUrl
|
|
7
|
-
*
|
|
6
|
+
* in PostGraphile. Combines the main mutation plugin (requestUploadUrl)
|
|
7
|
+
* with the downloadUrl computed field plugin.
|
|
8
8
|
*/
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
10
|
exports.PresignedUrlPreset = PresignedUrlPreset;
|
package/s3-signer.d.ts
CHANGED
|
@@ -30,8 +30,7 @@ export declare function generatePresignedGetUrl(s3Config: S3Config, key: string,
|
|
|
30
30
|
/**
|
|
31
31
|
* Check if an object exists in S3 and optionally verify its content-type.
|
|
32
32
|
*
|
|
33
|
-
*
|
|
34
|
-
* and that the content-type matches what was declared.
|
|
33
|
+
* Checks whether an object exists in S3 and retrieves its content-type.
|
|
35
34
|
*
|
|
36
35
|
* @param s3Config - S3 client and bucket configuration
|
|
37
36
|
* @param key - S3 object key
|
package/s3-signer.js
CHANGED
|
@@ -61,8 +61,7 @@ async function generatePresignedGetUrl(s3Config, key, expiresIn = 3600, filename
|
|
|
61
61
|
/**
|
|
62
62
|
* Check if an object exists in S3 and optionally verify its content-type.
|
|
63
63
|
*
|
|
64
|
-
*
|
|
65
|
-
* and that the content-type matches what was declared.
|
|
64
|
+
* Checks whether an object exists in S3 and retrieves its content-type.
|
|
66
65
|
*
|
|
67
66
|
* @param s3Config - S3 client and bucket configuration
|
|
68
67
|
* @param key - S3 object key
|
|
@@ -43,7 +43,6 @@ export declare function getStorageModuleConfigForOwner(pgClient: {
|
|
|
43
43
|
/**
|
|
44
44
|
* Resolve the storage module that owns a specific file by probing all file tables.
|
|
45
45
|
*
|
|
46
|
-
* Used by confirmUpload when only a fileId (UUID) is available.
|
|
47
46
|
* Since UUIDs are globally unique, exactly one table will contain the file.
|
|
48
47
|
*
|
|
49
48
|
* @param pgClient - A pg client from the Graphile context
|
|
@@ -64,7 +63,6 @@ export declare function resolveStorageModuleByFileId(pgClient: {
|
|
|
64
63
|
id: string;
|
|
65
64
|
key: string;
|
|
66
65
|
mime_type: string;
|
|
67
|
-
status: string;
|
|
68
66
|
bucket_id: string;
|
|
69
67
|
};
|
|
70
68
|
} | null>;
|
package/storage-module-cache.js
CHANGED
|
@@ -24,8 +24,8 @@ const ONE_HOUR_MS = 1000 * 60 * 60;
|
|
|
24
24
|
* LRU cache for per-database StorageModuleConfig.
|
|
25
25
|
*
|
|
26
26
|
* Each PostGraphile instance serves a single database, but the presigned URL
|
|
27
|
-
* plugin needs to know the generated table names (buckets, files
|
|
28
|
-
*
|
|
27
|
+
* plugin needs to know the generated table names (buckets, files)
|
|
28
|
+
* and their schemas. This cache avoids re-querying metaschema
|
|
29
29
|
* on every request.
|
|
30
30
|
*
|
|
31
31
|
* Pattern: same as graphile-cache's LRU with TTL-based eviction.
|
|
@@ -52,8 +52,6 @@ const APP_STORAGE_MODULE_QUERY = `
|
|
|
52
52
|
bt.name AS buckets_table,
|
|
53
53
|
fs.schema_name AS files_schema,
|
|
54
54
|
ft.name AS files_table,
|
|
55
|
-
urs.schema_name AS upload_requests_schema,
|
|
56
|
-
urt.name AS upload_requests_table,
|
|
57
55
|
sm.endpoint,
|
|
58
56
|
sm.public_url_prefix,
|
|
59
57
|
sm.provider,
|
|
@@ -70,8 +68,6 @@ const APP_STORAGE_MODULE_QUERY = `
|
|
|
70
68
|
JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
|
|
71
69
|
JOIN metaschema_public.table ft ON ft.id = sm.files_table_id
|
|
72
70
|
JOIN metaschema_public.schema fs ON fs.id = ft.schema_id
|
|
73
|
-
JOIN metaschema_public.table urt ON urt.id = sm.upload_requests_table_id
|
|
74
|
-
JOIN metaschema_public.schema urs ON urs.id = urt.schema_id
|
|
75
71
|
WHERE sm.database_id = $1
|
|
76
72
|
AND sm.membership_type IS NULL
|
|
77
73
|
LIMIT 1
|
|
@@ -91,8 +87,6 @@ const ALL_STORAGE_MODULES_QUERY = `
|
|
|
91
87
|
bt.name AS buckets_table,
|
|
92
88
|
fs.schema_name AS files_schema,
|
|
93
89
|
ft.name AS files_table,
|
|
94
|
-
urs.schema_name AS upload_requests_schema,
|
|
95
|
-
urt.name AS upload_requests_table,
|
|
96
90
|
sm.endpoint,
|
|
97
91
|
sm.public_url_prefix,
|
|
98
92
|
sm.provider,
|
|
@@ -109,8 +103,6 @@ const ALL_STORAGE_MODULES_QUERY = `
|
|
|
109
103
|
JOIN metaschema_public.schema bs ON bs.id = bt.schema_id
|
|
110
104
|
JOIN metaschema_public.table ft ON ft.id = sm.files_table_id
|
|
111
105
|
JOIN metaschema_public.schema fs ON fs.id = ft.schema_id
|
|
112
|
-
JOIN metaschema_public.table urt ON urt.id = sm.upload_requests_table_id
|
|
113
|
-
JOIN metaschema_public.schema urs ON urs.id = urt.schema_id
|
|
114
106
|
LEFT JOIN metaschema_public.table et ON et.id = sm.entity_table_id
|
|
115
107
|
LEFT JOIN metaschema_public.schema es ON es.id = et.schema_id
|
|
116
108
|
WHERE sm.database_id = $1
|
|
@@ -124,11 +116,9 @@ function buildConfig(row) {
|
|
|
124
116
|
id: row.id,
|
|
125
117
|
bucketsQualifiedName: quotes_1.QuoteUtils.quoteQualifiedIdentifier(row.buckets_schema, row.buckets_table),
|
|
126
118
|
filesQualifiedName: quotes_1.QuoteUtils.quoteQualifiedIdentifier(row.files_schema, row.files_table),
|
|
127
|
-
uploadRequestsQualifiedName: quotes_1.QuoteUtils.quoteQualifiedIdentifier(row.upload_requests_schema, row.upload_requests_table),
|
|
128
119
|
schemaName: row.buckets_schema,
|
|
129
120
|
bucketsTableName: row.buckets_table,
|
|
130
121
|
filesTableName: row.files_table,
|
|
131
|
-
uploadRequestsTableName: row.upload_requests_table,
|
|
132
122
|
membershipType: row.membership_type,
|
|
133
123
|
entityTableId: row.entity_table_id,
|
|
134
124
|
entityQualifiedName: row.entity_schema && row.entity_table
|
|
@@ -239,7 +229,6 @@ async function getStorageModuleConfigForOwner(pgClient, databaseId, ownerId) {
|
|
|
239
229
|
/**
|
|
240
230
|
* Resolve the storage module that owns a specific file by probing all file tables.
|
|
241
231
|
*
|
|
242
|
-
* Used by confirmUpload when only a fileId (UUID) is available.
|
|
243
232
|
* Since UUIDs are globally unique, exactly one table will contain the file.
|
|
244
233
|
*
|
|
245
234
|
* @param pgClient - A pg client from the Graphile context
|
|
@@ -254,7 +243,7 @@ async function resolveStorageModuleByFileId(pgClient, databaseId, fileId) {
|
|
|
254
243
|
// Probe each module's files table for the fileId
|
|
255
244
|
for (const config of allConfigs) {
|
|
256
245
|
const fileResult = await pgClient.query({
|
|
257
|
-
text: `SELECT id, key, mime_type,
|
|
246
|
+
text: `SELECT id, key, mime_type, bucket_id
|
|
258
247
|
FROM ${config.filesQualifiedName}
|
|
259
248
|
WHERE id = $1
|
|
260
249
|
LIMIT 1`,
|
package/types.d.ts
CHANGED
|
@@ -21,16 +21,12 @@ export interface StorageModuleConfig {
|
|
|
21
21
|
bucketsQualifiedName: string;
|
|
22
22
|
/** Resolved schema.table for files */
|
|
23
23
|
filesQualifiedName: string;
|
|
24
|
-
/** Resolved schema.table for upload_requests */
|
|
25
|
-
uploadRequestsQualifiedName: string;
|
|
26
24
|
/** Schema name (e.g., "app_public") */
|
|
27
25
|
schemaName: string;
|
|
28
26
|
/** Buckets table name */
|
|
29
27
|
bucketsTableName: string;
|
|
30
28
|
/** Files table name */
|
|
31
29
|
filesTableName: string;
|
|
32
|
-
/** Upload requests table name */
|
|
33
|
-
uploadRequestsTableName: string;
|
|
34
30
|
/** Membership type (NULL for app-level, non-NULL for entity-scoped) */
|
|
35
31
|
membershipType: number | null;
|
|
36
32
|
/** Entity table ID for entity-scoped storage (NULL for app-level) */
|
|
@@ -92,26 +88,6 @@ export interface RequestUploadUrlPayload {
|
|
|
92
88
|
deduplicated: boolean;
|
|
93
89
|
/** Presigned URL expiry time (null if deduplicated) */
|
|
94
90
|
expiresAt: string | null;
|
|
95
|
-
/** File status — 'pending' for fresh uploads, 'ready' or 'processed' for deduplicated files */
|
|
96
|
-
status: string;
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Input for the confirmUpload mutation.
|
|
100
|
-
*/
|
|
101
|
-
export interface ConfirmUploadInput {
|
|
102
|
-
/** The file ID returned by requestUploadUrl */
|
|
103
|
-
fileId: string;
|
|
104
|
-
}
|
|
105
|
-
/**
|
|
106
|
-
* Result of the confirmUpload mutation.
|
|
107
|
-
*/
|
|
108
|
-
export interface ConfirmUploadPayload {
|
|
109
|
-
/** The confirmed file ID */
|
|
110
|
-
fileId: string;
|
|
111
|
-
/** New file status (should be 'ready') */
|
|
112
|
-
status: string;
|
|
113
|
-
/** Whether confirmation succeeded */
|
|
114
|
-
success: boolean;
|
|
115
91
|
}
|
|
116
92
|
/**
|
|
117
93
|
* S3 configuration for the presigned URL plugin.
|