Add text format, CORS, and an importer so other instances can use the list
docker-build-push / build-push (push) Successful in 4s

Mastodon has no RBL/DNSBL support — nothing in it consults an outside
list when deciding whether to accept a signup, confirmed against v4.7.1
(the only outbound lookup in that path is EmailMxValidator's MX
resolution, which feeds the local email_domain_blocks table). So a
shared blocklist has to be imported rather than queried live, the same
way Garden Fence and other community lists propagate. These are the
pieces that make ours importable.

- ?format=txt on both per-list endpoints: one entry per line under a '#'
  comment header, the form blocklists are traded in and the form a cron
  job can consume without a JSON parser. The combined /api/blocks stays
  JSON-only — a text rendering would mix CIDRs with domains in one file,
  defeating the point. An unrecognised ?format is a 400 rather than a
  silent fallback to JSON, and the enabled-gate still runs first so a
  disabled deployment 404s either way.
- Access-Control-Allow-Origin: * on blocklist responses, so browser-side
  admin tooling can read them. The data is already public.
- bin/yttrx-blocklist-import: standard-library-only importer another
  admin can run against their own instance. Dry run unless --apply;
  422 from Mastodon counts as "already blocked", not an error.

The importer defaults IP entries to sign_up_requires_approval rather
than the sign_up_block yttrx uses itself. The entries are whole ASN
routes, which suits yttrx because a flagged signup here is nearly always
datacenter/VPN/proxy space — that calibration isn't transferable, so an
importer gets the reviewable severity by default and has to opt into the
hard one.
This commit is contained in:
pmb committed 2026-09-13 00:59:12 -07:00
1 parent af49aab79c
commit 8966de2a1d
5 files changed
+331 -9

No files matched your search

+33
View File
@@ -859,6 +859,39 @@ def blocks_api_tests():
assert len(client.get("/api/blocks/ip-ranges?limit=999").json()["items"]) == 1
main.BLOCKS_API_MAX_ITEMS = 5000
# G. CORS is open — other instances' tooling may fetch this from a browser.
assert r.headers["access-control-allow-origin"] == "*"
# H. ?format=txt renders one entry per line under a comment header, so it
# pipes into another admin's tooling without a JSON parser.
t = client.get("/api/blocks/ip-ranges?format=txt")
assert t.status_code == 200, t.status_code
assert t.headers["content-type"].startswith("text/plain"), t.headers["content-type"]
assert t.headers["access-control-allow-origin"] == "*"
lines = t.text.strip().split("\n")
header = [ln for ln in lines if ln.startswith("#")]
payload = [ln for ln in lines if not ln.startswith("#")]
assert any("source:" in ln for ln in header), header
assert f"entries: {len(ips_only['items'])}" in "\n".join(header)
assert payload == [e["cidr"] for e in ips_only["items"]], payload[:3]
td = client.get("/api/blocks/email-domains?format=txt")
assert [ln for ln in td.text.strip().split("\n") if not ln.startswith("#")] \
== [e["domain"] for e in domains_only["items"]]
# I. An unknown format is rejected rather than quietly serving JSON, and
# the combined endpoint refuses text (it would mix CIDRs with domains).
assert client.get("/api/blocks/ip-ranges?format=yaml").status_code == 400
assert client.get("/api/blocks?format=txt").status_code == 400
assert client.get("/api/blocks?format=json").status_code == 200
# J. The format gate never leaks existence when the API is switched off.
main.BLOCKS_API_ENABLED = False
assert client.get("/api/blocks/ip-ranges?format=txt").status_code == 404
assert client.get("/api/blocks?format=txt").status_code == 404
main.BLOCKS_API_ENABLED = True
main.BLOCKS_API_MAX_ITEMS = 5000
if __name__ == "__main__":
routing_tests()