An asset host's path can contain the asset directory too, as in
https://cdn.example.com/assets, and cutting each URL at the first one would
allow everything the host serves under it. Cut at the last one instead.
Behind a proxy that terminates TLS without X-Forwarded-Proto, Rails sees
http while the page is https, and an http source on a non-default port
doesn't match. A source without a scheme takes the page's own.
Uploads are served from this origin too, so script-src 'self' would run one
that a cache still holds under a script type from before the upload guard.
Application pages now allow each importmap URL up to its asset directory
(relative root and asset host included), the inline importmap by hash, and
the service worker by worker-src.
With DISABLE_CONTENT_SECURITY_POLICY=true there's no application policy,
and the hash callback started an empty one, so pages sent an empty
Content-Security-Policy header. Skip the callback then.
A request for several byte ranges gets a 206 multipart/byteranges whose
boundary the client needs to split the body. Rewriting it to
octet-stream lost the boundary. No browser runs that type as a script,
so it passes through.
script-src 'self', object-src 'none' and base-uri 'self', enforced. The
two inline scripts the importmap helper emits are allowed by their
SHA-256, and the origins its URLs point to (a configured asset host, in
any form Rails accepts) are allowed by name. There is no per-request
nonce, so cached pages stay valid.
DISABLE_CONTENT_SECURITY_POLICY=true leaves the policy off, for an
install whose proxy or customization injects scripts.
Uploaded files come from this origin, so one served with a JavaScript
type could run as one of the app's own scripts. Browsers accept sixteen
spellings of that type, and Disk URLs sign the type they were minted
with, so refusing a list of types at upload can't cover links already
out there. Instead, a middleware rewrites the type of anything Active
Storage reads out that isn't plainly media, a PDF or plain text to
application/octet-stream, whatever the URL says.
That's exactly what it was given before, with quotes and apostrophes
left as they are, rather than relying on its own sanitize pass to undo
h's '. Covers an email address with an apostrophe.
A <pre> whose text starts with a newline needs one more written out to
survive being parsed again, as the html5 serializer does with
preserve_newline. Without it, linking text dropped one more of a code
block's leading blank lines than rendering did before.
Replying swaps each mention in the quoted copy for its text. Assigning
that text to outerHTML parsed it as html; replace the mention with a
text node instead.
auto_link finds URLs and email addresses with regular expressions over
the rendered html, which can't tell an attribute value from text, so it
could put a link inside an attribute. Hand it each text node on its own,
escaped, after the final sanitize, so links only ever land between tags.
Bump the presentation cache versions so nothing rendered the old way is
served again.
The rejection cases built plain Net::HTTP connections that were also
unpinned or proxied, so they failed the guard either way. They're built
as WebPush::Connections::HTTP now, so each fails only for its stated
reason, and a pinned, direct, TLS plain Net::HTTP is refused for being
one. The error says which class is required.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WebPush::Connections mixed Stages into each new connection with
`http.extend Stages` just before starting it. The connection is built in
one place, WebPush::PersistentRequest, so it can be built from a class
that already includes Stages: the same method lookup, without giving
every pooled connection its own singleton class. Connections#request
now also refuses a connection that isn't one, so a caller that builds a
plain Net::HTTP gets the ArgumentError rather than a connection whose
failures can't be told apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opengraph::Fetch connects to the IP it checked, not to a fresh lookup of
the host. With http_proxy set, Net::HTTP would instead send the hostname
to the proxy, which looks it up again, so the connection could end up
somewhere other than the checked address. Pass nil as the proxy address,
as web push delivery already does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Apply the Laravel implementation’s MessageWriter architecture to Rails creation: save native Action Text and attachment metadata, insert the search entry and update unread memberships in the message transaction. Keep notification jobs after commit and preserve Room#receive and existing serialized jobs.
Cover native HTML/mentions, attachment-only search, derived-write failures, outer rollback and SQL commit ordering. Updates and destruction retain their current indexing behavior.
ensure! now rechecks trigger presence under BEGIN IMMEDIATE, backfills
drifted rooms.messages_count, and reinstalls all three triggers before
commit so concurrent writers and concurrent boot repairs cannot observe
a partial install or keep a wrong total. install! uses the same
immediate write lock for drop/recreate. Lifecycle regressions cover
missing/partial trigger drift, concurrent ensure!, and writes racing
repair.
Co-authored-by: Thomas Klemm <github@tklemm.eu>
Drop the explicit fixture list and prepend module. Override load_fixtures
only long enough to ensure! + backfill after alphabetical fixture load.
Move destructive trigger DDL into its own test file so parallel CI workers
do not strip triggers from the counter examples.
Co-authored-by: Thomas Klemm <github@tklemm.eu>
Require all three SQLite triggers before treating the counter as installed,
drop the schema.rb / dump-rewrite install paths in favor of rake ensure after
schema load, isolate destructive trigger tests, and cover foreign room moves
plus fixture baseline counts.
Co-authored-by: Thomas Klemm <github@tklemm.eu>
X-Total-Count on GET /rooms/:id/:bot_key/messages was COUNT(*) of the
room on every page. Serve it from rooms.messages_count updated by SQLite
triggers so Rails, bulk SQL, and foreign writers stay in step — without
ActiveRecord counter_cache callbacks those paths skip.
Fixes#309.
Co-authored-by: Thomas Klemm <github@tklemm.eu>
Keep the module under lib/rails_ext, use before_worker_boot, shorten the
SQLite busy timeout under stop's join, and PASSIVE-checkpoint on exit for
short-lived writers.
Co-authored-by: Thomas Klemm <github@tklemm.eu>
Unify startup: initializer starts every non-test process, Puma and Resque
stop before fork and start again in the child. Only the contender thread
releases the lock. Drop Puma::CLI / single_puma_process? special cases.
Co-authored-by: Thomas Klemm <github@tklemm.eu>
Disable wal_autocheckpoint and run PASSIVE checkpoints from one elected
writer process. Contenders start in console/rake (initializer), Puma
workers (including WEB_CONCURRENCY=auto), and Resque children, with
failover, capped error backoff, and tests for lock takeover.
Co-authored-by: Thomas Klemm <github@tklemm.eu>
Banning a user deletes their sessions and closes their connections but
keeps their push subscriptions, and Room::MessagePusher chose recipients
by membership alone. A banned user's browser or phone therefore went on
receiving the room name, sender and text of new direct messages,
mentions, and messages in rooms they had set to everything.
Choose subscriptions from active users only. The subscriptions are kept,
so unbanning brings notifications back without the user having to
subscribe again (the client doesn't resubscribe while the browser still
holds a subscription).
Co-authored-by: Marcello Costagliola <176920116+namespaceMarcello@users.noreply.github.com>
Transfer the C completed-response cache lesson into Rails, keeping authentication, room checks and cookies per request. A persistent read-only SQLite observer detects local and foreign commits and rejects racing admission. Whole-page misses render fresh to avoid stale nested fragments; message ETags reflect token-neutral presentation.
Every message rewrote the row of every disconnected member of the room,
including the ones who were unread already and stay unread. Open rooms
take in the whole account, so in steady state that is every member on
every message: 325 WAL pages per post at 10,000 members, against 16 when
only the members who had read the room are written.
Directs keep touching all their members: a direct's sidebar row is
cached by its membership and carries the room's recency, so it has to
be refreshed on every message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dFSDHrgrLoBELwjV3Qunq