152 Commits
Author SHA1 Message Date
SalastilandClaude Sonnet 5.5 cf5a66cde7 config: let the public port settle the link scheme
`server.https` picked the scheme for every generated link, and it also
decides whether cookies are marked Secure. The two are separate
questions, and a config that said `publicPort = 443` with `https =
false` produced `http://host:443` in every feed `<link>`, `<guid>`, the
OpenSearch template and the unfurl metadata. Nothing serves plain HTTP
on 443, so no such link was ever usable, and nothing warned about it.

Take the scheme from the port when the port is 443 or 80 -- they only
mean one thing -- and from `https` otherwise, so `https://host:8443`
still works. A default port is never appended. The `https` flag keeps
governing cookies, and startup now logs a warning when the port says TLS
and the flag does not, since the cookies are then not Secure.

The logic moves into a small pure function so the combinations can be
tested exhaustively rather than through a loaded config.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-11 21:03:10 -04:00
SalastilandClaude Opus 5 0f93fbaba5 api: read timelines that name their parts the new way
The list timeline comes back shaped like every other timeline but spelt
differently: instructions carry `__typename` where the schema read
`type`, and module items carry `tweetResult` where it read
`tweet_results`. Neither mismatch is an error -- the instruction falls
to `Other`, matches no branch, and the timeline parses to zero tweets.
A list that loads with no posts looks like an empty list, which is why
this survived a 500 being fixed in front of it.

Read both spellings. They are separate fields rather than serde aliases
because a payload carrying both would be a duplicate field and fail to
deserialize at all; `user_result` beside `user_results` already sets
that precedent. Route the other three parsers through the same accessor
so a future rename cannot empty a conversation or a search instead.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-11 20:32:09 -04:00
SalastilandClaude Opus 5 8e4b9aec76 api: read a list timeline under any of its three key names
X has moved timeline payloads between `timeline_v2`, `timeline` and
`timeline_response` over time, and `TimelineResultData` already accepts
all three. The list timeline took only `timeline_response`, and a key
that does not match parses to zero instructions rather than an error --
so a rename shows up as a list that loads fine and is simply empty.

Take the same three spellings there. Also log response bodies at trace,
off by default, since an empty parse is otherwise indistinguishable from
an empty list.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-11 20:26:14 -04:00
SalastilandClaude Opus 5 78686e3ad2 api: keep the data in a partial GraphQL response
X answers the list query with a complete `data.list` and one `errors`
entry beside it -- a normal GraphQL partial response, where a sub-field
failed and the rest resolved. `check_api_errors` failed on any error at
all, so it discarded a perfectly good list and every list page, members
page and feed returned 500.

Keep the data when the response carries some, and let the parsers decide
whether what they needed arrived; they already answer `NotFound` when a
field is missing. Errors that the session pool has to act on -- rate
limits, locked accounts, dead tokens -- stay fatal regardless, since
swallowing those would strand a session mid-rotation.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-11 17:33:56 -04:00
SalastilandClaude Opus 5 359227e386 lists: survive a metadata query that fails on its own
`ListByRestId` currently answers Error 214 for every list, while
`ListTimeline` beside it returns the tweets fine. Both routes bound the
metadata with `?`, so a cosmetic query taking down a working timeline
500'd the list page, the members page and the feed alike.

Fall back to an untitled list keyed by its id and log the reason. The
tweets are the page; the name is decoration, and a feed that publishes
without a title beats one that does not publish.

This does not fix `ListByRestId` -- the query appears to have gone stale
upstream -- it stops that failure from being fatal.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-11 17:29:42 -04:00
SalastilandClaude Opus 5 bb18a8ce44 api: log the body behind a 200 that carries errors
X answers some rejected queries with HTTP 200 and an `errors` array, and
that path never reaches the status logging, so the body is dropped and
all that survives is a bare code. `Error 214: BadRequest: DecodeException`
on every list page named neither the query nor the variable X objected
to; the same failure over HTTP 422 printed `path: ["variable","rest_id"]`
and settled it immediately.

Log the body, truncated, so the 200-with-errors case is as diagnosable
as the status-code case.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-11 17:29:42 -04:00
Salastil 06735deae3 Revert "api: key the list timeline on listId, not rest_id"
This reverts commit 773845807b.
2026-10-11 17:27:13 -04:00
SalastilandClaude Opus 5 773845807b api: key the list timeline on listId, not rest_id
`list_timeline_vars` reused the v2 media timeline's `rest_id_page_vars`,
so every `ListTimeline` query named the list `rest_id`. X cannot decode
that and rejects the whole query with

  Error 214: BadRequest: com.twitter.strato.serialization.DecodeException

