A thumbnail opens as the picture it stands for

Posts on that forum wrap a thumbnail in a link to the full-size image -
which is what every image host's "for forums" button hands you, and what
people there are used to doing by hand. Clicking one opened the viewer on
the thumbnail: a postage stamp shown full-window, which reads as the viewer
being broken rather than as the message having named two pictures.

The pairing is read from the raw BBCode, because normalising it unwraps both
tags into two bare URLs and by then nothing says they were ever related. A
link straight to a bigger file is used as it stands.

A link to a *page* - postimg.cc/abc123, the picture being one thing on it -
is asked what it is showing, once, at the moment somebody opens it. Every
such host answers in the same place, the OpenGraph tag it publishes so that
a pasted link unfurls anywhere else, so this needs no per-host knowledge:
checked against postimg's own page, where it returns exactly the full-size
file the thumbnail stands for.

The asking is done by the main process. A window here is a file:// document,
and a reply from somebody else's host is not one the browser will let it
read - the same wall the content-type probe in sniff.ts has always been
behind. It is a request made because a person clicked on a picture, never as
a message arrives, and it reads at most half a megabyte of a page that has
to be text/html before a byte of it is touched.

Two things fell out of pairing them that were wrong before: the wrapped link
was being drawn as a second embed of the same picture, once small and once
large, and stripping it out of the text left an anchor holding nothing.

A page that is not about one picture will name whatever it uses for a
preview - postimages.org's own front page answers with its touch icon - so
this is as right as the page it asked, and no righter.

