graphile-presigned-url-plugin 0.7.0 → 0.9.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 +338 -220
- 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 +17 -16
- package/esm/types.d.ts +16 -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 +336 -218
- 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 +17 -16
- package/types.d.ts +16 -24
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).
|
|
@@ -30,7 +26,9 @@ const log = new logger_1.Logger('graphile-presigned-url:plugin');
|
|
|
30
26
|
const MAX_CONTENT_HASH_LENGTH = 128;
|
|
31
27
|
const MAX_CONTENT_TYPE_LENGTH = 255;
|
|
32
28
|
const MAX_BUCKET_KEY_LENGTH = 255;
|
|
29
|
+
const MAX_CUSTOM_KEY_LENGTH = 1024;
|
|
33
30
|
const SHA256_HEX_REGEX = /^[a-f0-9]{64}$/;
|
|
31
|
+
const CUSTOM_KEY_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.\-\/]*$/;
|
|
34
32
|
// --- Helpers ---
|
|
35
33
|
/**
|
|
36
34
|
* Validate a SHA-256 hex string.
|
|
@@ -39,12 +37,46 @@ function isValidSha256(hash) {
|
|
|
39
37
|
return SHA256_HEX_REGEX.test(hash);
|
|
40
38
|
}
|
|
41
39
|
/**
|
|
42
|
-
* Build the S3 key from content hash
|
|
40
|
+
* Build the S3 key from content hash.
|
|
43
41
|
* Format: {contentHash} (flat namespace, content-addressed)
|
|
44
42
|
*/
|
|
45
43
|
function buildS3Key(contentHash) {
|
|
46
44
|
return contentHash;
|
|
47
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Validate a custom S3 key.
|
|
48
|
+
* Must be 1-1024 chars, no path traversal, no leading slash, no null bytes.
|
|
49
|
+
*/
|
|
50
|
+
function validateCustomKey(key) {
|
|
51
|
+
if (key.length === 0 || key.length > MAX_CUSTOM_KEY_LENGTH) {
|
|
52
|
+
return 'INVALID_KEY_LENGTH: must be 1-1024 characters';
|
|
53
|
+
}
|
|
54
|
+
if (key.includes('..')) {
|
|
55
|
+
return 'INVALID_KEY: path traversal (..) not allowed';
|
|
56
|
+
}
|
|
57
|
+
if (key.startsWith('/')) {
|
|
58
|
+
return 'INVALID_KEY: leading slash not allowed';
|
|
59
|
+
}
|
|
60
|
+
if (key.includes('\0')) {
|
|
61
|
+
return 'INVALID_KEY: null bytes not allowed';
|
|
62
|
+
}
|
|
63
|
+
if (!CUSTOM_KEY_REGEX.test(key)) {
|
|
64
|
+
return 'INVALID_KEY: must start with alphanumeric and contain only alphanumeric, dots, hyphens, underscores, and slashes';
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Derive an ltree path from a custom S3 key's directory portion.
|
|
70
|
+
* e.g., "reports/2024/Q1/revenue.pdf" → "reports.2024.Q1"
|
|
71
|
+
* Returns null if the key has no directory component.
|
|
72
|
+
*/
|
|
73
|
+
function derivePathFromKey(key) {
|
|
74
|
+
const lastSlash = key.lastIndexOf('/');
|
|
75
|
+
if (lastSlash <= 0)
|
|
76
|
+
return null;
|
|
77
|
+
const dir = key.substring(0, lastSlash);
|
|
78
|
+
return dir.replace(/\//g, '.');
|
|
79
|
+
}
|
|
48
80
|
/**
|
|
49
81
|
* Resolve the database_id from the JWT context.
|
|
50
82
|
* The server middleware sets jwt.claims.database_id, which is accessible
|
|
@@ -136,6 +168,14 @@ function createPresignedUrlPlugin(options) {
|
|
|
136
168
|
size: Int!
|
|
137
169
|
"""Original filename (optional, for display and Content-Disposition)"""
|
|
138
170
|
filename: String
|
|
171
|
+
"""
|
|
172
|
+
Custom S3 key (e.g., "reports/2024/Q1.pdf").
|
|
173
|
+
Only allowed when the bucket has allow_custom_keys=true.
|
|
174
|
+
When omitted, key defaults to contentHash (content-addressed dedup).
|
|
175
|
+
When provided, the file is stored at this key.
|
|
176
|
+
Re-uploading to an existing key auto-creates a new version.
|
|
177
|
+
"""
|
|
178
|
+
key: String
|
|
139
179
|
}
|
|
140
180
|
|
|
141
181
|
type RequestUploadUrlPayload {
|
|
@@ -149,22 +189,52 @@ function createPresignedUrlPlugin(options) {
|
|
|
149
189
|
deduplicated: Boolean!
|
|
150
190
|
"""Presigned URL expiry time (null if deduplicated)"""
|
|
151
191
|
expiresAt: Datetime
|
|
152
|
-
"""
|
|
153
|
-
|
|
192
|
+
"""ID of the previous version (set when re-uploading to an existing custom key)"""
|
|
193
|
+
previousVersionId: UUID
|
|
154
194
|
}
|
|
155
195
|
|
|
156
|
-
input
|
|
157
|
-
"""
|
|
158
|
-
|
|
196
|
+
input BulkUploadFileInput {
|
|
197
|
+
"""SHA-256 content hash computed by the client (hex-encoded, 64 chars)"""
|
|
198
|
+
contentHash: String!
|
|
199
|
+
"""MIME type of the file (e.g., "image/png")"""
|
|
200
|
+
contentType: String!
|
|
201
|
+
"""File size in bytes"""
|
|
202
|
+
size: Int!
|
|
203
|
+
"""Original filename (optional, for display and Content-Disposition)"""
|
|
204
|
+
filename: String
|
|
205
|
+
"""Custom S3 key (only when bucket has allow_custom_keys=true)"""
|
|
206
|
+
key: String
|
|
159
207
|
}
|
|
160
208
|
|
|
161
|
-
|
|
162
|
-
"""
|
|
209
|
+
input RequestBulkUploadUrlsInput {
|
|
210
|
+
"""Bucket key (e.g., "public", "private")"""
|
|
211
|
+
bucketKey: String!
|
|
212
|
+
"""Owner entity ID for entity-scoped uploads"""
|
|
213
|
+
ownerId: UUID
|
|
214
|
+
"""Array of files to upload"""
|
|
215
|
+
files: [BulkUploadFileInput!]!
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
type BulkUploadFilePayload {
|
|
219
|
+
"""Presigned PUT URL (null if file was deduplicated)"""
|
|
220
|
+
uploadUrl: String
|
|
221
|
+
"""The file ID"""
|
|
163
222
|
fileId: UUID!
|
|
164
|
-
"""
|
|
165
|
-
|
|
166
|
-
"""Whether
|
|
167
|
-
|
|
223
|
+
"""The S3 object key"""
|
|
224
|
+
key: String!
|
|
225
|
+
"""Whether this file was deduplicated"""
|
|
226
|
+
deduplicated: Boolean!
|
|
227
|
+
"""Presigned URL expiry time (null if deduplicated)"""
|
|
228
|
+
expiresAt: Datetime
|
|
229
|
+
"""ID of the previous version (set when re-uploading to an existing custom key)"""
|
|
230
|
+
previousVersionId: UUID
|
|
231
|
+
"""Index of this file in the input array (for client correlation)"""
|
|
232
|
+
index: Int!
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
type RequestBulkUploadUrlsPayload {
|
|
236
|
+
"""Array of results, one per input file"""
|
|
237
|
+
files: [BulkUploadFilePayload!]!
|
|
168
238
|
}
|
|
169
239
|
|
|
170
240
|
extend type Mutation {
|
|
@@ -179,13 +249,13 @@ function createPresignedUrlPlugin(options) {
|
|
|
179
249
|
): RequestUploadUrlPayload
|
|
180
250
|
|
|
181
251
|
"""
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
252
|
+
Request presigned URLs for uploading multiple files in a single batch.
|
|
253
|
+
Subject to per-storage-module limits (max_bulk_files, max_bulk_total_size).
|
|
254
|
+
Each file is processed independently — some may dedup while others get fresh URLs.
|
|
185
255
|
"""
|
|
186
|
-
|
|
187
|
-
input:
|
|
188
|
-
):
|
|
256
|
+
requestBulkUploadUrls(
|
|
257
|
+
input: RequestBulkUploadUrlsInput!
|
|
258
|
+
): RequestBulkUploadUrlsPayload
|
|
189
259
|
}
|
|
190
260
|
`,
|
|
191
261
|
plans: {
|
|
@@ -200,28 +270,33 @@ function createPresignedUrlPlugin(options) {
|
|
|
200
270
|
pgSettings: $pgSettings,
|
|
201
271
|
});
|
|
202
272
|
return (0, grafast_1.lambda)($combined, async ({ input, withPgClient, pgSettings }) => {
|
|
203
|
-
|
|
204
|
-
|
|
273
|
+
const result = await processUpload(options, input, withPgClient, pgSettings);
|
|
274
|
+
return result;
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
requestBulkUploadUrls(_$mutation, fieldArgs) {
|
|
278
|
+
const $input = fieldArgs.getRaw('input');
|
|
279
|
+
const $withPgClient = (0, grafast_1.context)().get('withPgClient');
|
|
280
|
+
const $pgSettings = (0, grafast_1.context)().get('pgSettings');
|
|
281
|
+
const $combined = (0, grafast_1.object)({
|
|
282
|
+
input: $input,
|
|
283
|
+
withPgClient: $withPgClient,
|
|
284
|
+
pgSettings: $pgSettings,
|
|
285
|
+
});
|
|
286
|
+
return (0, grafast_1.lambda)($combined, async ({ input, withPgClient, pgSettings }) => {
|
|
287
|
+
const { bucketKey, ownerId, files } = input;
|
|
205
288
|
if (!bucketKey || typeof bucketKey !== 'string' || bucketKey.length > MAX_BUCKET_KEY_LENGTH) {
|
|
206
289
|
throw new Error('INVALID_BUCKET_KEY');
|
|
207
290
|
}
|
|
208
|
-
if (!
|
|
209
|
-
throw new Error('
|
|
210
|
-
}
|
|
211
|
-
if (!isValidSha256(contentHash)) {
|
|
212
|
-
throw new Error('INVALID_CONTENT_HASH_FORMAT: must be a 64-char lowercase hex SHA-256');
|
|
213
|
-
}
|
|
214
|
-
if (!contentType || typeof contentType !== 'string' || contentType.length > MAX_CONTENT_TYPE_LENGTH) {
|
|
215
|
-
throw new Error('INVALID_CONTENT_TYPE');
|
|
291
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
292
|
+
throw new Error('INVALID_FILES: must provide at least one file');
|
|
216
293
|
}
|
|
217
294
|
return withPgClient(pgSettings, async (pgClient) => {
|
|
218
295
|
return pgClient.withTransaction(async (txClient) => {
|
|
219
|
-
// --- Resolve storage module config (all limits come from here) ---
|
|
220
296
|
const databaseId = await resolveDatabaseId(txClient);
|
|
221
297
|
if (!databaseId) {
|
|
222
298
|
throw new Error('DATABASE_NOT_FOUND');
|
|
223
299
|
}
|
|
224
|
-
// --- Resolve storage module (app-level or entity-scoped) ---
|
|
225
300
|
const storageConfig = ownerId
|
|
226
301
|
? await (0, storage_module_cache_1.getStorageModuleConfigForOwner)(txClient, databaseId, ownerId)
|
|
227
302
|
: await (0, storage_module_cache_1.getStorageModuleConfig)(txClient, databaseId);
|
|
@@ -230,198 +305,34 @@ function createPresignedUrlPlugin(options) {
|
|
|
230
305
|
? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
|
|
231
306
|
: 'STORAGE_MODULE_NOT_PROVISIONED');
|
|
232
307
|
}
|
|
233
|
-
// --- Validate
|
|
234
|
-
if (
|
|
235
|
-
throw new Error(`
|
|
308
|
+
// --- Validate bulk limits ---
|
|
309
|
+
if (files.length > storageConfig.maxBulkFiles) {
|
|
310
|
+
throw new Error(`BULK_LIMIT_EXCEEDED: max ${storageConfig.maxBulkFiles} files per batch`);
|
|
236
311
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
312
|
+
const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0);
|
|
313
|
+
if (totalSize > storageConfig.maxBulkTotalSize) {
|
|
314
|
+
throw new Error(`BULK_SIZE_EXCEEDED: total size ${totalSize} exceeds max ${storageConfig.maxBulkTotalSize} bytes`);
|
|
241
315
|
}
|
|
242
|
-
// --- Look up the bucket (cached; first miss queries via RLS) ---
|
|
243
316
|
const bucket = await (0, storage_module_cache_1.getBucketConfig)(txClient, storageConfig, databaseId, bucketKey, ownerId);
|
|
244
317
|
if (!bucket) {
|
|
245
318
|
throw new Error('BUCKET_NOT_FOUND');
|
|
246
319
|
}
|
|
247
|
-
// ---
|
|
248
|
-
if (bucket.allowed_mime_types && bucket.allowed_mime_types.length > 0) {
|
|
249
|
-
const allowed = bucket.allowed_mime_types;
|
|
250
|
-
const isAllowed = allowed.some((pattern) => {
|
|
251
|
-
if (pattern === '*/*')
|
|
252
|
-
return true;
|
|
253
|
-
if (pattern.endsWith('/*')) {
|
|
254
|
-
const prefix = pattern.slice(0, -1);
|
|
255
|
-
return contentType.startsWith(prefix);
|
|
256
|
-
}
|
|
257
|
-
return contentType === pattern;
|
|
258
|
-
});
|
|
259
|
-
if (!isAllowed) {
|
|
260
|
-
throw new Error(`CONTENT_TYPE_NOT_ALLOWED: ${contentType} not in bucket allowed types`);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
// --- Validate size against bucket's max_file_size ---
|
|
264
|
-
if (bucket.max_file_size && size > bucket.max_file_size) {
|
|
265
|
-
throw new Error(`FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes`);
|
|
266
|
-
}
|
|
267
|
-
const s3Key = buildS3Key(contentHash);
|
|
268
|
-
// --- Dedup check: look for existing file with same key (content hash) in this bucket ---
|
|
269
|
-
const dedupResult = await txClient.query({
|
|
270
|
-
text: `SELECT id, status
|
|
271
|
-
FROM ${storageConfig.filesQualifiedName}
|
|
272
|
-
WHERE key = $1
|
|
273
|
-
AND bucket_id = $2
|
|
274
|
-
AND status IN ('ready', 'processed')
|
|
275
|
-
LIMIT 1`,
|
|
276
|
-
values: [s3Key, bucket.id],
|
|
277
|
-
});
|
|
278
|
-
if (dedupResult.rows.length > 0) {
|
|
279
|
-
const existingFile = dedupResult.rows[0];
|
|
280
|
-
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
|
-
return {
|
|
289
|
-
uploadUrl: null,
|
|
290
|
-
fileId: existingFile.id,
|
|
291
|
-
key: s3Key,
|
|
292
|
-
deduplicated: true,
|
|
293
|
-
expiresAt: null,
|
|
294
|
-
status: existingFile.status,
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
// --- Create file record (status=pending) ---
|
|
298
|
-
// For app-level storage (no owner_id column), omit owner_id from the INSERT.
|
|
299
|
-
const hasOwnerColumn = storageConfig.membershipType !== null;
|
|
300
|
-
const fileResult = await txClient.query({
|
|
301
|
-
text: hasOwnerColumn
|
|
302
|
-
? `INSERT INTO ${storageConfig.filesQualifiedName}
|
|
303
|
-
(bucket_id, key, mime_type, size, filename, owner_id, is_public, status)
|
|
304
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending')
|
|
305
|
-
RETURNING id`
|
|
306
|
-
: `INSERT INTO ${storageConfig.filesQualifiedName}
|
|
307
|
-
(bucket_id, key, mime_type, size, filename, is_public, status)
|
|
308
|
-
VALUES ($1, $2, $3, $4, $5, $6, 'pending')
|
|
309
|
-
RETURNING id`,
|
|
310
|
-
values: hasOwnerColumn
|
|
311
|
-
? [
|
|
312
|
-
bucket.id,
|
|
313
|
-
s3Key,
|
|
314
|
-
contentType,
|
|
315
|
-
size,
|
|
316
|
-
filename || null,
|
|
317
|
-
bucket.owner_id,
|
|
318
|
-
bucket.is_public,
|
|
319
|
-
]
|
|
320
|
-
: [
|
|
321
|
-
bucket.id,
|
|
322
|
-
s3Key,
|
|
323
|
-
contentType,
|
|
324
|
-
size,
|
|
325
|
-
filename || null,
|
|
326
|
-
bucket.is_public,
|
|
327
|
-
],
|
|
328
|
-
});
|
|
329
|
-
const fileId = fileResult.rows[0].id;
|
|
330
|
-
// --- Ensure the S3 bucket exists (lazy provisioning) ---
|
|
320
|
+
// --- Ensure S3 bucket exists once for the batch ---
|
|
331
321
|
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
332
322
|
await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
|
|
333
|
-
// ---
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
values: [fileId, bucket.id, s3Key, contentType, contentHash, expiresAt],
|
|
342
|
-
});
|
|
343
|
-
return {
|
|
344
|
-
uploadUrl,
|
|
345
|
-
fileId,
|
|
346
|
-
key: s3Key,
|
|
347
|
-
deduplicated: false,
|
|
348
|
-
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,
|
|
323
|
+
// --- Process each file ---
|
|
324
|
+
const results = [];
|
|
325
|
+
for (let i = 0; i < files.length; i++) {
|
|
326
|
+
const fileInput = files[i];
|
|
327
|
+
const singleInput = {
|
|
328
|
+
...fileInput,
|
|
329
|
+
bucketKey,
|
|
330
|
+
ownerId,
|
|
387
331
|
};
|
|
332
|
+
const result = await processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, singleInput);
|
|
333
|
+
results.push({ ...result, index: i });
|
|
388
334
|
}
|
|
389
|
-
|
|
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
|
-
};
|
|
335
|
+
return { files: results };
|
|
425
336
|
});
|
|
426
337
|
});
|
|
427
338
|
});
|
|
@@ -430,5 +341,212 @@ function createPresignedUrlPlugin(options) {
|
|
|
430
341
|
},
|
|
431
342
|
}));
|
|
432
343
|
}
|
|
344
|
+
// --- Shared upload logic ---
|
|
345
|
+
/**
|
|
346
|
+
* Process a single upload request (used by both requestUploadUrl and requestBulkUploadUrls).
|
|
347
|
+
*/
|
|
348
|
+
async function processUpload(options, input, withPgClient, pgSettings) {
|
|
349
|
+
const { bucketKey, ownerId, contentHash, contentType, size, filename, key: customKey } = input;
|
|
350
|
+
if (!bucketKey || typeof bucketKey !== 'string' || bucketKey.length > MAX_BUCKET_KEY_LENGTH) {
|
|
351
|
+
throw new Error('INVALID_BUCKET_KEY');
|
|
352
|
+
}
|
|
353
|
+
if (!contentHash || typeof contentHash !== 'string' || contentHash.length > MAX_CONTENT_HASH_LENGTH) {
|
|
354
|
+
throw new Error('INVALID_CONTENT_HASH');
|
|
355
|
+
}
|
|
356
|
+
if (!isValidSha256(contentHash)) {
|
|
357
|
+
throw new Error('INVALID_CONTENT_HASH_FORMAT: must be a 64-char lowercase hex SHA-256');
|
|
358
|
+
}
|
|
359
|
+
if (!contentType || typeof contentType !== 'string' || contentType.length > MAX_CONTENT_TYPE_LENGTH) {
|
|
360
|
+
throw new Error('INVALID_CONTENT_TYPE');
|
|
361
|
+
}
|
|
362
|
+
return withPgClient(pgSettings, async (pgClient) => {
|
|
363
|
+
return pgClient.withTransaction(async (txClient) => {
|
|
364
|
+
const databaseId = await resolveDatabaseId(txClient);
|
|
365
|
+
if (!databaseId) {
|
|
366
|
+
throw new Error('DATABASE_NOT_FOUND');
|
|
367
|
+
}
|
|
368
|
+
const storageConfig = ownerId
|
|
369
|
+
? await (0, storage_module_cache_1.getStorageModuleConfigForOwner)(txClient, databaseId, ownerId)
|
|
370
|
+
: await (0, storage_module_cache_1.getStorageModuleConfig)(txClient, databaseId);
|
|
371
|
+
if (!storageConfig) {
|
|
372
|
+
throw new Error(ownerId
|
|
373
|
+
? 'STORAGE_MODULE_NOT_FOUND_FOR_OWNER: no storage module found for the given ownerId'
|
|
374
|
+
: 'STORAGE_MODULE_NOT_PROVISIONED');
|
|
375
|
+
}
|
|
376
|
+
if (typeof size !== 'number' || size <= 0 || size > storageConfig.defaultMaxFileSize) {
|
|
377
|
+
throw new Error(`INVALID_FILE_SIZE: must be between 1 and ${storageConfig.defaultMaxFileSize} bytes`);
|
|
378
|
+
}
|
|
379
|
+
if (filename !== undefined && filename !== null) {
|
|
380
|
+
if (typeof filename !== 'string' || filename.length > storageConfig.maxFilenameLength) {
|
|
381
|
+
throw new Error('INVALID_FILENAME');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const bucket = await (0, storage_module_cache_1.getBucketConfig)(txClient, storageConfig, databaseId, bucketKey, ownerId);
|
|
385
|
+
if (!bucket) {
|
|
386
|
+
throw new Error('BUCKET_NOT_FOUND');
|
|
387
|
+
}
|
|
388
|
+
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
|
|
389
|
+
await ensureS3BucketExists(options, s3ForDb.bucket, bucket, databaseId, storageConfig.allowedOrigins);
|
|
390
|
+
return processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, input);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Process a single file upload within an already-resolved context.
|
|
396
|
+
* Handles dedup, custom keys, versioning, and auto-path derivation.
|
|
397
|
+
*/
|
|
398
|
+
async function processSingleFile(options, txClient, storageConfig, databaseId, bucket, s3ForDb, input) {
|
|
399
|
+
const { contentHash, contentType, size, filename, key: customKey } = input;
|
|
400
|
+
// --- Validate inputs ---
|
|
401
|
+
if (!contentHash || !isValidSha256(contentHash)) {
|
|
402
|
+
throw new Error('INVALID_CONTENT_HASH_FORMAT: must be a 64-char lowercase hex SHA-256');
|
|
403
|
+
}
|
|
404
|
+
if (!contentType || typeof contentType !== 'string' || contentType.length > MAX_CONTENT_TYPE_LENGTH) {
|
|
405
|
+
throw new Error('INVALID_CONTENT_TYPE');
|
|
406
|
+
}
|
|
407
|
+
if (typeof size !== 'number' || size <= 0 || size > storageConfig.defaultMaxFileSize) {
|
|
408
|
+
throw new Error(`INVALID_FILE_SIZE: must be between 1 and ${storageConfig.defaultMaxFileSize} bytes`);
|
|
409
|
+
}
|
|
410
|
+
if (filename !== undefined && filename !== null) {
|
|
411
|
+
if (typeof filename !== 'string' || filename.length > storageConfig.maxFilenameLength) {
|
|
412
|
+
throw new Error('INVALID_FILENAME');
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// --- Validate content type against bucket's allowed_mime_types ---
|
|
416
|
+
if (bucket.allowed_mime_types && bucket.allowed_mime_types.length > 0) {
|
|
417
|
+
const allowed = bucket.allowed_mime_types;
|
|
418
|
+
const isAllowed = allowed.some((pattern) => {
|
|
419
|
+
if (pattern === '*/*')
|
|
420
|
+
return true;
|
|
421
|
+
if (pattern.endsWith('/*')) {
|
|
422
|
+
const prefix = pattern.slice(0, -1);
|
|
423
|
+
return contentType.startsWith(prefix);
|
|
424
|
+
}
|
|
425
|
+
return contentType === pattern;
|
|
426
|
+
});
|
|
427
|
+
if (!isAllowed) {
|
|
428
|
+
throw new Error(`CONTENT_TYPE_NOT_ALLOWED: ${contentType} not in bucket allowed types`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// --- Validate size against bucket's max_file_size ---
|
|
432
|
+
if (bucket.max_file_size && size > bucket.max_file_size) {
|
|
433
|
+
throw new Error(`FILE_TOO_LARGE: exceeds bucket max of ${bucket.max_file_size} bytes`);
|
|
434
|
+
}
|
|
435
|
+
// --- Determine S3 key ---
|
|
436
|
+
let s3Key;
|
|
437
|
+
let isCustomKey = false;
|
|
438
|
+
if (customKey) {
|
|
439
|
+
if (!bucket.allow_custom_keys) {
|
|
440
|
+
throw new Error('CUSTOM_KEY_NOT_ALLOWED: bucket does not allow custom keys');
|
|
441
|
+
}
|
|
442
|
+
const keyError = validateCustomKey(customKey);
|
|
443
|
+
if (keyError) {
|
|
444
|
+
throw new Error(keyError);
|
|
445
|
+
}
|
|
446
|
+
s3Key = customKey;
|
|
447
|
+
isCustomKey = true;
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
s3Key = buildS3Key(contentHash);
|
|
451
|
+
}
|
|
452
|
+
// --- Dedup / versioning check ---
|
|
453
|
+
let previousVersionId = null;
|
|
454
|
+
if (isCustomKey) {
|
|
455
|
+
// Custom key mode: check if a file with this key already exists in this bucket.
|
|
456
|
+
// If so, auto-version by linking via previous_version_id.
|
|
457
|
+
const existingResult = await txClient.query({
|
|
458
|
+
text: `SELECT id, content_hash
|
|
459
|
+
FROM ${storageConfig.filesQualifiedName}
|
|
460
|
+
WHERE key = $1
|
|
461
|
+
AND bucket_id = $2
|
|
462
|
+
ORDER BY created_at DESC
|
|
463
|
+
LIMIT 1`,
|
|
464
|
+
values: [s3Key, bucket.id],
|
|
465
|
+
});
|
|
466
|
+
if (existingResult.rows.length > 0) {
|
|
467
|
+
const existing = existingResult.rows[0];
|
|
468
|
+
// Same content hash = true dedup (no new upload needed)
|
|
469
|
+
if (existing.content_hash === contentHash) {
|
|
470
|
+
log.info(`Dedup hit (custom key): file ${existing.id} for key ${s3Key}`);
|
|
471
|
+
return {
|
|
472
|
+
uploadUrl: null,
|
|
473
|
+
fileId: existing.id,
|
|
474
|
+
key: s3Key,
|
|
475
|
+
deduplicated: true,
|
|
476
|
+
expiresAt: null,
|
|
477
|
+
previousVersionId: null,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
// Different content = new version
|
|
481
|
+
previousVersionId = existing.id;
|
|
482
|
+
log.info(`Versioning: new version of key ${s3Key}, previous=${previousVersionId}`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
// Hash-based mode: dedup by content_hash in this bucket
|
|
487
|
+
const dedupResult = await txClient.query({
|
|
488
|
+
text: `SELECT id
|
|
489
|
+
FROM ${storageConfig.filesQualifiedName}
|
|
490
|
+
WHERE content_hash = $1
|
|
491
|
+
AND bucket_id = $2
|
|
492
|
+
LIMIT 1`,
|
|
493
|
+
values: [contentHash, bucket.id],
|
|
494
|
+
});
|
|
495
|
+
if (dedupResult.rows.length > 0) {
|
|
496
|
+
const existingFile = dedupResult.rows[0];
|
|
497
|
+
log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`);
|
|
498
|
+
return {
|
|
499
|
+
uploadUrl: null,
|
|
500
|
+
fileId: existingFile.id,
|
|
501
|
+
key: s3Key,
|
|
502
|
+
deduplicated: true,
|
|
503
|
+
expiresAt: null,
|
|
504
|
+
previousVersionId: null,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// --- Auto-derive ltree path from custom key directory (only when has_path_shares) ---
|
|
509
|
+
const derivedPath = isCustomKey && storageConfig.hasPathShares ? derivePathFromKey(s3Key) : null;
|
|
510
|
+
// --- Create file record ---
|
|
511
|
+
const hasOwnerColumn = storageConfig.membershipType !== null;
|
|
512
|
+
const columns = ['bucket_id', 'key', 'content_hash', 'mime_type', 'size', 'filename', 'is_public'];
|
|
513
|
+
const values = [bucket.id, s3Key, contentHash, contentType, size, filename || null, bucket.is_public];
|
|
514
|
+
let paramIdx = values.length;
|
|
515
|
+
if (hasOwnerColumn) {
|
|
516
|
+
columns.push('owner_id');
|
|
517
|
+
values.push(bucket.owner_id);
|
|
518
|
+
paramIdx = values.length;
|
|
519
|
+
}
|
|
520
|
+
if (previousVersionId) {
|
|
521
|
+
columns.push('previous_version_id');
|
|
522
|
+
values.push(previousVersionId);
|
|
523
|
+
paramIdx = values.length;
|
|
524
|
+
}
|
|
525
|
+
if (derivedPath) {
|
|
526
|
+
columns.push('path');
|
|
527
|
+
values.push(derivedPath);
|
|
528
|
+
paramIdx = values.length;
|
|
529
|
+
}
|
|
530
|
+
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
|
|
531
|
+
const fileResult = await txClient.query({
|
|
532
|
+
text: `INSERT INTO ${storageConfig.filesQualifiedName}
|
|
533
|
+
(${columns.join(', ')})
|
|
534
|
+
VALUES (${placeholders})
|
|
535
|
+
RETURNING id`,
|
|
536
|
+
values,
|
|
537
|
+
});
|
|
538
|
+
const fileId = fileResult.rows[0].id;
|
|
539
|
+
// --- Generate presigned PUT URL ---
|
|
540
|
+
const uploadUrl = await (0, s3_signer_1.generatePresignedPutUrl)(s3ForDb, s3Key, contentType, size, storageConfig.uploadUrlExpirySeconds);
|
|
541
|
+
const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();
|
|
542
|
+
return {
|
|
543
|
+
uploadUrl,
|
|
544
|
+
fileId,
|
|
545
|
+
key: s3Key,
|
|
546
|
+
deduplicated: false,
|
|
547
|
+
expiresAt,
|
|
548
|
+
previousVersionId,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
433
551
|
exports.PresignedUrlPlugin = createPresignedUrlPlugin;
|
|
434
552
|
exports.default = exports.PresignedUrlPlugin;
|
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';
|