Skip to content
Open
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
35 changes: 35 additions & 0 deletions graphile/graphile-presigned-url-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# graphile-presigned-url-plugin

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

```typescript
import { PresignedUrlPreset } from 'graphile-presigned-url-plugin';
import { S3Client } from '@aws-sdk/client-s3';

const s3Client = new S3Client({ region: 'us-east-1' });

const preset = {
extends: [
PresignedUrlPreset({
s3: {
client: s3Client,
bucket: 'my-uploads',
publicUrlPrefix: 'https://cdn.example.com',
},
urlExpirySeconds: 900,
maxFileSize: 200 * 1024 * 1024,
}),
],
};
```
18 changes: 18 additions & 0 deletions graphile/graphile-presigned-url-plugin/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
babelConfig: false,
tsconfig: 'tsconfig.json'
}
]
},
transformIgnorePatterns: [`/node_modules/*`],
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*']
};
61 changes: 61 additions & 0 deletions graphile/graphile-presigned-url-plugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"name": "graphile-presigned-url-plugin",
"version": "0.1.0",
"description": "Presigned URL upload plugin for PostGraphile v5 — requestUploadUrl, confirmUpload mutations and downloadUrl computed field",
"author": "Constructive <developers@constructive.io>",
"homepage": "https://github.com/constructive-io/constructive",
"license": "MIT",
"main": "index.js",
"module": "esm/index.js",
"types": "index.d.ts",
"scripts": {
"clean": "makage clean",
"prepack": "npm run build",
"build": "makage build",
"build:dev": "makage build --dev",
"lint": "eslint . --fix",
"test": "jest --passWithNoTests",
"test:watch": "jest --watch"
},
"publishConfig": {
"access": "public",
"directory": "dist"
},
"repository": {
"type": "git",
"url": "https://github.com/constructive-io/constructive"
},
"keywords": [
"postgraphile",
"graphile",
"constructive",
"plugin",
"postgres",
"graphql",
"presigned-url",
"upload",
"s3"
],
"bugs": {
"url": "https://github.com/constructive-io/constructive/issues"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1009.0",
"@aws-sdk/s3-request-presigner": "^3.1009.0",
"@pgpmjs/logger": "workspace:^",
"lru-cache": "^11.2.7"
},
"peerDependencies": {
"grafast": "1.0.0-rc.9",
"graphile-build": "5.0.0-rc.6",
"graphile-build-pg": "5.0.0-rc.8",
"graphile-config": "1.0.0-rc.6",
"graphile-utils": "5.0.0-rc.8",
"graphql": "16.13.0",
"postgraphile": "5.0.0-rc.10"
},
"devDependencies": {
"@types/node": "^22.19.11",
"makage": "^0.1.10"
}
}
115 changes: 115 additions & 0 deletions graphile/graphile-presigned-url-plugin/src/download-url-field.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* downloadUrl Computed Field Plugin
*
* Adds a `downloadUrl` computed field to File types in the GraphQL schema.
* For public files, returns the public URL prefix + key.
* For private files, generates a presigned GET URL.
*
* Detection: Uses the `@storageFiles` smart tag on the codec (table).
* The storage module generator in constructive-db sets this tag on the
* generated files table via a smart comment:
* COMMENT ON TABLE files IS E'@storageFiles\nStorage files table';
*
* This is explicit and reliable — no duck-typing on column names.
*/

import type { GraphileConfig } from 'graphile-config';
import { Logger } from '@pgpmjs/logger';

import type { PresignedUrlPluginOptions } from './types';
import { generatePresignedGetUrl } from './s3-signer';

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

/**
* Creates the downloadUrl computed field plugin.
*
* This is a separate plugin from the main presigned URL plugin because it
* uses the GraphQLObjectType_fields hook (low-level) rather than extendSchema.
* The downloadUrl field needs to be added dynamically to whatever table is
* the storage module's files table, which we discover at schema-build time
* via the `@storageFiles` smart tag.
*/
export function createDownloadUrlPlugin(
options: PresignedUrlPluginOptions,
): GraphileConfig.Plugin {
const { s3 } = options;
const downloadUrlExpirySeconds = 3600; // 1 hour for GET URLs

return {
name: 'PresignedUrlDownloadPlugin',
version: '0.1.0',
description: 'Adds downloadUrl computed field to File types tagged with @storageFiles',

schema: {
hooks: {
GraphQLObjectType_fields(fields, build, context) {
const {
scope: { pgCodec, isPgClassType },
} = context as any;

// Only process PG class types (table row types)
if (!isPgClassType || !pgCodec || !pgCodec.attributes) {
return fields;
}

// Check for @storageFiles smart tag — set by the storage module generator
const tags = (pgCodec.extensions as any)?.tags;
if (!tags?.storageFiles) {
return fields;
}

log.debug(`Adding downloadUrl field to type: ${pgCodec.name} (has @storageFiles tag)`);

const {
graphql: { GraphQLString },
} = build;

return build.extend(
fields,
{
downloadUrl: context.fieldWithHooks(
{ fieldName: 'downloadUrl' } as any,
{
description:
'URL to download this file. For public files, returns the public URL. ' +
'For private files, returns a time-limited presigned URL.',
type: GraphQLString,
resolve(parent: any) {
const key = parent.key || parent.get?.('key');
const isPublic = parent.is_public ?? parent.get?.('is_public');
const filename = parent.filename || parent.get?.('filename');
const status = parent.status || parent.get?.('status');

if (!key) return null;

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

if (isPublic && s3.publicUrlPrefix) {
// Public file: return direct URL
return `${s3.publicUrlPrefix}/${key}`;
}

// Private file: generate presigned GET URL
return generatePresignedGetUrl(
s3,
key,
downloadUrlExpirySeconds,
filename || undefined,
);
},
},
),
},
'PresignedUrlDownloadPlugin adding downloadUrl field',
);
},
},
},
};
}

export default createDownloadUrlPlugin;
44 changes: 44 additions & 0 deletions graphile/graphile-presigned-url-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* 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)
* - downloadUrl computed field (presigned GET URL / public URL)
*
* @example
* ```typescript
* import { PresignedUrlPreset } from 'graphile-presigned-url-plugin';
* import { S3Client } from '@aws-sdk/client-s3';
*
* const s3Client = new S3Client({ region: 'us-east-1' });
*
* const preset = {
* extends: [
* PresignedUrlPreset({
* s3: {
* client: s3Client,
* bucket: 'my-uploads',
* publicUrlPrefix: 'https://cdn.example.com',
* },
* }),
* ],
* };
* ```
*/

export { PresignedUrlPlugin, createPresignedUrlPlugin } from './plugin';
export { createDownloadUrlPlugin } from './download-url-field';
export { PresignedUrlPreset } from './preset';
export { getStorageModuleConfig, clearStorageModuleCache } from './storage-module-cache';
export { generatePresignedPutUrl, generatePresignedGetUrl, headObject } from './s3-signer';
export type {
BucketConfig,
StorageModuleConfig,
RequestUploadUrlInput,
RequestUploadUrlPayload,
ConfirmUploadInput,
ConfirmUploadPayload,
S3Config,
PresignedUrlPluginOptions,
} from './types';
Loading