Skip to content

update from Gluejar - #94

Open
eshellman wants to merge 1230 commits into
EbookFoundation:masterfrom
Gluejar:master
Open

eshellman wants to merge 1230 commits into
EbookFoundation:masterfrom
Gluejar:master

Conversation

@eshellman

Copy link
Copy Markdown

No description provided.

simplify sitemap, remove expensive cover_image query
5% improvement
fix downloads, add 3 cmp providers
update MetadataReader for DOAB OAI feed
implement turnstile on the search form, shorten session life
rdhyee and others added 30 commits August 31, 2026 10:27
…sal log

Round-2's fix dropped the recipient list from the log/exception text
but added message.subject as a "safe" identifier instead -- Codex
round 3 correctly pointed out a subject can itself carry PII (e.g.
"Password reset for real@example.com"), recreating the exact exposure
the subject/body split (two commits back) exists to avoid. Dropped the
subject too; the log line is now just a bare recipient count.

Added test_refusal_log_line_carries_no_pii, which uses assertLogs to
capture the actual emitted log record (not just the exception text)
and asserts neither the recipient address nor a PII-bearing subject
appear in it.

Not fixed, deliberately: round 3 also suggested _is_allowed() should
do full RFC 5322 mailbox validation (parseaddr() accepts some
oddly-formed-but-still-matching values, e.g. a quoted local-part or a
trailing comma, that a stricter parser would reject). No concrete
example was found where this lets a genuinely non-allowlisted address
through -- every example given was an allowed address in an unusual
form. Given three rounds have now closed every demonstrated bypass,
treating this as a documented stopping point rather than open-ended
hardening of a backstop feature.

Verified: `manage.py test regluit.utils` -- 24 tests, all pass.
`manage.py test libraryauth frontend utils` -- 74 tests, still the
same 3 pre-existing failures + 2 pre-existing errors as origin/master,
zero net-new.

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uv8CgcRqX4qjx9C74bVHj
Add opt-in email allowlist/redirect backend for non-prod (backstop for #1238)
Password managers can auto-submit the login form right after autofill.
When the user then clicks "Sign in with Password" themselves, the second
POST carries the pre-login CSRF token (Django rotates the CSRF cookie on
successful login), so the user lands on a bare 403 even though the first
POST already logged them in. Confirmed live on prod 2026-08-31: an
isTrusted:false synthetic click submitted the form 1.2s before the
user's real click; every 302 login that day was paired with a 403 one
second later.

The guard is a document-level delegated submit listener in sitewide1.js,
scoped to forms posting to /accounts/superlogin/: the first submission
marks the form, later ones are prevented while it is in flight. It must
live in sitewide JS rather than the form template because the sign-in
lightbox is injected via jQuery .load(url + " #lightbox_content"), which
strips inline <script> tags.

Smoke tests pin the wiring: the login page loads sitewide1.js and the
form still posts to the guarded action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqzfARuryr6m6jzGscgAY6
…behavioral tests

- Blocker: the 8s timed unlock could itself recreate the 403 (unlocking
  while a slow login navigation is still pending lets a resubmission
  carry the stale token). Removed; the lock now holds until navigation
  or bfcache restore.
- Lock is module-global instead of per form node: standalone page +
  lightbox copies (or a reopened lightbox) are all locked by one login
  in flight.
- Split into capture-phase blocker + bubble-phase acquirer, so a
  submission some other handler cancels never acquires the lock, and a
  locked submission is stopped before other handlers run.
- Action matched by resolved pathname (URL()), not substring.
- pageshow handler: a bfcache-restored page still holds the lock and a
  pre-login CSRF token, so reload it for fresh state.
- Real behavioral coverage: dependency-free Node test with DOM fakes
  (static/js/tests/login_guard_test.js, 9 cases) extracting the guard
  between source markers; run from the Django suite when node exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqzfARuryr6m6jzGscgAY6
… rollback

- Acquire the lock in the document CAPTURE listener (runs before any
  target/ancestor handler can stopPropagation the event away), with a
  deferred post-dispatch check that releases it if a later handler
  cancelled the submission -- fixes both the stuck-lock-on-late-cancel
  and the stopPropagation acquisition bypass.
- Blocked submissions get stopImmediatePropagation() so downstream
  submit handlers cannot run side effects for them, and the blocked
  form's own button is disabled immediately (visible stuck-state even
  for a freshly reopened lightbox form).
