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 <k00b@stacker.news>
This commit is contained in:
soxa 2025-04-15 22:41:33 +02:00 committed by GitHub
parent c33b62abb4
commit b6f6cc821c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 116 additions and 28 deletions

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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 }) {
/>
</BootstrapForm.Group>
<Button
onClick={() => {
ref.current.getImageScaledToCanvas().toBlob(blob => {
if (blob) {
upload(blob)
onClose()
onClick={async () => {
const rect = ref.current.getCroppingRect()
const img = new window.Image()
img.onload = async () => {
const cropData = {
...rect,
originalWidth: img.width,
originalHeight: img.height,
scale
}
}, 'image/jpeg')
// upload original to S3 along with crop data
await onSave(cropData)
}
img.src = URL.createObjectURL(file)
onClose()
}}
>save
</Button>
@ -48,6 +62,45 @@ export default function Avatar ({ onSuccess }) {
)
}
const startCrop = async (file, upload) => {
return new Promise((resolve, reject) =>
showModal(onClose => (
<Body
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 (
<FileUpload
allow='image/*'
@ -56,26 +109,7 @@ export default function Avatar ({ onSuccess }) {
console.log(e)
setUploading(false)
}}
onSelect={(file, upload) => {
return new Promise((resolve, reject) =>
showModal(onClose => (
<Body
onClose={() => {
onClose()
resolve()
}}
file={file}
upload={async (blob) => {
await upload(blob)
resolve(blob)
}}
/>
)))
}}
onSuccess={({ id }) => {
onSuccess?.(id)
setUploading(false)
}}
onSelect={startCrop}
onUpload={() => {
setUploading(true)
}}

View File

@ -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()

View File

@ -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()
}