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/EditableProjecTitle/EditableProjecTitle.tsx b/app/web/src/components/EditableProjecTitle/EditableProjecTitle.tsx index fdb7102..ccc0333 100644 --- a/app/web/src/components/EditableProjecTitle/EditableProjecTitle.tsx +++ b/app/web/src/components/EditableProjecTitle/EditableProjecTitle.tsx @@ -76,10 +76,12 @@ const EditableProjectTitle = ({ value={newTitle} onChange={onTitleChange} ref={inputRef} - onBlur={() => setTimeout(() => { - setInEditMode(false) - setNewTitle(projectTitle) - }, 300)} + onBlur={() => + setTimeout(() => { + setInEditMode(false) + setNewTitle(projectTitle) + }, 300) + } />
diff --git a/app/web/src/components/IdeHeader/IdeHeader.tsx b/app/web/src/components/IdeHeader/IdeHeader.tsx index bd61ea6..0b077b8 100644 --- a/app/web/src/components/IdeHeader/IdeHeader.tsx +++ b/app/web/src/components/IdeHeader/IdeHeader.tsx @@ -95,6 +95,8 @@ const IdeHeader = ({ {canEdit && !projectTitle && ( ( setComment(target.value)} + onChange={({ target }) => + setComment(target.value) + } />