- isLoginForm now also requires same-origin, not just pathname.
- Test harness models real propagation (doc capture -> target -> doc
  bubble, honoring preventDefault/stopPropagation/stopImmediate-
  Propagation, default action decided post-dispatch); 14 cases including
  one per round-2 finding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqzfARuryr6m6jzGscgAY6
…tter-aware

- Finding 1 (deferred defaultPrevented cannot prove "no POST" -- a
  handler can cancel-and-replace via form.submit(), and the canceled
  flag can mutate after the default-action decision): adopted Codex's
  recommended fix for this codebase -- the lock is now FAIL-CLOSED,
  held until navigation or bfcache restore, with no timer rollback.
  A synchronous defaultPrevented check still skips acquisition for
  events cancelled before the guard runs.
- Finding 2 (disconnected form aborts submission, sticking the lock):
  subsumed by fail-closed; documented as an accepted limitation (no
  such handler exists; a stuck form is visible and reload fixes it).
- Finding 3 (window-capture listener bypass): guard moved from document
  capture to WINDOW capture, the earliest propagation point.
- Finding 4 (stopPropagation fallback weaker than claimed): fallback
  dropped; stopImmediatePropagation is universal in supported browsers.
- Finding 5 (submitter/formaction): event.submitter preferred for the
  button to disable, querySelector fallback kept for implicit
  Enter-key submission.
- Harness models window capture -> doc capture -> target -> doc bubble
  -> window bubble, plus pre-guard window-capture listeners; 16 cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqzfARuryr6m6jzGscgAY6
Guard login form against password-manager double-submit (403 CSRF on login) (#1240)
)

Pay.__init__ used token-presence alone to decide "user is anonymous" and
dropped transaction.user when calling make_account, even though a
logged-in donor entering fresh card details always carries a token (no
Account exists yet). Every such donation created a Stripe Customer
mislabeled "anonymous user" with no Account linked back to the donor's
profile (so saved-card reuse never worked either).

Now passes user=transaction.user through (None for genuinely anonymous
transactions, so that path is unchanged) -- make_account's existing
if-user branch already does the right thing once given one.

Reproduced and verified with the repo's mocked-StripeClient test
pattern (no live Stripe calls): confirmed the new
test_logged_in_donation_creates_identified_customer test fails against
the pre-fix code and passes after; the anonymous-donation case is
covered by a paired test and stays unchanged. payment+frontend suites
hold at the pre-existing 3-failure/1-error/2-skipped baseline
(re-verified against unmodified master), zero net-new failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHjXjrfha4X6TTSzY5obUZ
Codex round-1 confirmed the fix is correct and the tests genuinely
catch #1125, and flagged one out-of-scope finding: Pay.__init__ hands
Execute the just-created account_id via transaction.preapproval_key,
but Execute prefers a fresh transaction.user.profile.account DB lookup
over it -- a narrow race (two concurrent submissions for the same
user) could charge a different, concurrently-created Customer than
the one this call just created/labeled. Filed as #1249 rather than
folded into this narrowly-scoped fix: it's a pre-existing Execute
account-resolution characteristic shared with delayed/queued campaign
executions, not something #1125's Pay.__init__ change introduces.

Took Codex's cheap, in-scope coverage suggestions instead:
test_logged_in_donation_creates_identified_customer now uses a
token-shaped (not raw-dict) value, asserts create_customer got
card=<token>, asserts create_charge was called with
customer=<the newly-created Customer's id> (so a post-Customer-creation
failure wouldn't go unnoticed), and asserts the transaction actually
reaches TRANSACTION_STATUS_COMPLETE with the right pay_key.

payment+frontend suites still hold at the pre-existing 3F/1E/2S
baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHjXjrfha4X6TTSzY5obUZ
Codex round-2 verdict: ship, no blocking issues -- confirmed the
concurrency race deferral to #1249 is the right scope call, confirmed
the fix is correct, confirmed the hardened test genuinely detects the
regression (reran against pre-fix stripelib.py, failed as expected).
Only optional polish suggested: assert_called_once() on the mocked
create_customer/create_charge calls. Applied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHjXjrfha4X6TTSzY5obUZ
The AI-crawler rules added in the first commit had a precedence bug. A
crawler obeys only the single most specific User-agent group that matches
it and ignores "User-agent: *" entirely (RFC 9309). The ClaudeBot group
carried only Crawl-delay, so declaring it would have *removed* ClaudeBot's
existing Disallow rules for /accounts/, /feedback/, /socialauth/, /search/
and /googlebooks/ -- widening access to the faceted-search space whose URL
explosion #1116 was cut to fix, rather than narrowing it.

