Skip to content

Commit 7276344

Browse files
feat: graphile-presigned-url-plugin — requestUploadUrl, confirmUpload mutations + downloadUrl field
Phase 2A Step 2: Presigned URL plugin for PostGraphile v5 - requestUploadUrl mutation: presigned PUT URL generation with SHA-256 content-hash keys, bucket validation (RLS), MIME type enforcement, file size limits, deduplication - confirmUpload mutation: S3 HEAD verification, content-type check, status transition (pending -> ready), upload_request tracking - downloadUrl computed field: presigned GET URLs for private files, public URL prefix for public - StorageModuleCache: per-database LRU cache for storage module config (TTL-based) - S3 signer wrapper: AWS SDK v3 presigned URL generation (PUT/GET/HEAD) - PresignedUrlPreset factory for easy integration into ConstructivePreset Follows existing patterns: - extendSchema + grafast plans (same as PublicKeySignature) - GraphQLObjectType_fields hook for downloadUrl (same as graphile-search) - LRU cache with TTL (same as graphile-cache)
1 parent 091694a commit 7276344

13 files changed

Lines changed: 4752 additions & 8065 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# graphile-presigned-url-plugin
2+
3+
Presigned URL upload plugin for PostGraphile v5.
4+
5+
## Features
6+
7+
- `requestUploadUrl` mutation — generates presigned PUT URLs for direct client-to-S3 upload
8+
- `confirmUpload` mutation — verifies upload and transitions file status to 'ready'
9+
- `downloadUrl` computed field — presigned GET URLs for private files, public URLs for public files
10+
- Content-hash based S3 keys (SHA-256) with automatic deduplication
11+
- Per-bucket MIME type and file size validation
12+
- Upload request tracking for audit and rate limiting
13+
14+
## Usage
15+
16+
```typescript
17+
import { PresignedUrlPreset } from 'graphile-presigned-url-plugin';
18+
import { S3Client } from '@aws-sdk/client-s3';
19+
20+
const s3Client = new S3Client({ region: 'us-east-1' });
21+
22+
const preset = {
23+
extends: [
24+
PresignedUrlPreset({
25+
s3: {
26+
client: s3Client,
27+
bucket: 'my-uploads',
28+
publicUrlPrefix: 'https://cdn.example.com',
29+
},
30+
urlExpirySeconds: 900,
31+
maxFileSize: 200 * 1024 * 1024,
32+
}),
33+
],
34+
};
35+
```
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/** @type {import('ts-jest').JestConfigWithTsJest} */
2+
module.exports = {
3+
preset: 'ts-jest',
4+
testEnvironment: 'node',
5+
transform: {
6+
'^.+\\.tsx?$': [
7+
'ts-jest',
8+
{
9+
babelConfig: false,
10+
tsconfig: 'tsconfig.json'
11+
}
12+
]
13+
},
14+
transformIgnorePatterns: [`/node_modules/*`],
15+
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
16+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
17+
modulePathIgnorePatterns: ['dist/*']
18+
};
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"name": "graphile-presigned-url-plugin",
3+
"version": "0.1.0",
4+
"description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl, confirmUpload mutations and downloadUrl computed field",
5+
"author": "Constructive <developers@constructive.io>",
6+
"homepage": "https://github.com/constructive-io/constructive",
7+
"license": "MIT",
8+
"main": "index.js",
9+
"module": "esm/index.js",
10+
"types": "index.d.ts",
11+
"scripts": {
12+
"clean": "makage clean",
13+
"prepack": "npm run build",
14+
"build": "makage build",
15+
"build:dev": "makage build --dev",
16+
"lint": "eslint . --fix",
17+
"test": "jest --passWithNoTests",
18+
"test:watch": "jest --watch"
19+
},
20+
"publishConfig": {
21+
"access": "public",
22+
"directory": "dist"
23+
},
24+
"repository": {
25+
"type": "git",
26+
"url": "https://github.com/constructive-io/constructive"
27+
},
28+
"keywords": [
29+
"postgraphile",
30+
"graphile",
31+
"constructive",
32+
"plugin",
33+
"postgres",
34+
"graphql",
35+
"presigned-url",
36+
"upload",
37+
"s3"
38+
],
39+
"bugs": {
40+
"url": "https://github.com/constructive-io/constructive/issues"
41+
},
42+
"dependencies": {
43+
"@aws-sdk/client-s3": "^3.1009.0",
44+
"@aws-sdk/s3-request-presigner": "^3.1009.0",
45+
"@pgpmjs/logger": "workspace:^",
46+
"lru-cache": "^11.2.7"
47+
},
48+
"peerDependencies": {
49+
"grafast": "1.0.0-rc.9",
50+
"graphile-build": "5.0.0-rc.6",
51+
"graphile-build-pg": "5.0.0-rc.8",
52+
"graphile-config": "1.0.0-rc.6",
53+
"graphile-utils": "5.0.0-rc.8",
54+
"graphql": "16.13.0",
55+
"postgraphile": "5.0.0-rc.10"
56+
},
57+
"devDependencies": {
58+
"@types/node": "^22.19.11",
59+
"makage": "^0.1.10"
60+
}
61+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* downloadUrl Computed Field Plugin
3+
*
4+
* Adds a `downloadUrl` computed field to File types in the GraphQL schema.
5+
* For public files, returns the public URL prefix + key.
6+
* For private files, generates a presigned GET URL.
7+
*
8+
* This plugin uses the GraphQLObjectType_fields hook to detect file tables
9+
* (by checking for the storage module's files table) and add the computed field.
10+
*/
11+
12+
import type { GraphileConfig } from 'graphile-config';
13+
import { Logger } from '@pgpmjs/logger';
14+
15+
import type { PresignedUrlPluginOptions } from './types';
16+
import { generatePresignedGetUrl } from './s3-signer';
17+
18+
const log = new Logger('graphile-presigned-url:download-url');
19+
20+
/**
21+
* Creates the downloadUrl computed field plugin.
22+
*
23+
* This is a separate plugin from the main presigned URL plugin because it
24+
* uses the GraphQLObjectType_fields hook (low-level) rather than extendSchema.
25+
* The downloadUrl field needs to be added dynamically to whatever table is
26+
* the storage module's files table, which we discover at schema-build time.
27+
*/
28+
export function createDownloadUrlPlugin(
29+
options: PresignedUrlPluginOptions,
30+
): GraphileConfig.Plugin {
31+
const { s3 } = options;
32+
const downloadUrlExpirySeconds = 3600; // 1 hour for GET URLs
33+
34+
return {
35+
name: 'PresignedUrlDownloadPlugin',
36+
version: '0.1.0',
37+
description: 'Adds downloadUrl computed field to File types',
38+
39+
schema: {
40+
hooks: {
41+
GraphQLObjectType_fields(fields, build, context) {
42+
const {
43+
scope: { pgCodec, isPgClassType },
44+
} = context as any;
45+
46+
// Only process PG class types (table row types)
47+
if (!isPgClassType || !pgCodec || !pgCodec.attributes) {
48+
return fields;
49+
}
50+
51+
const attrs = pgCodec.attributes as Record<string, any>;
52+
53+
// Detect if this is a files table by checking for characteristic columns:
54+
// key, content_type, content_hash, status, bucket_id, is_public
55+
const hasKey = 'key' in attrs;
56+
const hasContentType = 'content_type' in attrs;
57+
const hasContentHash = 'content_hash' in attrs;
58+
const hasStatus = 'status' in attrs;
59+
const hasBucketId = 'bucket_id' in attrs;
60+
const hasIsPublic = 'is_public' in attrs;
61+
62+
if (!hasKey || !hasContentType || !hasContentHash || !hasStatus || !hasBucketId || !hasIsPublic) {
63+
return fields;
64+
}
65+
66+
log.debug(`Adding downloadUrl field to type: ${pgCodec.name}`);
67+
68+
const {
69+
graphql: { GraphQLString },
70+
} = build;
71+
72+
return build.extend(
73+
fields,
74+
{
75+
downloadUrl: context.fieldWithHooks(
76+
{ fieldName: 'downloadUrl' } as any,
77+
{
78+
description:
79+
'URL to download this file. For public files, returns the public URL. ' +
80+
'For private files, returns a time-limited presigned URL.',
81+
type: GraphQLString,
82+
resolve(parent: any) {
83+
const key = parent.key || parent.get?.('key');
84+
const isPublic = parent.is_public ?? parent.get?.('is_public');
85+
const filename = parent.filename || parent.get?.('filename');
86+
const status = parent.status || parent.get?.('status');
87+
88+
if (!key) return null;
89+
90+
// Only provide download URLs for ready/processed files
91+
if (status !== 'ready' && status !== 'processed') {
92+
return null;
93+
}
94+
95+
if (isPublic && s3.publicUrlPrefix) {
96+
// Public file: return direct URL
97+
return `${s3.publicUrlPrefix}/${key}`;
98+
}
99+
100+
// Private file: generate presigned GET URL
101+
return generatePresignedGetUrl(
102+
s3,
103+
key,
104+
downloadUrlExpirySeconds,
105+
filename || undefined,
106+
);
107+
},
108+
},
109+
),
110+
},
111+
'PresignedUrlDownloadPlugin adding downloadUrl field',
112+
);
113+
},
114+
},
115+
},
116+
};
117+
}
118+
119+
export default createDownloadUrlPlugin;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Presigned URL Plugin for PostGraphile v5
3+
*
4+
* Provides presigned URL upload capabilities for PostGraphile v5:
5+
* - requestUploadUrl mutation (presigned PUT URL generation)
6+
* - confirmUpload mutation (upload verification + status transition)
7+
* - downloadUrl computed field (presigned GET URL / public URL)
8+
*
9+
* @example
10+
* ```typescript
11+
* import { PresignedUrlPreset } from 'graphile-presigned-url-plugin';
12+
* import { S3Client } from '@aws-sdk/client-s3';
13+
*
14+
* const s3Client = new S3Client({ region: 'us-east-1' });
15+
*
16+
* const preset = {
17+
* extends: [
18+
* PresignedUrlPreset({
19+
* s3: {
20+
* client: s3Client,
21+
* bucket: 'my-uploads',
22+
* publicUrlPrefix: 'https://cdn.example.com',
23+
* },
24+
* }),
25+
* ],
26+
* };
27+
* ```
28+
*/
29+
30+
export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
31+
export { createDownloadUrlPlugin } from './download-url-field';
32+
export { PresignedUrlPreset } from './preset';
33+
export { getStorageModuleConfig, clearStorageModuleCache } from './storage-module-cache';
34+
export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
35+
export type {
36+
BucketConfig,
37+
StorageModuleConfig,
38+
RequestUploadUrlInput,
39+
RequestUploadUrlPayload,
40+
ConfirmUploadInput,
41+
ConfirmUploadPayload,
42+
S3Config,
43+
PresignedUrlPluginOptions,
44+
} from './types';

0 commit comments

Comments
 (0)