Compare commits
1
Commits
1.2.1
..
76441bb91d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76441bb91d
|
@@ -147,16 +147,7 @@ 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`. An image in a comment is
|
||||
written as a token rather than an address — ``,
|
||||
``, `` — 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 `<img>` 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.
|
||||
back the constructs we chose to support, never `body_html`.
|
||||
- **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
|
||||
|
||||
@@ -21,43 +21,15 @@ readable in your history.
|
||||
|
||||
Replace `antisocial.example.com` with wherever you are running it.
|
||||
|
||||
Each rule is two fields. Both are on their own line below, and neither needs any
|
||||
escaping — copy them straight out of this file.
|
||||
|
||||
```text
|
||||
# X
|
||||
/^https:\/\/(?:www\.|mobile\.)?(?:x|twitter)\.com\/(.*)$/
|
||||
https://antisocial.example.com/x/$1
|
||||
|
||||
# Threads
|
||||
/^https:\/\/(?:www\.)?threads\.(?:net|com)\/(.*)$/
|
||||
https://antisocial.example.com/threads/$1
|
||||
|
||||
# Instagram
|
||||
/^https:\/\/(?:www\.)?instagram\.com\/(.*)$/
|
||||
https://antisocial.example.com/ig/$1
|
||||
|
||||
# TikTok
|
||||
/^https:\/\/(?:www\.|vm\.|vt\.)?tiktok\.com\/(.*)$/
|
||||
https://antisocial.example.com/tiktok/$1
|
||||
|
||||
# Bluesky
|
||||
/^https:\/\/bsky\.app\/(.*)$/
|
||||
https://antisocial.example.com/bsky/$1
|
||||
|
||||
# Reddit
|
||||
/^https:\/\/(?:www\.|old\.|new\.|np\.|m\.)?reddit\.com\/(.*)$/
|
||||
https://antisocial.example.com/reddit/$1
|
||||
|
||||
# Reddit short links
|
||||
/^https:\/\/redd\.it\/(.*)$/
|
||||
https://antisocial.example.com/reddit/$1
|
||||
```
|
||||
|
||||
A code block rather than a table, because a table cell cannot hold a bare `|` — it has
|
||||
to be written `\|`, which renders correctly and copies wrongly. The alternation in these
|
||||
rules is full of them, and a regex whose pipes arrive as literal pipes matches nothing
|
||||
and says nothing about why.
|
||||
| Platform | Find | Replace |
|
||||
| --------- | ------------------------------------------------------------- | ------------------------------------------- |
|
||||
| X | `/^https:\/\/(?:www\.\|mobile\.)?(?:x\|twitter)\.com\/(.*)$/` | `https://antisocial.example.com/x/$1` |
|
||||
| Threads | `/^https:\/\/(?:www\.)?threads\.(?:net\|com)\/(.*)$/` | `https://antisocial.example.com/threads/$1` |
|
||||
| Instagram | `/^https:\/\/(?:www\.)?instagram\.com\/(.*)$/` | `https://antisocial.example.com/ig/$1` |
|
||||
| TikTok | `/^https:\/\/(?:www\.\|vm\.\|vt\.)?tiktok\.com\/(.*)$/` | `https://antisocial.example.com/tiktok/$1` |
|
||||
| Bluesky | `/^https:\/\/bsky\.app\/(.*)$/` | `https://antisocial.example.com/bsky/$1` |
|
||||
| Reddit | `/^https:\/\/(?:www\.\|old\.\|new\.\|np\.\|m\.)?reddit\.com\/(.*)$/` | `https://antisocial.example.com/reddit/$1` |
|
||||
| Reddit | `/^https:\/\/redd\.it\/(.*)$/` | `https://antisocial.example.com/reddit/$1` |
|
||||
|
||||
So `https://x.com/user/status/123` becomes
|
||||
`https://antisocial.example.com/x/user/status/123`.
|
||||
@@ -70,8 +42,7 @@ where it came from Reddit. A Reddit `/r/<sub>/s/<code>` share link is followed t
|
||||
post it points at, and that permalink — not the opaque share code — is what the copy
|
||||
button hands back.
|
||||
|
||||
`/` serves these rules with the live hostname already filled in, if you'd rather copy
|
||||
them from there.
|
||||
`/` serves this table with the live hostnames, if you'd rather read it there.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -126,13 +97,6 @@ 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/<id>` and
|
||||
|
||||
@@ -556,22 +556,3 @@ 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; }
|
||||
|
||||
+1
-33
@@ -62,7 +62,6 @@ type Link = {
|
||||
type CommentData = {
|
||||
author?: string;
|
||||
body?: string;
|
||||
media_metadata?: Record<string, MediaMeta>;
|
||||
created_utc?: number;
|
||||
score?: number;
|
||||
score_hidden?: boolean;
|
||||
@@ -189,37 +188,6 @@ 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 ``, ``
|
||||
* or `` — 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<string, MediaMeta> | 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 `<img>` on its own, which
|
||||
// an MP4 does not.
|
||||
const url = entry.s?.gif ?? entry.s?.u;
|
||||
return url ? `` : whole;
|
||||
});
|
||||
}
|
||||
|
||||
export function commentsFrom(listing: Listing<CommentData> | undefined): {
|
||||
comments: Comment[];
|
||||
more: number;
|
||||
@@ -241,7 +209,7 @@ export function commentsFrom(listing: Listing<CommentData> | undefined): {
|
||||
|
||||
comments.push({
|
||||
author: authorName(data.author),
|
||||
...(data.body ? { text: resolveInlineImages(data.body, data.media_metadata) } : {}),
|
||||
...(data.body ? { text: data.body } : {}),
|
||||
...(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.
|
||||
|
||||
+22
-62
@@ -13,20 +13,6 @@ 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 {
|
||||
@@ -60,69 +46,44 @@ function trimUrlTail(url: string): string {
|
||||
const INLINE = new RegExp(
|
||||
[
|
||||
'`([^`\\n]+)`', // 1 code
|
||||
// Before the link rule, or the `[` of an image matches as a link and
|
||||
// leaves its `!` behind as text.
|
||||
'!\\[([^\\]\\n]*)\\]\\(([^)\\s]+)\\)', // 2 alt, 3 src
|
||||
'\\[([^\\]\\n]+)\\]\\(([^)\\s]+)\\)', // 4 label, 5 href
|
||||
'\\*\\*([^*\\n]+)\\*\\*', // 6 strong
|
||||
'~~([^~\\n]+)~~', // 7 strike
|
||||
'(?<![\\w*])\\*([^*\\n]+)\\*(?![\\w*])', // 8 em with asterisks
|
||||
'(?<![\\w_])_([^_\\n]+)_(?![\\w_])', // 9 em with underscores
|
||||
'(https?://[^\\s<>]+)', // 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.
|
||||
'(?<![\\w@/.])((?:[a-z0-9-]+\\.)+[a-z]{2,}/[^\\s<>]*\\.(?:jpe?g|png|gif|webp|avif)(?:\\?[^\\s<>]*)?)',
|
||||
'(?<![\\w/])(/?[ru]/[A-Za-z0-9_][A-Za-z0-9_-]{1,30})', // 12 r/sub and u/name
|
||||
'\\[([^\\]\\n]+)\\]\\(([^)\\s]+)\\)', // 2 label, 3 href
|
||||
'\\*\\*([^*\\n]+)\\*\\*', // 4 strong
|
||||
'~~([^~\\n]+)~~', // 5 strike
|
||||
'(?<![\\w*])\\*([^*\\n]+)\\*(?![\\w*])', // 6 em with asterisks
|
||||
'(?<![\\w_])_([^_\\n]+)_(?![\\w_])', // 7 em with underscores
|
||||
'(https?://[^\\s<>]+)', // 8 bare url
|
||||
'(?<![\\w/])(/?[ru]/[A-Za-z0-9_][A-Za-z0-9_-]{1,30})', // 9 r/sub and u/name
|
||||
].join('|'),
|
||||
'g',
|
||||
);
|
||||
|
||||
/** One line of body text: escaped, with the inline constructs put back. */
|
||||
function inline(text: string, image?: ImageRenderer): string {
|
||||
function inline(text: string): string {
|
||||
let out = '';
|
||||
let cursor = 0;
|
||||
|
||||
for (const match of text.matchAll(INLINE)) {
|
||||
const [whole, code, alt, src, label, href, strong, strike, emStar, emScore, url, schemeless,
|
||||
subOrUser] = match;
|
||||
const [whole, code, label, href, strong, strike, emStar, emScore, url, subOrUser] = match;
|
||||
out += escapeHtml(text.slice(cursor, match.index));
|
||||
cursor = match.index + whole.length;
|
||||
|
||||
if (code !== undefined) {
|
||||
out += `<code>${escapeHtml(code)}</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 += `<strong>${inline(strong, image)}</strong>`;
|
||||
out += `<strong>${inline(strong)}</strong>`;
|
||||
} else if (strike !== undefined) {
|
||||
out += `<del>${inline(strike, image)}</del>`;
|
||||
out += `<del>${inline(strike)}</del>`;
|
||||
} else if (emStar !== undefined || emScore !== undefined) {
|
||||
out += `<em>${inline(emStar ?? emScore ?? '', image)}</em>`;
|
||||
out += `<em>${inline(emStar ?? emScore ?? '')}</em>`;
|
||||
} else if (url !== undefined) {
|
||||
const trimmed = trimUrlTail(url);
|
||||
const safe = safeHref(trimmed);
|
||||
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);
|
||||
out += safe
|
||||
? anchor(safe, trimmed.replace(/^https?:\/\/(www\.)?/, '')) + escapeHtml(url.slice(trimmed.length))
|
||||
: escapeHtml(whole);
|
||||
} else if (subOrUser !== undefined) {
|
||||
const path = subOrUser.startsWith('/') ? subOrUser : `/${subOrUser}`;
|
||||
out += anchor(`${REDDIT}${path}`, subOrUser);
|
||||
@@ -140,7 +101,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[], image?: ImageRenderer): string {
|
||||
function blocks(lines: string[]): string {
|
||||
let out = '';
|
||||
let at = 0;
|
||||
|
||||
@@ -178,7 +139,7 @@ function blocks(lines: string[], image?: ImageRenderer): string {
|
||||
if (/^\s*>/.test(line)) {
|
||||
const body = takeWhile((l) => /^\s*>/.test(l));
|
||||
// Nested, so a quote of a quote keeps its shape.
|
||||
out += `<blockquote>${blocks(body.map((l) => l.replace(/^\s*>\s?/, '')), image)}</blockquote>`;
|
||||
out += `<blockquote>${blocks(body.map((l) => l.replace(/^\s*>\s?/, '')))}</blockquote>`;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -187,21 +148,20 @@ function blocks(lines: string[], image?: ImageRenderer): string {
|
||||
const pattern = ordered ? NUMBERED : BULLET;
|
||||
const items = takeWhile((l) => pattern.test(l));
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
out += `<${tag}>${items.map((l) => `<li>${inline(l.replace(pattern, ''), image)}</li>`).join('')}</${tag}>`;
|
||||
out += `<${tag}>${items.map((l) => `<li>${inline(l.replace(pattern, ''))}</li>`).join('')}</${tag}>`;
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraph = takeWhile(
|
||||
(l) => l.trim() !== '' && !/^\s*>/.test(l) && !BULLET.test(l) && !NUMBERED.test(l) && !/^\s*```/.test(l),
|
||||
);
|
||||
out += `<p>${paragraph.map((l) => inline(l, image)).join('<br>')}</p>`;
|
||||
out += `<p>${paragraph.map((l) => inline(l)).join('<br>')}</p>`;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 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));
|
||||
/** Comment text, as safe markup. */
|
||||
export function renderMarkdown(text: string): Raw {
|
||||
return raw(blocks(text.replace(/\r\n?/g, '\n').split('\n')));
|
||||
}
|
||||
|
||||
+1
-19
@@ -145,22 +145,6 @@ function shortWhen(postedAt: string | undefined): Raw {
|
||||
})}</time>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`<img class="c__img" src="${proxyUrlFor({ url })}" alt="${alt}" loading="lazy" decoding="async">`
|
||||
.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 {
|
||||
@@ -195,9 +179,7 @@ function renderComment(comment: Comment, depth: number): Raw {
|
||||
}</span>`
|
||||
: ''}
|
||||
</summary>
|
||||
${comment.text
|
||||
? html`<div class="c__body">${renderMarkdown(comment.text, renderCommentImage)}</div>`
|
||||
: ''}
|
||||
${comment.text ? html`<div class="c__body">${renderMarkdown(comment.text)}</div>` : ''}
|
||||
${comment.replies.length || comment.moreReplies
|
||||
? html`<div class="c__replies">
|
||||
${comment.replies.map((reply) => renderComment(reply, depth + 1))}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
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 =>
|
||||
`<img src="${escapeHtml(url)}" alt="${escapeHtml(alt)}">`;
|
||||
const mdi = (text: string): string => String(renderMarkdown(text, img));
|
||||
|
||||
test('markup a commenter typed is text, not markup', () => {
|
||||
const out = md('<script>alert(1)</script> & "quoted"');
|
||||
assert.ok(!out.includes('<script>'));
|
||||
@@ -71,85 +64,3 @@ test('a single newline inside a paragraph is a line break, a blank line is a new
|
||||
assert.equal(md('one\ntwo'), '<p>one<br>two</p>');
|
||||
assert.equal(md('one\n\ntwo'), '<p>one</p><p>two</p>');
|
||||
});
|
||||
|
||||
test('an image is a picture when there is something to place it with', () => {
|
||||
assert.equal(mdi(''),
|
||||
'<p><img src="https://i.redd.it/x.jpg" alt="a cat"></p>');
|
||||
});
|
||||
|
||||
test('an image degrades to a link when there is not', () => {
|
||||
const out = md('');
|
||||
assert.ok(out.includes('<a href="https://i.redd.it/x.jpg"'));
|
||||
assert.ok(!out.includes('<img'));
|
||||
});
|
||||
|
||||
test("an image's `!` is not left behind as text", () => {
|
||||
// The link rule would otherwise match from the `[` and strand the bang.
|
||||
assert.ok(!mdi('').includes('!'));
|
||||
});
|
||||
|
||||
test('a pasted image address becomes the picture, not a link to it', () => {
|
||||
// Which is how most images in a Reddit comment arrive.
|
||||
const out = mdi('look\n\nhttps://preview.redd.it/abc.jpeg?width=1274&s=deadbeef');
|
||||
assert.ok(out.includes('<img src="https://preview.redd.it/abc.jpeg?width=1274&s=deadbeef"'));
|
||||
assert.ok(!out.includes('<a href'));
|
||||
});
|
||||
|
||||
test('a link that is not an image is still a link', () => {
|
||||
const out = mdi('see https://example.com/article');
|
||||
assert.ok(out.includes('<a href="https://example.com/article"'));
|
||||
assert.ok(!out.includes('<img'));
|
||||
});
|
||||
|
||||
test('a sentence after a pasted image keeps its punctuation out of the address', () => {
|
||||
const out = mdi('here https://i.redd.it/x.jpg.');
|
||||
assert.ok(out.includes('src="https://i.redd.it/x.jpg"'), out);
|
||||
assert.ok(out.endsWith('.</p>'), out);
|
||||
});
|
||||
|
||||
test('only http and https become pictures', () => {
|
||||
const out = mdi(')');
|
||||
assert.ok(!out.includes('<img'));
|
||||
assert.ok(out.includes('![x]'));
|
||||
});
|
||||
|
||||
test("an image's alt text is escaped like anything else a stranger wrote", () => {
|
||||
const out = mdi('');
|
||||
assert.ok(!out.includes('onerror=alert(1)>'), out);
|
||||
assert.ok(out.includes('"'));
|
||||
});
|
||||
|
||||
test('images inside a quote are still placed', () => {
|
||||
assert.match(mdi('> '), /<blockquote><p><img/);
|
||||
});
|
||||
|
||||
test('an image address typed without a scheme is still the picture', () => {
|
||||
// Which is how people type them: no https, straight from the address bar.
|
||||
const out = mdi('preview.redd.it/lz4drsqh0clh1.jpeg?width=1290&s=b27e');
|
||||
assert.ok(out.includes('<img src="https://preview.redd.it/lz4drsqh0clh1.jpeg?width=1290&s=b27e"'), out);
|
||||
});
|
||||
|
||||
test('a schemeless image address mid-sentence keeps the sentence', () => {
|
||||
const out = mdi('look at i.redd.it/x.png nice one');
|
||||
assert.ok(out.startsWith('<p>look at <img'), out);
|
||||
assert.ok(out.endsWith(' nice one</p>'), out);
|
||||
});
|
||||
|
||||
test('prose full of dots and slashes is not mistaken for an address', () => {
|
||||
// The reason this rule insists on a host, a path and an image extension.
|
||||
for (const text of [
|
||||
'the file is at src/render/post.ts',
|
||||
'see node_modules/foo/bar.js',
|
||||
'a path like ./images/cat.jpg',
|
||||
'C:/Users/x/cat.png',
|
||||
'email [email protected]/nope.jpg',
|
||||
'version 1.2.3/4.png',
|
||||
]) {
|
||||
assert.ok(!mdi(text).includes('<img'), `treated as an image: ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a schemeless address that is not an image is left alone', () => {
|
||||
// Guessing a scheme is worth it for a picture and not for prose.
|
||||
assert.equal(mdi('example.com/article'), '<p>example.com/article</p>');
|
||||
});
|
||||
|
||||
+1
-80
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { commentsFrom, mediaFromLink, resolveInlineImages, toPost, treeFromDepths } from '../src/platforms/reddit.ts';
|
||||
import { commentsFrom, mediaFromLink, toPost, treeFromDepths } from '../src/platforms/reddit.ts';
|
||||
import { reddit } from '../src/platforms/reddit.ts';
|
||||
import { originalUrlFor } from '../src/platforms/index.ts';
|
||||
import { fixture } from './helpers.ts';
|
||||
@@ -168,82 +168,3 @@ test('the page fallback rebuilds nesting from the depth on each comment', () =>
|
||||
test('a comment the page gave no text for is dropped rather than shown empty', () => {
|
||||
assert.deepEqual(treeFromDepths([{ depth: 0, author: 'a', score: 1, created: '', text: '' }]), []);
|
||||
});
|
||||
|
||||
// Real shapes, captured from comments carrying each kind.
|
||||
const GIPHY = {
|
||||
'giphy|Ve7wX45gaOFmw8eeEM': {
|
||||
status: 'valid',
|
||||
e: 'AnimatedImage',
|
||||
m: 'image/gif',
|
||||
s: {
|
||||
y: 200,
|
||||
x: 304,
|
||||
gif: 'https://external-preview.redd.it/CTp8.gif?width=304&height=200&s=b0e9',
|
||||
mp4: 'https://external-preview.redd.it/CTp8.gif?width=304&height=200&format=mp4&s=a389',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const UPLOAD = {
|
||||
jo8gf0ca92zd1: {
|
||||
status: 'valid',
|
||||
e: 'Image',
|
||||
m: 'image/jpeg',
|
||||
s: { y: 1270, x: 1274, u: 'https://preview.redd.it/jo8gf0ca92zd1.jpeg?width=1274&s=c226' },
|
||||
},
|
||||
};
|
||||
|
||||
test('a giphy comment points at the gif rather than at a token', () => {
|
||||
// `` is not an address, and renders as nothing at all until
|
||||
// it is looked up in the comment's own media_metadata.
|
||||
assert.equal(
|
||||
resolveInlineImages('', GIPHY),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('the animated form takes the gif, which plays on its own', () => {
|
||||
const out = resolveInlineImages('', GIPHY);
|
||||
assert.ok(out.includes('.gif?'), out);
|
||||
assert.ok(!out.includes('format=mp4'), 'an mp4 would need a player to move');
|
||||
});
|
||||
|
||||
test('an uploaded image resolves through the same lookup', () => {
|
||||
// Giphy, emotes and uploads are all a token that is a key in the same map,
|
||||
// so none of them needs naming.
|
||||
assert.equal(
|
||||
resolveInlineImages('', UPLOAD),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('a target that is already an address is left alone', () => {
|
||||
const already = '';
|
||||
assert.equal(resolveInlineImages(already, UPLOAD), already);
|
||||
assert.equal(resolveInlineImages(already, undefined), already);
|
||||
});
|
||||
|
||||
test('a token with no entry, or a broken one, is not invented', () => {
|
||||
assert.equal(resolveInlineImages('', GIPHY), '');
|
||||
assert.equal(
|
||||
resolveInlineImages('', { gone: { status: 'failed', e: 'Image' } }),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('inline images survive the walk into the comment tree', () => {
|
||||
const { comments } = commentsFrom({
|
||||
data: {
|
||||
children: [{
|
||||
kind: 't1',
|
||||
data: {
|
||||
author: 'a',
|
||||
body: 'ha ',
|
||||
media_metadata: GIPHY,
|
||||
replies: '',
|
||||
},
|
||||
}],
|
||||
},
|
||||
});
|
||||
assert.match(comments[0]?.text ?? '', /external-preview\.redd\.it/);
|
||||
});
|
||||
|
||||
@@ -237,21 +237,3 @@ test('markup in a quoted post is escaped like any other stranger\'s text', () =>
|
||||
assert.ok(!html_.includes('<script>alert(1)</script>'));
|
||||
assert.ok(!html_.includes('<img src=x'));
|
||||
});
|
||||
|
||||
test('a picture in a comment is proxied, and its alt text cannot break out', () => {
|
||||
const page = renderPost(redditPost({
|
||||
comments: [{
|
||||
author: 'u/a',
|
||||
text: '\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(/<img class="c__img" src="\/m\//g) ?? []).length, 2);
|
||||
// The payload survives as text inside the attribute, which is the point:
|
||||
// its quotes are neutered, so it cannot close `alt="` and become markup.
|
||||
assert.ok(page.includes('alt="" onerror=alert(1) x=""'), 'alt text must be escaped');
|
||||
assert.ok(!/alt="" onerror/.test(page), 'the attribute must not be closable');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user