Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions graphile/graphile-presigned-url-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,9 @@ Presigned URL upload plugin for PostGraphile v5.
## Features

- `requestUploadUrl` mutation — generates presigned PUT URLs for direct client-to-S3 upload
- `confirmUpload` mutation — verifies upload and transitions file status to 'ready'
- `downloadUrl` computed field — presigned GET URLs for private files, public URLs for public files
- Content-hash based S3 keys (SHA-256) with automatic deduplication
- Per-bucket MIME type and file size validation
- Upload request tracking for audit and rate limiting

## Usage

Expand Down
2 changes: 1 addition & 1 deletion graphile/graphile-presigned-url-plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "graphile-presigned-url-plugin",
"version": "0.7.0",
"description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl, confirmUpload mutations and downloadUrl computed field",
"description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl mutation and downloadUrl computed field",
"author": "Constructive <developers@constructive.io>",
"homepage": "https://github.com/constructive-io/constructive",
"license": "MIT",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ export function createDownloadUrlPlugin(
const $key = $parent.get('key');
const $isPublic = $parent.get('is_public');
const $filename = $parent.get('filename');
const $status = $parent.get('status');

// Access GraphQL context for per-database config resolution
const $withPgClient = (grafastContext() as any).get('withPgClient');
Expand All @@ -135,19 +134,13 @@ export function createDownloadUrlPlugin(
key: $key,
isPublic: $isPublic,
filename: $filename,
status: $status,
withPgClient: $withPgClient,
pgSettings: $pgSettings,
});

