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
|
NEXTAUTH_SECRET=3_0W_PhDRZVanbeJsZZGIEljexkKoGbL6qGIqSwTjjI
|
||||||
JWT_SIGNING_PRIVATE_KEY={"kty":"oct","kid":"FvD__hmeKoKHu2fKjUrWbRKfhjimIM4IKshyrJG4KSM","alg":"HS512","k":"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
|
# prisma db url
|
||||||
DATABASE_URL="postgresql://sn:password@db:5432/stackernews?schema=public"
|
DATABASE_URL="postgresql://sn:password@db:5432/stackernews?schema=public"
|
||||||
|
|
||||||
|
47
api/imgproxy/index.js
Normal file
47
api/imgproxy/index.js
Normal 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
|
||||||
|
}
|
@ -13,6 +13,7 @@ import { parse } from 'tldts'
|
|||||||
import uu from 'url-unshort'
|
import uu from 'url-unshort'
|
||||||
import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
|
import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
|
||||||
import { sendUserNotification } from '../webPush'
|
import { sendUserNotification } from '../webPush'
|
||||||
|
import { useImageProxy } from '../imgproxy'
|
||||||
|
|
||||||
async function comments (me, models, id, sort) {
|
async function comments (me, models, id, sort) {
|
||||||
let orderBy
|
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,
|
const [item] = await serialize(models,
|
||||||
models.$queryRaw(
|
models.$queryRaw(
|
||||||
`${SELECT} FROM update_item($1, $2, $3, $4, $5, $6, $7, $8) AS "Item"`,
|
`${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(
|
const [item] = await serialize(
|
||||||
models,
|
models,
|
||||||
models.$queryRaw(
|
models.$queryRaw(
|
||||||
|
@ -150,7 +150,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
|||||||
</div>
|
</div>
|
||||||
<div className={tab !== 'preview' ? 'd-none' : 'form-group'}>
|
<div className={tab !== 'preview' ? 'd-none' : 'form-group'}>
|
||||||
<div className={`${styles.text} form-control`}>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,7 +2,7 @@ import Item from './item'
|
|||||||
import ItemJob from './item-job'
|
import ItemJob from './item-job'
|
||||||
import Reply from './reply'
|
import Reply from './reply'
|
||||||
import Comment from './comment'
|
import Comment from './comment'
|
||||||
import Text from './text'
|
import Text, { ZoomableImage } from './text'
|
||||||
import Comments from './comments'
|
import Comments from './comments'
|
||||||
import styles from '../styles/item.module.css'
|
import styles from '../styles/item.module.css'
|
||||||
import { NOFOLLOW_LIMIT } from '../lib/constants'
|
import { NOFOLLOW_LIMIT } from '../lib/constants'
|
||||||
@ -21,6 +21,7 @@ import Share from './share'
|
|||||||
import Toc from './table-of-contents'
|
import Toc from './table-of-contents'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { RootProvider } from './root'
|
import { RootProvider } from './root'
|
||||||
|
import { IMGPROXY_URL_REGEXP } from '../lib/url'
|
||||||
|
|
||||||
function BioItem ({ item, handleClick }) {
|
function BioItem ({ item, handleClick }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
@ -92,6 +93,10 @@ function ItemEmbed ({ item }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.url?.match(IMGPROXY_URL_REGEXP)) {
|
||||||
|
return <ZoomableImage src={item.url} />
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,12 +8,14 @@ import sub from '../lib/remark-sub'
|
|||||||
import remarkDirective from 'remark-directive'
|
import remarkDirective from 'remark-directive'
|
||||||
import { visit } from 'unist-util-visit'
|
import { visit } from 'unist-util-visit'
|
||||||
import reactStringReplace from 'react-string-replace'
|
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 GithubSlugger from 'github-slugger'
|
||||||
import LinkIcon from '../svgs/link.svg'
|
import LinkIcon from '../svgs/link.svg'
|
||||||
import Thumb from '../svgs/thumb-up-fill.svg'
|
import Thumb from '../svgs/thumb-up-fill.svg'
|
||||||
import { toString } from 'mdast-util-to-string'
|
import { toString } from 'mdast-util-to-string'
|
||||||
import copy from 'clipboard-copy'
|
import copy from 'clipboard-copy'
|
||||||
|
import { IMGPROXY_URL_REGEXP, IMG_URL_REGEXP } from '../lib/url'
|
||||||
|
import { extractUrls } from '../lib/md'
|
||||||
|
|
||||||
function myRemarkPlugin () {
|
function myRemarkPlugin () {
|
||||||
return (tree) => {
|
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
|
// all the reactStringReplace calls are to facilitate search highlighting
|
||||||
const slugger = new GithubSlugger()
|
const slugger = new GithubSlugger()
|
||||||
|
onlyImgProxy = onlyImgProxy ?? true
|
||||||
|
|
||||||
const HeadingWrapper = (props) => Heading({ topLevel, slugger, noFragments, ...props })
|
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 (
|
return (
|
||||||
<div className={styles.text}>
|
<div className={styles.text}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
@ -103,6 +147,10 @@ export default function Text ({ topLevel, noFragments, nofollow, children }) {
|
|||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (urlCache[href] === CACHE_STATES.IS_LOADED) {
|
||||||
|
return <ZoomableImage topLevel={topLevel} {...props} src={href} />
|
||||||
|
}
|
||||||
|
|
||||||
// map: fix any highlighted links
|
// map: fix any highlighted links
|
||||||
children = children?.map(e =>
|
children = children?.map(e =>
|
||||||
typeof e === 'string'
|
typeof e === 'string'
|
||||||
|
@ -50,6 +50,22 @@ services:
|
|||||||
entrypoint: ["/bin/sh", "-c"]
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
command:
|
command:
|
||||||
- node worker/index.js
|
- 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:
|
volumes:
|
||||||
db:
|
db:
|
17
lib/md.js
17
lib/md.js
@ -17,3 +17,20 @@ export function mdHas (md, test) {
|
|||||||
|
|
||||||
return found
|
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
|
// 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 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…
x
Reference in New Issue
Block a user