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:
		
							parent
							
								
									c33b62abb4
								
							
						
					
					
						commit
						b6f6cc821c
					
				@ -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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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()
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
      }}
 | 
			
		||||
 | 
			
		||||
@ -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()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user