diff --git a/.env.sample b/.env.sample index 9f5e894def..a0d16073f0 100644 --- a/.env.sample +++ b/.env.sample @@ -51,8 +51,10 @@ NEXT_PUBLIC_IMGPROXY_URL= IMGPROXY_KEY= IMGPROXY_SALT= IMGPROXY_ENABLE_WEBP_DETECTION=1 +IMGPROXY_ENABLE_AVIF_DETECTION=1 IMGPROXY_MAX_ANIMATION_FRAMES=100 -IMGPROXY_MAX_SRC_RESOLUTION=200 +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 39d7a0b684..0000000000 --- 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 9711cccdd3..169a7f3f93 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 { 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 { createHmac } from './wallet' import { settleHodlInvoice } from 'ln-service' @@ -1130,13 +1129,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) } item = { subName, userId: me.id, ...item } @@ -1176,13 +1171,9 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo } 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 trx = [ @@ -1254,7 +1245,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 6a6fc51cb8..a28099dd17 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 e7b64e8ff9..759f941889 100644 --- a/components/comment.js +++ b/components/comment.js @@ -194,7 +194,7 @@ export default function Comment ({ ) : (
- + {truncate ? truncateString(item.text) : item.searchText || item.text}
diff --git a/components/form.js b/components/form.js index 13a2e26c98..8fc5073af2 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 0000000000..1a2d900449 --- /dev/null +++ b/components/image.js @@ -0,0 +1,218 @@ +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' + +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) { + const ref = useRef({}) + const [imgUrlCache, setImgUrlCache] = useState({}) + + const updateCache = (url, state) => setImgUrlCache((prev) => ({ ...prev, [url]: state })) + + useEffect(() => { + const urls = extractUrls(text) + + urls.forEach((url) => { + if (IMG_URL_REGEXP.test(url)) { + // it's probably an image + updateCache(url, IMG_CACHE_STATES.LOADED) + } else { + // 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 [fullScreen, setFullScreen] = useState(false) + const showModal = useShowModal() + const [originalUrlConsent, setOriginalUrlConsent] = useState(!me ? true : !me.clickToLoadImg) + // if there is no srcset obj, image is still processing. we handle this 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]) + + useEffect(() => { + if (fullScreen) { + showModal(close => ( +
+ { + close() + setFullScreen(false) + }} + onError={onError} + {...props} + /> + + {loadOriginalUrl ? 'open in new tab' : 'open original'} + +
+ ), { fullScreen: true, onClose: () => setFullScreen(false) }) + } + }, [fullScreen]) + + if (!src) return null + + if (!srcSetObj && originalUrlConsent && !originalErr) { + // image is still processing and user is okay with loading original url automatically + return ( + setFullScreen(true)} + onError={() => setOriginalErr(true)} + {...props} + /> + ) + } + + if (!srcSetObj && !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 ( + setFullScreen(true)} + onError={onError} + {...props} + /> + ) +} diff --git a/components/item-full.js b/components/item-full.js index d93493a396..2379735eee 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 4569f4a3b1..3db857dc4d 100644 --- a/components/modal.js +++ b/components/modal.js @@ -37,19 +37,26 @@ export default function useModal () { const onClose = useCallback(() => { setModalContent(null) setModalStack([]) - }, []) + modalOptions?.onClose?.() + }, [modalOptions?.onClose]) const modal = useMemo(() => { if (modalContent === null) { return null } + const className = modalOptions?.fullScreen ? 'fullscreen' : '' return ( - +
{modalStack.length > 0 ?
: null} -
X
+
X
- + {modalContent}
diff --git a/components/text.js b/components/text.js index 38ddbeb656..b003131980 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) return (
@@ -159,8 +108,10 @@ 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 + const srcSet = imgproxyUrls ? imgproxyUrls[url] : undefined + return } // map: fix any highlighted links @@ -183,8 +134,10 @@ 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 + const srcSet = imgproxyUrls ? imgproxyUrls[url] : undefined + return } }} remarkPlugins={[gfm, mention, sub, remarkDirective, searchHighlighter]} @@ -194,76 +147,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 c57cb5fb37..7a97213eda 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -29,6 +29,7 @@ export const COMMENT_FIELDS = gql` mine otsHash ncomments + imgproxyUrls } ` diff --git a/fragments/items.js b/fragments/items.js index 383cbdcea4..befcdd6d60 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -44,6 +44,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 0000000000..5e5548fe94 --- /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('"' || 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('"' || 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 b580a04db4..dabc629ddd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -250,6 +250,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 0000000000..1a297a9aea Binary files /dev/null and b/public/placeholder_click_to_load.png differ diff --git a/public/placeholder_processing.png b/public/placeholder_processing.png new file mode 100644 index 0000000000..49487fbb77 Binary files /dev/null and b/public/placeholder_processing.png differ diff --git a/styles/globals.scss b/styles/globals.scss index 624d290154..f838f98143 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -193,6 +193,33 @@ $btn-close-bg: none; margin-bottom: 0 !important; } +.modal.fullscreen { + padding-right: 0; + background-color: color-mix(in srgb, var(--theme-inputBg) 66%, transparent); +} +.modal-dialog.fullscreen { + margin: 0; + max-width: 100vw; + max-height: 100vh; + background-color: transparent; +} +.modal-content.fullscreen { + border: none; + background-color: transparent; +} +.modal-body.fullscreen { + width: 100%; + height: calc(100vh - 5em); + padding: 3em; + background-color: transparent; +} +.modal-body.fullscreen > div > a { + width: fit-content; +} +.modal-close.fullscreen { + padding-right: 15px; +} + .modal-btn { cursor: pointer; display: flex; diff --git a/worker/imgproxy.js b/worker/imgproxy.js new file mode 100644 index 0000000000..f2974418a6 --- /dev/null +++ b/worker/imgproxy.js @@ -0,0 +1,153 @@ +import { createHmac } from 'node:crypto' +import { extractUrls } from '../lib/md.js' + +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 cache = new Map() + +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 } }) { + 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(item.text) + } + if (item.url && !isJob) { + imgproxyUrls = { ...imgproxyUrls, ...(await createImgproxyUrls(item.url)) } + } + } 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 } }) + } +} + +const createImgproxyUrls = async (text) => { + const urls = extractUrls(text) + console.log("[imgproxy] extracted urls:", urls) + // resolutions that we target: + // - nHD: 640x360 + // - qHD: 960x540 + // - HD: 1280x720 + // - HD+: 1600x900 + // - 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 widths = [640, 960, 1280, 1600, 1920, 2560] + const imgproxyUrls = {} + for (let url of urls) { + console.log("[imgproxy] processing url:", url) + if (url.startsWith(IMGPROXY_URL)) { + console.log("[imgproxy] proxy url, decoding original url:", url) + // backwards compatibility: we used to replace image urls with imgproxy urls + url = decodeOriginalUrl(url) + console.log("[imgproxy] original url:", url) + } + if (!(await isImageURL(url))) { + console.log("[imgproxy] not image url:", url) + continue + } + imgproxyUrls[url] = {} + for (const w of widths) { + const processingOptions = `/rs:fill:${w}` + 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 => { + if (cache.has(url)) return cache.get(url) + + 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 e3cd909b80..dc0ae71a6d 100644 --- a/worker/index.js +++ b/worker/index.js @@ -15,6 +15,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 @@ -68,6 +69,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') }