which surfaced as a 500 on every list page and list feed. The other
three list queries -- `ListByRestId`, `ListBySlug`, `ListMembers` --
already send `listId`; the timeline was the one that borrowed a struct
shaped for a different endpoint.

Give it its own variables and leave `rest_id_page_vars` to the media
timeline that genuinely wants `rest_id`. Tests pin both spellings so the
two cannot be unified again by mistake.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-11 17:24:20 -04:00
SalastilandClaude Opus 5 8e4f15db3b config: admit Cloudflare's analytics beacon too
`behindCloudflare` allowed the challenge host but not Web Analytics,
which loads its beacon from static.cloudflareinsights.com and reports to
cloudflareinsights.com. Both were refused, so a zone with Web Analytics
on still logged a CSP violation on every page -- the flag only half did
what it claims.

Found by reading the console on a live instance: the challenge bootstrap
and JS detection now run, the beacon was the one thing left blocked.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-10 18:29:09 -04:00
SalastilandClaude Opus 5 eb1b9e0682 config: let Cloudflare's challenge run behind behindCloudflare
Cloudflare injects its JS-detection bootstrap inline into origin HTML.
`script-src 'self'` stops it executing, so the challenge never records
that it was solved and Cloudflare serves another one on the next
request -- a loop no visitor can escape, which reads as a Cloudflare
bug rather than a header of ours.

Allowing it needs `'unsafe-inline'`, since an inline script cannot be
allowlisted by URL and Cloudflare cannot know a nonce we generate. That
is a real weakening, so it is opt-in: the default policy is unchanged
byte for byte, and only an instance Cloudflare actually fronts turns it
on. Nothing else in the policy relaxes.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-10 15:30:29 -04:00
SalastilandClaude Opus 5 9582c9eed3 country: assert the tables match what the lookup assumes
`flag` binary searches both tables, so an entry inserted out of order
stops resolving with no error anywhere -- and the sort order lived only
in a comment. Check it, along with the two invariants the rest of the
lookup rests on: names are lowercased, because the needle is, and codes
are `A..=Z`, because `Flag`'s `Display` offsets off `b'A'` and panics
otherwise.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-09 22:55:24 -04:00
SalastilandClaude Opus 5 bd7f9e9414 embed: cover the photo-prefixed activity id
The `/photo/N` unfurl encodes the photo in the status id Discord echoes
back, zero-padding the tweet to 19 digits so the prefix stays outside
snowflake range. Nothing exercised that round trip, and the padding is
easy to break without noticing, so pin the encoding, the plain-id case,
the tenth-photo fallback and the rejections. Same for `keep_only_photo`,
which the unfurl leans on to narrow the media.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-09 22:55:24 -04:00
Salastil 64a4331512 Merge upstream teapot into our fork 2026-10-09 22:50:32 -04:00
Amaan Qureshi 68ba24381a status: preview only the linked photo for /photo/N unfurls 2026-10-04 21:32:45 -04:00
Amaan Qureshi 7b6fa095d0 views: draw thread lines between replies in a chain 2026-10-01 02:50:49 -04:00
Amaan Qureshi 4a5ac3a1fe routes: trim trailing slashes and fix indexed media redirects 2026-10-01 02:39:52 -04:00
entailzandAmaan Qureshi 0c69db354e country: show the account location as a flag instead of text
Co-authored-by: Amaan Qureshi <[email protected]>
2026-09-21 18:46:14 -04:00
SalastilandClaude Opus 5 938e3c6037 search: read every filter toggle the panel emits
The panel generated `f-<name>` and `e-<name>` checkboxes for ten filters,
but `SearchQuery` named a subset of nine fields and serde dropped the
rest, so "retweets only" and every exclude but replies did nothing. The
one exclude field that existed, `e-retweets`, was a name no checkbox ever
submitted.

Collect the toggles by prefix instead, validating each against
`VALID_FILTERS`, and drive the checkboxes from a single table so the two
sides cannot drift apart again. An exclude now wins over the matching
include, which would otherwise build a query that can never match.

