392 lines
14 KiB
JavaScript
392 lines
14 KiB
JavaScript
import styles from './text.module.css'
|
|
import { Fragment, useState, useEffect, useMemo, useCallback, forwardRef, useRef, memo } from 'react'
|
|
import { IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP } from '@/lib/url'
|
|
import { useShowModal } from './modal'
|
|
import { useMe } from './me'
|
|
import { Dropdown } from 'react-bootstrap'
|
|
import { UNKNOWN_LINK_REL, UPLOAD_TYPES_ALLOW, MEDIA_URL } from '@/lib/constants'
|
|
import { useToast } from './toast'
|
|
import gql from 'graphql-tag'
|
|
import { useMutation } from '@apollo/client'
|
|
import piexif from 'piexifjs'
|
|
|
|
export function decodeOriginalUrl (imgproxyUrl) {
|
|
const parts = imgproxyUrl.split('/')
|
|
// base64url is not a known encoding in browsers
|
|
// so we need to replace the invalid chars
|
|
const b64Url = parts[parts.length - 1].replace(/-/g, '+').replace(/_/, '/')
|
|
const originalUrl = Buffer.from(b64Url, 'base64').toString('utf-8')
|
|
return originalUrl
|
|
}
|
|
|
|
function ImageOriginal ({ src, topLevel, rel, tab, children, onClick, ...props }) {
|
|
const me = useMe()
|
|
const [showImage, setShowImage] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (me?.privates?.imgproxyOnly && tab !== 'preview') return
|
|
// make sure it's not a false negative by trying to load URL as <img>
|
|
const img = new window.Image()
|
|
img.onload = () => setShowImage(true)
|
|
img.src = src
|
|
|
|
return () => {
|
|
img.onload = null
|
|
img.src = ''
|
|
}
|
|
}, [src, showImage])
|
|
|
|
if (showImage) {
|
|
return (
|
|
<img
|
|
className={topLevel ? styles.topLevel : undefined}
|
|
src={src}
|
|
onClick={() => onClick(src)}
|
|
onError={() => setShowImage(false)}
|
|
/>
|
|
)
|
|
} else {
|
|
// user is not okay with loading original url automatically or there was an error loading the image
|
|
|
|
// If element parsed by markdown is a raw URL, we use src as the text to not mislead users.
|
|
// This will not be the case if [text](url) format is used. Then we will show what was chosen as text.
|
|
const isRawURL = /^https?:\/\//.test(children?.[0])
|
|
return (
|
|
// eslint-disable-next-line
|
|
<a
|
|
target='_blank'
|
|
rel={rel ?? UNKNOWN_LINK_REL}
|
|
href={src}
|
|
>{isRawURL ? src : children}
|
|
</a>
|
|
)
|
|
}
|
|
}
|
|
|
|
function TrustedImage ({ src, srcSet: { dimensions, ...srcSetObj } = {}, onClick, topLevel, onError, ...props }) {
|
|
const srcSet = useMemo(() => {
|
|
if (Object.keys(srcSetObj).length === 0) return undefined
|
|
// srcSetObj shape: { [widthDescriptor]: <imgproxyUrl>, ... }
|
|
return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url], i, arr) => {
|
|
// backwards compatibility: we used to replace image urls with imgproxy urls rather just storing paths
|
|
if (!url.startsWith('http')) {
|
|
url = new URL(url, process.env.NEXT_PUBLIC_IMGPROXY_URL).toString()
|
|
}
|
|
return acc + `${url} ${wDescriptor}` + (i < arr.length - 1 ? ', ' : '')
|
|
}, '')
|
|
}, [srcSetObj])
|
|
const sizes = srcSet ? `${(topLevel ? 100 : 66)}vw` : undefined
|
|
|
|
// get source url in best resolution
|
|
const bestResSrc = useMemo(() => {
|
|
if (Object.keys(srcSetObj).length === 0) return src
|
|
return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url]) => {
|
|
if (!url.startsWith('http')) {
|
|
url = new URL(url, process.env.NEXT_PUBLIC_IMGPROXY_URL).toString()
|
|
}
|
|
const w = Number(wDescriptor.replace(/w$/, ''))
|
|
return w > acc.w ? { w, url } : acc
|
|
}, { w: 0, url: undefined }).url
|
|
}, [srcSetObj])
|
|
|
|
const handleError = useCallback(onError, [onError])
|
|
const handleClick = useCallback(() => onClick(bestResSrc), [onClick, bestResSrc])
|
|
|
|
return (
|
|
<Image
|
|
className={topLevel ? styles.topLevel : undefined}
|
|
// browsers that don't support srcSet and sizes will use src. use best resolution possible in that case
|
|
src={bestResSrc}
|
|
srcSet={srcSet}
|
|
sizes={sizes}
|
|
width={dimensions?.width}
|
|
height={dimensions?.height}
|
|
onClick={handleClick}
|
|
onError={handleError}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const Image = memo(({ className, src, srcSet, sizes, width, height, onClick, onError }) => {
|
|
const style = width && height
|
|
? { '--height': `${height}px`, '--width': `${width}px`, '--aspect-ratio': `${width} / ${height}` }
|
|
: undefined
|
|
|
|
return (
|
|
<img
|
|
className={className}
|
|
// browsers that don't support srcSet and sizes will use src. use best resolution possible in that case
|
|
src={src}
|
|
srcSet={srcSet}
|
|
sizes={sizes}
|
|
width={width}
|
|
height={height}
|
|
onClick={onClick}
|
|
onError={onError}
|
|
style={style}
|
|
/>
|
|
)
|
|
})
|
|
|
|
export default function ZoomableImage ({ src, srcSet, ...props }) {
|
|
const showModal = useShowModal()
|
|
|
|
// if `srcSet` is falsy, it means the image was not processed by worker yet
|
|
const [trustedDomain, setTrustedDomain] = useState(!!srcSet || IMGPROXY_URL_REGEXP.test(src) || MEDIA_DOMAIN_REGEXP.test(src))
|
|
|
|
// backwards compatibility:
|
|
// src may already be imgproxy url since we used to replace image urls with imgproxy urls
|
|
const originalUrl = IMGPROXY_URL_REGEXP.test(src) ? decodeOriginalUrl(src) : src
|
|
|
|
const handleClick = useCallback((src) => showModal(close => {
|
|
return (
|
|
<div
|
|
className={styles.fullScreenContainer}
|
|
onClick={close}
|
|
>
|
|
<img className={styles.fullScreen} src={src} />
|
|
</div>
|
|
)
|
|
}, {
|
|
fullScreen: true,
|
|
overflow: (
|
|
<Dropdown.Item
|
|
href={originalUrl} target='_blank'
|
|
rel={props.rel ?? UNKNOWN_LINK_REL}
|
|
>
|
|
open original
|
|
</Dropdown.Item>)
|
|
}), [showModal, originalUrl, styles])
|
|
|
|
const handleError = useCallback(() => setTrustedDomain(false), [setTrustedDomain])
|
|
|
|
if (!src) return null
|
|
|
|
if (trustedDomain) {
|
|
return (
|
|
<TrustedImage
|
|
src={src} srcSet={srcSet}
|
|
onClick={handleClick} onError={handleError} {...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return <ImageOriginal src={originalUrl} onClick={handleClick} {...props} />
|
|
}
|
|
|
|
export const ImageUpload = forwardRef(({ children, className, onSelect, onUpload, onSuccess, onError, multiple, avatar }, ref) => {
|
|
const toaster = useToast()
|
|
ref ??= useRef(null)
|
|
|
|
const [getSignedPOST] = useMutation(
|
|
gql`
|
|
mutation getSignedPOST($type: String!, $size: Int!, $width: Int!, $height: Int!, $avatar: Boolean) {
|
|
getSignedPOST(type: $type, size: $size, width: $width, height: $height, avatar: $avatar) {
|
|
url
|
|
fields
|
|
}
|
|
}`)
|
|
|
|
const s3Upload = useCallback(async file => {
|
|
const img = new window.Image()
|
|
file = await removeExifData(file)
|
|
return new Promise((resolve, reject) => {
|
|
img.onload = async () => {
|
|
onUpload?.(file)
|
|
let data
|
|
const variables = {
|
|
avatar,
|
|
type: file.type,
|
|
size: file.size,
|
|
width: img.width,
|
|
height: img.height
|
|
}
|
|
try {
|
|
({ data } = await getSignedPOST({ variables }))
|
|
} catch (e) {
|
|
toaster.danger('error initiating upload: ' + e.message || e.toString?.())
|
|
onError?.({ ...variables, name: file.name, file })
|
|
reject(e)
|
|
return
|
|
}
|
|
|
|
const form = new FormData()
|
|
Object.keys(data.getSignedPOST.fields).forEach(key => form.append(key, data.getSignedPOST.fields[key]))
|
|
form.append('Content-Type', file.type)
|
|
form.append('Cache-Control', 'max-age=31536000')
|
|
form.append('acl', 'public-read')
|
|
form.append('file', file)
|
|
|
|
const res = await fetch(data.getSignedPOST.url, {
|
|
method: 'POST',
|
|
body: form
|
|
})
|
|
|
|
if (!res.ok) {
|
|
// TODO make sure this is actually a helpful error message and does not expose anything to the user we don't want
|
|
const err = res.statusText
|
|
toaster.danger('error uploading: ' + err)
|
|
onError?.({ ...variables, name: file.name, file })
|
|
reject(err)
|
|
return
|
|
}
|
|
|
|
const url = `${MEDIA_URL}/${data.getSignedPOST.fields.key}`
|
|
// key is upload id in database
|
|
const id = data.getSignedPOST.fields.key
|
|
onSuccess?.({ ...variables, id, name: file.name, url, file })
|
|
resolve(id)
|
|
}
|
|
img.onerror = reject
|
|
img.src = window.URL.createObjectURL(file)
|
|
})
|
|
}, [toaster, getSignedPOST])
|
|
|
|
return (
|
|
<>
|
|
<input
|
|
ref={ref}
|
|
type='file'
|
|
multiple={multiple}
|
|
className='d-none'
|
|
accept={UPLOAD_TYPES_ALLOW.join(', ')}
|
|
onChange={async (e) => {
|
|
const fileList = e.target.files
|
|
for (const file of Array.from(fileList)) {
|
|
if (UPLOAD_TYPES_ALLOW.indexOf(file.type) === -1) {
|
|
toaster.danger(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`)
|
|
continue
|
|
}
|
|
if (onSelect) await onSelect?.(file, s3Upload)
|
|
else await s3Upload(file)
|
|
// reset file input
|
|
// see https://bobbyhadz.com/blog/react-reset-file-input#reset-a-file-input-in-react
|
|
e.target.value = null
|
|
}
|
|
}}
|
|
/>
|
|
<div
|
|
className={className} onClick={() => ref.current?.click()} style={{ cursor: 'pointer' }} tabIndex={0} onKeyDown={(e) => {
|
|
if (e.key === 'Enter') { ref.current?.click() }
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
</>
|
|
)
|
|
})
|
|
|
|
// from https://stackoverflow.com/a/77472484
|
|
const removeExifData = async (file) => {
|
|
if (!file || !file.type.startsWith('image/')) return file
|
|
const cleanBuffer = (arrayBuffer) => {
|
|
let dataView = new DataView(arrayBuffer)
|
|
const exifMarker = 0xffe1
|
|
let offset = 2 // Skip the first two bytes (0xFFD8)
|
|
while (offset < dataView.byteLength) {
|
|
if (dataView.getUint16(offset) === exifMarker) {
|
|
// Found an EXIF marker
|
|
const segmentLength = dataView.getUint16(offset + 2, false) + 2
|
|
arrayBuffer = removeSegment(arrayBuffer, offset, segmentLength)
|
|
dataView = new DataView(arrayBuffer)
|
|
} else {
|
|
// Move to the next marker
|
|
offset += 2 + dataView.getUint16(offset + 2, false)
|
|
}
|
|
}
|
|
return arrayBuffer
|
|
}
|
|
const removeSegment = (buffer, offset, length) => {
|
|
// Create a new buffer without the specified segment
|
|
const modifiedBuffer = new Uint8Array(buffer.byteLength - length)
|
|
modifiedBuffer.set(new Uint8Array(buffer.slice(0, offset)), 0)
|
|
modifiedBuffer.set(new Uint8Array(buffer.slice(offset + length)), offset)
|
|
return modifiedBuffer.buffer
|
|
}
|
|
function getOrientation (file) {
|
|
const fr = new window.FileReader()
|
|
return new Promise((resolve, reject) => {
|
|
fr.onload = function () {
|
|
const view = new DataView(this.result)
|
|
if (view.getUint16(0, false) !== 0xFFD8) {
|
|
// not JPEG
|
|
return resolve(-2)
|
|
}
|
|
const length = view.byteLength; let offset = 2
|
|
while (offset < length) {
|
|
if (view.getUint16(offset + 2, false) <= 8) return resolve(-1) // no orientation available
|
|
const marker = view.getUint16(offset, false)
|
|
offset += 2
|
|
if (marker === 0xFFE1) {
|
|
if (view.getUint32(offset += 2, false) !== 0x45786966) {
|
|
// no orientation available
|
|
return resolve(-1)
|
|
}
|
|
const little = view.getUint16(offset += 6, false) === 0x4949
|
|
offset += view.getUint32(offset + 4, little)
|
|
const tags = view.getUint16(offset, little)
|
|
offset += 2
|
|
for (let i = 0; i < tags; i++) {
|
|
if (view.getUint16(offset + (i * 12), little) === 0x0112) {
|
|
// orientation available
|
|
return resolve(view.getUint16(offset + (i * 12) + 8, little))
|
|
}
|
|
}
|
|
} else if ((marker & 0xFF00) !== 0xFF00) {
|
|
break
|
|
} else {
|
|
offset += view.getUint16(offset, false)
|
|
}
|
|
}
|
|
// no orientation available
|
|
return resolve(-1)
|
|
}
|
|
fr.onerror = reject
|
|
fr.readAsArrayBuffer(file)
|
|
})
|
|
}
|
|
const orientation = await getOrientation(file)
|
|
const cleanFile = await new Promise((resolve, reject) => {
|
|
const fr = new window.FileReader()
|
|
fr.onload = function () {
|
|
const cleanedBuffer = cleanBuffer(this.result)
|
|
const blob = new Blob([cleanedBuffer], { type: file.type })
|
|
const newFile = new File([blob], file.name, { type: file.type })
|
|
resolve(newFile)
|
|
}
|
|
fr.onerror = reject
|
|
fr.readAsArrayBuffer(file)
|
|
})
|
|
if (orientation <= 0) {
|
|
// not orientation available (-1) or not JPEG (-2)
|
|
return cleanFile
|
|
}
|
|
// put orientation value back in
|
|
return new Promise((resolve, reject) => {
|
|
const fr = new window.FileReader()
|
|
fr.onload = function () {
|
|
const zeroth = {}
|
|
// Orientation is of type SHORT so single int is ok, see https://piexifjs.readthedocs.io/en/latest/appendices.html
|
|
zeroth[piexif.ImageIFD.Orientation] = orientation
|
|
const exifObj = { '0th': zeroth }
|
|
const exifStr = piexif.dump(exifObj)
|
|
const inserted = piexif.insert(exifStr, this.result)
|
|
const dataUriToBuffer = (dataUri) => {
|
|
// data-uri scheme regexp from https://github.com/ragingwind/data-uri-regex/blob/a9d7474c833e8fbf5b1821fe65d8cccd6aea4536/index.js
|
|
// data:[<media type>][;charset=<character set>][;base64],<data>
|
|
const regexp = /^(data:)([\w/+-]*)(;charset=[\w-]+|;base64){0,1},(.*)/gi
|
|
const b64 = regexp.exec(dataUri)[4]
|
|
const buf = Buffer.from(b64, 'base64')
|
|
return buf
|
|
}
|
|
const buf = dataUriToBuffer(inserted)
|
|
const blob = new Blob([buf], { type: file.type })
|
|
const newFile = new File([blob], file.name, { type: file.type })
|
|
resolve(newFile)
|
|
}
|
|
fr.onerror = reject
|
|
// piexifjs library needs data URI as input
|
|
fr.readAsDataURL(cleanFile)
|
|
})
|
|
}
|