Render images without markdown and use image proxy (#245)

* Parse image links during markdown rendering

* Use imgproxy to replace links

* Add healthcheck

See https://docs.imgproxy.net/healthcheck

* Enable WebP and animation support

* Only replace image URLs

* Replace all occurrences

* Fix creating posts with no text

* Embed image on link posts where link is image

---------

Co-authored-by: ekzyis <ek@stacker.news>
This commit is contained in:
Jo Wo 2023-07-13 02:10:01 +02:00 committed by GitHub
parent bc9081eaab
commit bf4b8714fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 156 additions and 5 deletions

View File

@ -48,6 +48,13 @@ LND_CONNECT_ADDRESS=03cc1d0932bb99b0697f5b5e5961b83ab7fd66f1efc4c9f5c7bad66c1bcb
NEXTAUTH_SECRET=3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI
JWT_SIGNING_PRIVATE_KEY={"kty":"oct","kid":"FvD__hmeKoKHu2fKjUrWbRKfhjimIM4IKshyrJG4KSM","alg":"HS512","k":"3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI"}
# imgproxy
NEXT_PUBLIC_IMGPROXY_URL=
IMGPROXY_KEY=
IMGPROXY_SALT=
IMGPROXY_ENABLE_WEBP_DETECTION=1
IMGPROXY_MAX_ANIMATION_FRAMES=100
# prisma db url
DATABASE_URL="postgresql://sn:password@db:5432/stackernews?schema=public"

47
api/imgproxy/index.js Normal file
View File

@ -0,0 +1,47 @@
import { createHmac } from 'node:crypto'
import { extractUrls } from '../../lib/md'
const IMGPROXY_URL = process.env.NEXT_PUBLIC_IMGPROXY_URL
const IMGPROXY_SALT = process.env.IMGPROXY_SALT
const IMGPROXY_KEY = process.env.IMGPROXY_KEY
const hexDecode = (hex) => Buffer.from(hex, 'hex')
const sign = (target) => {
// https://github.com/imgproxy/imgproxy/blob/master/examples/signature.js
const hmac = createHmac('sha256', hexDecode(IMGPROXY_KEY))
hmac.update(hexDecode(IMGPROXY_SALT))
hmac.update(target)
return hmac.digest('base64url')
}
const createImageProxyUrl = url => {
const processingOptions = '/rs:fit:600:500:0/g:no'
const b64Url = Buffer.from(url, 'utf-8').toString('base64url')
const target = `${processingOptions}/${b64Url}`
const signature = sign(target)
return `${IMGPROXY_URL}/${signature}${target}`
}
const isImageURL = async url => {
// https://stackoverflow.com/a/68118683
try {
const res = await fetch(url, { method: 'HEAD' })
const buf = await res.blob()
return buf.type.startsWith('image/')
} catch (err) {
console.log(err)
return false
}
}
export const useImageProxy = async text => {
const urls = extractUrls(text)
for (const url of urls) {
if (url.startsWith(IMGPROXY_URL)) continue
if (!(await isImageURL(url))) continue
const proxyUrl = createImageProxyUrl(url)
text = text.replace(new RegExp(url, 'g'), proxyUrl)
}
return text
}

View File

@ -13,6 +13,7 @@ import { parse } from 'tldts'
import uu from 'url-unshort'
import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
import { sendUserNotification } from '../webPush'
import { useImageProxy } from '../imgproxy'
async function comments (me, models, id, sort) {
let orderBy
@ -1250,6 +1251,9 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo
}
}
url = await useImageProxy(url)
text = await useImageProxy(text)
const [item] = await serialize(models,
models.$queryRaw(
`${SELECT} FROM update_item($1, $2, $3, $4, $5, $6, $7, $8) AS "Item"`,
@ -1282,6 +1286,9 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount
}
}
url = await useImageProxy(url)
text = await useImageProxy(text)
const [item] = await serialize(
models,
models.$queryRaw(

View File

@ -150,7 +150,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
</div>
<div className={tab !== 'preview' ? 'd-none' : 'form-group'}>
<div className={`${styles.text} form-control`}>
{tab === 'preview' && <Text topLevel={topLevel} noFragments>{meta.value}</Text>}
{tab === 'preview' && <Text topLevel={topLevel} noFragments onlyImgProxy={false}>{meta.value}</Text>}
</div>
</div>
</div>

View File

@ -2,7 +2,7 @@ import Item from './item'
import ItemJob from './item-job'
import Reply from './reply'
import Comment from './comment'
import Text from './text'
import Text, { ZoomableImage } from './text'
import Comments from './comments'
import styles from '../styles/item.module.css'
import { NOFOLLOW_LIMIT } from '../lib/constants'
@ -21,6 +21,7 @@ import Share from './share'
import Toc from './table-of-contents'
import Link from 'next/link'
import { RootProvider } from './root'
import { IMGPROXY_URL_REGEXP } from '../lib/url'
function BioItem ({ item, handleClick }) {
const me = useMe()
@ -92,6 +93,10 @@ function ItemEmbed ({ item }) {
)
}
if (item.url?.match(IMGPROXY_URL_REGEXP)) {
return <ZoomableImage src={item.url} />
}
return null
}

View File

@ -8,12 +8,14 @@ import sub from '../lib/remark-sub'
import remarkDirective from 'remark-directive'
import { visit } from 'unist-util-visit'
import reactStringReplace from 'react-string-replace'
import React, { useEffect, useState } from 'react'
import React, { useRef, useEffect, useState } from 'react'
import GithubSlugger from 'github-slugger'
import LinkIcon from '../svgs/link.svg'
import Thumb from '../svgs/thumb-up-fill.svg'
import { toString } from 'mdast-util-to-string'
import copy from 'clipboard-copy'
import { IMGPROXY_URL_REGEXP, IMG_URL_REGEXP } from '../lib/url'
import { extractUrls } from '../lib/md'
function myRemarkPlugin () {
return (tree) => {
@ -60,12 +62,54 @@ function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props
)
}
export default function Text ({ topLevel, noFragments, nofollow, children }) {
const CACHE_STATES = {
IS_LOADING: 'IS_LOADING',
IS_LOADED: 'IS_LOADED',
IS_ERROR: 'IS_ERROR'
}
export default function Text ({ topLevel, noFragments, nofollow, onlyImgProxy, children }) {
// all the reactStringReplace calls are to facilitate search highlighting
const slugger = new GithubSlugger()
onlyImgProxy = onlyImgProxy ?? true
const HeadingWrapper = (props) => Heading({ topLevel, slugger, noFragments, ...props })
const imgCache = useRef({})
const [urlCache, setUrlCache] = useState({})
useEffect(() => {
const imgRegexp = onlyImgProxy ? IMGPROXY_URL_REGEXP : IMG_URL_REGEXP
const urls = extractUrls(children)
urls.forEach((url) => {
if (imgRegexp.test(url)) {
setUrlCache((prev) => ({ ...prev, [url]: CACHE_STATES.IS_LOADED }))
} else if (!onlyImgProxy) {
const img = new Image()
imgCache.current[url] = img
setUrlCache((prev) => ({ ...prev, [url]: CACHE_STATES.IS_LOADING }))
const callback = (state) => {
setUrlCache((prev) => ({ ...prev, [url]: state }))
delete imgCache.current[url]
}
img.onload = () => callback(CACHE_STATES.IS_LOADED)
img.onerror = () => callback(CACHE_STATES.IS_ERROR)
img.src = url
}
})
return () => {
Object.values(imgCache.current).forEach((img) => {
img.onload = null
img.onerror = null
img.src = ''
})
}
}, [children])
return (
<div className={styles.text}>
<ReactMarkdown
@ -103,6 +147,10 @@ export default function Text ({ topLevel, noFragments, nofollow, children }) {
return <>{children}</>
}
if (urlCache[href] === CACHE_STATES.IS_LOADED) {
return <ZoomableImage topLevel={topLevel} {...props} src={href} />
}
// map: fix any highlighted links
children = children?.map(e =>
typeof e === 'string'

View File

@ -50,6 +50,22 @@ services:
entrypoint: ["/bin/sh", "-c"]
command:
- node worker/index.js
imgproxy:
container_name: imgproxy
image: darthsim/imgproxy:v3.18.1
healthcheck:
test: [ "CMD", "imgproxy", "health" ]
timeout: 10s
interval: 10s
retries: 3
restart: always
env_file:
- ./.env.sample
expose:
- "8080"
ports:
- "3001:8080"
links:
- app
volumes:
db:

View File

@ -17,3 +17,20 @@ export function mdHas (md, test) {
return found
}
export function extractUrls (md) {
if (!md) return []
const tree = fromMarkdown(md, {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()]
})
const urls = new Set()
visit(tree, ({ type }) => {
return type === 'link'
}, ({ url }) => {
urls.add(url)
})
return Array.from(urls)
}

View File

@ -26,3 +26,7 @@ export const URL_REGEXP = /^((https?|ftp):\/\/)?(www.)?(((([a-z]|\d|-|\.|_|~|[\u
// eslint-disable-next-line
export const WS_REGEXP = /^(wss?:\/\/)([0-9]{1,3}(?:\.[0-9]{1,3}){3}|(?=[^\/]{1,254}(?![^\/]))(?:(?=[a-zA-Z0-9-]{1,63}\.)(?:xn--+)?[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,63})(:([0-9]{1,5}))?$/
export const IMGPROXY_URL_REGEXP = new RegExp(`^${process.env.NEXT_PUBLIC_IMGPROXY_URL}.*$`)
// this regex is not a bullet proof way of checking if a url points to an image. to be sure, fetch the url and check the mimetype
export const IMG_URL_REGEXP = /^(https?:\/\/.*\.(?:png|jpg|jpeg|gif))$/