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:
parent
bc9081eaab
commit
bf4b8714fe
|
@ -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"
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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:
|
17
lib/md.js
17
lib/md.js
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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))$/
|
||||
|
|
Loading…
Reference in New Issue