diff --git a/CLAUDE.md b/CLAUDE.md index c39cd82..da93618 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,7 +147,16 @@ Things worth knowing before editing: timeline thumbnails, and the still is in `preview.images`. A gallery's pictures are in `media_metadata`, keyed and unordered; their order is only in `gallery_data`. Comment bodies are Markdown, rendered by `src/render/markdown.ts` — escape first, then put - back the constructs we chose to support, never `body_html`. + back the constructs we chose to support, never `body_html`. An image in a comment is + written as a token rather than an address — `![gif](giphy|Ve7wX45)`, + `![img](emote|t5_2th52|4358)`, `![img](jo8gf0ca92zd1)` — and in every case the token is + a key in that same comment's own `media_metadata`, so `resolveInlineImages` is one + lookup rather than three special cases. A bare `preview.redd.it` address pasted into a + comment is in there too, keyed by the id inside the URL. Prefer `s.gif` over `s.mp4` + for an animated one: a GIF moves in an `` and an MP4 needs a player. An address + typed without a scheme counts as well, but only when it ends in an image extension — + the rule wants a host, a path *and* that extension, because comments are full of + dotted, slashed prose that must not turn into links. - **Threads** — same media schema as Instagram (`src/platforms/meta-media.ts`). Its payloads are full of empty stub nodes, so the finder only accepts a node with actual candidates in it. The page ships the linked post, the author's follow-ups, other diff --git a/README.md b/README.md index 4bdcf9c..22e0f3c 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,13 @@ which is why it only appears once the script has run. What was behind a _load mo not fetched — that is a second page and often a third — but it is counted and said out loud rather than quietly dropped. +Pictures inside comments are shown as pictures. Reddit writes them as a token rather +than an address — a Giphy id, a subreddit emote, or an image uploaded to the comment — +and all three are looked up in the comment's own metadata to find the real file. An +image address someone simply pasted is shown too, which on Reddit is how most of them +arrive. All of it goes through the same `/m/` proxy as everything else, so reading a +comment thread never has your browser talking to Reddit. + Media never gets linked straight at a CDN. Instagram and TikTok reject requests without a matching `Referer` (and sometimes cookies), and proxying keeps your browser from talking to the platform at all. Every asset is registered under an opaque `/m/` and diff --git a/public/app.css b/public/app.css index 3c0d00b..a0c150f 100644 --- a/public/app.css +++ b/public/app.css @@ -556,3 +556,22 @@ main { max-width: 680px; margin: 0 auto; } max-height: 45dvh; min-height: 0; } + +/* A picture someone put in a comment. Capped hard: it is a remark inside a + conversation, not the thing the page is about. */ +.c__img { + display: block; + max-width: min(100%, 420px); + max-height: 40vh; + max-height: 40dvh; + width: auto; + height: auto; + margin: 8px 0; + border: 1px solid var(--line); + border-radius: 8px; + background: color-mix(in srgb, var(--ink) 4%, transparent); +} + +/* A lone image is the whole comment more often than not, so it should not + carry a paragraph's worth of space above it as well as its own. */ +.c__body > p:first-child > .c__img:first-child { margin-top: 2px; } diff --git a/src/platforms/reddit.ts b/src/platforms/reddit.ts index 52ba669..fc307b1 100644 --- a/src/platforms/reddit.ts +++ b/src/platforms/reddit.ts @@ -62,6 +62,7 @@ type Link = { type CommentData = { author?: string; body?: string; + media_metadata?: Record; created_utc?: number; score?: number; score_hidden?: boolean; @@ -188,6 +189,37 @@ function bodyOf(link: Link): string | undefined { return undefined; } +/** The whole of `![...](...)`, with the target captured. */ +const INLINE_IMAGE = /!\[([^\]\n]*)\]\(([^)\s]+)\)/g; + +/** + * Point a comment's inline images at something fetchable. + * + * Reddit writes them as `![gif](giphy|Ve7wX45)`, `![img](emote|t5_2th52|4358)` + * or `![img](jo8gf0ca92zd1)` — a token rather than an address. In every case + * the token is a key in that same comment's `media_metadata`, which is where + * the real URL is, so one lookup covers all three and none of them needs + * naming here. + * + * A target that is already an address is not a key, so it falls through + * untouched. + */ +export function resolveInlineImages( + body: string, + meta: Record | undefined, +): string { + if (!meta) return body; + + return body.replace(INLINE_IMAGE, (whole, alt: string, token: string) => { + const entry = meta[token]; + if (!entry || entry.status !== 'valid') return whole; + // An animated one has both; the GIF plays in an `` on its own, which + // an MP4 does not. + const url = entry.s?.gif ?? entry.s?.u; + return url ? `![${alt}](${url})` : whole; + }); +} + export function commentsFrom(listing: Listing | undefined): { comments: Comment[]; more: number; @@ -209,7 +241,7 @@ export function commentsFrom(listing: Listing | undefined): { comments.push({ author: authorName(data.author), - ...(data.body ? { text: data.body } : {}), + ...(data.body ? { text: resolveInlineImages(data.body, data.media_metadata) } : {}), ...(isoFrom(data.created_utc) ? { postedAt: isoFrom(data.created_utc) } : {}), // Reddit hides the score on a new comment so an early downvote cannot // steer the rest. Showing a placeholder 1 would be a lie. diff --git a/src/render/markdown.ts b/src/render/markdown.ts index 018fb1c..cd1c0c4 100644 --- a/src/render/markdown.ts +++ b/src/render/markdown.ts @@ -13,6 +13,20 @@ import { escapeHtml, raw, type Raw } from './html.ts'; const REDDIT = 'https://www.reddit.com'; +/** + * How an image in a comment becomes markup. + * + * Supplied by the caller rather than decided here, because the address has to + * go through the media proxy and this file knows nothing about that. Without + * one an image degrades to a link, which is what it was before. + */ +export type ImageRenderer = (url: string, alt: string) => string; + +/** Worth showing as a picture rather than as a link to one. */ +function looksLikeImage(url: string): boolean { + return /\.(jpe?g|png|gif|webp|avif)(\?|$)/i.test(url); +} + /** Absolute http(s) only. `javascript:` and friends never become links. */ function safeHref(url: string): string | undefined { try { @@ -46,44 +60,69 @@ function trimUrlTail(url: string): string { const INLINE = new RegExp( [ '`([^`\\n]+)`', // 1 code - '\\[([^\\]\\n]+)\\]\\(([^)\\s]+)\\)', // 2 label, 3 href - '\\*\\*([^*\\n]+)\\*\\*', // 4 strong - '~~([^~\\n]+)~~', // 5 strike - '(?]+)', // 8 bare url - '(?]+)', // 10 bare url + // 11 the same thing with the scheme left off, which is how people type + // them. Narrow on purpose: a host, a path, and an image extension. Prose + // is full of dotted words, and `src/render/post.ts` must not become a + // link to a website in Tonga. + '(?]*\\.(?:jpe?g|png|gif|webp|avif)(?:\\?[^\\s<>]*)?)', + '(?${escapeHtml(code)}`; + } else if (src !== undefined) { + const safe = safeHref(src); + // Without a renderer to place it, an image is still a link to one. + out += safe ? (image ? image(safe, alt ?? '') : anchor(safe, alt || safe)) : escapeHtml(whole); } else if (label !== undefined && href !== undefined) { const safe = safeHref(href); out += safe ? anchor(safe, label) : escapeHtml(whole); } else if (strong !== undefined) { - out += `${inline(strong)}`; + out += `${inline(strong, image)}`; } else if (strike !== undefined) { - out += `${inline(strike)}`; + out += `${inline(strike, image)}`; } else if (emStar !== undefined || emScore !== undefined) { - out += `${inline(emStar ?? emScore ?? '')}`; + out += `${inline(emStar ?? emScore ?? '', image)}`; } else if (url !== undefined) { const trimmed = trimUrlTail(url); const safe = safeHref(trimmed); - out += safe - ? anchor(safe, trimmed.replace(/^https?:\/\/(www\.)?/, '')) + escapeHtml(url.slice(trimmed.length)) - : escapeHtml(whole); + const tail = escapeHtml(url.slice(trimmed.length)); + if (!safe) { + out += escapeHtml(whole); + } else if (image && looksLikeImage(trimmed)) { + // People paste the address of a picture and mean the picture. On + // Reddit that is most of what an image in a comment even is. + out += image(safe, '') + tail; + } else { + out += anchor(safe, trimmed.replace(/^https?:\/\/(www\.)?/, '')) + tail; + } + } else if (schemeless !== undefined) { + // Assumed https: every host that serves these redirects to it anyway, + // and a picture is the one thing worth guessing a scheme for. + const safe = safeHref(`https://${schemeless}`); + out += safe ? (image ? image(safe, '') : anchor(safe, schemeless)) : escapeHtml(whole); } else if (subOrUser !== undefined) { const path = subOrUser.startsWith('/') ? subOrUser : `/${subOrUser}`; out += anchor(`${REDDIT}${path}`, subOrUser); @@ -101,7 +140,7 @@ const NUMBERED = /^\s{0,3}\d+[.)]\s+/; * from the reply to it, and a comment that loses that separation reads as * though the commenter said both halves. */ -function blocks(lines: string[]): string { +function blocks(lines: string[], image?: ImageRenderer): string { let out = ''; let at = 0; @@ -139,7 +178,7 @@ function blocks(lines: string[]): string { if (/^\s*>/.test(line)) { const body = takeWhile((l) => /^\s*>/.test(l)); // Nested, so a quote of a quote keeps its shape. - out += `
${blocks(body.map((l) => l.replace(/^\s*>\s?/, '')))}
`; + out += `
${blocks(body.map((l) => l.replace(/^\s*>\s?/, '')), image)}
`; continue; } @@ -148,20 +187,21 @@ function blocks(lines: string[]): string { const pattern = ordered ? NUMBERED : BULLET; const items = takeWhile((l) => pattern.test(l)); const tag = ordered ? 'ol' : 'ul'; - out += `<${tag}>${items.map((l) => `
  • ${inline(l.replace(pattern, ''))}
  • `).join('')}`; + out += `<${tag}>${items.map((l) => `
  • ${inline(l.replace(pattern, ''), image)}
  • `).join('')}`; continue; } const paragraph = takeWhile( (l) => l.trim() !== '' && !/^\s*>/.test(l) && !BULLET.test(l) && !NUMBERED.test(l) && !/^\s*```/.test(l), ); - out += `

    ${paragraph.map((l) => inline(l)).join('
    ')}

    `; + out += `

    ${paragraph.map((l) => inline(l, image)).join('
    ')}

    `; } return out; } -/** Comment text, as safe markup. */ -export function renderMarkdown(text: string): Raw { - return raw(blocks(text.replace(/\r\n?/g, '\n').split('\n'))); +/** Comment text, as safe markup. `image` places the pictures; without it + * they stay links, which is what they were before. */ +export function renderMarkdown(text: string, image?: ImageRenderer): Raw { + return raw(blocks(text.replace(/\r\n?/g, '\n').split('\n'), image)); } diff --git a/src/render/post.ts b/src/render/post.ts index cf98db7..48914ca 100644 --- a/src/render/post.ts +++ b/src/render/post.ts @@ -145,6 +145,22 @@ function shortWhen(postedAt: string | undefined): Raw { })}`; } +/** + * A picture inside a comment. + * + * Through the proxy like everything else — a comment full of `preview.redd.it` + * addresses would otherwise have the viewer's browser fetch every one of them + * straight from Reddit, which is the thing this whole app exists to avoid. + * + * No dimensions to reserve space with: the size is in the payload but not in + * the Markdown, so these are capped by the stylesheet and load at whatever + * shape they are. + */ +function renderCommentImage(url: string, alt: string): string { + return html`${alt}` + .value; +} + /** Everything hanging off a comment, however deep. Shown only while it is * collapsed, so what a fold is hiding is never a mystery. */ function descendantsOf(comment: Comment): number { @@ -179,7 +195,9 @@ function renderComment(comment: Comment, depth: number): Raw { }` : ''} - ${comment.text ? html`
    ${renderMarkdown(comment.text)}
    ` : ''} + ${comment.text + ? html`
    ${renderMarkdown(comment.text, renderCommentImage)}
    ` + : ''} ${comment.replies.length || comment.moreReplies ? html`
    ${comment.replies.map((reply) => renderComment(reply, depth + 1))} diff --git a/test/markdown.test.ts b/test/markdown.test.ts index be2e2b6..7d7576e 100644 --- a/test/markdown.test.ts +++ b/test/markdown.test.ts @@ -1,9 +1,16 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import { escapeHtml } from '../src/render/html.ts'; import { renderMarkdown } from '../src/render/markdown.ts'; const md = (text: string): string => String(renderMarkdown(text)); +/** Stands in for the real one, which proxies. Escapes the way that one does: + * placing the image is the renderer's job, and so is making it safe. */ +const img = (url: string, alt: string): string => + `${escapeHtml(alt)}`; +const mdi = (text: string): string => String(renderMarkdown(text, img)); + test('markup a commenter typed is text, not markup', () => { const out = md(' & "quoted"'); assert.ok(!out.includes('')); assert.ok(!html_.includes(' { + const page = renderPost(redditPost({ + comments: [{ + author: 'u/a', + text: '![" onerror=alert(1) x="](https://preview.redd.it/x.jpeg?s=abc)\n\nhttps://i.redd.it/y.png', + replies: [], + }], + })); + + assert.ok(!page.includes('https://preview.redd.it/x.jpeg'), 'upstream URLs must not reach the page'); + assert.ok(!page.includes('https://i.redd.it/y.png'), 'a pasted address is proxied too'); + assert.equal((page.match(/