Every named group that is not a blanket "Disallow: /" now restates the
baseline rules. ClaudeBot additionally excludes the listing and feed
endpoints identified in #1189 as the expensive bot-hit paths (/free/,
/bypub/, /pid/, /unglued/, /campaigns/, /api/) while leaving /work/ pages
crawlable, so the throttle sheds load without costing discoverability.

Verified Anthropic's published crawler documentation rather than relying on
the earlier claim: ClaudeBot does support the non-standard Crawl-delay
extension, so throttling it is meaningful. GPTBot and the other training
crawlers document no such support and stay fully disallowed. Added
Google-Extended and Applebot-Extended, which are AI-training opt-out tokens
and carry no search-ranking effect. Googlebot and user-triggered agents
(Claude-User, ChatGPT-User, OAI-SearchBot, Claude-SearchBot) remain
unrestricted.

Removed the inline "DECISION FOR REVIEW" comments now that the decisions
are made, and dropped the traffic statistics from the served file -- this
template is public output, so it carries policy, not operational detail.

Tests: adds RobotsTxtTests (SimpleTestCase, no database) covering the
production and non-production host branches, and a regression guard
asserting that no named group omits the baseline rules. The guard was
confirmed to fail against the previous version of this template and pass
against this one. Full frontend suite: 40 tests, 3 failures, all three
pre-existing on the branch without this change (RhPageTests, unrelated).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANtyDhUBYCsvEytDfMXUJe
Codex round 1 caught a real error I carried over from the original draft
without checking it: PerplexityBot was grouped with the training crawlers
and given Disallow: /, which contradicts this PR's own stated policy of
keeping AI *search* discovery enabled.

Verified against Perplexity's published crawler documentation rather than
assuming either way: PerplexityBot is their search-indexing crawler and is
explicitly "not used to crawl content for AI foundation models"; the
on-demand fetcher is Perplexity-User. Blocking it would have removed the
collection from Perplexity search results while shedding no training load
at all -- pure cost, no benefit. Removed the group so it falls through to
User-agent: *.

Checked Amazonbot the same way while here, since it was inherited from the
same list: Amazon documents it as crawling that "may be used to train
Amazon AI models," so it stays disallowed, correctly. Their separate
Amzn-SearchBot does not crawl for generative training and already falls
through untouched.

Tests: extended the absence assertions from four agents to eight, so a
future edit cannot quietly give a search or user-triggered agent its own
group -- PerplexityBot, Perplexity-User, Amzn-SearchBot and
Claude-SearchBot are now covered alongside the originals. This is the
assertion that would have caught the PerplexityBot mistake.

RobotsTxtTests: 3 tests, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANtyDhUBYCsvEytDfMXUJe
…C wording

Three findings, all fair. Taken in order of consequence:

1. Removed Google-Extended and Applebot-Extended. Codex flagged that
   Disallow: / on Google-Extended also costs Gemini grounding, not only
   training. Following that through: neither token is a crawler at all --
   Googlebot and Applebot do the fetching, and these are content-*usage*
   opt-outs. So they shed exactly zero crawl load while carrying a real
   AI-answer discoverability cost. That is the same "pure cost, no benefit"
   shape as the PerplexityBot entry removed in the previous commit, and it
   is a content-policy decision rather than a load question, so it does not
   belong in a load-shedding change. Dropped from this PR; worth deciding
   deliberately on its own terms.

2. Tests now assert the rules this PR actually exists for. The previous
   version checked Crawl-delay but never ClaudeBot's six expensive-path
   exclusions, and covered only three of the blocked agents -- deleting
   /api/ or the Bytespider stanza would have passed. Added
   CLAUDEBOT_EXTRA_DISALLOWS and BLOCKED_AGENTS constants, asserted every
   entry, and added an exhaustive equality check on the set of groups so a
   stanza cannot be dropped or slipped in unnoticed. Verified by deleting
   /api/ and Bytespider and confirming the suite fails.

3. Corrected the RFC 9309 explanation in the template comment and the test
   docstring. Multiple matching groups are combined rather than one winning;
   the accurate and load-bearing point is that "User-agent: *" applies only
   to crawlers matching no named group. Behavior was already correct -- the
   explanation could have misled a future edit.

RobotsTxtTests: 3 tests, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANtyDhUBYCsvEytDfMXUJe
Four findings, all verified against primary sources before acting rather
than taken on the reviewer's word.

