import styles from './text.module.css' import { Fragment, useState, useEffect, useMemo, useCallback, forwardRef, useRef } from 'react' import { IMGPROXY_URL_REGEXP } from '../lib/url' import { useShowModal } from './modal' import { useMe } from './me' import { Dropdown } from 'react-bootstrap' import { UNKNOWN_LINK_REL, UPLOAD_TYPES_ALLOW } 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 const img = new window.Image() img.onload = () => setShowImage(true) img.src = src return () => { img.onload = null img.src = '' } }, [src, showImage]) if (showImage) { return ( 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 {isRawURL ? src : children} ) } } function ImageProxy ({ src, srcSet: srcSetObj, onClick, topLevel, onError, ...props }) { const srcSet = useMemo(() => { if (!srcSetObj) return undefined // srcSetObj shape: { [widthDescriptor]: , ... } return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url], i, arr) => { 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 (!srcSetObj) return src return Object.entries(srcSetObj).reduce((acc, [wDescriptor, url]) => { const w = Number(wDescriptor.replace(/w$/, '')) return w > acc.w ? { w, url } : acc }, { w: 0, url: undefined }).url }, [srcSetObj]) return ( onClick(bestResSrc)} onError={onError} /> ) } 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 [imgproxy, setImgproxy] = useState(!!srcSet || IMGPROXY_URL_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 (
) }, { fullScreen: true, overflow: ( open original ) }), [showModal, originalUrl, styles]) if (!src) return null if (imgproxy) { return ( setImgproxy(false)} {...props} /> ) } return } 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 = `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${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 ( <> { 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 } }} />
ref.current?.click()} style={{ cursor: 'pointer' }} tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter') { ref.current?.click() } }} > {children}
) }) // 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:[][;charset=][;base64], 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) }) }