From b6f6cc821c8f13fc4332facf8270dbc6fa9a8984 Mon Sep 17 00:00:00 2001 From: soxa <6390896+Soxasora@users.noreply.github.com> Date: Tue, 15 Apr 2025 22:41:33 +0200 Subject: [PATCH] Crop avatars with Imgproxy (#2074) * cropPhoto mutation, crop avatars with Imgproxy * cropjob logging, conditional uploads url * comment typo * use public Imgproxy URL to re-upload cropped pic * fix avatar in dev --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b --- .env.development | 1 + api/resolvers/user.js | 13 ++++++ api/typeDefs/user.js | 11 +++++ components/avatar.js | 88 +++++++++++++++++++++++++++------------ components/user-header.js | 4 +- worker/imgproxy.js | 27 ++++++++++++ 6 files changed, 116 insertions(+), 28 deletions(-) diff --git a/.env.development b/.env.development index 7f6af164..7572da56 100644 --- a/.env.development +++ b/.env.development @@ -85,6 +85,7 @@ IMGPROXY_READ_TIMEOUT=10 IMGPROXY_WRITE_TIMEOUT=10 IMGPROXY_DOWNLOAD_TIMEOUT=9 IMGPROXY_ENABLE_VIDEO_THUMBNAILS=1 +IMGPROXY_ALLOW_ORIGIN=http://localhost:3000 # IMGPROXY_DEVELOPMENT_ERRORS_MODE=1 # IMGPROXY_ENABLE_DEBUG_HEADERS=true diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 7e255671..2822e643 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -11,6 +11,7 @@ import assertApiKeyNotPermitted from './apiKey' import { hashEmail } from '@/lib/crypto' import { isMuted } from '@/lib/user' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' +import { processCrop } from '@/worker/imgproxy' const contributors = new Set() @@ -727,6 +728,18 @@ export default { return true }, + cropPhoto: async (parent, { photoId, cropData }, { me, models }) => { + if (!me) { + throw new GqlAuthenticationError() + } + + const croppedUrl = await processCrop({ photoId: Number(photoId), cropData }) + if (!croppedUrl) { + throw new GqlInputError('can\'t crop photo') + } + + return croppedUrl + }, setPhoto: async (parent, { photoId }, { me, models }) => { if (!me) { throw new GqlAuthenticationError() diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 7cb4e560..07adebf5 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -29,9 +29,20 @@ export default gql` users: [User!]! } + input CropData { + x: Float! + y: Float! + width: Float! + height: Float! + originalWidth: Int! + originalHeight: Int! + scale: Float! + } + extend type Mutation { setName(name: String!): String setSettings(settings: SettingsInput!): User + cropPhoto(photoId: ID!, cropData: CropData): String! setPhoto(photoId: ID!): Int! upsertBio(text: String!): ItemPaidAction! setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean diff --git a/components/avatar.js b/components/avatar.js index ad05378b..50ce98ae 100644 --- a/components/avatar.js +++ b/components/avatar.js @@ -6,12 +6,18 @@ import EditImage from '@/svgs/image-edit-fill.svg' import Moon from '@/svgs/moon-fill.svg' import { useShowModal } from './modal' import { FileUpload } from './file-upload' +import { gql, useMutation } from '@apollo/client' export default function Avatar ({ onSuccess }) { + const [cropPhoto] = useMutation(gql` + mutation cropPhoto($photoId: ID!, $cropData: CropData) { + cropPhoto(photoId: $photoId, cropData: $cropData) + } + `) const [uploading, setUploading] = useState() const showModal = useShowModal() - const Body = ({ onClose, file, upload }) => { + const Body = ({ onClose, file, onSave }) => { const [scale, setScale] = useState(1) const ref = useRef() @@ -34,13 +40,21 @@ export default function Avatar ({ onSuccess }) { /> @@ -48,6 +62,45 @@ export default function Avatar ({ onSuccess }) { ) } + const startCrop = async (file, upload) => { + return new Promise((resolve, reject) => + showModal(onClose => ( + { + onClose() + resolve() + }} + file={file} + onSave={async (cropData) => { + setUploading(true) + try { + // upload original to S3 + const photoId = await upload(file) + + // crop it + const { data } = await cropPhoto({ variables: { photoId, cropData } }) + const res = await fetch(data.cropPhoto) + const blob = await res.blob() + + // create a file from the blob + const croppedImage = new File([blob], 'avatar.jpg', { type: 'image/jpeg' }) + + // upload the imgproxy cropped image + const croppedPhotoId = await upload(croppedImage) + + onSuccess?.(croppedPhotoId) + setUploading(false) + } catch (e) { + console.error(e) + setUploading(false) + reject(e) + } + }} + /> + )) + ) + } + return ( { - return new Promise((resolve, reject) => - showModal(onClose => ( - { - onClose() - resolve() - }} - file={file} - upload={async (blob) => { - await upload(blob) - resolve(blob) - }} - /> - ))) - }} - onSuccess={({ id }) => { - onSuccess?.(id) - setUploading(false) - }} + onSelect={startCrop} onUpload={() => { setUploading(true) }} diff --git a/components/user-header.js b/components/user-header.js index 2b5684fe..fbb315ad 100644 --- a/components/user-header.js +++ b/components/user-header.js @@ -28,9 +28,11 @@ import { hexToBech32 } from '@/lib/nostr' import NostrIcon from '@/svgs/nostr.svg' import GithubIcon from '@/svgs/github-fill.svg' import TwitterIcon from '@/svgs/twitter-fill.svg' -import { UNKNOWN_LINK_REL, MEDIA_URL } from '@/lib/constants' +import { UNKNOWN_LINK_REL } from '@/lib/constants' import ItemPopover from './item-popover' +const MEDIA_URL = process.env.NEXT_PUBLIC_MEDIA_URL || `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}` + export default function UserHeader ({ user }) { const router = useRouter() diff --git a/worker/imgproxy.js b/worker/imgproxy.js index 9cc46c51..65af14f6 100644 --- a/worker/imgproxy.js +++ b/worker/imgproxy.js @@ -185,3 +185,30 @@ const sign = (target) => { hmac.update(target) return hmac.digest('base64url') } + +export async function processCrop ({ photoId, cropData }) { + const { x, y, width, height, originalWidth, originalHeight, scale } = cropData + const cropWidth = Math.round(originalWidth * width) + const cropHeight = Math.round(originalHeight * height) + + const centerX = x + width / scale + const centerY = y + height / scale + + const size = 200 // 200px avatar size + + const options = [ + `/crop:${cropWidth}:${cropHeight}`, + `/gravity:fp:${centerX}:${centerY}`, + `/rs:fill:${size}:${size}` + ].join('') + + const uploadsUrl = process.env.MEDIA_URL_DOCKER || process.env.NEXT_PUBLIC_MEDIA_URL + const url = uploadsUrl + `/${photoId}` + console.log('[imgproxy - cropjob] id:', photoId, '-- url:', url) + + const pathname = '/' + const path = createImgproxyPath({ url, pathname, options }) + const publicImgproxyUrl = process.env.NEXT_PUBLIC_IMGPROXY_URL + + return new URL(path, publicImgproxyUrl).toString() +}