diff --git a/app/api/db/migrations/20210815062510_make_social_card_one_to_one_with_project/migration.sql b/app/api/db/migrations/20210815062510_make_social_card_one_to_one_with_project/migration.sql new file mode 100644 index 0000000..344416a --- /dev/null +++ b/app/api/db/migrations/20210815062510_make_social_card_one_to_one_with_project/migration.sql @@ -0,0 +1,2 @@ +-- AlterIndex +ALTER INDEX "SocialCard_projectId_unique" RENAME TO "SocialCard.projectId_unique"; diff --git a/app/api/db/schema.prisma b/app/api/db/schema.prisma index 2d0c5ee..a852690 100644 --- a/app/api/db/schema.prisma +++ b/app/api/db/schema.prisma @@ -65,8 +65,8 @@ model Project { model SocialCard { id String @id @default(uuid()) - projectId String - project Project @relation(fields: [projectId], references: [id]) + projectId String @unique + project Project @relation(fields: [projectId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/app/api/src/functions/image-upload.ts b/app/api/src/functions/image-upload.ts deleted file mode 100644 index caa6665..0000000 --- a/app/api/src/functions/image-upload.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { APIGatewayEvent } from 'aws-lambda' -import { logger } from 'src/lib/logger' -import { v2 as cloudinary, UploadApiResponse } from 'cloudinary' -// import { requireOwnership } from 'src/lib/owner' -// import { requireAuth } from 'src/lib/auth' - -cloudinary.config({ - cloud_name: 'irevdev', - api_key: process.env.CLOUDINARY_API_KEY, - api_secret: process.env.CLOUDINARY_API_SECRET, -}) - -/** - * The handler function is your code that processes http request events. - * You can use return and throw to send a response or error, respectively. - * - * Important: When deployed, a custom serverless function is an open API endpoint and - * is your responsibility to secure appropriately. - * - * @see {@link https://redwoodjs.com/docs/serverless-functions#security-considerations|Serverless Function Considerations} - * in the RedwoodJS documentation for more information. - * - * @typedef { import('aws-lambda').APIGatewayEvent } APIGatewayEvent - * @typedef { import('aws-lambda').Context } Context - * @param { APIGatewayEvent } event - an object which contains information from the invoker. - * @param { Context } context - contains information about the invocation, - * function, and execution environment. - */ -export const handler = async (event: APIGatewayEvent, _context) => { - logger.info('Invoked image-upload function') - // requireAuth() - const { - image64, - upload_preset, - public_id, - invalidate, - projectId, - }: { - image64: string - upload_preset: string - public_id: string - invalidate: boolean - projectId: string - } = JSON.parse(event.body) - // await requireOwnership({projectId}) - - const uploadResult: UploadApiResponse = await new Promise( - (resolve, reject) => { - cloudinary.uploader.upload( - image64, - { upload_preset, public_id, invalidate }, - (error, result) => { - if (error) { - reject(error) - return - } - resolve(result) - } - ) - } - ) - - return { - statusCode: 200, - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - publicId: uploadResult.public_id, - }), - } -} diff --git a/app/api/src/graphql/projects.sdl.ts b/app/api/src/graphql/projects.sdl.ts index 8412ab8..5544ee6 100644 --- a/app/api/src/graphql/projects.sdl.ts +++ b/app/api/src/graphql/projects.sdl.ts @@ -48,6 +48,11 @@ export const schema = gql` createProject(input: CreateProjectInput!): Project! forkProject(input: CreateProjectInput!): Project! updateProject(id: String!, input: UpdateProjectInput!): Project! + updateProjectImages( + id: String! + mainImage64: String + socialCard64: String + ): Project! deleteProject(id: String!): Project! } ` diff --git a/app/api/src/lib/cloudinary.ts b/app/api/src/lib/cloudinary.ts new file mode 100644 index 0000000..fcdfbae --- /dev/null +++ b/app/api/src/lib/cloudinary.ts @@ -0,0 +1,45 @@ +import { + v2 as cloudinary, + UploadApiResponse, + UpdateApiOptions, +} from 'cloudinary' + +cloudinary.config({ + cloud_name: 'irevdev', + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}) + +interface UploadImageArgs { + image64: string + uploadPreset?: string + publicId?: string + invalidate: boolean +} + +export const uploadImage = async ({ + image64, + uploadPreset = 'CadHub_project_images', + publicId, + invalidate = true, +}: UploadImageArgs): Promise => { + const options: UpdateApiOptions = { upload_preset: uploadPreset, invalidate } + if (publicId) { + options.public_id = publicId + } + + return new Promise((resolve, reject) => { + cloudinary.uploader.upload(image64, options, (error, result) => { + if (error) { + reject(error) + return + } + resolve(result) + }) + }) +} + +export const makeSocialPublicIdServer = ( + userName: string, + projectTitle: string +): string => `u-${userName}-slash-p-${projectTitle}` diff --git a/app/api/src/lib/owner.ts b/app/api/src/lib/owner.ts index aecfb77..7a6304d 100644 --- a/app/api/src/lib/owner.ts +++ b/app/api/src/lib/owner.ts @@ -1,4 +1,5 @@ import { AuthenticationError, ForbiddenError } from '@redwoodjs/api' +import type { Project } from '@prisma/client' import { db } from 'src/lib/db' export const requireOwnership = async ({ @@ -55,3 +56,39 @@ export const requireOwnership = async ({ } } } +export const requireProjectOwnership = async ({ + projectId, +}: { + userId?: string + userName?: string + projectId?: string + sub?: string +} = {}): Promise => { + // IMPORTANT, don't forget to await this function, as it will only block + // unwanted db actions if it has time to look up resources in the db. + if (!context?.currentUser) { + throw new AuthenticationError("You don't have permission to do that.") + } + if (!projectId) { + throw new ForbiddenError("You don't have access to do that.") + } + + const netlifyUserId = context?.currentUser?.sub + + if (projectId || context.currentUser.roles?.includes('admin')) { + if (context.currentUser?.sub === '5cea3906-1e8e-4673-8f0d-89e6a963c096') { + throw new ForbiddenError("That's a local admin ONLY.") + } + const project = await db.project.findUnique({ + where: { id: projectId }, + }) + const hasPermission = + (project && project?.userId === netlifyUserId) || + context.currentUser.roles?.includes('admin') + + if (!hasPermission) { + throw new ForbiddenError("You don't own this resource.") + } + return project + } +} diff --git a/app/api/src/services/projects/projects.ts b/app/api/src/services/projects/projects.ts index 4f9a86c..54da56f 100644 --- a/app/api/src/services/projects/projects.ts +++ b/app/api/src/services/projects/projects.ts @@ -1,5 +1,6 @@ -import type { Prisma } from '@prisma/client' +import type { Prisma, Project as ProjectType } from '@prisma/client' import type { ResolverArgs } from '@redwoodjs/api' +import { uploadImage, makeSocialPublicIdServer } from 'src/lib/cloudinary' import { db } from 'src/lib/db' import { @@ -10,7 +11,7 @@ import { destroyImage, } from 'src/services/helpers' import { requireAuth } from 'src/lib/auth' -import { requireOwnership } from 'src/lib/owner' +import { requireOwnership, requireProjectOwnership } from 'src/lib/owner' export const projects = ({ userName }) => { if (!userName) { @@ -116,6 +117,107 @@ export const updateProject = async ({ id, input }: UpdateProjectArgs) => { return update } +export const updateProjectImages = async ({ + id, + mainImage64, + socialCard64, +}: { + id: string + mainImage64?: string + socialCard64?: string +}): Promise => { + requireAuth() + const project = await requireProjectOwnership({ projectId: id }) + const replaceSocialCard = async () => { + if (!socialCard64) { + return + } + let publicId = '' + let socialCardId = '' + try { + ;({ id: socialCardId, url: publicId } = await db.socialCard.findUnique({ + where: { projectId: id }, + })) + } catch (e) { + const { userName } = await db.user.findUnique({ + where: { id: project.userId }, + }) + publicId = makeSocialPublicIdServer(userName, project.title) + } + const imagePromise = uploadImage({ + image64: socialCard64, + uploadPreset: 'CadHub_project_images', + publicId, + invalidate: true, + }) + const saveOrUpdateSocialCard = () => { + const data = { + outOfDate: false, + url: publicId, + } + if (socialCardId) { + return db.socialCard.update({ + data, + where: { projectId: id }, + }) + } + return db.socialCard.create({ + data: { + ...data, + project: { + connect: { + id: id, + }, + }, + }, + }) + } + const socialCardUpdatePromise = saveOrUpdateSocialCard() + const [socialCard] = await Promise.all([ + socialCardUpdatePromise, + imagePromise, + ]) + return socialCard + } + + const updateMainImage = async (): Promise => { + if (!mainImage64) { + return project + } + const { public_id: mainImage } = await uploadImage({ + image64: mainImage64, + uploadPreset: 'CadHub_project_images', + invalidate: true, + }) + const projectPromise = db.project.update({ + data: { + mainImage, + }, + where: { id }, + }) + let imageDestroyPromise = new Promise((r) => r(null)) + if (project.mainImage) { + console.log( + `image destroyed, publicId: ${project.mainImage}, projectId: ${id}, replacing image is ${mainImage}` + ) + // destroy after the db has been updated + imageDestroyPromise = destroyImage({ publicId: project.mainImage }) + } + const [updatedProject] = await Promise.all([ + projectPromise, + imageDestroyPromise, + ]) + return updatedProject + } + + const [updatedProject] = await Promise.all([ + updateMainImage(), + replaceSocialCard(), + ]) + + return updatedProject +} + export const deleteProject = async ({ id }: Prisma.ProjectWhereUniqueInput) => { requireAuth() await requireOwnership({ projectId: id }) diff --git a/app/web/src/components/CaptureButton/CaptureButton.tsx b/app/web/src/components/CaptureButton/CaptureButton.tsx index 6f1af62..c855cd9 100644 --- a/app/web/src/components/CaptureButton/CaptureButton.tsx +++ b/app/web/src/components/CaptureButton/CaptureButton.tsx @@ -5,18 +5,10 @@ import Svg from 'src/components/Svg/Svg' import Button from 'src/components/Button/Button' import { useIdeContext } from 'src/helpers/hooks/useIdeContext' import { canvasToBlob, blobTo64 } from 'src/helpers/canvasToBlob' -import { useUpdateProject } from 'src/helpers/hooks/useUpdateProject' -import { - useUpdateSocialCard, - makeSocialPublicId, -} from 'src/helpers/hooks/useUpdateSocialCard' -import { - uploadToCloudinary, - serverVerifiedImageUpload, -} from 'src/helpers/cloudinary' +import { useUpdateProjectImages } from 'src/helpers/hooks/useUpdateProjectImages' + import SocialCardCell from 'src/components/SocialCardCell/SocialCardCell' import { toJpeg } from 'html-to-image' -import { useAuth } from '@redwoodjs/auth' const anchorOrigin = { vertical: 'bottom', @@ -39,24 +31,7 @@ const CaptureButton = ({ const [whichPopup, setWhichPopup] = useState(null) const { state, project } = useIdeContext() const ref = React.useRef(null) - const { updateProject } = useUpdateProject({ - onCompleted: () => toast.success('Image updated'), - }) - const { updateSocialCard } = useUpdateSocialCard({}) - const { getToken } = useAuth() - - const getSocialBlob = async (): Promise => { - const tokenPromise = getToken() - const blob = await toJpeg(ref.current, { cacheBust: true, quality: 0.75 }) - const token = await tokenPromise - const { publicId } = await serverVerifiedImageUpload( - blob, - project?.id, - token, - makeSocialPublicId(userName, projectTitle) - ) - return publicId - } + const { updateProjectImages } = useUpdateProjectImages({}) const onCapture = async () => { const threeInstance = state.threeInstance @@ -84,29 +59,36 @@ const CaptureButton = ({ setCaptureState(config) async function uploadAndUpdateImage() { - const [cloudinaryImgURL, socialCloudinaryURL] = await Promise.all([ - uploadToCloudinary(config.image), - getSocialBlob(), - ]) + const derp = async () => { + const socialCard64 = toJpeg(ref.current, { + cacheBust: true, + quality: 0.7, + }) - updateSocialCard({ - variables: { - projectId: project?.id, - url: socialCloudinaryURL, - }, - }) - - // Save the screenshot as the mainImage - updateProject({ - variables: { - id: project?.id, - input: { - mainImage: cloudinaryImgURL.public_id, + const promise1 = updateProjectImages({ + variables: { + id: project?.id, + // socialCard64, + mainImage64: await config.image64, }, - }, + }) + const promise2 = updateProjectImages({ + variables: { + id: project?.id, + socialCard64: await socialCard64, + }, + }) + return Promise.all([promise2, promise1]) + } + const promise = derp() + toast.promise(promise, { + loading: 'Saving Image/s', + success: Image/s saved!, + error: Problem saving., }) - - return cloudinaryImgURL + const [{ data }] = await promise + console.log(data?.updateProjectImages) + return data?.updateProjectImages?.mainImage } // if there isn't a screenshot saved yet, just go ahead and save right away diff --git a/app/web/src/helpers/canvasToBlob.ts b/app/web/src/helpers/canvasToBlob.ts index f991200..33d2d65 100644 --- a/app/web/src/helpers/canvasToBlob.ts +++ b/app/web/src/helpers/canvasToBlob.ts @@ -27,7 +27,7 @@ export const canvasToBlob = async ( resolve(blob) }, 'image/jpeg', - 1 + 0.75 ) }) updateCanvasSize(oldSize) diff --git a/app/web/src/helpers/cloudinary.ts b/app/web/src/helpers/cloudinary.ts deleted file mode 100644 index d203eb6..0000000 --- a/app/web/src/helpers/cloudinary.ts +++ /dev/null @@ -1,57 +0,0 @@ -// TODO: create a tidy util for uploading to Cloudinary and returning the public ID -import axios from 'axios' - -const CLOUDINARY_UPLOAD_PRESET = 'CadHub_project_images' -const CLOUDINARY_UPLOAD_URL = 'https://api.cloudinary.com/v1_1/irevdev/upload' - -export async function uploadToCloudinary( - imgBlob: Blob, - publicId?: string -): Promise<{ public_id: string }> { - const imageData = new FormData() - imageData.append('upload_preset', CLOUDINARY_UPLOAD_PRESET) - imageData.append('file', imgBlob) - if (publicId) { - imageData.append('public_id', publicId) - } - const upload = axios.post(CLOUDINARY_UPLOAD_URL, imageData) - - try { - const { data } = await upload - if (data && data.public_id !== '') { - return data - } - } catch (e) { - console.error('ERROR', e) - } -} - -export async function serverVerifiedImageUpload( - imgBlob: string, - projectId: string, - token: string, - publicId?: string -): Promise<{ publicId: string }> { - const imageData = { - image64: imgBlob, - upload_preset: CLOUDINARY_UPLOAD_PRESET, - public_id: publicId, - invalidate: true, - projectId, - } - const upload = axios.post('/.netlify/functions/image-upload', imageData, { - headers: { - 'auth-provider': 'goTrue', - authorization: `Bearer ${token}`, - }, - }) - - try { - const { data } = await upload - if (data && data.public_id !== '') { - return data - } - } catch (e) { - console.error('ERROR', e) - } -} diff --git a/app/web/src/helpers/hooks/useUpdateProjectImages.ts b/app/web/src/helpers/hooks/useUpdateProjectImages.ts new file mode 100644 index 0000000..70feb59 --- /dev/null +++ b/app/web/src/helpers/hooks/useUpdateProjectImages.ts @@ -0,0 +1,31 @@ +import { useMutation } from '@redwoodjs/web' + +const UPDATE_PROJECT_IMAGES_MUTATION_HOOK = gql` + mutation updateProjectImages( + $id: String! + $mainImage64: String + $socialCard64: String + ) { + updateProjectImages( + id: $id + mainImage64: $mainImage64 + socialCard64: $socialCard64 + ) { + id + mainImage + socialCard { + id + url + } + } + } +` + +export const useUpdateProjectImages = ({ onCompleted = () => {} }) => { + const [updateProjectImages, { loading, error }] = useMutation( + UPDATE_PROJECT_IMAGES_MUTATION_HOOK, + { onCompleted } + ) + + return { updateProjectImages, loading, error } +} diff --git a/app/web/src/helpers/hooks/useUpdateSocialCard.ts b/app/web/src/helpers/hooks/useUpdateSocialCard.ts index 5759191..8352e2d 100644 --- a/app/web/src/helpers/hooks/useUpdateSocialCard.ts +++ b/app/web/src/helpers/hooks/useUpdateSocialCard.ts @@ -1,23 +1,3 @@ -import { useMutation } from '@redwoodjs/web' - -const UPDATE_SOCIAL_CARD_MUTATION_HOOK = gql` - mutation updateSocialCardByProjectId($projectId: String!, $url: String!) { - updateSocialCardByProjectId(projectId: $projectId, url: $url) { - id - url - } - } -` - -export const useUpdateSocialCard = ({ onCompleted = () => {} }) => { - const [updateSocialCard, { loading, error }] = useMutation( - UPDATE_SOCIAL_CARD_MUTATION_HOOK, - { onCompleted } - ) - - return { updateSocialCard, loading, error } -} - export const makeSocialPublicId = ( userName: string, projectTitle: string