Closes #175.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-11 17:35:28 -04:00
co-authored by Claude Opus 5
parent 03f9353fa1
commit 6af6ac3e82
8 changed files with 311 additions and 8 deletions
+66
View File
@@ -0,0 +1,66 @@
/**
* Reading a page to find out which picture it is showing.
*
* Its own file, and free of anything Electron, so the parsing can be exercised
* against the pages of real image hosts without standing up a window - which
* is the only way to know that a tag written by somebody else's template is
* one this actually matches.
*/
/**
* How much of a page to read before giving up on finding the tag.
*
* The tag being looked for is in `<head>`, so a page that has not named its
* picture in the first half megabyte is not going to. The cap is the point:
* this reads a document from a host nobody here controls, and "read until it
* ends" is a promise about somebody else's server.
*/
const MAX_PAGE_BYTES = 512 * 1024
export async function readCapped(res: Response): Promise<string> {
const reader = res.body?.getReader()
if (!reader) return ''
const chunks: Uint8Array[] = []
let total = 0
while (total < MAX_PAGE_BYTES) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
total += value.length
}
await reader.cancel().catch(() => {})
return Buffer.concat(chunks).subarray(0, MAX_PAGE_BYTES).toString('utf8')
}
/**
* The picture a page says it is showing.
*
* Matched rather than parsed because this process has no DOM and the answer is
* one attribute: the tags are machine-written by the host's own template, and
* the shapes that vary between hosts are the attribute order and the quoting,
* both of which this allows for. A page that says nothing recognisable gives
* nothing, and the thumbnail stays.
*/
export function pictureNamedIn(html: string, page: URL): string | null {
const head = html.split(/<\/head>/i)[0]
const names = ['og:image:secure_url', 'og:image', 'twitter:image', 'twitter:image:src']
for (const name of names) {
const tag = new RegExp(
`<meta[^>]+(?:property|name)\\s*=\\s*["']${name}["'][^>]*>`,
'i'
).exec(head)
if (!tag) continue
const content = /content\s*=\s*["']([^"']+)["']/i.exec(tag[0])?.[1]
if (!content) continue
try {
const found = new URL(content, page)
// https only: the viewer showing it is a window in this app, and a
// plaintext picture in it would be a plaintext request made by it.
if (found.protocol === 'https:') return found.toString()
} catch {
// Somebody else's markup naming something that is not a URL is not an
// error here; it is simply not an answer.
}
}
return null
}
+41
View File
@@ -27,6 +27,7 @@ import { browserLogin, LOGIN_FLOWS } from './browser-login'
import { solveCaptcha } from './captcha'
import { IPC, POPOUT_FLAG, type PopoutState } from '../shared/ipc'
import { clearnetLinks } from '../shared/clearnet'
import { readCapped, pictureNamedIn } from './imagepage'
import { DEEP_LINK_SCHEMES, isDeepLink } from '../shared/deeplink'
import { allowPickedFile, allowRoot, installMediaHandler, registerMediaScheme } from './media-protocol'
import { defaultDownloadDir, saveMedia } from './downloads'
@@ -543,6 +544,46 @@ function wireIpc(): void {
})
ipcMain.handle(IPC.popoutList, () => popoutState())
/**
* Which picture a page is showing.
*
* Forum posts wrap a thumbnail in a link to the image host's *page* - the
* picture is one thing on it - so the only address the message carries for
* the full-size copy is a page address. Every such host says what it is
* showing in an OpenGraph tag, because every one of them wants a preview
* when the link is pasted into a chat window, so one request and one tag
* answers it for all of them with no per-host knowledge.
*
* In the main process because a `file://` document cannot read a
* cross-origin reply, and asked only when somebody opens a picture - never
* as a message arrives.
*/
ipcMain.handle(IPC.resolveImagePage, async (_e, raw: string): Promise<string | null> => {
let page: URL
try {
page = new URL(clearnetLinks(raw))
} catch {
return null
}
if (page.protocol !== 'https:') return null
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 8000)
try {
const res = await fetch(page, { signal: controller.signal, redirect: 'follow' })
// Only a page has the tag, and only a page is small enough to read
// without asking how big it is first. Anything else - a file, a stream,
// a download - is refused before a byte of the body is touched.
if (!res.ok || !(res.headers.get('content-type') || '').startsWith('text/html')) return null
const html = await readCapped(res)
return pictureNamedIn(html, page)
} catch {
return null
} finally {
clearTimeout(timer)
}
})
ipcMain.handle(IPC.openExternal, (_e, raw: string) => {
// The forum's onion address, sent to the browser as the clearnet one it is
// the same site as. Here as well as in the window because a link is not
+11
View File
@@ -110,6 +110,17 @@ const api = {
openExternal: (url: string): Promise<void> => ipcRenderer.invoke(IPC.openExternal, url),
/**
* Which picture a page is showing, read from its own OpenGraph tag.
*
* Here rather than in the window because the window is a `file://` document
* and a fetch from one carries no origin any host will answer: the reply
* comes back and the browser refuses to let the page read it. The main
* process has no such rule, being the thing that would enforce it.
*/
resolveImagePage: (url: string): Promise<string | null> =>
ipcRenderer.invoke(IPC.resolveImagePage, url),
/** Puts a line of text on the system clipboard. */
copyText: (text: string): Promise<void> => ipcRenderer.invoke(IPC.writeClipboardText, text),
/**
+32 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Icon } from './Icon'
import { Lightbox } from './Lightbox'
import { fullImageFor, knownFullImage } from '../lib/fullimage'
import { resolveMediaUrl } from '../lib/util'
import type { MediaItem } from '../lib/format'
import type { Attachment } from '../../../shared/wire'
@@ -98,6 +99,17 @@ export function MediaEmbed({
const [expanded, setExpanded] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [refreshError, setRefreshError] = useState('')
/**
* The full-size picture behind a thumbnail, once the page holding it has
* been asked.
*
* Starts at whatever is already known, so the second time somebody opens a
* picture from a host they have opened one from before, it opens large
* immediately.
*/
const [fromPage, setFromPage] = useState<string | null>(() =>
item?.page ? knownFullImage(item.page) : null
)
// An attachment knows its own kind; an unfurled link only has a guess.
const kind = attachment?.kind ?? item?.kind ?? 'file'
@@ -109,7 +121,18 @@ export function MediaEmbed({
// A server-generated thumbnail is enough for a preview and much cheaper -
// until it has been swept, after which the original is all there is.
const previewSrc = (!previewGone && attachment?.thumbnailPath) || fullSrc
const openTarget = attachment?.url || fullSrc
/**
* What the viewer shows, which is not always what the log shows.
*
* A forum post that wraps a thumbnail in a link to the bigger copy is
* naming two pictures, and the small one is only the right answer for the
* row. Opening it used to hand the viewer the thumbnail - a postage stamp
* shown full-window, which reads as the viewer being broken.
*/
const expandedSrc = item?.full || fromPage || fullSrc
// And where the link went, for opening outside the app: the page an image
// host wraps its thumbnail in is the page a person means to land on.
const openTarget = attachment?.url || item?.full || item?.page || fullSrc
const open = (): void => void window.moho.openExternal(openTarget)
@@ -230,6 +253,13 @@ export function MediaEmbed({
.finally(() => setRefreshing(false))
return
}
// Opened at once, with whatever is in hand. Where all the message named
// was a page, that page is asked what it is showing at the same moment -
// a request made because somebody clicked, and never before - and the
// viewer swaps the thumbnail for the real picture when the answer lands.
if (item?.page && !item.full && !fromPage) {
void fullImageFor(item.page).then(setFromPage)
}
setExpanded(true)
}
@@ -269,7 +299,7 @@ export function MediaEmbed({
<Lightbox
source={{
kind: 'image',
src: resolveMediaUrl(fullSrc),
src: resolveMediaUrl(expandedSrc),
externalUrl: openTarget,
filename: attachment?.filename,
width: attachment?.width,
+7 -2
View File
@@ -27,6 +27,7 @@ import {
formatMessage,
normalizeBBCode,
clearnetLinks,
thumbnailLinks,
youtubeId,
stripCodeBlocks,
stripEmbeddedUrls,
@@ -367,11 +368,15 @@ function MessageRowBody({
// because it is about what this reader's browser can reach, and it has to
// hold for an edited line and for backlog alike - both of which arrive at
// this function again and neither of which is re-stored.
const normalized = normalizeBBCode(clearnetLinks(message.body || ''))
const raw = clearnetLinks(message.body || '')
const normalized = normalizeBBCode(raw)
const media = extractMedia(normalized, {
contentSniffing,
sniffed,
onNeedSniff: sniffUrl
onNeedSniff: sniffUrl,
// Read from the un-normalised body: the pairing lives in the BBCode,
// and normalising unwraps both tags into two unrelated bare URLs.
thumbnails: thumbnailLinks(raw)
})
const codeBlocks = extractCodeBlocks(normalized)
const quoteBlocks = extractQuoteBlocks(stripCodeBlocks(normalized))
+84 -4
View File
@@ -17,6 +17,21 @@ export interface MediaItem {
url: string
kind: 'image' | 'video' | 'youtube'
youtubeId?: string
/**
* A bigger copy of the same picture, where the message said where one is.
*
* A forum post that wraps a thumbnail in a link to the full-size image is
* saying two things - show this, open that - and only the first of them used
* to arrive here. Set when the wrapped link is itself an image.
*/
full?: string
/**
* And where the wrapped link is a page about the picture rather than the
* picture - which is what every image host's "thumbnail for forums" button
* actually produces - the page, to be asked what it is showing at the moment
* somebody opens it.
*/
page?: string
}
/**
@@ -142,6 +157,31 @@ export function normalizeBBCode(text: string): string {
return out
}
/**
* The pairs a forum post makes when it wraps a picture in a link.
*
* `[url=X][img]Y[/img][/url]` is what every image host's "for forums" button
* hands you, and it means show Y, open X. Read from the raw body because
* `normalizeBBCode` unwraps both tags into bare URLs, after which the pair is
* two links in a row and nothing says they were ever related.
*
* Keyed by the thumbnail, which is what `extractMedia` will go on to find in
* the normalised text.
*/
export function thumbnailLinks(text: string): Record<string, string> {
const pairs: Record<string, string> = {}
if (!text) return pairs
const re = /\[url=(https?:\/\/[^\]\s]+)\]\s*\[img\]\s*(https?:\/\/[^\[\s]+?)\s*\[\/img\]\s*\[\/url\]/gi
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
const [, href, thumb] = m
// A link to the picture it is already showing says nothing; the pair only
// means something when the two differ.
if (href !== thumb) pairs[thumb] = href
}
return pairs
}
export interface SmilieIndex {
byAlias: Record<string, SmilieEntry>
regex: RegExp | null
@@ -509,6 +549,25 @@ export const OPAQUE_IMAGE_HOSTS = ['i.ddos.lgbt']
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp)(\?\S*)?$/i
const VIDEO_EXT_RE = /\.(mp4|webm|mov|mkv|ogv)(\?\S*)?$/i
/**
* What a thumbnail was linked to, told apart by what it is.
*
* A link straight to a bigger file can be opened as it stands. A link to a
* page - which is what an image host's share button gives, the picture being
* one thing on it - has to be asked what it is showing, and that is a request
* worth making only when somebody opens the picture, so it is carried as a
* page and left alone until then.
*/
function biggerCopy(
thumb: string,
thumbnails?: Record<string, string>
): { full?: string; page?: string } {
const target = thumbnails?.[thumb]
if (!target) return {}
const direct = IMAGE_EXT_RE.test(target) || OPAQUE_IMAGE_HOSTS.includes(hostnameOf(target))
return direct ? { full: target } : { page: target }
}
/**
* Heuristic, extension- and pattern-based detection: IRC has no attachment
* metadata the way Discord's API does.
@@ -523,6 +582,8 @@ export function extractMedia(
contentSniffing?: boolean
sniffed?: Record<string, string>
onNeedSniff?: (url: string) => void
/** Thumbnail URL -> what the post linked it to; see `thumbnailLinks`. */
thumbnails?: Record<string, string>
} = {}
): MediaItem[] {
if (!text) return []
@@ -545,31 +606,41 @@ export function extractMedia(
// - two strings, one video - and keying on the string showed it twice.
const seen = new Set<string>()
// What the thumbnails were linked to. Those URLs are in the text as well -
// inside the `[url=]` that wrapped the picture - and each is the same
// picture as the thumbnail naming it, so drawing both shows the message
// twice: once small, once large.
const targets = new Set(Object.values(opts.thumbnails ?? {}))
for (const raw of urls) {
const url = raw.replace(/[),.;!?]+$/, '')
const yt = youtubeId(url)
const identity = yt ? `yt:${yt}` : url
if (seen.has(identity)) continue
if (seen.has(identity) || targets.has(url)) continue
if (yt) {
seen.add(identity)
result.push({ url, kind: 'youtube', youtubeId: yt })
} else if (IMAGE_EXT_RE.test(url)) {
seen.add(identity)
result.push({ url, kind: 'image' })
result.push({ url, kind: 'image', ...biggerCopy(url, opts.thumbnails) })
} else if (VIDEO_EXT_RE.test(url)) {
seen.add(identity)
result.push({ url, kind: 'video' })
} else if (OPAQUE_IMAGE_HOSTS.includes(hostnameOf(url))) {
seen.add(identity)
result.push({ url, kind: 'image' })
result.push({ url, kind: 'image', ...biggerCopy(url, opts.thumbnails) })
} else if (opts.contentSniffing && /^https?:\/\//i.test(url)) {
// Nothing else could classify this. Not for file:// URLs: those are
// always nobilis's own already-classified attachment paths.
const sniffed = opts.sniffed?.[url]
if (sniffed === 'image' || sniffed === 'video') {
seen.add(identity)
result.push({ url, kind: sniffed })
result.push({
url,
kind: sniffed,
...(sniffed === 'image' ? biggerCopy(url, opts.thumbnails) : {})
})
} else if (sniffed === undefined) {
opts.onNeedSniff?.(url)
}
@@ -588,6 +659,15 @@ export function stripEmbeddedUrls(text: string, mediaItems: MediaItem[]): string
let out = text
for (const item of mediaItems) out = out.split(item.url).join('')
// A link left holding nothing, which is what the wrapper round a thumbnail
// becomes once the picture it was wrapping has been lifted out and drawn as
// an embed. Rendered, it is an anchor with no text: nothing to see, a stray
// clickable pixel, and an empty element in the log. Removed here rather than
// when the markup is rendered because this is where it was emptied, and the
// whitespace tidy at the end of this function is what puts the line back
// together afterwards.
out = out.replace(/\[url=[^\]]*\]\s*\[\/url\]/gi, '')
// A second link to a video already embedded goes too. It renders no second
// embed - the same video is shown once - so leaving its URL as text is the
// one case where a stripped link would come back as a bare line above the
+69
View File
@@ -0,0 +1,69 @@
/**
* Asking a page which picture it is showing.
*
* An image host's "for forums" button hands out a thumbnail wrapped in a link
* to a *page* - postimg.cc/abc123, not the file - and the page is where the
* full-size image lives. Opening the thumbnail in the viewer therefore showed
* the thumbnail: the only thing the message ever named was the small copy.
*
* Every one of these pages says what it is showing, in the same place, because
* they all want a preview when they are pasted into a chat window: the
* OpenGraph `og:image` tag. Reading it is how a link unfurls anywhere else,
* and it needs no per-host knowledge - postimg, ibb, imgur and the rest all
* answer the same question the same way.
*
* Asked only when somebody opens the picture, unlike the content-type probe in
* `sniff.ts` which runs as a message arrives. That is the whole difference in
* what it discloses: a click is a person deciding to look at the thing, and
* the request goes to the host the message already named.
*
* The request itself is made by the main process - see `resolveImagePage`.
* What lives here is the remembering, so that a picture opened twice is asked
* about once.
*/
/** Answers already had, including the failures - see `asked`. */
const known: Record<string, string | null> = {}
const inFlight = new Map<string, Promise<string | null>>()
/**
* How many answers to keep, oldest out first.
*
* The same ceiling and the same reason as the sniff cache: this is a cache,
* not a record, and a session that has read a busy channel for a day should
* not be holding every image page it ever saw.
*/
const MAX_REMEMBERED = 200
/** Not merely "no answer yet": a page that gave nothing is not asked twice. */
export function asked(pageUrl: string): boolean {
return known[pageUrl] !== undefined
}
export function knownFullImage(pageUrl: string): string | null {
return known[pageUrl] ?? null
}
export async function fullImageFor(pageUrl: string): Promise<string | null> {
if (known[pageUrl] !== undefined) return known[pageUrl]
const already = inFlight.get(pageUrl)
if (already) return already
// Asked of the main process: this document is a `file://` one, and a reply
// from somebody else's host is not a reply it is allowed to read.
const run = window.moho
.resolveImagePage(pageUrl)
.catch(() => null)
.then((found) => {
inFlight.delete(pageUrl)
known[pageUrl] = found
// Insertion order is age order for string keys that are not array
// indices, which a URL never is - so the oldest go first.
const keys = Object.keys(known)
for (let i = 0; i < keys.length - MAX_REMEMBERED; i++) delete known[keys[i]]
return found
})
inFlight.set(pageUrl, run)
return run
}
+1
View File
@@ -9,6 +9,7 @@ export const IPC = {
windowClose: 'moho:window:close',
windowIsMaximized: 'moho:window:isMaximized',
openExternal: 'moho:openExternal',
resolveImagePage: 'moho:resolveImagePage',
pickFile: 'moho:pickFile',
pickSavePath: 'moho:pickSavePath',
pickDirectory: 'moho:pickDirectory',