From b2b38d89249bcc1209b08de3d1d6dc6e1e030f80 Mon Sep 17 00:00:00 2001 From: ekzyis <27162016+ekzyis@users.noreply.github.com> Date: Mon, 2 Oct 2023 01:03:52 +0200 Subject: [PATCH] Images v2 (#513) --- .env.sample | 6 +- api/resolvers/imgproxy/index.js | 69 ------ api/resolvers/item.js | 11 +- api/typeDefs/item.js | 1 + components/comment.js | 2 +- components/form.js | 2 +- components/image.js | 221 ++++++++++++++++++ components/item-full.js | 2 +- components/modal.js | 31 ++- components/text.js | 150 ++---------- fragments/comments.js | 1 + fragments/items.js | 1 + .../migration.sql | 175 ++++++++++++++ prisma/schema.prisma | 1 + public/placeholder_click_to_load.png | Bin 0 -> 9254 bytes public/placeholder_processing.png | Bin 0 -> 6400 bytes scripts/imgproxy.js | 71 ++++++ styles/globals.scss | 29 +++ worker/imgproxy.js | 194 +++++++++++++++ worker/index.js | 2 + 20 files changed, 747 insertions(+), 222 deletions(-) delete mode 100644 api/resolvers/imgproxy/index.js create mode 100644 components/image.js create mode 100644 prisma/migrations/20230920192620_item_imgproxy_urls/migration.sql create mode 100644 public/placeholder_click_to_load.png create mode 100644 public/placeholder_processing.png create mode 100644 scripts/imgproxy.js create mode 100644 worker/imgproxy.js diff --git a/.env.sample b/.env.sample index c5ea5bcf..f9aa0d4b 100644 --- a/.env.sample +++ b/.env.sample @@ -71,8 +71,10 @@ OPENSEARCH_PASSWORD= # imgproxy options IMGPROXY_ENABLE_WEBP_DETECTION=1 -IMGPROXY_MAX_ANIMATION_FRAMES=100 -IMGPROXY_MAX_SRC_RESOLUTION=200 +IMGPROXY_ENABLE_AVIF_DETECTION=1 +IMGPROXY_MAX_ANIMATION_FRAMES=2000 +IMGPROXY_MAX_SRC_RESOLUTION=50 +IMGPROXY_MAX_ANIMATION_FRAME_RESOLUTION=200 IMGPROXY_READ_TIMEOUT=10 IMGPROXY_WRITE_TIMEOUT=10 IMGPROXY_DOWNLOAD_TIMEOUT=9 diff --git a/api/resolvers/imgproxy/index.js b/api/resolvers/imgproxy/index.js deleted file mode 100644 index 39d7a0b6..00000000 --- a/api/resolvers/imgproxy/index.js +++ /dev/null @@ -1,69 +0,0 @@ -import { createHmac } from 'node:crypto' -import { extractUrls } from '../../../lib/md' - -const imgProxyEnabled = process.env.NODE_ENV === 'production' || - (process.env.NEXT_PUBLIC_IMGPROXY_URL && process.env.IMGPROXY_SALT && process.env.IMGPROXY_KEY) - -if (!imgProxyEnabled) { - console.warn('IMGPROXY_* env vars not set, imgproxy calls are no-ops now') -} - -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}` -} - -async function fetchWithTimeout (resource, { timeout = 1000, ...options } = {}) { - const controller = new AbortController() - const id = setTimeout(() => controller.abort(), timeout) - - const response = await fetch(resource, { - ...options, - signal: controller.signal - }) - clearTimeout(id) - - return response -} - -const isImageURL = async url => { - // https://stackoverflow.com/a/68118683 - try { - const res = await fetchWithTimeout(url, { method: 'HEAD' }) - const buf = await res.blob() - return buf.type.startsWith('image/') - } catch (err) { - console.log(url, err) - return false - } -} - -export const proxyImages = async text => { - if (!imgProxyEnabled) return 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.replaceAll(url, proxyUrl) - } - return text -} diff --git a/api/resolvers/item.js b/api/resolvers/item.js index e895fc0a..eb3460bf 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -14,7 +14,6 @@ import { parse } from 'tldts' import uu from 'url-unshort' import { advSchema, amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate' import { sendUserNotification } from '../webPush' -import { proxyImages } from './imgproxy' import { defaultCommentSort } from '../../lib/item' import { notifyItemParents, notifyUserSubscribers, notifyZapped } from '../../lib/push-notifications' @@ -1019,13 +1018,9 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } }) } - if (item.text) { - item.text = await proxyImages(item.text) - } if (item.url && typeof item.maxBid === 'undefined') { item.url = ensureProtocol(item.url) item.url = removeTracking(item.url) - item.url = await proxyImages(item.url) } // only update item with the boost delta ... this is a bit of hack given the way // boost used to work @@ -1063,13 +1058,9 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo item.userId = me ? Number(me.id) : ANON_USER_ID const fwdUsers = await getForwardUsers(models, forward) - if (item.text) { - item.text = await proxyImages(item.text) - } if (item.url && typeof item.maxBid === 'undefined') { item.url = ensureProtocol(item.url) item.url = removeTracking(item.url) - item.url = await proxyImages(item.url) } const enforceFee = me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE + (item.boost || 0))) @@ -1113,7 +1104,7 @@ export const SELECT = "Item"."subName", "Item".status, "Item"."uploadId", "Item"."pollCost", "Item".boost, "Item".msats, "Item".ncomments, "Item"."commentMsats", "Item"."lastCommentAt", "Item"."weightedVotes", "Item"."weightedDownVotes", "Item".freebie, "Item"."otsHash", "Item"."bountyPaidTo", - ltree2text("Item"."path") AS "path", "Item"."weightedComments"` + ltree2text("Item"."path") AS "path", "Item"."weightedComments", "Item"."imgproxyUrls"` async function topOrderByWeightedSats (me, models) { return `ORDER BY ${await orderByNumerator(me, models)} DESC NULLS LAST, "Item".id DESC` diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 6a6fc51c..a28099dd 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -114,6 +114,7 @@ export default gql` otsHash: String parentOtsHash: String forwards: [ItemForward] + imgproxyUrls: JSONObject } input ItemForwardInput { diff --git a/components/comment.js b/components/comment.js index d92eab6c..563e11c1 100644 --- a/components/comment.js +++ b/components/comment.js @@ -208,7 +208,7 @@ export default function Comment ({ ) : (
- + {truncate ? truncateString(item.text) : item.searchText || item.text}
diff --git a/components/form.js b/components/form.js index c94bbf0c..77dd3dad 100644 --- a/components/form.js +++ b/components/form.js @@ -167,7 +167,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH : (
- {meta.value} + {meta.value}
)} diff --git a/components/image.js b/components/image.js new file mode 100644 index 00000000..52c87e72 --- /dev/null +++ b/components/image.js @@ -0,0 +1,221 @@ +import styles from './text.module.css' +import { useState, useRef, useEffect, useMemo, useCallback } from 'react' +import { extractUrls } from '../lib/md' +import { IMGPROXY_URL_REGEXP, IMG_URL_REGEXP } from '../lib/url' +import FileMissing from '../svgs/file-warning-line.svg' +import { useShowModal } from './modal' +import { useMe } from './me' +import { Dropdown } from 'react-bootstrap' + +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 +} + +export const IMG_CACHE_STATES = { + LOADING: 'IS_LOADING', + LOADED: 'IS_LOADED', + ERROR: 'IS_ERROR' +} + +// this is the image at public/placeholder_click_to_load.png as a data URI so we don't have to rely on network to render it +const IMAGE_CLICK_TO_LOAD_DATA_URI = 'data:image/png;base64,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' + +const IMAGE_PROCESSING_DATA_URI = 'data:image/png;base64,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' + +export function useImgUrlCache (text, imgproxyUrls) { + const ref = useRef({}) + const [imgUrlCache, setImgUrlCache] = useState({}) + const me = useMe() + + const updateCache = (url, state) => setImgUrlCache((prev) => ({ ...prev, [url]: state })) + + useEffect(() => { + const urls = extractUrls(text) + + urls.forEach((url) => { + if (IMG_URL_REGEXP.test(url) || !!imgproxyUrls?.[url]) { + // it's probably an image if the regexp matches or if we processed the URL as an image in the worker + updateCache(url, IMG_CACHE_STATES.LOADED) + } else { + // don't use image detection by trying to load as an image if user opted-out of loading external images automatically + if (me?.clickToLoadImg) return + // make sure it's not a false negative by trying to load URL as + const img = new window.Image() + ref.current[url] = img + + updateCache(url, IMG_CACHE_STATES.LOADING) + + const callback = (state) => { + updateCache(url, state) + delete ref.current[url] + } + img.onload = () => callback(IMG_CACHE_STATES.LOADED) + img.onerror = () => callback(IMG_CACHE_STATES.ERROR) + img.src = url + } + }) + + return () => { + Object.values(ref.current).forEach((img) => { + img.onload = null + img.onerror = null + img.src = '' + }) + } + }, [text]) + + return imgUrlCache +} + +export function ZoomableImage ({ src, topLevel, srcSet: srcSetObj, ...props }) { + const me = useMe() + const showModal = useShowModal() + const [originalUrlConsent, setOriginalUrlConsent] = useState(!me ? true : !me.clickToLoadImg) + // if there is no srcset obj, image is still processing (srcSetObj === undefined) or it wasn't detected as an image by the worker (srcSetObj === null). + // we handle both cases the same as imgproxy errors. + const [imgproxyErr, setImgproxyErr] = useState(!srcSetObj) + const [originalErr, setOriginalErr] = useState() + + // 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 + + // we will fallback to the original error if there was an error with our image proxy + const loadOriginalUrl = !!imgproxyErr + + 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 = `${(topLevel ? 100 : 66)}vw` + + // get source url in best resolution + const bestResSrc = useMemo(() => { + if (!srcSetObj) return undefined + 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]) + + const onError = useCallback((err) => { + if (!imgproxyErr) { + // first error is imgproxy error since that was loaded + console.error('imgproxy image error:', err) + setImgproxyErr(true) + } else { + // second error is error from original url + console.error('original image error:', err) + setOriginalErr(true) + } + }, [setImgproxyErr, setOriginalErr, imgproxyErr, originalUrl]) + + const handleClick = useCallback(() => showModal(close => ( +
+ +
+ ), { + fullScreen: true, + overflow: ( + + {loadOriginalUrl ? 'open in new tab' : 'open original'} + ) + }), [showModal, loadOriginalUrl, originalUrl, bestResSrc, onError, props]) + + if (!src) return null + + if ((srcSetObj === undefined) && originalUrlConsent && !originalErr) { + // image is still processing and user is okay with loading original url automatically + return ( + setOriginalErr(true)} + {...props} + /> + ) + } + + if ((srcSetObj === undefined) && !originalUrlConsent && !originalErr) { + // image is still processing and user is not okay with loading original url automatically + const { host } = new URL(originalUrl) + return ( +
+ setOriginalUrlConsent(true)} + /> +
click to load original from
+
{host}
+
+ ) + } + + if (originalErr) { + // we already tried original URL: degrade to tag + return ( + <> + + + failed to load image + + {originalUrl} + + ) + } + + if (imgproxyErr && !originalUrlConsent) { + // respect privacy setting that external images should not be loaded automatically + const { host } = new URL(originalUrl) + return ( +
+
+ + image proxy error +
+ setOriginalUrlConsent(true)} + /> +
from {host}
+
+ ) + } + + return ( + + ) +} diff --git a/components/item-full.js b/components/item-full.js index d93493a3..2379735e 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -165,7 +165,7 @@ function TopLevelItem ({ item, noReply, ...props }) { } function ItemText ({ item }) { - return {item.searchText || item.text} + return {item.searchText || item.text} } export default function ItemFull ({ item, bio, rank, ...props }) { diff --git a/components/modal.js b/components/modal.js index 4569f4a3..7ebe0613 100644 --- a/components/modal.js +++ b/components/modal.js @@ -1,6 +1,8 @@ -import { createContext, useCallback, useContext, useMemo, useState } from 'react' +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import Modal from 'react-bootstrap/Modal' import BackArrow from '../svgs/arrow-left-line.svg' +import { useRouter } from 'next/router' +import ActionDropdown from './action-dropdown' export const ShowModalContext = createContext(() => null) @@ -37,19 +39,38 @@ export default function useModal () { const onClose = useCallback(() => { setModalContent(null) setModalStack([]) - }, []) + modalOptions?.onClose?.() + }, [modalOptions?.onClose]) + + const router = useRouter() + useEffect(() => { + router.events.on('routeChangeStart', onClose) + return () => router.events.off('routeChangeStart', onClose) + }, [router, onClose]) const modal = useMemo(() => { if (modalContent === null) { return null } + const className = modalOptions?.fullScreen ? 'fullscreen' : '' return ( - +
+ {modalOptions?.overflow && +
+ + {modalOptions.overflow} + +
} {modalStack.length > 0 ?
: null} -
X
+
X
- + {modalContent}
diff --git a/components/text.js b/components/text.js index 38ddbeb6..88c19b3b 100644 --- a/components/text.js +++ b/components/text.js @@ -8,16 +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, { useRef, useEffect, useState, memo } from 'react' +import React, { useState, memo } 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' -import FileMissing from '../svgs/file-warning-line.svg' -import { useMe } from './me' +import { useImgUrlCache, IMG_CACHE_STATES, ZoomableImage, decodeOriginalUrl } from './image' +import { IMGPROXY_URL_REGEXP } from '../lib/url' function searchHighlighter () { return (tree) => { @@ -36,15 +34,6 @@ function searchHighlighter () { } } -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 Heading ({ h, slugger, noFragments, topLevel, children, node, ...props }) { const [copied, setCopied] = useState(false) const [id] = useState(noFragments ? undefined : slugger.slug(toString(node).replace(/[^\w\-\s]+/gi, ''))) @@ -73,54 +62,14 @@ function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props ) } -const CACHE_STATES = { - IS_LOADING: 'IS_LOADING', - IS_LOADED: 'IS_LOADED', - IS_ERROR: 'IS_ERROR' -} - // this is one of the slowest components to render -export default memo(function Text ({ topLevel, noFragments, nofollow, fetchOnlyImgProxy, children }) { +export default memo(function Text ({ topLevel, noFragments, nofollow, imgproxyUrls, children }) { // all the reactStringReplace calls are to facilitate search highlighting const slugger = new GithubSlugger() - fetchOnlyImgProxy ??= true const HeadingWrapper = (props) => Heading({ topLevel, slugger, noFragments, ...props }) - const imgCache = useRef({}) - const [urlCache, setUrlCache] = useState({}) - - useEffect(() => { - const imgRegexp = fetchOnlyImgProxy ? 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 (!fetchOnlyImgProxy) { - const img = new window.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]) + const imgUrlCache = useImgUrlCache(children, imgproxyUrls) return (
@@ -159,8 +108,12 @@ export default memo(function Text ({ topLevel, noFragments, nofollow, fetchOnlyI return <>{children} } - if (urlCache[href] === CACHE_STATES.IS_LOADED) { - return + if (imgUrlCache[href] === IMG_CACHE_STATES.LOADED) { + const url = IMGPROXY_URL_REGEXP.test(href) ? decodeOriginalUrl(href) : href + // if `srcSet` is undefined, it means the image was not processed by worker yet + // if `srcSet` is null, image was processed but this specific url was not detected as an image by the worker + const srcSet = imgproxyUrls ? (imgproxyUrls[url] || null) : undefined + return } // map: fix any highlighted links @@ -183,8 +136,12 @@ export default memo(function Text ({ topLevel, noFragments, nofollow, fetchOnlyI ) }, - img: ({ node, ...props }) => { - return + img: ({ node, src, ...props }) => { + const url = IMGPROXY_URL_REGEXP.test(src) ? decodeOriginalUrl(src) : src + // if `srcSet` is undefined, it means the image was not processed by worker yet + // if `srcSet` is null, image was processed but this specific url was not detected as an image by the worker + const srcSet = imgproxyUrls ? (imgproxyUrls[url] || null) : undefined + return } }} remarkPlugins={[gfm, mention, sub, remarkDirective, searchHighlighter]} @@ -194,76 +151,3 @@ export default memo(function Text ({ topLevel, noFragments, nofollow, fetchOnlyI
) }) - -function ClickToLoad ({ children }) { - const [clicked, setClicked] = useState(false) - return clicked ? children :
setClicked(true)}>click to load image
-} - -export function ZoomableImage ({ src, topLevel, useClickToLoad, ...props }) { - const me = useMe() - const [err, setErr] = useState() - const [imgSrc, setImgSrc] = useState(src) - const [isImgProxy, setIsImgProxy] = useState(IMGPROXY_URL_REGEXP.test(src)) - const defaultMediaStyle = { - maxHeight: topLevel ? '75vh' : '25vh', - cursor: 'zoom-in' - } - useClickToLoad ??= true - - // if image changes we need to update state - const [mediaStyle, setMediaStyle] = useState(defaultMediaStyle) - useEffect(() => { - setMediaStyle(defaultMediaStyle) - setErr(null) - }, [src]) - - if (!src) return null - if (err) { - if (!isImgProxy) { - return ( - - - image error - - ) - } - try { - const originalUrl = decodeOriginalUrl(src) - setImgSrc(originalUrl) - setErr(null) - } catch (err) { - console.error(err) - setErr(err) - } - // always set to false since imgproxy returned error - setIsImgProxy(false) - } - - const img = ( - { - if (mediaStyle.cursor === 'zoom-in') { - setMediaStyle({ - width: '100%', - cursor: 'zoom-out' - }) - } else { - setMediaStyle(defaultMediaStyle) - } - }} - onError={() => setErr(true)} - {...props} - /> - ) - - return ( - (!me || !me.clickToLoadImg || isImgProxy || !useClickToLoad) - ? img - : {img} - - ) -} diff --git a/fragments/comments.js b/fragments/comments.js index b33fe9c6..8e00f89e 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -30,6 +30,7 @@ export const COMMENT_FIELDS = gql` mine otsHash ncomments + imgproxyUrls } ` diff --git a/fragments/items.js b/fragments/items.js index 5345ca10..bb5d4f02 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -45,6 +45,7 @@ export const ITEM_FIELDS = gql` status uploadId mine + imgproxyUrls }` export const ITEM_FULL_FIELDS = gql` diff --git a/prisma/migrations/20230920192620_item_imgproxy_urls/migration.sql b/prisma/migrations/20230920192620_item_imgproxy_urls/migration.sql new file mode 100644 index 00000000..38ca2f36 --- /dev/null +++ b/prisma/migrations/20230920192620_item_imgproxy_urls/migration.sql @@ -0,0 +1,175 @@ +-- AlterTable +ALTER TABLE "Item" ADD COLUMN "imgproxyUrls" JSONB; + +-- schedule imgproxy job +CREATE OR REPLACE FUNCTION create_item( + jitem JSONB, forward JSONB, poll_options JSONB, spam_within INTERVAL) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats BIGINT; + cost_msats BIGINT; + freebie BOOLEAN; + item "Item"; + med_votes FLOAT; + select_clause TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + -- access fields with appropriate types + item := jsonb_populate_record(NULL::"Item", jitem); + + SELECT msats INTO user_msats FROM users WHERE id = item."userId"; + + IF item."maxBid" IS NOT NULL THEN + cost_msats := 1000000; + ELSE + cost_msats := 1000 * POWER(10, item_spam(item."parentId", item."userId", spam_within)); + END IF; + -- it's only a freebie if it's a 1 sat cost, they have < 1 sat, and boost = 0 + freebie := (cost_msats <= 1000) AND (user_msats < 1000) AND (item.boost = 0); + + IF NOT freebie AND cost_msats > user_msats THEN + RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS'; + END IF; + + -- get this user's median item score + SELECT COALESCE( + percentile_cont(0.5) WITHIN GROUP( + ORDER BY "weightedVotes" - "weightedDownVotes"), 0) + INTO med_votes FROM "Item" WHERE "userId" = item."userId"; + + -- if their median votes are positive, start at 0 + -- if the median votes are negative, start their post with that many down votes + -- basically: if their median post is bad, presume this post is too + -- addendum: if they're an anon poster, always start at 0 + IF med_votes >= 0 OR item."userId" = 27 THEN + med_votes := 0; + ELSE + med_votes := ABS(med_votes); + END IF; + + -- there's no great way to set default column values when using json_populate_record + -- so we need to only select fields with non-null values that way when func input + -- does not include a value, the default value is used instead of null + SELECT string_agg(quote_ident(key), ',') INTO select_clause + FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key); + -- insert the item + EXECUTE format($fmt$ + INSERT INTO "Item" (%s, "weightedDownVotes") + SELECT %1$s, %L + FROM jsonb_populate_record(NULL::"Item", %L) RETURNING * + $fmt$, select_clause, med_votes, jitem) INTO item; + + INSERT INTO "ItemForward" ("itemId", "userId", "pct") + SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward); + + -- Automatically subscribe forward recipients to the new post + INSERT INTO "ThreadSubscription" ("itemId", "userId") + SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward); + + INSERT INTO "PollOption" ("itemId", "option") + SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option"); + + IF NOT freebie THEN + UPDATE users SET msats = msats - cost_msats WHERE id = item."userId"; + + INSERT INTO "ItemAct" (msats, "itemId", "userId", act) + VALUES (cost_msats, item.id, item."userId", 'FEE'); + END IF; + + -- if this item has boost + IF item.boost > 0 THEN + PERFORM item_act(item.id, item."userId", 'BOOST', item.boost); + END IF; + + -- if this is a job + IF item."maxBid" IS NOT NULL THEN + PERFORM run_auction(item.id); + END IF; + + -- if this is a bio + IF item.bio THEN + UPDATE users SET "bioId" = item.id WHERE id = item."userId"; + END IF; + + -- schedule imgproxy job + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds'); + + RETURN item; +END; +$$; + +-- schedule imgproxy job +CREATE OR REPLACE FUNCTION update_item( + jitem JSONB, forward JSONB, poll_options JSONB) +RETURNS "Item" +LANGUAGE plpgsql +AS $$ +DECLARE + user_msats INTEGER; + item "Item"; + select_clause TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + item := jsonb_populate_record(NULL::"Item", jitem); + + IF item.boost > 0 THEN + UPDATE "Item" SET boost = boost + item.boost WHERE id = item.id; + PERFORM item_act(item.id, item."userId", 'BOOST', item.boost); + END IF; + + IF item.status IS NOT NULL THEN + UPDATE "Item" SET "statusUpdatedAt" = now_utc() + WHERE id = item.id AND status <> item.status; + END IF; + + SELECT string_agg(quote_ident(key), ',') INTO select_clause + FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key) + WHERE key <> 'boost'; + + EXECUTE format($fmt$ + UPDATE "Item" SET (%s) = ( + SELECT %1$s + FROM jsonb_populate_record(NULL::"Item", %L) + ) WHERE id = %L RETURNING * + $fmt$, select_clause, jitem, item.id) INTO item; + + -- Delete any old thread subs if the user is no longer a fwd recipient + DELETE FROM "ThreadSubscription" + WHERE "itemId" = item.id + -- they aren't in the new forward list + AND NOT EXISTS (SELECT 1 FROM jsonb_populate_recordset(NULL::"ItemForward", forward) as nf WHERE "ThreadSubscription"."userId" = nf."userId") + -- and they are in the old forward list + AND EXISTS (SELECT 1 FROM "ItemForward" WHERE "ItemForward"."itemId" = item.id AND "ItemForward"."userId" = "ThreadSubscription"."userId" ); + + -- Automatically subscribe any new forward recipients to the post + INSERT INTO "ThreadSubscription" ("itemId", "userId") + SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward) + EXCEPT + SELECT item.id, "userId" FROM "ItemForward" WHERE "itemId" = item.id; + + -- Delete all old forward entries, to recreate in next command + DELETE FROM "ItemForward" WHERE "itemId" = item.id; + + INSERT INTO "ItemForward" ("itemId", "userId", "pct") + SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward); + + INSERT INTO "PollOption" ("itemId", "option") + SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option"); + + -- if this is a job + IF item."maxBid" IS NOT NULL THEN + PERFORM run_auction(item.id); + END IF; + + -- schedule imgproxy job + INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter) + VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds'); + + RETURN item; +END; +$$; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3f9f9d0e..59970053 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -264,6 +264,7 @@ model Item { deletedAt DateTime? otsFile Bytes? otsHash String? + imgproxyUrls Json? bounty Int? rootId Int? bountyPaidTo Int[] diff --git a/public/placeholder_click_to_load.png b/public/placeholder_click_to_load.png new file mode 100644 index 0000000000000000000000000000000000000000..1a297a9aead4d26ae018405be6cc213cadd14192 GIT binary patch literal 9254 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 u.host === 'i.postimg.cc', + u => u.host === 'pbs.twimg.com', + u => u.host === 'i.ibb.co', + u => u.host === 'nostr.build' || u.host === 'cdn.nostr.build', + u => u.host === 'www.zapread.com' && u.pathname.startsWith('/i'), + u => u.host === 'i.imgflip.com', + u => u.host === 'i.redd.it', + u => u.host === 'media.tenor.com', + u => u.host === 'i.imgur.com' +] +const exclude = [ + u => u.protocol === 'mailto:', + u => u.host.endsWith('.onion') || u.host.endsWith('.b32.ip') || u.host.endsWith('.loki'), + u => ['twitter.com', 'x.com', 'nitter.it', 'nitter.at'].some(h => h === u.host), + u => u.host === 'stacker.news', + u => u.host === 'news.ycombinator.com', + u => u.host === 'www.youtube.com' || u.host === 'youtu.be', + u => u.host === 'github.com' +] + +function matchUrl (matchers, url) { + try { + return matchers.some(matcher => matcher(new URL(url))) + } catch (err) { + console.log(url, err) + return false + } +} + +function decodeOriginalUrl (imgproxyUrl) { + const parts = imgproxyUrl.split('/') + const b64Url = parts[parts.length - 1] + const originalUrl = Buffer.from(b64Url, 'base64url').toString('utf-8') + return originalUrl +} + +export function imgproxy ({ models }) { + return async function ({ data: { id, forceFetch = false } }) { + if (!imgProxyEnabled) return + + console.log('running imgproxy job', id) + + const item = await models.item.findUnique({ where: { id } }) + + const isJob = typeof item.maxBid !== 'undefined' + + let imgproxyUrls = {} + try { + if (item.text) { + imgproxyUrls = await createImgproxyUrls(id, item.text, { forceFetch }) + } + if (item.url && !isJob) { + imgproxyUrls = { ...imgproxyUrls, ...(await createImgproxyUrls(id, item.url, { forceFetch })) } + } + } catch (err) { + console.log('[imgproxy] error:', err) + // rethrow for retry + throw err + } + + console.log('[imgproxy] updating item', id, 'with urls', imgproxyUrls) + + await models.item.update({ where: { id }, data: { imgproxyUrls } }) + } +} + +export const createImgproxyUrls = async (id, text, { forceFetch }) => { + const urls = extractUrls(text) + console.log('[imgproxy] id:', id, '-- extracted urls:', urls) + // resolutions that we target: + // - nHD: 640x 360 + // - qHD: 960x 540 + // - HD: 1280x 720 + // - HD+: 1600x 900 + // - FHD: 1920x1080 + // - QHD: 2560x1440 + // reference: + // - https://en.wikipedia.org/wiki/Graphics_display_resolution#High-definition_(HD_and_derivatives) + // - https://www.browserstack.com/guide/ideal-screen-sizes-for-responsive-design + const resolutions = ['640x360', '960x540', '1280x720', '1600x900', '1920x1080', '2560x1440'] + const imgproxyUrls = {} + for (let url of urls) { + if (!url) continue + + console.log('[imgproxy] id:', id, '-- processing url:', url) + if (url.startsWith(IMGPROXY_URL)) { + console.log('[imgproxy] id:', id, '-- proxy url, decoding original url:', url) + // backwards compatibility: we used to replace image urls with imgproxy urls + url = decodeOriginalUrl(url) + console.log('[imgproxy] id:', id, '-- original url:', url) + } + if (!(await isImageURL(url, { forceFetch }))) { + console.log('[imgproxy] id:', id, '-- not image url:', url) + continue + } + imgproxyUrls[url] = {} + for (const res of resolutions) { + const [w, h] = res.split('x') + const processingOptions = `/rs:fill:${w}:${h}` + imgproxyUrls[url][`${w}w`] = createImgproxyUrl(url, processingOptions) + } + } + return imgproxyUrls +} + +const createImgproxyUrl = (url, processingOptions) => { + const b64Url = Buffer.from(url, 'utf-8').toString('base64url') + const target = `${processingOptions}/${b64Url}` + const signature = sign(target) + return `${IMGPROXY_URL}${signature}${target}` +} + +async function fetchWithTimeout (resource, { timeout = 1000, ...options } = {}) { + const controller = new AbortController() + const id = setTimeout(() => controller.abort(), timeout) + + const response = await fetch(resource, { + ...options, + signal: controller.signal + }) + clearTimeout(id) + + return response +} + +const isImageURL = async (url, { forceFetch }) => { + if (cache.has(url)) return cache.get(url) + + if (!forceFetch && matchUrl(imageUrlMatchers, url)) { + return true + } + if (!forceFetch && matchUrl(exclude, url)) { + return false + } + + let isImage + + // first run HEAD with small timeout + try { + // https://stackoverflow.com/a/68118683 + const res = await fetchWithTimeout(url, { timeout: 1000, method: 'HEAD' }) + const buf = await res.blob() + isImage = buf.type.startsWith('image/') + } catch (err) { + console.log(url, err) + } + + // For HEAD requests, positives are most likely true positives. + // However, negatives may be false negatives + if (isImage) { + cache.set(url, true) + return true + } + + // if not known yet, run GET request with longer timeout + try { + const res = await fetchWithTimeout(url, { timeout: 10000 }) + const buf = await res.blob() + isImage = buf.type.startsWith('image/') + } catch (err) { + console.log(url, err) + } + + cache.set(url, isImage) + return isImage +} + +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') +} diff --git a/worker/index.js b/worker/index.js index 7651fab9..55f19a41 100644 --- a/worker/index.js +++ b/worker/index.js @@ -14,6 +14,7 @@ import { nip57 } from './nostr.js' import fetch from 'cross-fetch' import { authenticatedLndGrpc } from 'ln-service' import { views, rankViews } from './views.js' +import { imgproxy } from './imgproxy.js' const { loadEnvConfig } = nextEnv const { ApolloClient, HttpLink, InMemoryCache } = apolloClient @@ -66,6 +67,7 @@ async function work () { await boss.work('nip57', nip57(args)) await boss.work('views', views(args)) await boss.work('rankViews', rankViews(args)) + await boss.work('imgproxy', imgproxy(args)) console.log('working jobs') }