Compare commits
24
Commits
1.2.0
...
51dfade4f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51dfade4f9
|
||
|
|
6e687cdddc | ||
|
|
4e45456097 | ||
|
|
05455a86da | ||
|
|
4c78ce8a27
|
||
|
|
d9fa2f5768 | ||
|
|
dfabfc18d6 | ||
|
|
3afdf6ffe2 | ||
|
|
5b4378a838
|
||
|
|
4669fe0b6a
|
||
|
|
ebfcc9ebf2 | ||
|
|
8fc7a83f8e | ||
|
|
91d4c2da50
|
||
|
|
899b6e38d8
|
||
|
|
abf8ec317c
|
||
|
|
3ff7375320
|
||
|
|
43dc10e1dc
|
||
|
|
b94c43a10c
|
||
|
|
b9b56e2195
|
||
|
|
031101c382
|
||
|
|
0db18547c8
|
||
|
|
9abc0eaf62
|
||
|
|
24db9be5af
|
||
|
|
503d8a8dec
|
@@ -13,6 +13,7 @@ name: Publish
|
||||
# vars.IMAGE_NAME optional, defaults to this repository's owner/name
|
||||
# vars.REGISTRY_USER optional, defaults to the actor running the workflow
|
||||
# secrets.REGISTRY_TOKEN required to push
|
||||
# secrets.RELEASE_BOT_SIGNING_KEY required to sign the version bump commit
|
||||
#
|
||||
# Point REGISTRY at a host the runner reaches directly, without an intermediate
|
||||
# proxy that caps request bodies: a browser image has layers well over 100MB,
|
||||
@@ -141,3 +142,190 @@ jobs:
|
||||
- name: Log out
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
run: docker logout "${{ vars.REGISTRY }}" || true
|
||||
|
||||
# Once a release is out, the version in package.json has already shipped.
|
||||
# Moving it on to the next patch means the working tree is never sitting on
|
||||
# a number that is published and immutable, and that a build from main is
|
||||
# always identifiable as "after 1.2.0" rather than "1.2.0, but not really".
|
||||
#
|
||||
# `needs: build` is the point of putting this here rather than in a workflow
|
||||
# of its own: a version that failed to publish has not been released, and
|
||||
# bumping past it would say it had.
|
||||
bump:
|
||||
name: Move the working version on
|
||||
needs: build
|
||||
# Tags only, and only final ones. A prerelease has not shipped the version
|
||||
# it is a candidate for, so there is nothing yet to move past.
|
||||
if: github.ref_type == 'tag' && !contains(github.ref_name, '-')
|
||||
runs-on: ubuntu-latest
|
||||
# npm does the bump, so this one job wants node. A job in a container is
|
||||
# given `sh -e {0}` as its shell rather than the bash the runner's own jobs
|
||||
# get, and dash has neither `pipefail` nor the `10#` below — which failed
|
||||
# the first line of the first step the one time this ran. node:22 is Debian
|
||||
# and carries bash, so asking for it keeps these scripts the same as the
|
||||
# ones in the job above.
|
||||
container:
|
||||
image: node:22
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
# The tag names a commit in main's history, but the bump belongs on the
|
||||
# branch, so this checks out main rather than the tag. The full history
|
||||
# because a shallow clone cannot reliably push a branch back.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Work out the next patch version
|
||||
id: next
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
version="${{ github.ref_name }}"
|
||||
version="${version#v}"
|
||||
|
||||
major="${version%%.*}"
|
||||
rest="${version#*.}"
|
||||
minor="${rest%%.*}"
|
||||
patch="${rest##*.}"
|
||||
|
||||
# `10#` forces base ten: a patch number written 08 would otherwise be
|
||||
# read as octal and fail to parse.
|
||||
next="${major}.${minor}.$((10#${patch} + 1))"
|
||||
|
||||
echo "next=${next}" >> "$GITHUB_OUTPUT"
|
||||
echo "Released ${version}; the working version becomes ${next}"
|
||||
|
||||
- name: Bump package.json
|
||||
id: bump
|
||||
env:
|
||||
NEXT: ${{ steps.next.outputs.next }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
current="$(node -p "require('./package.json').version")"
|
||||
if [ "${current}" = "${NEXT}" ]; then
|
||||
echo "package.json is already ${NEXT}; nothing to do."
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# npm rather than editing the file: the version is in the lockfile
|
||||
# too, in more than one place, and they have to agree.
|
||||
npm version "${NEXT}" --no-git-tag-version --allow-same-version >/dev/null
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "package.json ${current} -> ${NEXT}"
|
||||
|
||||
# main requires signed commits, and a pull request carrying an unsigned
|
||||
# one cannot be merged. The key the server signs merge commits with lives
|
||||
# on the server and no runner can reach it, so the bump commit is signed
|
||||
# here with a dedicated key the instance trusts through
|
||||
# `[repository.signing] TRUSTED_SSH_KEYS`. Setting that up is in
|
||||
# CLAUDE.md; nothing about it is committed here.
|
||||
- name: Configure signing as the release bot
|
||||
if: steps.bump.outputs.changed == 'true'
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.RELEASE_BOT_SIGNING_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${SIGNING_KEY}" ]; then
|
||||
echo "RELEASE_BOT_SIGNING_KEY is not set; the bump commit would be unsigned and unmergeable." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v ssh-keygen >/dev/null; then
|
||||
echo "ssh-keygen is missing from this image; git cannot make SSH signatures without it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The secret holds an OpenSSH private key. git signs by shelling out
|
||||
# to ssh-keygen, which wants the key on disk beside the `.pub` it is
|
||||
# pointed at, readable only by us, and rejects it unless the trailing
|
||||
# newline survived the round trip through the secret store.
|
||||
keydir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/release-bot-signing"
|
||||
install -m 700 -d "${keydir}"
|
||||
printf '%s\n' "${SIGNING_KEY}" | tr -d '\r' > "${keydir}/key"
|
||||
chmod 600 "${keydir}/key"
|
||||
|
||||
# Doubles as a format check: a truncated, re-wrapped or
|
||||
# passphrase-protected key fails here rather than as "gpg failed to
|
||||
# sign the data" three steps later.
|
||||
if ! ssh-keygen -y -f "${keydir}/key" </dev/null > "${keydir}/key.pub"; then
|
||||
echo "RELEASE_BOT_SIGNING_KEY is not a usable OpenSSH private key (passphrase-protected, truncated, or re-wrapped on paste)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A name that is not a person, and an address no account backs: the
|
||||
# signature verifies against the trusted key rather than against a
|
||||
# user, so this is a label on the commit and not an identity.
|
||||
git config user.name 'Release Bot'
|
||||
git config user.email '[email protected]'
|
||||
# `gpg.format` is the historical name; `ssh` is what switches git to
|
||||
# signing with the key above rather than with a GPG key.
|
||||
git config gpg.format ssh
|
||||
git config user.signingkey "${keydir}/key.pub"
|
||||
git config commit.gpgsign true
|
||||
|
||||
# The bump arrives as a pull request rather than as a commit straight to
|
||||
# main. Pushing a branch asks nothing of the task token beyond ordinary
|
||||
# write access, so nothing here depends on being allowed past whatever
|
||||
# protects main; and the pull request puts the changed package.json
|
||||
# through the build before it lands. Since main is never pushed, the
|
||||
# `[skip ci]` that would otherwise be needed to stop this rebuilding the
|
||||
# image just published is not.
|
||||
- name: Open a pull request for it
|
||||
if: steps.bump.outputs.changed == 'true'
|
||||
env:
|
||||
NEXT: ${{ steps.next.outputs.next }}
|
||||
RELEASED: ${{ github.ref_name }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
branch="release/bump-${NEXT}"
|
||||
|
||||
git checkout -b "${branch}"
|
||||
git add package.json package-lock.json
|
||||
git commit -m "Set the working version to ${NEXT}"
|
||||
|
||||
# An unsigned commit would go unnoticed until someone tried to merge
|
||||
# the pull request, so it fails here instead, where the cause is in
|
||||
# front of you.
|
||||
if ! grep -q "^gpgsig" <<<"$(git cat-file commit HEAD)"; then
|
||||
echo "The bump commit came out unsigned; refusing to push it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git push origin "${branch}"
|
||||
|
||||
# node rather than jq to build the request body: jq is not in this
|
||||
# image, and node is the one thing that certainly is.
|
||||
payload="$(BRANCH="${branch}" node -e 'process.stdout.write(JSON.stringify({
|
||||
head: process.env.BRANCH,
|
||||
base: "main",
|
||||
title: `Set the working version to ${process.env.NEXT}`,
|
||||
body: `${process.env.RELEASED} has shipped, so the tree was left on a version that is published and immutable. This moves it on to ${process.env.NEXT}, which is deliberately not a version that exists.`,
|
||||
}))')"
|
||||
|
||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
response="$(mktemp)"
|
||||
code="$(curl -sS -o "${response}" -w '%{http_code}' \
|
||||
-X POST "${api}/pulls" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "${payload}")"
|
||||
|
||||
case "${code}" in
|
||||
201) echo "Opened ${branch} against main." ;;
|
||||
# A release re-run that got this far: the branch and its pull
|
||||
# request are already there, which is the state we wanted anyway.
|
||||
409) echo "A pull request for ${branch} is already open." ;;
|
||||
*)
|
||||
echo "Could not open the pull request (HTTP ${code}):" >&2
|
||||
cat "${response}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -96,6 +96,15 @@ Things worth knowing before editing:
|
||||
`src/media/registry.ts` and served from `/m/<id>` with the `Referer`/`Cookie` the CDN
|
||||
demands. `Range` is forwarded — without it the native video scrubber cannot seek.
|
||||
The exception is HLS (`direct: true`), because proxying would mean rewriting playlists.
|
||||
- **A video is sized by its poster**, not by the ratio the renderer puts on it. A
|
||||
`<video>` with no data has a natural size of 300x150, and WebKit sizes it from that —
|
||||
so one with no poster of its own gets an empty SVG of the right shape as a stand-in,
|
||||
without which a portrait video sits in a squat landscape box until you press play.
|
||||
- **A page with a video claims the `playback` audio session** (`public/app.js`). iOS
|
||||
hands inline video the ambient session, which the Ring/Silent switch mutes, so
|
||||
without this the video plays and says nothing until it goes fullscreen — and the
|
||||
report that arrives is "this one platform has no sound". The session only activates
|
||||
once something plays, so declaring it costs nothing on a page nobody presses play on.
|
||||
- **Never a bare error page.** A failed resolve renders a card carrying the platform, the
|
||||
original URL and the copy button. A broken adapter must still leave the link one tap
|
||||
away.
|
||||
@@ -144,10 +153,25 @@ Things worth knowing before editing:
|
||||
Video: `fallback_url` is the *video track alone* whenever `has_audio` is true, so a
|
||||
post with sound has to use `hls_url`, direct and unproxied; a silent one gets the
|
||||
proxied MP4. `scrubber_media_url` is not a poster — it is a second MP4 for the
|
||||
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
|
||||
timeline thumbnails, and the still is in `preview.images` — which belongs to both
|
||||
forms: the HLS one used to go without, and showed an empty box where a silent post
|
||||
showed a frame. 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 — ``,
|
||||
``, `` — and the token is usually a key
|
||||
in that same comment's own `media_metadata`, so `resolveInlineImages` is one lookup
|
||||
rather than three special cases. Usually: plenty of comments carry a Giphy token and
|
||||
no `media_metadata` at all, and Giphy is the one of the three whose id means something
|
||||
off Reddit, so that token alone falls back to `i.giphy.com/media/<id>/giphy.gif`. A
|
||||
variant name after the id is dropped — Giphy does not serve every variant of every
|
||||
gif. 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.
|
||||
- **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
|
||||
@@ -174,6 +198,10 @@ Login walls are not passed through — those need an account and cannot be solve
|
||||
- **No ESLint** — `typescript-eslint` does not support TS 7 yet. `npm run typecheck` is
|
||||
the lint step.
|
||||
- Tests are `node:test` against captured fixtures. No network in the test suite.
|
||||
- `public/` is served as-is to the browser, so what is in there is plain JS, not TS.
|
||||
`public/browsers.js` — the per-browser URL schemes behind the "open in" picker — is
|
||||
a module rather than more of `app.js` so a test can import it; that is what `allowJs`
|
||||
in `tsconfig.json` is for.
|
||||
- Comments explain *why*, especially where the code looks odd because a platform is odd.
|
||||
Match that; do not add narration of what the next line does.
|
||||
- Post text comes from strangers: everything goes through the `html` tagged template or
|
||||
@@ -201,6 +229,59 @@ The registry comes from the `REGISTRY` repository variable, the image name from
|
||||
`IMAGE_NAME` or the repository name, and credentials from `REGISTRY_USER` and the
|
||||
`REGISTRY_TOKEN` secret. Nothing about any particular deployment is committed here.
|
||||
|
||||
A release also moves `package.json` on to the next patch version, opened as a pull
|
||||
request by the `bump` job — so the number in the tree is never one that has already
|
||||
shipped and been made immutable. It lives in `publish.yml` rather than a workflow of its
|
||||
own so it can say `needs: build`: a version that failed to publish has not been
|
||||
released, and bumping past it would claim otherwise. Prereleases are skipped, being
|
||||
candidates for a version that has not shipped. The bump goes through `npm version`
|
||||
rather than an edit in place, because the version is in the lockfile too, in more than
|
||||
one place, and the two have to agree.
|
||||
|
||||
It arrives as a pull request rather than as a commit straight to main: pushing a branch
|
||||
asks nothing of the task token beyond ordinary write access, so it does not matter what
|
||||
protects main, and the changed `package.json` goes through a build before it lands.
|
||||
Nothing pushes to main, so no `[skip ci]` is needed to stop the bump rebuilding the
|
||||
image just published. The job is the only one that runs in a container (`node:22`, for
|
||||
npm), and a job in a container is handed `sh`, not bash — hence the explicit
|
||||
`shell: bash`, without which `set -o pipefail` fails the first line of the first step.
|
||||
|
||||
### Signing the bump commit
|
||||
|
||||
main requires signed commits, and a pull request carrying an unsigned one cannot be
|
||||
merged — so the bump job signs the commit it makes. Not with the key the server signs
|
||||
merge commits with: that one lives on the server and no runner can reach it. It uses a
|
||||
dedicated release-bot SSH key instead, which also means it can be rotated on its own if
|
||||
the secret ever leaks.
|
||||
|
||||
There is deliberately no release-bot account. A key attached to an account is only
|
||||
consulted for signature checking once it has been through the web *Verify* flow, and
|
||||
that flow has no API, so a bot account would need an interactive login to be worth
|
||||
anything. Listing the key under `[repository.signing] TRUSTED_SSH_KEYS` instead makes
|
||||
the signature verify with no account lookup at all, which is all the protected branch
|
||||
asks for. `[email protected]` is therefore a label and not an identity, and
|
||||
the signature is attributed to the instance's `SIGNING_NAME`/`SIGNING_EMAIL` rather
|
||||
than to it. The other side of trusting a key instance-wide: a commit signed with it
|
||||
verifies in *every* repository on that instance, because the trust is in the key and not
|
||||
in a user whose permissions you could scope.
|
||||
|
||||
Set up once per instance, and again only on rotation:
|
||||
|
||||
1. Generate a passphrase-less key — it has to be usable unattended:
|
||||
`ssh-keygen -t ed25519 -C release-bot -f release-bot -N ''`.
|
||||
2. Add the public half to `TRUSTED_SSH_KEYS` in the server config and restart.
|
||||
3. Store the private half as the `RELEASE_BOT_SIGNING_KEY` Actions secret — the whole
|
||||
file verbatim, `-----BEGIN OPENSSH PRIVATE KEY-----` and footer included, not the
|
||||
`.pub` and not a GPG export. An organisation secret covers every repository at once.
|
||||
Delete both local files afterwards.
|
||||
|
||||
Until both are in place the bump job fails, loudly and on purpose: it checks the secret
|
||||
is set and that `ssh-keygen` exists before it starts, feeds the key through
|
||||
`ssh-keygen -y` so a truncated or re-wrapped one is caught as itself rather than as
|
||||
"gpg failed to sign the data", and re-reads the commit for a `gpgsig` header before
|
||||
pushing. Nothing else in the pipeline signs anything — release tags are made by hand,
|
||||
and the merge commit is signed by the server.
|
||||
|
||||
Two things any deployment has to get right, both learned the hard way:
|
||||
|
||||
- **Chromium needs more than the default 64Mi `/dev/shm`** or it crashes. Mount a
|
||||
|
||||
@@ -21,15 +21,43 @@ readable in your history.
|
||||
|
||||
Replace `antisocial.example.com` with wherever you are running it.
|
||||
|
||||
| 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` |
|
||||
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.
|
||||
|
||||
So `https://x.com/user/status/123` becomes
|
||||
`https://antisocial.example.com/x/user/status/123`.
|
||||
@@ -42,7 +70,37 @@ 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 this table with the live hostnames, if you'd rather read it there.
|
||||
`/` serves these rules with the live hostname already filled in, if you'd rather copy
|
||||
them from there.
|
||||
|
||||
## Opening the original
|
||||
|
||||
The rules are indiscriminate, which is the point — but that catches the way back out
|
||||
too. Tapping "Open on Instagram" in the browser the rules are installed in redirects
|
||||
straight back here, so the one button meant to get you to the app is the one button that
|
||||
cannot.
|
||||
|
||||
The way past it is to hand the address to a *different* browser, by its own URL scheme.
|
||||
`/` has a picker for which one:
|
||||
|
||||
| Browser | macOS | iOS |
|
||||
| --- | --- | --- |
|
||||
| Chrome | `googlechromes://…` | `googlechromes://…` |
|
||||
| Edge | `microsoft-edge:https://…` | `microsoft-edge-https://…` |
|
||||
| Orion | `orion://open-url?url=…` | `orion://open-url?url=…` |
|
||||
| Firefox | — | `firefox://open-url?url=…` |
|
||||
|
||||
Firefox on macOS registers no scheme, so it is not offered there; nor is Safari itself,
|
||||
which is the browser being escaped. The choice is kept in that browser's own
|
||||
`localStorage` — not a cookie and not synced, because "which browser is installed" is a
|
||||
fact about the device, not about you, and the phone's answer is not the Mac's.
|
||||
|
||||
Until a browser is chosen there is no "Open on …" button at all — the plain address is
|
||||
the one thing it must not offer, since following it only comes back here. The markup
|
||||
carries that address anyway, hidden, and the script swaps in the scheme and reveals the
|
||||
button once there is somewhere to send it. So with JavaScript off, or with the default
|
||||
left alone, the copy button and the selectable URL are the whole of it, which is all
|
||||
that was ever load-bearing.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -59,13 +117,13 @@ Each adapter layers its extraction, most structured first:
|
||||
3. **The rendered DOM** — whatever is actually on screen is real.
|
||||
4. **Open Graph tags** — the floor, and enough to show something.
|
||||
|
||||
| Platform | Loads | Reads |
|
||||
| --------- | ---------------------------- | -------------------------------------------------------- |
|
||||
| Bluesky | the public AT Protocol API | `getPostThread`; falls back to the post page |
|
||||
| X | `platform.twitter.com` embed | the `cdn.syndication.twimg.com/tweet-result` response |
|
||||
| Instagram | `/embed/captioned/` | `shortcode_media`, then the rendered `<video>`/`<img>` |
|
||||
| TikTok | the post page | `__UNIVERSAL_DATA_FOR_REHYDRATION__` |
|
||||
| Threads | the post page | the Relay payloads in `<script type="application/json">` |
|
||||
| Platform | Loads | Reads |
|
||||
| --------- | ---------------------------- | --------------------------------------------------------------- |
|
||||
| Bluesky | the public AT Protocol API | `getPostThread`; falls back to the post page |
|
||||
| X | `platform.twitter.com` embed | the `cdn.syndication.twimg.com/tweet-result` response |
|
||||
| Instagram | `/embed/captioned/` | `shortcode_media`, then the rendered `<video>`/`<img>` |
|
||||
| TikTok | the post page | `__UNIVERSAL_DATA_FOR_REHYDRATION__` |
|
||||
| Threads | the post page | the Relay payloads in `<script type="application/json">` |
|
||||
| Reddit | the post's own `.json` | the post and the first page of comments; falls back to the page |
|
||||
|
||||
On Bluesky and Threads people write in chains, so where the linked post is part
|
||||
@@ -93,10 +151,17 @@ Reddit posts come with it: every comment the first page carried, nested the way
|
||||
written. Each comment is a `<details>` element, so folding one takes its whole subtree
|
||||
with it, works without JavaScript and works from the keyboard; a collapsed comment says
|
||||
how many replies it is hiding. "Collapse all" is the one piece that needs the script,
|
||||
which is why it only appears once the script has run. What was behind a *load more* is
|
||||
which is why it only appears once the script has run. What was behind a _load more_ is
|
||||
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
|
||||
@@ -106,6 +171,10 @@ The one exception is Bluesky video, which is an HLS playlist — proxying it wou
|
||||
rewriting the manifest and every segment, so it is linked directly. Safari plays HLS
|
||||
natively; other browsers show a note.
|
||||
|
||||
A page carrying a video asks iOS for the playback audio session. Without it a video
|
||||
playing inline is treated as ambience and the Ring/Silent switch mutes it, so the post
|
||||
plays perfectly and says nothing unless you go fullscreen.
|
||||
|
||||
Resolved posts are cached in memory for an hour, so a reload or a back button doesn't
|
||||
drive the browser again.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "antisocial",
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "antisocial",
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.5",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@fastify/static": "10.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "antisocial",
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.5",
|
||||
"private": true,
|
||||
"description": "Reads social posts back to you without the app.",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
@@ -242,6 +242,30 @@ main { max-width: 680px; margin: 0 auto; }
|
||||
.index__table td { padding: 8px 8px 8px 0; border-top: 1px solid var(--line); vertical-align: middle; }
|
||||
.index__table code { overflow-wrap: anywhere; }
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
margin-top: 18px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
/* The control ships hidden and is revealed by the script, and `display:
|
||||
flex` above would otherwise beat the browser's own rule for [hidden]. */
|
||||
.picker[hidden] { display: none; }
|
||||
.picker__label { font-size: 13px; color: var(--ink-dim); }
|
||||
.picker__select {
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
}
|
||||
.picker__note { flex-basis: 100%; margin: 0; font-size: 12px; color: var(--ink-dim); }
|
||||
|
||||
/* ---------- verification puzzle ---------- */
|
||||
|
||||
.challenge__lede { margin: 14px; font-size: 14px; color: var(--ink-dim); }
|
||||
@@ -556,3 +580,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; }
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// Progressive enhancement only. Without this file the page still shows the
|
||||
// media in a swipeable rail and the original URL as selectable text.
|
||||
|
||||
import { browsersFor, detectOs, openUrlFor } from './browsers.js';
|
||||
|
||||
const VIEW_KEY = 'antisocial:view';
|
||||
const BROWSER_KEY = 'antisocial:browser';
|
||||
|
||||
function setupCopy() {
|
||||
for (const button of document.querySelectorAll('.copy')) {
|
||||
@@ -37,6 +40,62 @@ function setupCopy() {
|
||||
}
|
||||
}
|
||||
|
||||
function storedBrowser() {
|
||||
try {
|
||||
return localStorage.getItem(BROWSER_KEY) ?? 'default';
|
||||
} catch {
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
// The markup carries the plain address, which is the one thing the button
|
||||
// must not offer: followed in the browser the rewrite rules are installed
|
||||
// in, it redirects straight back to this page. So the link ships hidden and
|
||||
// is only revealed once a browser has been picked to hand it to. With no
|
||||
// choice made -- or none this system has a scheme for -- the copy button
|
||||
// and the URL below it are the whole of it.
|
||||
function applyBrowser(id) {
|
||||
const os = detectOs();
|
||||
|
||||
for (const link of document.querySelectorAll('.original__open')) {
|
||||
link.dataset.original ??= link.getAttribute('href');
|
||||
|
||||
const href = openUrlFor(link.dataset.original, id, os);
|
||||
link.href = href;
|
||||
link.hidden = href === link.dataset.original;
|
||||
}
|
||||
}
|
||||
|
||||
// Only on the index page, and only with this file running: the choice is
|
||||
// useless without the rewriting above, so the control ships hidden.
|
||||
function setupBrowserPicker() {
|
||||
const select = document.querySelector('.picker__select');
|
||||
if (!select) return;
|
||||
|
||||
for (const browser of browsersFor(detectOs())) {
|
||||
const option = document.createElement('option');
|
||||
option.value = browser.id;
|
||||
option.textContent = browser.label;
|
||||
select.append(option);
|
||||
}
|
||||
|
||||
// A stored id with no scheme on this system would leave the control
|
||||
// showing nothing at all, so fall back rather than render a blank.
|
||||
const stored = storedBrowser();
|
||||
select.value = [...select.options].some((option) => option.value === stored) ? stored : 'default';
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
try {
|
||||
localStorage.setItem(BROWSER_KEY, select.value);
|
||||
} catch {
|
||||
// Private browsing. The choice just won't survive the page.
|
||||
}
|
||||
applyBrowser(select.value);
|
||||
});
|
||||
|
||||
select.closest('.picker').hidden = false;
|
||||
}
|
||||
|
||||
// One per rail: a post can carry several -- a thread of them, or a post and
|
||||
// the post it quotes -- and wiring only the first leaves the rest inert.
|
||||
function setupMedia(media) {
|
||||
@@ -96,6 +155,19 @@ function setupMedia(media) {
|
||||
}
|
||||
}
|
||||
|
||||
// On iOS a video playing inline gets the "ambient" audio session, which the
|
||||
// Ring/Silent switch mutes -- so the post plays perfectly and says nothing,
|
||||
// and the only way to hear it is to go fullscreen. Claiming "playback" says
|
||||
// what is true here: the sound is the point, not decoration. The session is
|
||||
// not activated until something actually plays, so declaring it up front
|
||||
// interrupts nothing; it is declared only on a page that has a video so an
|
||||
// ordinary text post never claims it at all.
|
||||
function setupAudioSession() {
|
||||
if (!('audioSession' in navigator)) return;
|
||||
if (!document.querySelector('video')) return;
|
||||
navigator.audioSession.type = 'playback';
|
||||
}
|
||||
|
||||
// Every comment is a <details open>, so folding one already works with this
|
||||
// file missing. All this adds is doing the whole page at once, which is why
|
||||
// the button ships hidden and is only revealed here.
|
||||
@@ -127,7 +199,10 @@ function setupComments() {
|
||||
}
|
||||
|
||||
setupCopy();
|
||||
applyBrowser(storedBrowser());
|
||||
setupBrowserPicker();
|
||||
for (const media of document.querySelectorAll('.media')) setupMedia(media);
|
||||
setupAudioSession();
|
||||
setupComments();
|
||||
|
||||
// --- Verification puzzles -------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// The rewrite rules that send a shared link here catch the original link on
|
||||
// the way back out too, so in Safari "Open on <platform>" lands back on this
|
||||
// page. Handing the address to a different browser is the way out, and the
|
||||
// only way to do that from a web page is that browser's own URL scheme --
|
||||
// which every one of them spells differently. Some swap the scheme, some
|
||||
// prefix it, some take the whole address as a query parameter.
|
||||
|
||||
/** http -> googlechrome://, https -> googlechromes:// */
|
||||
const chrome = (url) => url.replace(/^http(s?):/, 'googlechrome$1:');
|
||||
|
||||
// Edge keeps the original scheme either way, but on macOS it stays in the
|
||||
// address and the scheme is prefixed, while on iOS it is folded into the
|
||||
// scheme itself.
|
||||
const edgeMacos = (url) => `microsoft-edge:${url}`;
|
||||
const edgeIos = (url) => url.replace(/^http(s?):/, 'microsoft-edge-http$1:');
|
||||
|
||||
/** Firefox and Orion take the address as a parameter instead. */
|
||||
const openUrl = (scheme) => (url) => `${scheme}://open-url?url=${encodeURIComponent(url)}`;
|
||||
|
||||
// `null` means the browser is there but ships no scheme to reach it on that
|
||||
// system, so there is nothing to offer beyond the plain link.
|
||||
export const BROWSERS = [
|
||||
{ id: 'default', label: 'Default browser', macos: null, ios: null },
|
||||
{ id: 'chrome', label: 'Chrome', macos: chrome, ios: chrome },
|
||||
{ id: 'edge', label: 'Edge', macos: edgeMacos, ios: edgeIos },
|
||||
{ id: 'firefox', label: 'Firefox', macos: null, ios: openUrl('firefox') },
|
||||
{ id: 'orion', label: 'Orion', macos: openUrl('orion'), ios: openUrl('orion') },
|
||||
];
|
||||
|
||||
/**
|
||||
* iPadOS calls itself MacIntel, so the platform string alone cannot tell the
|
||||
* two apart; the touch points can. Everything else gets the desktop table --
|
||||
* the only desktop this is ever opened on is a Mac.
|
||||
*
|
||||
* @param {{ platform?: string, maxTouchPoints?: number }} [nav]
|
||||
*/
|
||||
export function detectOs(nav = globalThis.navigator) {
|
||||
const platform = nav?.platform ?? '';
|
||||
if (/^iP(hone|ad|od)/.test(platform)) return 'ios';
|
||||
if (platform === 'MacIntel' && (nav?.maxTouchPoints ?? 0) > 1) return 'ios';
|
||||
return 'macos';
|
||||
}
|
||||
|
||||
/** The browsers worth offering here: the default, plus the ones this system
|
||||
* actually has a scheme for. */
|
||||
export function browsersFor(os) {
|
||||
return BROWSERS.filter((browser) => browser.id === 'default' || browser[os]);
|
||||
}
|
||||
|
||||
function browserById(id) {
|
||||
return BROWSERS.find((browser) => browser.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The address to open. A browser with no scheme on this system, an unknown
|
||||
* id, or anything that is not an ordinary web link is handed back untouched:
|
||||
* a dead custom scheme opens nothing at all, which is worse than the plain
|
||||
* link opening in the wrong browser.
|
||||
*/
|
||||
export function openUrlFor(url, browserId, os) {
|
||||
const rewrite = browserById(browserId)?.[os];
|
||||
if (!rewrite || !/^https?:\/\//i.test(url)) return url;
|
||||
return rewrite(url);
|
||||
}
|
||||
+56
-7
@@ -62,6 +62,7 @@ type Link = {
|
||||
type CommentData = {
|
||||
author?: string;
|
||||
body?: string;
|
||||
media_metadata?: Record<string, MediaMeta>;
|
||||
created_utc?: number;
|
||||
score?: number;
|
||||
score_hidden?: boolean;
|
||||
@@ -103,6 +104,10 @@ function fromRedditVideo(video: RedditVideo, poster: string | undefined): Media[
|
||||
const common = {
|
||||
...sized(video.width, video.height),
|
||||
...(video.duration ? { durationSec: video.duration } : {}),
|
||||
// The still belongs to the video and not to the format it is served in.
|
||||
// The HLS branch used to drop it, which is why a post with sound showed
|
||||
// an empty box where every silent one showed a frame.
|
||||
...(poster ? { poster: { url: poster } } : {}),
|
||||
};
|
||||
|
||||
if (video.has_audio !== false && video.hls_url) {
|
||||
@@ -113,12 +118,7 @@ function fromRedditVideo(video: RedditVideo, poster: string | undefined): Media[
|
||||
if (url === video.hls_url) {
|
||||
return [{ kind: 'video', url, hls: true, direct: true, ...common }];
|
||||
}
|
||||
return [{
|
||||
kind: 'video',
|
||||
url,
|
||||
...(poster ? { poster: { url: poster } } : {}),
|
||||
...common,
|
||||
}];
|
||||
return [{ kind: 'video', url, ...common }];
|
||||
}
|
||||
|
||||
/** One entry of a gallery post. Reddit keeps the pictures somewhere other
|
||||
@@ -188,6 +188,55 @@ function bodyOf(link: Link): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The whole of ``, with the target captured. */
|
||||
const INLINE_IMAGE = /!\[([^\]\n]*)\]\(([^)\s]+)\)/g;
|
||||
|
||||
/**
|
||||
* A Giphy token, which unlike the others says what it points at.
|
||||
*
|
||||
* Reddit writes a variant name after the id on some of them
|
||||
* (`giphy|abc123|downsized`); only the id is kept, because Giphy does not
|
||||
* serve every variant for every gif but always serves the full one.
|
||||
*/
|
||||
const GIPHY_TOKEN = /^giphy\|([A-Za-z0-9]+)(?:\|[a-z_]+)?$/;
|
||||
|
||||
/**
|
||||
* Point a comment's inline images at something fetchable.
|
||||
*
|
||||
* Reddit writes them as ``, ``
|
||||
* or `` — a token rather than an address. Usually 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.
|
||||
*
|
||||
* Usually, not always: Reddit ships plenty of comments carrying a Giphy token
|
||||
* and no `media_metadata` at all, and with nothing to look the token up in
|
||||
* those showed the token itself where the gif should have been. Giphy is the
|
||||
* one kind that can be resolved without the lookup, the id in it being Giphy's
|
||||
* own, so it falls back to Giphy's address for that id. The other two cannot:
|
||||
* their ids mean nothing off Reddit.
|
||||
*
|
||||
* 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 {
|
||||
return body.replace(INLINE_IMAGE, (whole, alt: string, token: string) => {
|
||||
const entry = meta?.[token];
|
||||
if (entry?.status === 'valid') {
|
||||
// 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;
|
||||
if (url) return ``;
|
||||
}
|
||||
|
||||
const giphy = GIPHY_TOKEN.exec(token)?.[1];
|
||||
return giphy ? `` : whole;
|
||||
});
|
||||
}
|
||||
|
||||
export function commentsFrom(listing: Listing<CommentData> | undefined): {
|
||||
comments: Comment[];
|
||||
more: number;
|
||||
@@ -209,7 +258,7 @@ export function commentsFrom(listing: Listing<CommentData> | 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.
|
||||
|
||||
@@ -28,7 +28,7 @@ export function renderChallenge(challenge: Challenge, box: Box | undefined): str
|
||||
<footer class="post__foot">
|
||||
<div class="original">
|
||||
<button type="button" class="copy" id="give-up">Give up and show me the link</button>
|
||||
<a class="original__open" href="${challenge.originalUrl}" rel="noopener noreferrer nofollow" target="_blank">
|
||||
<a class="original__open" href="${challenge.originalUrl}" rel="noopener noreferrer nofollow" target="_blank" hidden>
|
||||
Open on ${challenge.platformLabel}
|
||||
</a>
|
||||
<p class="original__url"><code>${challenge.originalUrl}</code></p>
|
||||
|
||||
@@ -30,6 +30,14 @@ export function renderIndex(platforms: readonly PlatformSpec[]): string {
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<section class="picker" hidden>
|
||||
<label class="picker__label" for="open-in">Open original links in</label>
|
||||
<select class="picker__select" id="open-in"></select>
|
||||
<p class="picker__note">
|
||||
The rewrite rules catch the original link too, so in Safari “Open on …” comes
|
||||
straight back here. Another browser is a way out of that. Kept on this device.
|
||||
</p>
|
||||
</section>
|
||||
</article>`;
|
||||
|
||||
return layout('antisocial', body);
|
||||
|
||||
@@ -43,14 +43,19 @@ export function badge(platform: string, label: string): Raw {
|
||||
}
|
||||
|
||||
/** The copy control, plus the URL itself so it is always selectable even if
|
||||
* the clipboard API is unavailable. */
|
||||
* the clipboard API is unavailable.
|
||||
*
|
||||
* The open link ships hidden: followed in the browser the rewrite rules are
|
||||
* installed in, it redirects straight back here, so it is only worth showing
|
||||
* once a browser has been picked to hand it to. The script reveals it, and
|
||||
* the href in the markup is the plain address it starts from. */
|
||||
export function originalUrlBlock(originalUrl: string, platformLabel: string): Raw {
|
||||
return html`<div class="original">
|
||||
<button type="button" class="copy" data-url="${originalUrl}">
|
||||
<span class="copy__idle">Copy original link</span>
|
||||
<span class="copy__done" hidden>Copied</span>
|
||||
</button>
|
||||
<a class="original__open" href="${originalUrl}" rel="noopener noreferrer nofollow" target="_blank">Open on ${platformLabel}</a>
|
||||
<a class="original__open" href="${originalUrl}" rel="noopener noreferrer nofollow" target="_blank" hidden>Open on ${platformLabel}</a>
|
||||
<p class="original__url"><code>${originalUrl}</code></p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
+62
-22
@@ -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
|
||||
'(?<![\\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
|
||||
// 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
|
||||
].join('|'),
|
||||
'g',
|
||||
);
|
||||
|
||||
/** One line of body text: escaped, with the inline constructs put back. */
|
||||
function inline(text: string): string {
|
||||
function inline(text: string, image?: ImageRenderer): string {
|
||||
let out = '';
|
||||
let cursor = 0;
|
||||
|
||||
for (const match of text.matchAll(INLINE)) {
|
||||
const [whole, code, label, href, strong, strike, emStar, emScore, url, subOrUser] = match;
|
||||
const [whole, code, alt, src, label, href, strong, strike, emStar, emScore, url, schemeless,
|
||||
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)}</strong>`;
|
||||
out += `<strong>${inline(strong, image)}</strong>`;
|
||||
} else if (strike !== undefined) {
|
||||
out += `<del>${inline(strike)}</del>`;
|
||||
out += `<del>${inline(strike, image)}</del>`;
|
||||
} else if (emStar !== undefined || emScore !== undefined) {
|
||||
out += `<em>${inline(emStar ?? emScore ?? '')}</em>`;
|
||||
out += `<em>${inline(emStar ?? emScore ?? '', image)}</em>`;
|
||||
} 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 += `<blockquote>${blocks(body.map((l) => l.replace(/^\s*>\s?/, '')))}</blockquote>`;
|
||||
out += `<blockquote>${blocks(body.map((l) => l.replace(/^\s*>\s?/, '')), image)}</blockquote>`;
|
||||
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) => `<li>${inline(l.replace(pattern, ''))}</li>`).join('')}</${tag}>`;
|
||||
out += `<${tag}>${items.map((l) => `<li>${inline(l.replace(pattern, ''), image)}</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)).join('<br>')}</p>`;
|
||||
out += `<p>${paragraph.map((l) => inline(l, image)).join('<br>')}</p>`;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
+38
-2
@@ -14,11 +14,29 @@ function aspect(item: Media): string {
|
||||
return item.width && item.height ? `aspect-ratio: ${item.width} / ${item.height};` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in poster carrying nothing but the video's shape.
|
||||
*
|
||||
* The ratio above is not enough on its own before the video has any data: a
|
||||
* `<video>` in that state has a natural size of 300x150, and WebKit sizes it
|
||||
* from that rather than from the ratio, so a portrait video sat in a squat
|
||||
* landscape box until you pressed play and it snapped to shape. A video's
|
||||
* size before its data arrives is its poster's, which makes an empty SVG of
|
||||
* the right shape enough to put the box right, and as a data URI it costs no
|
||||
* request. Only for a video the platform gave no poster for, since a real one
|
||||
* already says the same thing.
|
||||
*/
|
||||
function placeholderPoster(item: Media): string | undefined {
|
||||
if (!item.width || !item.height) return undefined;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${item.width}" height="${item.height}"/>`;
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
function renderItem(item: Media, index: number): Raw {
|
||||
const src = proxyUrlFor(item);
|
||||
|
||||
if (item.kind === 'video') {
|
||||
const poster = item.poster ? proxyUrlFor(item.poster) : undefined;
|
||||
const poster = item.poster ? proxyUrlFor(item.poster) : placeholderPoster(item);
|
||||
return html`<figure class="item item--video" data-index="${index}">
|
||||
<video
|
||||
controls
|
||||
@@ -145,6 +163,22 @@ 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 {
|
||||
@@ -179,7 +213,9 @@ function renderComment(comment: Comment, depth: number): Raw {
|
||||
}</span>`
|
||||
: ''}
|
||||
</summary>
|
||||
${comment.text ? html`<div class="c__body">${renderMarkdown(comment.text)}</div>` : ''}
|
||||
${comment.text
|
||||
? html`<div class="c__body">${renderMarkdown(comment.text, renderCommentImage)}</div>`
|
||||
: ''}
|
||||
${comment.replies.length || comment.moreReplies
|
||||
? html`<div class="c__replies">
|
||||
${comment.replies.map((reply) => renderComment(reply, depth + 1))}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { browsersFor, detectOs, openUrlFor } from '../public/browsers.js';
|
||||
|
||||
const URL = 'https://www.instagram.com/p/ABC/?a=1&b=2';
|
||||
|
||||
test('each browser gets the scheme it actually answers to', () => {
|
||||
assert.equal(openUrlFor(URL, 'chrome', 'macos'), 'googlechromes://www.instagram.com/p/ABC/?a=1&b=2');
|
||||
assert.equal(openUrlFor(URL, 'chrome', 'ios'), 'googlechromes://www.instagram.com/p/ABC/?a=1&b=2');
|
||||
assert.equal(openUrlFor('http://example.com/a', 'chrome', 'ios'), 'googlechrome://example.com/a');
|
||||
|
||||
// Edge folds the scheme into its own on iOS and prefixes it on macOS.
|
||||
assert.equal(openUrlFor(URL, 'edge', 'macos'), `microsoft-edge:${URL}`);
|
||||
assert.equal(openUrlFor(URL, 'edge', 'ios'), 'microsoft-edge-https://www.instagram.com/p/ABC/?a=1&b=2');
|
||||
|
||||
// The parameter form has to be encoded, or the original query string ends
|
||||
// up read as the opener's own.
|
||||
assert.equal(openUrlFor(URL, 'orion', 'macos'),
|
||||
`orion://open-url?url=${encodeURIComponent(URL)}`);
|
||||
assert.equal(openUrlFor(URL, 'firefox', 'ios'),
|
||||
`firefox://open-url?url=${encodeURIComponent(URL)}`);
|
||||
});
|
||||
|
||||
test('a browser with no scheme on this system keeps the plain link', () => {
|
||||
// Firefox on macOS has none, and a dead scheme opens nothing at all --
|
||||
// worse than opening in the wrong browser.
|
||||
assert.equal(openUrlFor(URL, 'firefox', 'macos'), URL);
|
||||
assert.equal(openUrlFor(URL, 'default', 'ios'), URL);
|
||||
assert.equal(openUrlFor(URL, 'nonesuch', 'ios'), URL);
|
||||
});
|
||||
|
||||
test('only ordinary web links are rewritten', () => {
|
||||
assert.equal(openUrlFor('mailto:[email protected]', 'chrome', 'ios'), 'mailto:[email protected]');
|
||||
assert.equal(openUrlFor('/reddit/r/a/comments/b', 'chrome', 'ios'), '/reddit/r/a/comments/b');
|
||||
});
|
||||
|
||||
test('only the browsers reachable on that system are offered', () => {
|
||||
assert.deepEqual(browsersFor('macos').map((b) => b.id), ['default', 'chrome', 'edge', 'orion']);
|
||||
assert.deepEqual(browsersFor('ios').map((b) => b.id), ['default', 'chrome', 'edge', 'firefox', 'orion']);
|
||||
});
|
||||
|
||||
test('an iPad is told from a Mac by its touch points, not its platform string', () => {
|
||||
assert.equal(detectOs({ platform: 'iPhone', maxTouchPoints: 5 }), 'ios');
|
||||
assert.equal(detectOs({ platform: 'MacIntel', maxTouchPoints: 5 }), 'ios');
|
||||
assert.equal(detectOs({ platform: 'MacIntel', maxTouchPoints: 0 }), 'macos');
|
||||
assert.equal(detectOs({}), 'macos');
|
||||
});
|
||||
@@ -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 =>
|
||||
`<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>'));
|
||||
@@ -64,3 +71,85 @@ 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>');
|
||||
});
|
||||
|
||||
+134
-1
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { commentsFrom, mediaFromLink, toPost, treeFromDepths } from '../src/platforms/reddit.ts';
|
||||
import { commentsFrom, mediaFromLink, resolveInlineImages, 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';
|
||||
@@ -56,6 +56,7 @@ test('a video with sound is the HLS playlist, because the MP4 has no audio track
|
||||
duration: 42,
|
||||
},
|
||||
},
|
||||
preview: { images: [{ source: { url: 'https://external-preview.redd.it/still.png', width: 1920, height: 1080 } }] },
|
||||
});
|
||||
|
||||
assert.deepEqual(media, [
|
||||
@@ -67,6 +68,9 @@ test('a video with sound is the HLS playlist, because the MP4 has no audio track
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
durationSec: 42,
|
||||
// The still is the video's, not the MP4's. Dropping it here left every
|
||||
// post with sound showing an empty box where a silent one showed a frame.
|
||||
poster: { url: 'https://external-preview.redd.it/still.png' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -168,3 +172,132 @@ 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', () => {
|
||||
// An upload id and an emote id mean nothing off Reddit, so with no entry to
|
||||
// look them up in there is nothing to point them at.
|
||||
assert.equal(resolveInlineImages('', GIPHY), '');
|
||||
assert.equal(
|
||||
resolveInlineImages('', { gone: { status: 'failed', e: 'Image' } }),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('a giphy token resolves even when the comment carried no metadata', () => {
|
||||
// Reddit ships plenty of these with no `media_metadata` at all. The id in
|
||||
// the token is Giphy's own, so it does not need Reddit to be readable.
|
||||
assert.equal(
|
||||
resolveInlineImages('', undefined),
|
||||
'',
|
||||
);
|
||||
assert.equal(
|
||||
resolveInlineImages('', GIPHY),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('the metadata still wins where there is any, being what Reddit will serve', () => {
|
||||
assert.match(resolveInlineImages('', GIPHY), /redd\.it/);
|
||||
});
|
||||
|
||||
test('a variant name after the id is dropped, not all of them being served', () => {
|
||||
assert.equal(
|
||||
resolveInlineImages('', undefined),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('only a giphy token is guessed at, and only a well-formed one', () => {
|
||||
for (const token of ['emote|t5_2th52|4358', 'giphy|', 'giphy|../evil', 'giphy|a|b|c']) {
|
||||
assert.equal(resolveInlineImages(``, undefined), ``);
|
||||
}
|
||||
});
|
||||
|
||||
test('a real comment carrying a giphy token gets the gif', () => {
|
||||
// Captured from the post itself: the comment has the token and no
|
||||
// `media_metadata`, which is the shape that used to show the token instead.
|
||||
const post = toPost(fixture('reddit/video.json'), URL_);
|
||||
const all: string[] = [];
|
||||
const walk = (list: typeof post.comments) => {
|
||||
for (const comment of list ?? []) {
|
||||
if (comment.text) all.push(comment.text);
|
||||
walk(comment.replies);
|
||||
}
|
||||
};
|
||||
walk(post.comments);
|
||||
|
||||
const gif = all.find((text) => text.includes('giphy'));
|
||||
assert.ok(gif, 'the fixture should still carry a giphy comment');
|
||||
assert.match(gif, /!\[gif\]\(https:\/\/i\.giphy\.com\/media\/QfzMP70zmNQiDf5sGP\/giphy\.gif\)/);
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
+44
-1
@@ -90,6 +90,27 @@ test('a video gets native controls and a source, not an iframe', () => {
|
||||
assert.ok(!page.includes('<iframe'));
|
||||
});
|
||||
|
||||
test('a video with no poster still carries its shape, so the box is right before play', () => {
|
||||
// WebKit sizes a video from its natural size, which without data or a
|
||||
// poster is 300x150 — a portrait video sat in a landscape box until you
|
||||
// pressed play. The poster is where the shape comes from until then.
|
||||
const page = renderPost(withMedia([
|
||||
{ kind: 'video', url: 'https://video.example/p.m3u8', hls: true, direct: true, width: 720, height: 1280 },
|
||||
]));
|
||||
assert.ok(page.includes('aspect-ratio: 720 / 1280;'));
|
||||
assert.match(page, /poster="data:image\/svg\+xml,[^"]*width%3D%22720%22[^"]*height%3D%221280%22/);
|
||||
});
|
||||
|
||||
test('a real poster is left in place, and an unmeasured video gets none', () => {
|
||||
const withPoster = renderPost(withMedia([
|
||||
{ kind: 'video', url: 'https://cdn/v.mp4', width: 720, height: 1280, poster: { url: 'https://cdn/p.jpg' } },
|
||||
]));
|
||||
assert.match(withPoster, /poster="\/m\//);
|
||||
assert.ok(!withPoster.includes('data:image/svg'));
|
||||
|
||||
assert.ok(!renderPost(withMedia([{ kind: 'video', url: 'https://cdn/v.mp4' }])).includes('poster='));
|
||||
});
|
||||
|
||||
test('the copy button carries the clean original URL, and so does the page text', () => {
|
||||
const page = renderPost(post());
|
||||
assert.ok(page.includes('data-url="https://www.instagram.com/p/ABC/"'));
|
||||
@@ -105,8 +126,12 @@ test('a failure still hands the link back', () => {
|
||||
detail: 'TikTok showed a verification puzzle instead of the post.',
|
||||
});
|
||||
assert.ok(page.includes('data-url="https://www.tiktok.com/@a/video/1"'));
|
||||
assert.ok(page.includes('Open on TikTok'));
|
||||
assert.ok(page.includes('<code>https://www.tiktok.com/@a/video/1</code>'));
|
||||
assert.ok(page.includes('verification puzzle'));
|
||||
// Following the original link in the browser the rewrite rules are
|
||||
// installed in only comes back here, so it stays hidden until the script
|
||||
// has a browser to hand it to.
|
||||
assert.ok(/<a class="original__open"[^>]* hidden>Open on TikTok<\/a>/.test(page));
|
||||
});
|
||||
|
||||
function redditPost(overrides: Partial<Post> = {}): Post {
|
||||
@@ -237,3 +262,21 @@ 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');
|
||||
});
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
// Source imports carry the real `.ts` specifier so `node --experimental-
|
||||
// strip-types` can run the tree directly for dev, tests and the resolve
|
||||
// CLI. tsc rewrites them to `.js` on the way into dist.
|
||||
// The client-side scheme table is plain JS, because the browser loads it
|
||||
// as-is out of `public`. Its types are inferred so the test can check it.
|
||||
"allowJs": true,
|
||||
|
||||
"allowImportingTsExtensions": true,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user