Skip to content

Cache the anonymous page, so a bot costs a file read instead of a render (#233) - #242

Merged
openipc-ai merged 3 commits into
masterfrom
cache-the-anonymous-page
Sep 21, 2026
Merged

openipc-ai merged 3 commits into
masterfrom
cache-the-anonymous-page

Conversation

@openipc-ai

Copy link
Copy Markdown
Collaborator

Closes #233.

On 2026-09-20 the front page took 62,588 requests and every one was cache=- — not one served by nginx, every one a Rails render, for 1,787 distinct clients. Assets were at 89% hit, snapshots and the Open Wall were cached, and the largest class of traffic on the site was the only large one that was not.

25,525 of those arrived from openipc.kz, openipc.ru and openipc.eu, which proxy without caching. So caching at the origin is what stops a mirror visitor costing a render, without touching three configs this repository does not own.

Rails had been asking for this for days: since #155 a GET on / answers public, max-age=300, stale-while-revalidate=3600 and a catalogue page max-age=3600, stale-while-revalidate=86400, and nothing was listening. The change is a proxy_cache on location /, the way the three locations above it already have one.

Measured on production

front page, before:  75.1 ms mean,  0 cached — every request a Rails render
front page, after:    0.6 ms mean, 95 HIT / 5 MISS

A 125× drop on the site's largest request class.

Also verified live, being the things that could have gone badly:

  • / and /ru HIT; /donate, /privacy, /get-started MISS then HIT.
  • /admin/sign_in is MISS, max-age=0, private, and still sets its session cookie — signing in works and is never cached.
  • Accept-Language: ru on / still answers lang="ru", so Vary is being honoured. That header is load-bearing here rather than decoration: the unprefixed / really does negotiate.
  • HSTS headers unchanged.

What this location needs that the others do not

It is the one that also serves /admin and Devise, so two guards that are right for a gallery page are wrong here, and both would have been natural to copy.

No proxy_hide_header Set-Cookie. Every other cached location strips it, because those pages never set one. This location serves the sign-in form, and stripping the header there means nobody can sign in. Not storing such a response is what is wanted, and nginx does that by itself.

No proxy_cache_valid. Rails is the only thing that knows about the flash, the signed-in admin and the CSRF token, so it is the only thing that can decide what may be cached. proxy_cache_valid applies to responses that say nothing — which here are exactly the ones that must not be stored.

Two things that would have gone unnoticed

add_header in a location replaces every inherited one. Adding X-Cache-Status would have removed the vhost's HSTS from every page on the site — invisibly, because Rails sends a stronger one of its own and a browser would still have been protected. Repeated in the location, and asserted.

The key keeps the query string. Only 444 of 63,032 front-page requests carry one, so there is nothing to gain by dropping it — and dropping it is what produced the bug openipc-microcache.conf documents at length, where a key that ignored ?locale= served Russian to English readers.

Verification

Six guards, each confirmed by reverting it. That took three attempts to do honestly:

  • The first round of reverts was editing the assets location, because the line pair I was replacing appears there too — five probes "passed" against an untouched catch-all.
  • The first version of the tests matched the comments explaining why proxy_cache_valid and proxy_hide_header Set-Cookie are absent, and so reported the opposite of the truth. They read directives now, comments stripped.
  • The first catch_all helper matched the port-80 redirect block rather than the proxying one; it now asserts there is exactly one proxying location /.

microcache_language_test already reads every cached Rails location and holds the properties that keep languages apart and admins isolated; the new location is covered by it automatically and passes.

733 tests.

Already on production

The nginx half is applied — that is how #144's push-nginx.sh works, with the repository as the source of truth — so the numbers above are real rather than projected. Backups are at *.bak.20260921-091423 and it reverts in one reload.

Noticed, not mine

25% of requests to /snapshots (463 of 1,854 yesterday) end in a 60-second timeout and HTTP 408. The rate is unchanged across this deploy — 5 of 18 before the reload, 4 of 17 after — so it predates this and wants its own issue.

…der (#233)

On 2026-09-20 the front page took 62,588 requests and every one was cache=- :
not one served by nginx, every one a Rails render, for 1,787 distinct clients.
Assets were at 89% hit, snapshots and the Open Wall were cached, and the
largest class of traffic on the site was the only large one that was not.

25,525 of those arrived from openipc.kz, openipc.ru and openipc.eu, which
proxy without caching. So caching at the origin is what stops a mirror visitor
costing a render, without touching three configs this repository does not own.

Rails had been asking for this for days. Since #155 a GET on / answers
`public, max-age=300, stale-while-revalidate=3600` and a catalogue page
`max-age=3600, stale-while-revalidate=86400`, and nothing was listening. The
change is a proxy_cache on `location /`, the way the three locations above it
already have one.

## What this location needs that the others do not

It is the one that also serves /admin and Devise, so two of the guards that are
right for a gallery page are wrong here.

No `proxy_hide_header Set-Cookie`. Every other cached location strips it,
because those pages never set one; this location serves the sign-in form, and
stripping the header there means nobody can sign in. Not *storing* such a
response is what is wanted, and nginx does that by itself.

No `proxy_cache_valid`. Rails is the only thing that knows about the flash, the
signed-in admin and the CSRF token, so it is the only thing that can decide
what may be cached; proxy_cache_valid applies to responses that say nothing,
which here are exactly the ones that must not be stored.

## Two things that would have gone unnoticed

`add_header` in a location replaces every inherited one, so adding
X-Cache-Status would have removed the vhost's HSTS from every page on the site
-- invisibly, because Rails sends a stronger one of its own. Repeated in the
location, and asserted.

The key keeps the query string. Only 444 of 63,032 front-page requests carry
one, so there is nothing to gain by dropping it, and dropping it is what
produced the bug openipc-microcache.conf documents at length.

## Verified on production

/ and /ru HIT; /donate, /privacy and /get-started MISS then HIT. Two HSTS
headers, as before. Accept-Language still selects the language, so Vary is
being honoured -- / with Accept-Language: ru answers lang="ru", which is why
that header is load-bearing rather than decoration. /admin/sign_in is MISS,
max-age=0 private, and still sets its session cookie.

733 tests. Six guards, each confirmed by reverting it -- after the first round
of reverts turned out to be editing the assets location instead of this one,
and the first version of the tests matched the comments explaining why a
directive is absent rather than the directives themselves.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Cache anonymous pages at the nginx origin

✨ Enhancement ⚙️ Configuration changes 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Cache anonymous catch-all pages at nginx, honoring Rails cache-control lifetimes.
• Preserve admin sessions, locale-specific keys, stale serving, and HSTS behavior.
• Add regression tests for cache safety and security invariants.
Diagram

sequenceDiagram
    actor Client
    participant Nginx as nginx Catch-All
    participant Cache as Microcache
    participant Rails
    Client->>Nginx: GET page
    Nginx->>Cache: Lookup full URI
    alt Anonymous cache hit
        Cache-->>Nginx: Cached page
        Nginx-->>Client: HIT with HSTS
    else Miss or admin session
        Nginx->>Rails: Proxy request
        Rails-->>Nginx: Cache-Control response
        alt Public and cookie-free
            Nginx->>Cache: Store response
        else Private or Set-Cookie
            Note over Nginx,Cache: Response is not stored
        end
        Nginx-->>Client: MISS with HSTS
    end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Split public and admin nginx locations
  • ➕ Creates an explicit caching boundary around administrative routes.
  • ➕ Could allow simpler fixed cache policies for known public routes.
  • ➖ Requires nginx routes to remain synchronized with Rails routing.
  • ➖ Does not inherently protect flash, CSRF, non-200, or newly private responses.
  • ➖ Increases the risk of accidentally caching a sensitive route.
2. Configure caching on each mirror
  • ➕ Prevents repeated requests from reaching the origin at all.
  • ➕ Allows cache policy tuning near mirror users.
  • ➖ The repository does not control the three mirror configurations.
  • ➖ Direct bot traffic would continue causing Rails renders.
  • ➖ Policy could diverge between mirrors and the canonical site.

Recommendation: Keep the proposed origin nginx cache with Rails-controlled cache lifetimes. It reduces rendering for direct and mirrored traffic from one controlled configuration while preserving application knowledge of sessions, flash state, CSRF, and private responses; route splitting and mirror-specific caching introduce greater policy drift and incomplete coverage.

Files changed (2) +174 / -0

Tests (1) +119 / -0
page_cache_test.rbGuard catch-all page-cache safety invariants +119/-0

Guard catch-all page-cache safety invariants

• Adds configuration tests that locate the single proxying catch-all and inspect directives without matching comments. The tests require caching, admin isolation, query-preserving keys, and HSTS while forbidding fixed cache lifetimes and Set-Cookie suppression.

test/deploy/page_cache_test.rb

Other (1) +55 / -0
org.openipcEnable safe microcaching for catch-all page requests +55/-0

Enable safe microcaching for catch-all page requests

• Enables the existing nginx microcache for the TLS catch-all location using a query-preserving cache key, locking, and stale-response support. Session-bearing administrators bypass cached pages, admin renders are excluded from storage, Rails controls response lifetimes, and HSTS is repeated alongside cache-status diagnostics.

deploy/nginx/sites-available/org.openipc

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 21, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. HSTS regressions can pass unnoticed ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
The HSTS test builds server_level by grepping the entire vhost, so the catch-all location's own
HSTS directive satisfies the assertion even when no server-level directive exists. If the
server-level header is later removed, this test still passes while static and other locations that
depend on inheritance can stop receiving HSTS.
Code

test/deploy/page_cache_test.rb[85]

+    server_level = VHOST.lines.grep(/add_header Strict-Transport-Security/).map(&:strip).uniq
Evidence
The test's unrestricted grep at line 85 sees both the actual server-level directive and the newly
added location-level copy. Because the copy remains inside directives, deleting the server-level
declaration leaves both assertions satisfied, despite the assertion message claiming that this
condition is detected.

test/deploy/page_cache_test.rb[80-96]
deploy/nginx/sites-available/org.openipc[38-43]
deploy/nginx/sites-available/org.openipc[499-505]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The HSTS regression test searches the complete vhost and therefore mistakes the catch-all location's repeated header for the server-level header it intends to verify.
## Fix Focus Areas
- test/deploy/page_cache_test.rb[84-96]
## Recommended Fix
Restrict the server-level HSTS lookup to directives with server-level indentation, or parse the TLS server block while excluding location blocks. Keep the separate assertion that the catch-all repeats the exact server-level directive.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can add REVIEW.md to your repo root and Qodo follows it on every PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread test/deploy/page_cache_test.rb Outdated
The test grepped the whole vhost for `add_header Strict-Transport-Security`,
and since this change the catch-all contains one. So removing the server-level
directive left the assertion looking at the location's own copy, passed, and
would have let every other location -- assets, fonts, the snapshot pages, the
Open Wall -- lose the header it inherits, silently.

Confirmed before fixing: with the server-level line deleted, the old test was
still green.

It now reads directives that belong to a `server` block rather than to any
location inside it, tracked by brace depth rather than by indentation so it
does not quietly stop working when someone reformats the file. Both directions
fail as they should: deleting the inherited declaration, and dropping the
location's repeat of it.

733 tests, no rubocop offences.
Same behaviour, three small methods instead of one that tripped four Metrics
cops at once. Both revert directions still fail.
@openipc-ai
openipc-ai merged commit 9b4e5d6 into master Sep 21, 2026
2 checks passed
@openipc-ai
openipc-ai deleted the cache-the-anonymous-page branch September 21, 2026 09:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cache the anonymous page, so a bot costs a file read instead of a render

1 participant