Profile search and the search feed grew the same handling: both parsed
the query text alone and ignored the toggles entirely. The feed keys its
cache on the built query, since filters are part of a feed's identity.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-20 20:28:32 -04:00
Amaan Qureshi 8e1996e397 parser: preserve media grid order
Fixes #3
2026-09-16 19:59:24 -04:00
Amaan Qureshi 04b1cfee52 media: preserve every GIF in multi-GIF posts 2026-09-06 04:02:45 -04:00
Amaan Qureshi cc1330ebcb http: name every transport failure instead of formatting a string 2026-09-06 04:02:45 -04:00
Amaan Qureshi de37f0587a views: take preferences everywhere instead of an optional twin 2026-09-06 04:02:45 -04:00
Amaan Qureshi bfb52f143f sessions: keep the limit maps ordered 2026-09-06 04:02:45 -04:00
Amaan Qureshi 6de4cde206 comments: drop the ones that restate the code beneath them 2026-09-06 04:02:45 -04:00
Amaan Qureshi 99d0564fbf types: name the search product, ranking mode and tab kinds 2026-09-06 04:02:44 -04:00
Amaan Qureshi 884fb7c658 modules: drop every pub use and import from the defining module 2026-09-06 04:02:42 -04:00
Amaan Qureshi b641bd8482 all: prefer turbofish 2026-09-03 01:52:37 -04:00
Amaan Qureshi fceb9834ff api: type the GraphQL variables instead of assembling json values 2026-09-03 01:52:37 -04:00
Amaan Qureshi 9c71848770 cache: serve stale search and profile pages while one refresh runs 2026-09-03 01:52:37 -04:00
Amaan Qureshi 42baad12b5 api: refuse ids that cannot be snowflakes before spending a session 2026-09-03 01:52:37 -04:00
Amaan Qureshi 13bf915b1f parser: read media search modules and grid entries 2026-09-03 01:52:36 -04:00
Amaan Qureshi b0af8d499f parser: return an empty timeline when X sends no instructions 2026-09-03 01:52:36 -04:00
Amaan Qureshi 1262c11ab3 utils: append the cursor after an existing query string 2026-09-03 01:52:36 -04:00
Amaan Qureshi 85c36c26a0 api: stop paging a search that repeats one post 2026-09-03 01:52:36 -04:00
Amaan Qureshi 9aed08c046 api: report an expired cursor as a bad request 2026-09-03 01:52:36 -04:00
Amaan Qureshi f26ef79007 api: rotate a locked cookie session instead of calling the profile suspended 2026-09-03 01:52:36 -04:00
Amaan Qureshi 8e88f648bf api: treat a bodyless 404 as transient and retry once 2026-09-03 01:52:34 -04:00
Amaan Qureshi 5054640a27 prefs: let the theme cookie be read from script 2026-08-31 21:41:06 -04:00
Amaan Qureshi 9bfb7e75a3 css: rework thread connectors, nav and the about panel 2026-08-30 03:09:38 -04:00
Amaan Qureshi 458358c525 views: show country and translation on quoted posts 2026-08-30 02:15:46 -04:00
Amaan Qureshi 2ab6875626 views: say what a suspended or missing account means 2026-08-30 01:47:26 -04:00
Amaan Qureshiandentailz 4f08ae5fe5 views: fill the photo rail grid evenly
Co-authored-by: entailz <[email protected]>
2026-08-29 22:35:17 -04:00
Amaan Qureshiandentailz 155f36dbf7 views: mark thread position on timeline items
Co-authored-by: entailz <[email protected]>
2026-08-29 22:35:17 -04:00
Amaan Qureshiandentailz b9772f09a7 views: add a mobile navigation menu
Co-authored-by: entailz <[email protected]>
2026-08-29 22:35:17 -04:00
Amaan Qureshiandentailz c36ffc52e8 views: abbreviate profile stat counts
Co-authored-by: entailz <[email protected]>
2026-08-29 22:35:17 -04:00
Amaan Qureshiandentailz fb5bcc7bf4 views: add the sticky thread context bar
Co-authored-by: entailz <[email protected]>
2026-08-29 22:35:17 -04:00
Amaan Qureshiandentailz 6498856052 views: rework the landing page and search results
Co-authored-by: entailz <[email protected]>
2026-08-29 22:35:14 -04:00
Amaan Qureshiandentailz bbd493cbd0 views: group the settings form into sections
Co-authored-by: entailz <[email protected]>
2026-08-29 22:08:50 -04:00
Amaan Qureshiandentailz d762c36b50 views: show X's about-account panel
Co-authored-by: entailz <[email protected]>
2026-08-29 22:08:50 -04:00
Amaan Qureshiandentailz e741c9e7be routes: resolve about-account data without fanning out
Co-authored-by: entailz <[email protected]>
2026-08-29 22:08:50 -04:00