From 9abc41b7b217b078d8aaddf3ceefbe5469becae5 Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 12 May 2022 13:44:21 -0500 Subject: [PATCH] image uploading backend --- api/resolvers/index.js | 5 +- api/resolvers/upload.js | 61 +++++++++ api/typeDefs/index.js | 4 +- api/typeDefs/upload.js | 14 +++ components/header.module.css | 4 - components/upload.js | 118 ++++++++++++++++++ lib/constants.js | 9 ++ package-lock.json | 5 + package.json | 1 + .../20220511171526_upload/migration.sql | 29 +++++ prisma/schema.prisma | 24 +++- svgs/image-add-fill.svg | 1 + 12 files changed, 267 insertions(+), 8 deletions(-) create mode 100644 api/resolvers/upload.js create mode 100644 api/typeDefs/upload.js create mode 100644 components/upload.js create mode 100644 prisma/migrations/20220511171526_upload/migration.sql create mode 100644 svgs/image-add-fill.svg diff --git a/api/resolvers/index.js b/api/resolvers/index.js index 3749a0f9..d165ec47 100644 --- a/api/resolvers/index.js +++ b/api/resolvers/index.js @@ -6,5 +6,8 @@ import lnurl from './lnurl' import notifications from './notifications' import invite from './invite' import sub from './sub' +import upload from './upload' +import { GraphQLJSONObject } from 'graphql-type-json' -export default [user, item, message, wallet, lnurl, notifications, invite, sub] +export default [user, item, message, wallet, lnurl, notifications, invite, sub, + upload, { JSONObject: GraphQLJSONObject }] diff --git a/api/resolvers/upload.js b/api/resolvers/upload.js new file mode 100644 index 00000000..a3b9acca --- /dev/null +++ b/api/resolvers/upload.js @@ -0,0 +1,61 @@ +import { AuthenticationError, UserInputError } from 'apollo-server-micro' +import AWS from 'aws-sdk' +import { IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_TYPES_ALLOW } from '../../lib/constants' + +const bucketRegion = 'us-east-1' + +AWS.config.update({ + region: bucketRegion +}) + +export default { + Mutation: { + getSignedPOST: async (parent, { type, size, width, height }, { models, me }) => { + if (!me) { + throw new AuthenticationError('you must be logged in to get a signed url') + } + + if (UPLOAD_TYPES_ALLOW.indexOf(type) === -1) { + throw new UserInputError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`) + } + + if (size > UPLOAD_SIZE_MAX) { + throw new UserInputError(`image must be less than ${UPLOAD_SIZE_MAX} bytes`) + } + + if (width * height > IMAGE_PIXELS_MAX) { + throw new UserInputError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`) + } + + // create upload record + const upload = await models.upload.create({ + data: { + type, + size, + width, + height, + userId: me.id + } + }) + + // get presigned POST ur + const s3 = new AWS.S3({ apiVersion: '2006-03-01' }) + const res = await new Promise((resolve, reject) => { + s3.createPresignedPost({ + Bucket: process.env.AWS_UPLOAD_BUCKET, + Fields: { + key: String(upload.id) + }, + Expires: 300, + Conditions: [ + { 'Content-Type': type }, + { acl: 'public-read' }, + ['content-length-range', size, size] + ] + }, (err, preSigned) => { if (err) { reject(err) } else { resolve(preSigned) } }) + }) + + return res + } + } +} diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index 9866656c..b58f246d 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -8,6 +8,7 @@ import lnurl from './lnurl' import notifications from './notifications' import invite from './invite' import sub from './sub' +import upload from './upload' const link = gql` type Query { @@ -23,4 +24,5 @@ const link = gql` } ` -export default [link, user, item, message, wallet, lnurl, notifications, invite, sub] +export default [link, user, item, message, wallet, lnurl, notifications, invite, + sub, upload] diff --git a/api/typeDefs/upload.js b/api/typeDefs/upload.js new file mode 100644 index 00000000..9ea1621b --- /dev/null +++ b/api/typeDefs/upload.js @@ -0,0 +1,14 @@ +import { gql } from 'apollo-server-micro' + +export default gql` + scalar JSONObject + + extend type Mutation { + getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!): SignedPost! + } + + type SignedPost { + url: String! + fields: JSONObject! + } +` diff --git a/components/header.module.css b/components/header.module.css index c8d5ed59..2e3d7a6e 100644 --- a/components/header.module.css +++ b/components/header.module.css @@ -45,10 +45,6 @@ border: 1px solid var(--theme-body); } -.navLink:last-child { - padding-right: 0 !important; -} - .navbarNav { width: 100%; flex-wrap: wrap; diff --git a/components/upload.js b/components/upload.js new file mode 100644 index 00000000..8c4ea025 --- /dev/null +++ b/components/upload.js @@ -0,0 +1,118 @@ +import { useRef, useState } from 'react' +import { gql, useMutation } from '@apollo/client' +import { UPLOAD_TYPES_ALLOW } from '../lib/constants' +import AddImage from '../svgs/image-add-fill.svg' +import Moon from '../svgs/moon-fill.svg' +import { Image as BImage } from 'react-bootstrap' + +// what we want is that they supply a component that we turn into an upload button +// we need to return an error if the upload fails +// we need to report that the upload started +// we return the image id on success + +export default function Upload ({ as: Component, onStarted, onError, onSuccess }) { + const [getSignedPOST] = useMutation( + gql` + mutation getSignedPOST($type: String!, $size: Int!, $width: Int!, $height: Int!) { + getSignedPOST(type: $type, size: $size, width: $width, height: $height) { + url + fields + } + }`) + const ref = useRef() + + return ( + <> + { + if (e.target.files.length === 0) { + return + } + + onStarted && onStarted() + + const file = e.target.files[0] + if (UPLOAD_TYPES_ALLOW.indexOf(file.type) === -1) { + onError && onError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`) + return + } + const img = new Image() + img.src = window.URL.createObjectURL(file) + img.onload = async () => { + let data + try { + ({ data } = await getSignedPOST({ + variables: { + type: file.type, + size: file.size, + width: img.width, + height: img.height + } + })) + } catch (e) { + onError && onError(e.toString()) + return + } + + const form = new FormData() + Object.keys(data.getSignedPOST.fields).forEach(key => + form.append(key, data.getSignedPOST.fields[key])) + form.append('Content-Type', file.type) + form.append('acl', 'public-read') + form.append('file', file) + + const res = await fetch(data.getSignedPOST.url, { + method: 'POST', + body: form + }) + + if (!res.ok) { + onError && onError(res.statusText) + return + } + + onSuccess && onSuccess(data.getSignedPOST.fields.key) + } + }} + /> + ref.current?.click()} /> + + ) +} + +export function UploadExample () { + const [error, setError] = useState() + const [key, setKey] = useState() + const [uploading, setUploading] = useState() + + const Component = uploading + ? ({ onClick }) => + : ({ onClick }) => + + return ( + <> + { + setUploading(false) + setError(e) + }} + onSuccess={key => { + setUploading(false) + setKey(key) + }} + onStarted={() => { + setError(false) + setUploading(true) + }} + as={Component} + /> +
+ {key && } + {error &&
{error}
} +
+ + ) +} diff --git a/lib/constants.js b/lib/constants.js index 841b4b8b..ed92d091 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -1,2 +1,11 @@ export const NOFOLLOW_LIMIT = 100 export const BOOST_MIN = 1000 +export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024 +export const IMAGE_PIXELS_MAX = 35000000 +export const UPLOAD_TYPES_ALLOW = [ + 'image/gif', + 'image/heic', + 'image/png', + 'image/jpeg', + 'image/webp' +] diff --git a/package-lock.json b/package-lock.json index 430c598c..eec1d617 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3764,6 +3764,11 @@ } } }, + "graphql-type-json": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/graphql-type-json/-/graphql-type-json-0.3.2.tgz", + "integrity": "sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg==" + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", diff --git a/package.json b/package.json index 889f753d..9dce1527 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "domino": "^2.1.6", "formik": "^2.2.6", "graphql": "^15.5.0", + "graphql-type-json": "^0.3.2", "ln-service": "^52.8.0", "mdast-util-find-and-replace": "^1.1.1", "next": "^11.1.2", diff --git a/prisma/migrations/20220511171526_upload/migration.sql b/prisma/migrations/20220511171526_upload/migration.sql new file mode 100644 index 00000000..f7552f84 --- /dev/null +++ b/prisma/migrations/20220511171526_upload/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "Upload" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "type" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "width" INTEGER, + "height" INTEGER, + "itemId" INTEGER, + "userId" INTEGER NOT NULL, + + PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Upload.created_at_index" ON "Upload"("created_at"); + +-- CreateIndex +CREATE INDEX "Upload.itemId_index" ON "Upload"("itemId"); + +-- CreateIndex +CREATE INDEX "Upload.userId_index" ON "Upload"("userId"); + +-- AddForeignKey +ALTER TABLE "Upload" ADD FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Upload" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0c40c252..d613da91 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -53,12 +53,31 @@ model User { noteInvites Boolean @default(true) noteJobIndicator Boolean @default(true) - Earn Earn[] + Earn Earn[] + Upload Upload[] @@index([createdAt]) @@index([inviteId]) @@map(name: "users") } +model Upload { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at") + type String + size Int + width Int? + height Int? + item Item? @relation(fields: [itemId], references: [id]) + itemId Int? + user User @relation(fields: [userId], references: [id]) + userId Int + + @@index([createdAt]) + @@index([itemId]) + @@index([userId]) +} + model Earn { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map(name: "created_at") @@ -153,7 +172,8 @@ model Item { longitude Float? remote Boolean? - User User[] + User User[] + Upload Upload[] @@index([createdAt]) @@index([userId]) @@index([parentId]) diff --git a/svgs/image-add-fill.svg b/svgs/image-add-fill.svg new file mode 100644 index 00000000..aac208ee --- /dev/null +++ b/svgs/image-add-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file