1. The tests were not actually database-free, and the docstring said they
   were. Issuing the client request loads the root URLconf, which imports a
   module that runs a query at import time. Under SimpleTestCase Django
   creates no test database, so that query hit whatever database the
   settings pointed at -- meaning the "isolated" test could read live
   application data. Switched to TestCase so Django builds an isolated test
   database, and removed the no-database claim. Kept the real URLconf rather
   than substituting a minimal one, so the test still proves /robots.txt is
   actually routed.

2. CCBot was blocked under a stated rationale that is false. Common Crawl
   documents that CCBot honors Crawl-delay ("By increasing that number, you
   will indicate to CCBot to slow down"), so the comment claiming these
   crawlers offer nothing to throttle was wrong for it. Behavior is
   unchanged -- CCBot is a real crawler and blocking it does shed load --
   but the comment now says plainly that this is a deliberate opt-out from
   the Common Crawl dataset rather than a technical necessity, and notes it
   could be softened to a throttle. That is a content-policy call worth
   making deliberately.

3. Removed cohere-ai. Cohere's own crawler documentation states they do not
   operate crawlers for training generative models "at this time" and names
   no active user agent; Coherebot appears only as a hypothetical example.
   The token sheds no load and, pinned into an exhaustive test, implied a
   protection that does not exist.

4. Fixed the test parser's group boundaries. It treated any non-User-agent
   field as ending a run of user-agent lines, so an extension record such as
   Crawl-delay between two consecutive User-agent lines would have split one
   group into two -- and the precedence guard would then silently stop
   covering the second agent. Now only Allow/Disallow close the run, per RFC
   9309 2.2.4. Added a fixture covering exactly that shape.

RobotsTxtTests: 4 tests, all passing. Full frontend suite: 41 tests, 3
failures, the same three pre-existing RhPageTests failures unrelated to
this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANtyDhUBYCsvEytDfMXUJe
Diffbot documents Crawl-delay support too, so the comment naming CCBot as
the sole exception was wrong again -- the fourth round in a row to catch a
factual error in a per-crawler capability claim.

Rather than patch in "and Diffbot" and wait for the next round to find
another, removed the class of claim entirely. The comment now states the
policy (this is a content-usage decision to opt out of training-data
collection), notes plainly that several of these crawlers do honor
Crawl-delay and could be throttled instead, and tells the next editor to
check the operator's current documentation before adding or moving an
entry. That is accurate regardless of which agents support what, and it
cannot rot the way the enumerated version kept doing.

No behavior change: the same agents are disallowed. Same correction applied
to the BLOCKED_AGENTS comment in the tests.

RobotsTxtTests: 4 tests, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANtyDhUBYCsvEytDfMXUJe
Round 5 returned "no blocking defects found" with three P3s. Took two,
declined one.

Taken:

1. The precedence guard checked only that named groups restate the baseline
   Disallow rules, and discarded Allow rules entirely. RFC 9309 gives the
   longest match precedence, so a future "Allow: /accounts/public/" would
   re-open a path under the shorter "Disallow: /accounts/" while the guard
   still passed. The parser now records Allow rules and the guard asserts no
   named group allows anything beneath a baseline prefix. Verified by
   injecting exactly that rule and confirming the suite fails.

2. Corrected two comments that claimed more than is true. "Every training
   crawler is blocked" was wrong -- ClaudeBot trains and is deliberately
   throttled instead; it now reads "every crawler selected for blocking" and
   says why. The template's allowed-agents note implied those publishers
   collect no training data at all, which glosses over the Google-Extended
   grounding tradeoff; it now scopes the claim to crawling and states that
   training opt-outs are a separate content-usage decision not made here.

Declined, and flagged for a separate change instead:

3. Host matching. Because the template compares raw HTTP_HOST, "unglue.it:443"
   and "Unglue.it" fall to the non-production branch and would serve
   "Disallow: /". That is real, but it is pre-existing behavior of the
   prod/non-prod gate rather than anything this PR introduces, it is
   orthogonal to AI crawlers, and getting it wrong in either direction is
   costly -- too strict and prod tells crawlers to go away, too loose and
   staging gets indexed. It deserves its own change with its own test, not a
   quiet edit inside a crawler-policy PR. The same applies to "www.unglue.it".

RobotsTxtTests: 4 tests, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANtyDhUBYCsvEytDfMXUJe
robots.txt: throttle ClaudeBot, disallow training crawlers, keep search agents (refs #1172)
Pass transaction.user through to make_account for donation tokens (#1125)
…s (refs #1253)

meta-webindexer was the largest single source of /free/ facet-browse
requests on 2026-09-09 (48,318 of 86,287), and it fell through to the
permissive "User-agent: *" group.

Meta documents it as the crawler behind Meta AI search results, so it is
treated like the other search-indexing crawlers here: not blocked. It gets
a ClaudeBot-style group that restates the baseline rules and excludes the
same expensive listing and feed endpoints, so work pages stay indexable.
No Crawl-delay, since Meta does not document support for it.

Test: RobotsTxtTests asserts the new group's rules and adds it to the
exhaustive group set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjPGJQCBFvZ1D2fJgJQpe4
Every /free/ facet query starts from Work.objects.filter(is_free=True),
optionally narrows by language (?pub_lang=), and by default sorts
newest-first by (-featured, -created). Work has separate single-column
indexes on is_free, language, featured and created, but none covering
filter + sort together, so MySQL intersects the is_free and language
indexes, then builds and sorts a temporary table on every request.
EXPLAIN on test and production showed exactly that plan; test's
slow-query log caught three 1-5 second instances examining 600-1,700x
more rows than returned.

Adds two indexes (migration core 0033, AddIndex only, no data change):
- (is_free, language, featured DESC, created DESC) for ?pub_lang= views
- (is_free, featured DESC, created DESC) for views without a language

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjPGJQCBFvZ1D2fJgJQpe4
- Test asserts meta-webindexer's exact Disallow set, an empty Allow list
  and no extension records, so an added Allow, an over-broad Disallow, a
  Crawl-delay or a duplicate stanza now fails (each verified by mutation).
- robots.txt comment: "not blocked outright", "work pages remain
  crawlable".
- Fix an older test comment that claimed blocking a search crawler sheds
  no crawl load; #1253 shows one can.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjPGJQCBFvZ1D2fJgJQpe4
- Assert there is no crawl-delay record for meta-webindexer, rather than
  that it has no extension records at all: a legitimate Sitemap line after
  the stanza must not fail the test (verified: passes; Crawl-delay still
  fails).
- Comment: exact equality catches a duplicated copy of the rules, which is
  what it actually guarantees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjPGJQCBFvZ1D2fJgJQpe4
- Replace the single AddIndex migration with 0033 (language index) and
  0034 (no-language index), so a failure cannot leave one migration half
  applied.
- Each uses SeparateDatabaseAndState: RunSQL with
  ALGORITHM=INPLACE LOCK=NONE (fail instead of falling back to a blocking
  table copy) and a 10-second session lock_wait_timeout (fail fast instead
  of queueing an exclusive metadata lock behind a long query), with
  matching state_operations so makemigrations sees no drift. Reverse SQL
  drops each index the same way.
- Model comment now describes the intended benefit, not an established
  one: whether DISTINCT over the joined facet tables still forces a
  temporary table must be measured on real data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjPGJQCBFvZ1D2fJgJQpe4
- 0033 header: one index per migration prevents one index stranding the
  other, but MySQL DDL is not transactional, so a crash between DDL and
  migration recording can still desync schema and history; say to compare
  SHOW INDEX with showmigrations before retrying.
- Model comment attributes the index-intersection plan to the captured
  slow-query plans rather than stating it universally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjPGJQCBFvZ1D2fJgJQpe4
robots.txt: keep meta-webindexer out of /free/ and other listing pages (refs #1253)
Adds Crawl-delay: 10 to the meta-webindexer group as a labeled experiment.
Meta documents no Crawl-delay support, but the crawler has run at a flat
~7,200 requests/hour since 2026-09-09, so production logs will show whether
it honors the line. Also records the observation that Meta's crawlers
ignore the "*" group while obeying a group that names them.

RobotsTxtTests now requires exactly one Crawl-delay of 10 in that group
(mutation-checked: missing, 5, and duplicated all fail).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjPGJQCBFvZ1D2fJgJQpe4
- The named-group observation is stated for meta-externalagent only, and the
  /feedback/ observation without "for weeks".
- The experiment note says a rate change would be assessed, not proven, and
  records the baseline start as 2026-09-09 12:00 UTC.

Comment-only change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjPGJQCBFvZ1D2fJgJQpe4
…l-delay

robots.txt: Crawl-delay experiment for meta-webindexer (refs #1253)
core.Work: composite indexes for /free/ facet browsing (refs #1253)
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.

2 participants