diff --git a/app/api/db/migrations/20210812210054_add_social_image/migration.sql b/app/api/db/migrations/20210812210054_add_social_image/migration.sql new file mode 100644 index 0000000..fd012e5 --- /dev/null +++ b/app/api/db/migrations/20210812210054_add_social_image/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "SocialCard" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "url" TEXT, + "outOfDate" BOOLEAN NOT NULL DEFAULT true, + + PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SocialCard_projectId_unique" ON "SocialCard"("projectId"); + +-- AddForeignKey +ALTER TABLE "SocialCard" ADD FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/app/api/db/schema.prisma b/app/api/db/schema.prisma index 34f86f1..2d0c5ee 100644 --- a/app/api/db/schema.prisma +++ b/app/api/db/schema.prisma @@ -56,12 +56,24 @@ model Project { userId String deleted Boolean @default(false) cadPackage CadPackage @default(openscad) + socialCard SocialCard? Comment Comment[] Reaction ProjectReaction[] @@unique([title, userId]) } +model SocialCard { + id String @id @default(uuid()) + projectId String + project Project @relation(fields: [projectId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + url String? // link to cloudinary + outOfDate Boolean @default(true) +} + model ProjectReaction { id String @id @default(uuid()) emote String // an emoji diff --git a/app/api/src/functions/image-upload.ts b/app/api/src/functions/image-upload.ts new file mode 100644 index 0000000..caa6665 --- /dev/null +++ b/app/api/src/functions/image-upload.ts @@ -0,0 +1,72 @@ +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 6ec8af2..8412ab8 100644 --- a/app/api/src/graphql/projects.sdl.ts +++ b/app/api/src/graphql/projects.sdl.ts @@ -11,6 +11,7 @@ export const schema = gql` userId: String! deleted: Boolean! cadPackage: CadPackage! + socialCard: SocialCard Comment: [Comment]! Reaction(userId: String): [ProjectReaction]! } diff --git a/app/api/src/graphql/socialCards.sdl.ts b/app/api/src/graphql/socialCards.sdl.ts new file mode 100644 index 0000000..084cbc9 --- /dev/null +++ b/app/api/src/graphql/socialCards.sdl.ts @@ -0,0 +1,35 @@ +export const schema = gql` + type SocialCard { + id: String! + projectId: String! + project: Project! + createdAt: DateTime! + updatedAt: DateTime! + url: String + outOfDate: Boolean! + } + + type Query { + socialCards: [SocialCard!]! + socialCard(id: String!): SocialCard + } + + input CreateSocialCardInput { + projectId: String! + url: String + outOfDate: Boolean! + } + + input UpdateSocialCardInput { + projectId: String + url: String + outOfDate: Boolean + } + + type Mutation { + createSocialCard(input: CreateSocialCardInput!): SocialCard! + updateSocialCard(id: String!, input: UpdateSocialCardInput!): SocialCard! + deleteSocialCard(id: String!): SocialCard! + updateSocialCardByProjectId(projectId: String!, url: String!): SocialCard! + } +` diff --git a/app/api/src/lib/owner.ts b/app/api/src/lib/owner.ts index 3210355..aecfb77 100644 --- a/app/api/src/lib/owner.ts +++ b/app/api/src/lib/owner.ts @@ -5,10 +5,16 @@ export const requireOwnership = async ({ userId, userName, projectId, -}: { userId?: string; userName?: string; projectId?: string } = {}) => { + sub, +}: { + userId?: string + userName?: string + projectId?: string + sub?: string +} = {}) => { // 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) { + if (!(context?.currentUser || sub)) { throw new AuthenticationError("You don't have permission to do that.") } if (!userId && !userName && !projectId) { @@ -22,7 +28,7 @@ export const requireOwnership = async ({ return } - const netlifyUserId = context.currentUser?.sub + const netlifyUserId = context?.currentUser?.sub || sub if (userId && userId !== netlifyUserId) { throw new ForbiddenError("You don't own this resource.") } diff --git a/app/api/src/services/projects/projects.ts b/app/api/src/services/projects/projects.ts index ba6c5d8..4f9a86c 100644 --- a/app/api/src/services/projects/projects.ts +++ b/app/api/src/services/projects/projects.ts @@ -130,6 +130,8 @@ export const deleteProject = async ({ id }: Prisma.ProjectWhereUniqueInput) => { export const Project = { user: (_obj, { root }: ResolverArgs>) => db.project.findUnique({ where: { id: root.id } }).user(), + socialCard: (_obj, { root }: ResolverArgs>) => + db.project.findUnique({ where: { id: root.id } }).socialCard(), Comment: (_obj, { root }: ResolverArgs>) => db.project .findUnique({ where: { id: root.id } }) diff --git a/app/api/src/services/socialCards/socialCards.ts b/app/api/src/services/socialCards/socialCards.ts new file mode 100644 index 0000000..8c02bc3 --- /dev/null +++ b/app/api/src/services/socialCards/socialCards.ts @@ -0,0 +1,84 @@ +import type { Prisma } from '@prisma/client' +import type { ResolverArgs, BeforeResolverSpecType } from '@redwoodjs/api' + +import { db } from 'src/lib/db' +import { requireAuth } from 'src/lib/auth' + +// Used when the environment variable REDWOOD_SECURE_SERVICES=1 +export const beforeResolver = (rules: BeforeResolverSpecType) => { + rules.add(requireAuth) +} + +export const socialCards = () => { + return db.socialCard.findMany() +} + +export const socialCard = ({ id }: Prisma.SocialCardWhereUniqueInput) => { + return db.socialCard.findUnique({ + where: { id }, + }) +} + +interface CreateSocialCardArgs { + input: Prisma.SocialCardCreateInput +} + +export const createSocialCard = ({ input }: CreateSocialCardArgs) => { + return db.socialCard.create({ + data: input, + }) +} + +interface UpdateSocialCardArgs extends Prisma.SocialCardWhereUniqueInput { + input: Prisma.SocialCardUpdateInput +} + +export const updateSocialCard = ({ id, input }: UpdateSocialCardArgs) => { + return db.socialCard.update({ + data: input, + where: { id }, + }) +} + +export const updateSocialCardByProjectId = async ({ + projectId, + url, +}: { + url: string + projectId: string +}) => { + let id: string + try { + const socialCard = await db.project + .findUnique({ where: { id: projectId } }) + .socialCard() + id = socialCard.id + } catch (e) { + return db.socialCard.create({ + data: { + url, + project: { + connect: { + id: projectId, + }, + }, + }, + }) + } + + return db.socialCard.update({ + data: { url }, + where: { id }, + }) +} + +export const deleteSocialCard = ({ id }: Prisma.SocialCardWhereUniqueInput) => { + return db.socialCard.delete({ + where: { id }, + }) +} + +export const SocialCard = { + project: (_obj, { root }: ResolverArgs>) => + db.socialCard.findUnique({ where: { id: root.id } }).project(), +} diff --git a/app/web/package.json b/app/web/package.json index fd72779..c13f7f6 100644 --- a/app/web/package.json +++ b/app/web/package.json @@ -28,6 +28,7 @@ "cloudinary-react": "^1.6.7", "get-active-classes": "^0.0.11", "gotrue-js": "^0.9.27", + "html-to-image": "^1.7.0", "lodash": "^4.17.21", "netlify-identity-widget": "^1.9.1", "pako": "^2.0.3", diff --git a/app/web/src/components/CaptureButton/CaptureButton.tsx b/app/web/src/components/CaptureButton/CaptureButton.tsx index 213bb6d..ee547e4 100644 --- a/app/web/src/components/CaptureButton/CaptureButton.tsx +++ b/app/web/src/components/CaptureButton/CaptureButton.tsx @@ -4,8 +4,19 @@ import Popover from '@material-ui/core/Popover' 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 { uploadToCloudinary } from 'src/helpers/cloudinary' +import { + useUpdateSocialCard, + makeSocialPublicId, +} from 'src/helpers/hooks/useUpdateSocialCard' +import { + uploadToCloudinary, + serverVerifiedImageUpload, +} from 'src/helpers/cloudinary' +import SocialCardCell from 'src/components/SocialCardCell/SocialCardCell' +import { toJpeg } from 'html-to-image' +import { useAuth } from '@redwoodjs/auth' const anchorOrigin = { vertical: 'bottom', @@ -16,51 +27,50 @@ const transformOrigin = { horizontal: 'center', } -const CaptureButton = ({ canEdit, TheButton, shouldUpdateImage }) => { +const CaptureButton = ({ + canEdit, + TheButton, + shouldUpdateImage, + projectTitle, + userName, +}) => { const [captureState, setCaptureState] = useState({}) const [anchorEl, setAnchorEl] = useState(null) 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 onCapture = async () => { const threeInstance = state.threeInstance const isOpenScadImage = state?.objectData?.type === 'png' let imgBlob + let image64 if (!isOpenScadImage) { - const updateCanvasSize = ({ - width, - height, - }: { - width: number - height: number - }) => { - threeInstance.camera.aspect = width / height - threeInstance.camera.updateProjectionMatrix() - threeInstance.gl.setSize(width, height) - threeInstance.gl.render( - threeInstance.scene, - threeInstance.camera, - null, - false - ) - } - const oldSize = threeInstance.size - updateCanvasSize({ width: 400, height: 300 }) - imgBlob = new Promise((resolve, reject) => { - threeInstance.gl.domElement.toBlob( - (blob) => { - resolve(blob) - }, - 'image/jpeg', - 1 - ) - }) - updateCanvasSize(oldSize) + imgBlob = canvasToBlob(threeInstance, { width: 400, height: 300 }) + image64 = blobTo64( + await canvasToBlob(threeInstance, { width: 500, height: 522 }) + ) } else { imgBlob = state.objectData.data + image64 = blobTo64(state.objectData.data) } const config = { image: await imgBlob, @@ -69,11 +79,22 @@ const CaptureButton = ({ canEdit, TheButton, shouldUpdateImage }) => { callback: uploadAndUpdateImage, cloudinaryImgURL: '', updated: false, + image64: await image64, } + setCaptureState(config) async function uploadAndUpdateImage() { - // Upload the image to Cloudinary - const cloudinaryImgURL = await uploadToCloudinary(config.image) + const [cloudinaryImgURL, socialCloudinaryURL] = await Promise.all([ + uploadToCloudinary(config.image), + getSocialBlob(), + ]) + + updateSocialCard({ + variables: { + projectId: project?.id, + url: socialCloudinaryURL, + }, + }) // Save the screenshot as the mainImage updateProject({ @@ -92,9 +113,8 @@ const CaptureButton = ({ canEdit, TheButton, shouldUpdateImage }) => { if (shouldUpdateImage) { config.cloudinaryImgURL = (await uploadAndUpdateImage()).public_id config.updated = true + setCaptureState(config) } - - return config } const handleDownload = (url) => { @@ -123,7 +143,7 @@ const CaptureButton = ({ canEdit, TheButton, shouldUpdateImage }) => { { handleClick({ event, whichPopup: 'capture' }) - setCaptureState(await onCapture()) + onCapture() }} /> { name="refresh" className="mr-2 w-4 text-indigo-600" />{' '} - Update Part Image + Update Project Image ) : (
@@ -187,6 +207,20 @@ const CaptureButton = ({ canEdit, TheButton, shouldUpdateImage }) => {
)} +
+
+
+ +
+
+
diff --git a/app/web/src/components/SocialCardCell/SocialCardCell.tsx b/app/web/src/components/SocialCardCell/SocialCardCell.tsx index 151bef7..cb63267 100644 --- a/app/web/src/components/SocialCardCell/SocialCardCell.tsx +++ b/app/web/src/components/SocialCardCell/SocialCardCell.tsx @@ -37,12 +37,13 @@ export const Failure = ({ error }: CellFailureProps) => ( export const Success = ({ userProject, + variables: { image64 }, }: CellSuccessProps) => { const image = userProject?.Project?.mainImage const gravatar = userProject?.image return (
)} -
+
{userProject?.userName}
@@ -86,13 +87,19 @@ export const Success = ({
- + {image64 ? ( +
+ ) : ( +
+ )}
@@ -133,7 +140,7 @@ export const Success = ({ CadHub
pre-alpha diff --git a/app/web/src/helpers/canvasToBlob.ts b/app/web/src/helpers/canvasToBlob.ts index 27a6d8e..f991200 100644 --- a/app/web/src/helpers/canvasToBlob.ts +++ b/app/web/src/helpers/canvasToBlob.ts @@ -33,3 +33,16 @@ export const canvasToBlob = async ( updateCanvasSize(oldSize) return imgBlobPromise } + +export const blobTo64 = async (blob: Blob): Promise => { + return new Promise(async (resolve, reject) => { + const reader = new FileReader() + reader.onloadend = () => { + if (typeof reader.result === 'string') { + resolve(reader.result) + } + } + reader.onerror = reject + reader.readAsDataURL(blob) + }) +} diff --git a/app/web/src/helpers/cloudinary.js b/app/web/src/helpers/cloudinary.js deleted file mode 100644 index ff1767c..0000000 --- a/app/web/src/helpers/cloudinary.js +++ /dev/null @@ -1,21 +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) { - const imageData = new FormData() - imageData.append('upload_preset', CLOUDINARY_UPLOAD_PRESET) - imageData.append('file', imgBlob) - let 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) - } -} diff --git a/app/web/src/helpers/cloudinary.ts b/app/web/src/helpers/cloudinary.ts new file mode 100644 index 0000000..d203eb6 --- /dev/null +++ b/app/web/src/helpers/cloudinary.ts @@ -0,0 +1,57 @@ +// 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/useUpdateSocialCard.ts b/app/web/src/helpers/hooks/useUpdateSocialCard.ts new file mode 100644 index 0000000..5759191 --- /dev/null +++ b/app/web/src/helpers/hooks/useUpdateSocialCard.ts @@ -0,0 +1,24 @@ +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 +): string => `u-${userName}-slash-p-${projectTitle}` diff --git a/app/web/src/pages/ProjectPage/ProjectPage.tsx b/app/web/src/pages/ProjectPage/ProjectPage.tsx index 187db51..81309db 100644 --- a/app/web/src/pages/ProjectPage/ProjectPage.tsx +++ b/app/web/src/pages/ProjectPage/ProjectPage.tsx @@ -5,16 +5,15 @@ import Seo from 'src/components/Seo/Seo' import { useIdeState } from 'src/helpers/hooks/useIdeState' import { IdeContext } from 'src/helpers/hooks/useIdeContext' import { Toaster } from '@redwoodjs/web/toast' +import { makeSocialPublicId } from 'src/helpers/hooks/useUpdateSocialCard' const ProjectPage = ({ userName, projectTitle }) => { const { currentUser } = useAuth() const [state, thunkDispatch] = useIdeState() - const cacheInvalidator = new Date() - .toISOString() - .split('-') - .slice(0, 2) - .join('-') - const socialImageUrl = `/.netlify/functions/og-image-generator/${userName}/${projectTitle}/og-image-${cacheInvalidator}.jpg` + const socialImageUrl = `http://res.cloudinary.com/irevdev/image/upload/c_scale,w_1200/v1/CadHub/${makeSocialPublicId( + userName, + projectTitle + )}` return ( <>