return lambda($combined, async ({ key, isPublic, filename, status, withPgClient, pgSettings }: any) => {
return lambda($combined, async ({ key, isPublic, filename, withPgClient, pgSettings }: any) => {
if (!key) return null;

// Only provide download URLs for ready/processed files
if (status !== 'ready' && status !== 'processed') {
return null;
}

// Resolve per-database config (bucket, publicUrlPrefix, expiry)
let s3ForDb = resolveS3(options); // fallback to global
let downloadUrlExpirySeconds = 3600; // fallback default
Expand Down
5 changes: 1 addition & 4 deletions graphile/graphile-presigned-url-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
* Presigned URL Plugin for PostGraphile v5
*
* Provides presigned URL upload capabilities for PostGraphile v5:
* - requestUploadUrl mutation (presigned PUT URL generation)
* - confirmUpload mutation (upload verification + status transition)
* - requestUploadUrl mutation (presigned PUT URL generation + dedup)
* - downloadUrl computed field (presigned GET URL / public URL)
*
* @example
Expand Down Expand Up @@ -37,8 +36,6 @@ export type {
StorageModuleConfig,
RequestUploadUrlInput,
RequestUploadUrlPayload,
ConfirmUploadInput,
ConfirmUploadPayload,
S3Config,
S3ConfigOrGetter,
PresignedUrlPluginOptions,
Expand Down
158 changes: 10 additions & 148 deletions graphile/graphile-presigned-url-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,9 @@
*
* 1. `requestUploadUrl` mutation — generates a presigned PUT URL for direct
* client-to-S3 upload. Checks bucket access via RLS, deduplicates by
* content hash, tracks the request in upload_requests.
* content hash via UNIQUE(bucket_id, key) constraint.
*
* 2. `confirmUpload` mutation — confirms a file was uploaded to S3, verifies
* the object exists with correct content-type, transitions file status
* from 'pending' to 'ready'.
*
* 3. `downloadUrl` computed field on File types — generates presigned GET URLs
* 2. `downloadUrl` computed field on File types — generates presigned GET URLs
* for private files, returns public URL prefix + key for public files.
*
* Uses the extendSchema + grafast plan pattern (same as PublicKeySignature).
Expand All @@ -23,8 +19,8 @@ import { extendSchema, gql } from 'graphile-utils';
import { Logger } from '@pgpmjs/logger';

import type { PresignedUrlPluginOptions, S3Config, StorageModuleConfig, BucketConfig } from './types';
import { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, resolveStorageModuleByFileId, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
import { generatePresignedPutUrl, headObject } from './s3-signer';
import { getStorageModuleConfig, getStorageModuleConfigForOwner, getBucketConfig, isS3BucketProvisioned, markS3BucketProvisioned } from './storage-module-cache';
import { generatePresignedPutUrl } from './s3-signer';

const log = new Logger('graphile-presigned-url:plugin');

Expand Down Expand Up @@ -175,22 +171,6 @@ export function createPresignedUrlPlugin(
deduplicated: Boolean!
"""Presigned URL expiry time (null if deduplicated)"""
expiresAt: Datetime
"""File status — 'pending' for fresh uploads, 'ready' or 'processed' for deduplicated files. Clients can use this to know immediately whether the file is usable."""
status: String!
}

input ConfirmUploadInput {
"""The file ID returned by requestUploadUrl"""
fileId: UUID!
}

type ConfirmUploadPayload {
"""The confirmed file ID"""
fileId: UUID!
"""New file status"""
status: String!
"""Whether confirmation succeeded"""
success: Boolean!
}

extend type Mutation {
Expand All @@ -203,15 +183,6 @@ export function createPresignedUrlPlugin(
requestUploadUrl(
input: RequestUploadUrlInput!
): RequestUploadUrlPayload

"""
Confirm that a file has been uploaded to S3.
Verifies the object exists in S3, checks content-type,
and transitions the file status from 'pending' to 'ready'.
"""
confirmUpload(
input: ConfirmUploadInput!
): ConfirmUploadPayload
}
`,
plans: {
Expand Down Expand Up @@ -304,11 +275,10 @@ export function createPresignedUrlPlugin(

// --- Dedup check: look for existing file with same key (content hash) in this bucket ---
const dedupResult = await txClient.query({
text: `SELECT id, status
text: `SELECT id
FROM ${storageConfig.filesQualifiedName}
WHERE key = $1
AND bucket_id = $2
AND status IN ('ready', 'processed')
LIMIT 1`,
values: [s3Key, bucket.id],
});
Expand All @@ -317,36 +287,27 @@ export function createPresignedUrlPlugin(
const existingFile = dedupResult.rows[0];
log.info(`Dedup hit: file ${existingFile.id} for hash ${contentHash}`);

// Track the dedup request
await txClient.query({
text: `INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
(file_id, bucket_id, key, content_type, content_hash, status, expires_at)
VALUES ($1, $2, $3, $4, $5, 'confirmed', NOW())`,
values: [existingFile.id, bucket.id, s3Key, contentType, contentHash],
});

return {
uploadUrl: null as string | null,
fileId: existingFile.id as string,
key: s3Key,
deduplicated: true,
expiresAt: null as string | null,
status: existingFile.status as string,
};
}

// --- Create file record (status=pending) ---
// --- Create file record ---
// For app-level storage (no owner_id column), omit owner_id from the INSERT.
const hasOwnerColumn = storageConfig.membershipType !== null;
const fileResult = await txClient.query({
text: hasOwnerColumn
? `INSERT INTO ${storageConfig.filesQualifiedName}
(bucket_id, key, mime_type, size, filename, owner_id, is_public, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending')
(bucket_id, key, mime_type, size, filename, owner_id, is_public)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`
: `INSERT INTO ${storageConfig.filesQualifiedName}
(bucket_id, key, mime_type, size, filename, is_public, status)
VALUES ($1, $2, $3, $4, $5, $6, 'pending')
(bucket_id, key, mime_type, size, filename, is_public)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`,
values: hasOwnerColumn
? [
Expand Down Expand Up @@ -385,111 +346,12 @@ export function createPresignedUrlPlugin(

const expiresAt = new Date(Date.now() + storageConfig.uploadUrlExpirySeconds * 1000).toISOString();

// --- Track the upload request ---
await txClient.query({
text: `INSERT INTO ${storageConfig.uploadRequestsQualifiedName}
(file_id, bucket_id, key, content_type, content_hash, status, expires_at)
VALUES ($1, $2, $3, $4, $5, 'issued', $6)`,
values: [fileId, bucket.id, s3Key, contentType, contentHash, expiresAt],
});

return {
uploadUrl,
fileId,
key: s3Key,
deduplicated: false,
expiresAt,
status: 'pending',
};
});
});
});
},

confirmUpload(_$mutation: any, fieldArgs: any) {
const $input = fieldArgs.getRaw('input');
const $withPgClient = (grafastContext() as any).get('withPgClient');
const $pgSettings = (grafastContext() as any).get('pgSettings');
const $combined = object({
input: $input,
withPgClient: $withPgClient,
pgSettings: $pgSettings,
});

return lambda($combined, async ({ input, withPgClient, pgSettings }: any) => {
const { fileId } = input;

if (!fileId || typeof fileId !== 'string') {
throw new Error('INVALID_FILE_ID');
}

return withPgClient(pgSettings, async (pgClient: any) => {
return pgClient.withTransaction(async (txClient: any) => {
// --- Resolve storage module by file ID (probes all file tables) ---
const databaseId = await resolveDatabaseId(txClient);
if (!databaseId) {
throw new Error('DATABASE_NOT_FOUND');
}

const resolved = await resolveStorageModuleByFileId(txClient, databaseId, fileId);
if (!resolved) {
throw new Error('FILE_NOT_FOUND');
}

const { storageConfig, file } = resolved;

if (file.status !== 'pending') {
// File is already confirmed or processed — idempotent success
return {
fileId: file.id,
status: file.status,
success: true,
};
}

// --- Verify file exists in S3 (per-database bucket) ---
const s3ForDb = resolveS3ForDatabase(options, storageConfig, databaseId);
const s3Head = await headObject(s3ForDb, file.key, file.mime_type as string);

if (!s3Head) {
throw new Error('FILE_NOT_IN_S3: the file has not been uploaded yet');
}

// --- Content-type verification ---
if (s3Head.contentType && s3Head.contentType !== file.mime_type) {
// Mark upload_request as rejected
await txClient.query({
text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
SET status = 'rejected'
WHERE file_id = $1 AND status = 'issued'`,
values: [fileId],
});

throw new Error(
`CONTENT_TYPE_MISMATCH: expected ${file.mime_type}, got ${s3Head.contentType}`,
);
}

// --- Transition file to 'ready' ---
await txClient.query({
text: `UPDATE ${storageConfig.filesQualifiedName}
SET status = 'ready'
WHERE id = $1`,
values: [fileId],
});

// --- Update upload_request to 'confirmed' ---
await txClient.query({
text: `UPDATE ${storageConfig.uploadRequestsQualifiedName}
SET status = 'confirmed', confirmed_at = NOW()
WHERE file_id = $1 AND status = 'issued'`,
values: [fileId],
});

return {
fileId: file.id,
status: 'ready',
success: true,
};
});
});
Expand Down
4 changes: 2 additions & 2 deletions graphile/graphile-presigned-url-plugin/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* PostGraphile v5 Presigned URL Preset
*
* Provides a convenient preset for including presigned URL upload support
* in PostGraphile. Combines the main mutation plugin (requestUploadUrl,
* confirmUpload) with the downloadUrl computed field plugin.
* in PostGraphile. Combines the main mutation plugin (requestUploadUrl)
* with the downloadUrl computed field plugin.
*/

import type { GraphileConfig } from 'graphile-config';
Expand Down
3 changes: 1 addition & 2 deletions graphile/graphile-presigned-url-plugin/src/s3-signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,7 @@ export async function generatePresignedGetUrl(
/**
* Check if an object exists in S3 and optionally verify its content-type.
*
* Used by confirmUpload to verify the file was actually uploaded to S3
* and that the content-type matches what was declared.
* Checks whether an object exists in S3 and retrieves its content-type.
*
* @param s3Config - S3 client and bucket configuration
* @param key - S3 object key
Expand Down
Loading
Loading