diff --git a/@app/config/src/index.ts b/@app/config/src/index.ts index e6a6ee3f..a3dd5690 100644 --- a/@app/config/src/index.ts +++ b/@app/config/src/index.ts @@ -6,6 +6,7 @@ const packageJson = require("../../../package.json"); export const fromEmail = '"PostGraphile Starter" '; 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 = diff --git a/@app/server/package.json b/@app/server/package.json index 9a2c5f92..90c3c7c1 100644 --- a/@app/server/package.json +++ b/@app/server/package.json @@ -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", @@ -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", diff --git a/@app/server/src/middleware/installPostGraphile.ts b/@app/server/src/middleware/installPostGraphile.ts index 705d899a..54ba5940 100644 --- a/@app/server/src/middleware/installPostGraphile.ts +++ b/@app/server/src/middleware/installPostGraphile.ts @@ -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"; @@ -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, ], /* diff --git a/@app/server/src/plugins/CreateUploadUrlPlugin.ts b/@app/server/src/plugins/CreateUploadUrlPlugin.ts new file mode 100644 index 00000000..8dac6012 --- /dev/null +++ b/@app/server/src/plugins/CreateUploadUrlPlugin.ts @@ -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 { + 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; diff --git a/data/schema.graphql b/data/schema.graphql index 1619f4ff..00ec324a 100644 --- a/data/schema.graphql +++ b/data/schema.graphql @@ -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 { """ @@ -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 { """ @@ -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( """ diff --git a/docs/error_codes.md b/docs/error_codes.md index 83898e4b..8c55c58a 100644 --- a/docs/error_codes.md +++ b/docs/error_codes.md @@ -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 diff --git a/yarn.lock b/yarn.lock index b4f1592a..b8144727 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3174,6 +3174,11 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== +"@types/uuid@^7.0.3": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-7.0.3.tgz#45cd03e98e758f8581c79c535afbd4fc27ba7ac8" + integrity sha512-PUdqTZVrNYTNcIhLHkiaYzoOIaUi5LFg/XLerAdgvwQrUCx+oSbtoBze1AMyvYbcwzUSNC+Isl58SM4Sm/6COw== + "@types/ws@^6.0.1": version "6.0.4" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-6.0.4.tgz#7797707c8acce8f76d8c34b370d4645b70421ff1" @@ -6133,11 +6138,6 @@ detect-indent@^5.0.0: resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d" integrity sha1-OHHMCmoALow+Wzz38zYmRnXwa50= -detect-libc@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= - detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" @@ -8206,7 +8206,7 @@ humanize-ms@^1.2.1: dependencies: ms "^2.0.0" -iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: +iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.13: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -10962,15 +10962,6 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= -needle@^2.2.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.4.1.tgz#14af48732463d7475696f937626b1b993247a56a" - integrity sha512-x/gi6ijr4B7fwl6WYL9FwlCvRQKGlUNvnceho8wxkwXqN8jvVmmmATTmZPRRG7b/yC1eode26C2HO9jl78Du9g== - dependencies: - debug "^3.2.6" - iconv-lite "^0.4.4" - sax "^1.2.4" - negotiator@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" @@ -11171,22 +11162,6 @@ node-notifier@^6.0.0: shellwords "^0.1.1" which "^1.3.1" -node-pre-gyp@*: - version "0.14.0" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.14.0.tgz#9a0596533b877289bcad4e143982ca3d904ddc83" - integrity sha512-+CvDC7ZttU/sSt9rFjix/P05iS43qHCOOGzcr3Ry99bXG7VX953+vFyEuph/tfqoYu8dttBkE86JSKBO2OzcxA== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4.4.2" - node-releases@^1.1.44, node-releases@^1.1.53: version "1.1.53" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.53.tgz#2d821bfa499ed7c5dffc5e2f28c88e78a08ee3f4" @@ -11318,7 +11293,7 @@ npm-normalize-package-bin@^1.0.0, npm-normalize-package-bin@^1.0.1: semver "^5.6.0" validate-npm-package-name "^3.0.0" -npm-packlist@^1.1.6, npm-packlist@^1.4.4: +npm-packlist@^1.4.4: version "1.4.8" resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.8.tgz#56ee6cc135b9f98ad3d51c1c95da22bbb9b2ef3e" integrity sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A== @@ -11350,7 +11325,7 @@ npm-run-path@^4.0.0: dependencies: path-key "^3.0.0" -npmlog@^4.0.2, npmlog@^4.1.2: +npmlog@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== @@ -13284,7 +13259,7 @@ rc-virtual-list@^1.1.0, rc-virtual-list@^1.1.2: raf "^3.4.1" rc-util "^4.8.0" -rc@^1.2.7, rc@^1.2.8: +rc@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== @@ -13931,7 +13906,7 @@ rimraf@2.6.3: dependencies: glob "^7.1.3" -rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3: +rimraf@^2.5.4, rimraf@^2.6.2, rimraf@^2.6.3: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -14035,7 +14010,7 @@ sax@1.2.1: resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o= -sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4: +sax@>=0.6.0, sax@~1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -14096,7 +14071,7 @@ semver-diff@^3.1.1: dependencies: semver "^6.3.0" -"semver@2 || 3 || 4 || 5", "semver@2.x || 3.x || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1: +"semver@2 || 3 || 4 || 5", "semver@2.x || 3.x || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -14887,7 +14862,7 @@ tapable@^1.0.0, tapable@^1.1.3: resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== -tar@^4.4.10, tar@^4.4.12, tar@^4.4.2, tar@^4.4.8: +tar@^4.4.10, tar@^4.4.12, tar@^4.4.8: version "4.4.13" resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA== @@ -15653,6 +15628,11 @@ uuid@^7.0.3: resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== +uuid@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.0.0.tgz#bc6ccf91b5ff0ac07bbcdbf1c7c4e150db4dbb6c" + integrity sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw== + v8-compile-cache@^2.0.3: version "2.1.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e"