Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extend schema to allow for presigned URLs for uploading files #108

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions @app/config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const packageJson = require("../../../package.json");
export const fromEmail =
'"PostGraphile Starter" <[email protected]>';
export const awsRegion = "us-east-1";
export const uploadBucket = process.env.AWS_BUCKET_UPLOAD;
export const projectName = packageJson.name.replace(/[-_]/g, " ");
export const companyName = projectName; // For copyright ownership
export const emailLegalText =
Expand Down
5 changes: 4 additions & 1 deletion @app/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
"@types/passport-github2": "^1.2.4",
"@types/pg": "^7.14.1",
"@types/redis": "^2.8.18",
"@types/uuid": "^7.0.3",
"aws-sdk": "^2.668.0",
"body-parser": "^1.19.0",
"chalk": "^4.0.0",
"connect-pg-simple": "^6.1.0",
Expand All @@ -43,7 +45,8 @@
"postgraphile": "^4.7.0",
"redis": "^3.0.2",
"source-map-support": "^0.5.13",
"tslib": "^1.11.1"
"tslib": "^1.11.1",
"uuid": "^8.0.0"
},
"devDependencies": {
"@types/node": "^13.13.4",
Expand Down
4 changes: 4 additions & 0 deletions @app/server/src/middleware/installPostGraphile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { makePgSmartTagsFromFilePlugin } from "postgraphile/plugins";

import { getHttpServer, getWebsocketMiddlewares } from "../app";
import CreateUploadUrlPlugin from "../plugins/CreateUploadUrlPlugin";
import OrdersPlugin from "../plugins/Orders";
import PassportLoginPlugin from "../plugins/PassportLoginPlugin";
import PrimaryKeyMutationsOnlyPlugin from "../plugins/PrimaryKeyMutationsOnlyPlugin";
Expand Down Expand Up @@ -183,6 +184,9 @@ export function getPostGraphileOptions({

// Adds custom orders to our GraphQL schema
OrdersPlugin,

// Allows API clients to fetch a pre-signed URL for uploading files
CreateUploadUrlPlugin,
],

/*
Expand Down
197 changes: 197 additions & 0 deletions @app/server/src/plugins/CreateUploadUrlPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { awsRegion, uploadBucket } from "@app/config";
import * as aws from "aws-sdk";
import { gql, makeExtendSchemaPlugin } from "graphile-utils";
import { Pool } from "pg";
import { v4 as uuidv4 } from "uuid";

import { OurGraphQLContext } from "../middleware/installPostGraphile";

enum AllowedUploadContentType {
IMAGE_APNG = "image/apng",
IMAGE_BMP = "image/bmp",
IMAGE_GIF = "image/gif",
IMAGE_JPEG = "image/jpeg",
IMAGE_PNG = "image/png",
IMAGE_SVG_XML = "image/svg+xml",
IMAGE_TIFF = "image/tiff",
IMAGE_WEBP = "image/webp",
}

interface CreateUploadUrlInput {
clientMutationId?: string;
contentType: AllowedUploadContentType;
}

/** The minimal set of information that this plugin needs to know about users. */
interface User {
id: string;
isVerified: boolean;
}

async function getCurrentUser(pool: Pool): Promise<User | null> {
await pool.query("SAVEPOINT");
try {
const {
rows: [row],
} = await pool.query(
"select id, is_verified from app_public.users where id = app_public.current_user_id()"
);
if (!row) {
return null;
}
return {
id: row.id,
isVerified: row.is_verified,
};
} catch (err) {
await pool.query("ROLLBACK TO SAVEPOINT");
throw err;
} finally {
await pool.query("RELEASE SAVEPOINT");
}
}

const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({
typeDefs: gql`
"""
The set of content types that we allow users to upload.
"""
enum AllowedUploadContentType {
"""
image/apng
"""
IMAGE_APNG
"""
image/bmp
"""
IMAGE_BMP
"""
image/gif
"""
IMAGE_GIF
"""
image/jpeg
"""
IMAGE_JPEG
"""
image/png
"""
IMAGE_PNG
"""
image/svg+xml
"""
IMAGE_SVG_XML
"""
image/tiff
"""
IMAGE_TIFF
"""
image/webp
"""
IMAGE_WEBP
}

"""
All input for the \`createUploadUrl\` mutation.
"""
input CreateUploadUrlInput @scope(isMutationInput: true) {
"""
An arbitrary string value with no semantic meaning. Will be included in the
payload verbatim. May be used to track mutations by the client.
"""
clientMutationId: String

"""
You must provide the content type (or MIME type) of the content you intend
to upload. For further information about content types, see
https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types
"""
contentType: AllowedUploadContentType!
}

"""
The output of our \`createUploadUrl\` mutation.
"""
type CreateUploadUrlPayload @scope(isMutationPayload: true) {
"""
The exact same \`clientMutationId\` that was provided in the mutation input,
unchanged and unused. May be used by a client to track mutations.
"""
clientMutationId: String

"""
Upload content to this signed URL.
"""
uploadUrl: String!
}

extend type Mutation {
"""
Get a signed URL for uploading files. It will expire in 5 minutes.
"""
createUploadUrl(
"""
The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.
"""
input: CreateUploadUrlInput!
): CreateUploadUrlPayload
}
`,
resolvers: {
Mutation: {
async createUploadUrl(
_query,
args: { input: CreateUploadUrlInput },
context: OurGraphQLContext,
_resolveInfo
) {
if (!uploadBucket) {
const err = new Error(
"Server misconfigured: missing `AWS_BUCKET_UPLOAD` envvar"
);
// @ts-ignore
err.code = "MSCFG";
throw err;
}

const user = await getCurrentUser(context.rootPgPool);

if (!user) {
const err = new Error("Login required");
// @ts-ignore
err.code = "LOGIN";
throw err;
}

if (!user.isVerified) {
const err = new Error("Only verified users may upload files");
// @ts-ignore
err.code = "DNIED";
throw err;
}

const { input } = args;
const contentType: string = AllowedUploadContentType[input.contentType];
const s3 = new aws.S3({
region: awsRegion,
signatureVersion: "v4",
});
const params = {
Bucket: uploadBucket,
ContentType: contentType,
// randomly generated filename, nested under username directory
Key: `${user.id}/${uuidv4()}`,
Expires: 300, // signed URL will expire in 5 minutes
ACL: "public-read", // uploaded file will be publicly readable
};
const signedUrl = await s3.getSignedUrlPromise("putObject", params);
return {
clientMutationId: input.clientMutationId,
uploadUrl: signedUrl,
};
},
},
},
}));

export default CreateUploadUrlPlugin;
68 changes: 68 additions & 0 deletions data/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,33 @@ type AcceptInvitationToOrganizationPayload {
query: Query
}

"""The set of content types that we allow users to upload."""
enum AllowedUploadContentType {
"""image/apng"""
IMAGE_APNG

"""image/bmp"""
IMAGE_BMP

"""image/gif"""
IMAGE_GIF

"""image/jpeg"""
IMAGE_JPEG

"""image/png"""
IMAGE_PNG

"""image/svg+xml"""
IMAGE_SVG_XML

"""image/tiff"""
IMAGE_TIFF

"""image/webp"""
IMAGE_WEBP
}

"""All input for the `changePassword` mutation."""
input ChangePasswordInput {
"""
Expand Down Expand Up @@ -106,6 +133,39 @@ type CreateOrganizationPayload {
query: Query
}

"""All input for the `createUploadUrl` mutation."""
input CreateUploadUrlInput {
"""
An arbitrary string value with no semantic meaning. Will be included in the
payload verbatim. May be used to track mutations by the client.
"""
clientMutationId: String

"""
You must provide the content type (or MIME type) of the content you intend
to upload. For further information about content types, see
https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types
"""
contentType: AllowedUploadContentType!
}

"""The output of our `createUploadUrl` mutation."""
type CreateUploadUrlPayload {
"""
The exact same `clientMutationId` that was provided in the mutation input,
unchanged and unused. May be used by a client to track mutations.
"""
clientMutationId: String

"""
Our root query field type. Allows us to run any query from our mutation payload.
"""
query: Query

"""Upload content to this signed URL."""
uploadUrl: String!
}

"""All input for the create `UserEmail` mutation."""
input CreateUserEmailInput {
"""
Expand Down Expand Up @@ -391,6 +451,14 @@ type Mutation {
input: CreateOrganizationInput!
): CreateOrganizationPayload

"""Get a signed URL for uploading files. It will expire in 5 minutes."""
createUploadUrl(
"""
The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields.
"""
input: CreateUploadUrlInput!
): CreateUploadUrlPayload

"""Creates a single `UserEmail`."""
createUserEmail(
"""
Expand Down
1 change: 1 addition & 0 deletions docs/error_codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Rewritten, the above rules state:
- DNIED: permission denied
- NUNIQ: not unique (from PostgreSQL 23505)
- NTFND: not found
- MSCFG: server misconfigured

## Registration

Expand Down
Loading