Split ctf_status.py into a tracker package, config from .env only
ctf_status.py is now just the command line; the work lives in tracker/. The rules that decide whether a challenge is believed are in tracker/checks.py, one function per rule, listed in README_CHECKS and REPO_CHECKS. Output is unchanged. Read the config from .env only (ctf_status.env.example -> .env.example), let single_run_lock() hold its own lock files, and rewrite the README as a short overview of what the repo does and how to run it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""Writing the note into HedgeDoc 1.x.
|
||||
|
||||
HedgeDoc 1.x has no REST endpoint that edits a note's body, so this pretends to
|
||||
be the web editor. Nothing in here knows about challenges.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import requests
|
||||
|
||||
# engine.io v3 packet types
|
||||
EIO_OPEN, EIO_CLOSE, EIO_PING, EIO_PONG, EIO_MSG = "0", "1", "2", "3", "4"
|
||||
# socket.io packet types, as the second character of an EIO_MSG packet
|
||||
SIO_CONNECT, SIO_DISCONNECT, SIO_EVENT, SIO_ERROR = "0", "1", "2", "4"
|
||||
|
||||
|
||||
def u16len(s: str) -> int:
|
||||
"""Length in UTF-16 code units — what JavaScript's `String.length` counts, and
|
||||
therefore what engine.io payload framing and OT operations count. Matters as
|
||||
soon as the markdown holds an emoji (🔴 is one Python char but two units)."""
|
||||
return len(s.encode("utf-16-le")) // 2
|
||||
|
||||
|
||||
def encode_payload(packets: tuple) -> bytes:
|
||||
return "".join(f"{u16len(p)}:{p}" for p in packets).encode("utf-8")
|
||||
|
||||
|
||||
def decode_payload(text: str) -> list[str]:
|
||||
buf = text.encode("utf-16-le")
|
||||
out, i = [], 0
|
||||
while i < len(buf):
|
||||
j = i
|
||||
while buf[j:j + 2] != b":\x00":
|
||||
j += 2
|
||||
if j >= len(buf):
|
||||
raise RuntimeError("malformed engine.io payload")
|
||||
n = int(buf[i:j].decode("utf-16-le"))
|
||||
j += 2
|
||||
out.append(buf[j:j + 2 * n].decode("utf-16-le"))
|
||||
i = j + 2 * n
|
||||
return out
|
||||
|
||||
|
||||
class HedgeDocNote:
|
||||
"""Overwrite an existing HedgeDoc 1.x note in place.
|
||||
|
||||
Speaks the same engine.io/socket.io protocol the web editor uses, over XHR
|
||||
long-polling (no extra dependencies beyond `requests`):
|
||||
|
||||
GET /<note> -> session cookie; the socket rejects us without one
|
||||
GET /socket.io/ -> engine.io handshake, yields a session id
|
||||
poll for 'doc' -> the note's current text and OT revision
|
||||
emit 'operation' -> [-len(old), new]: delete everything, insert ours
|
||||
poll for 'ack' -> the server applied it and told the other editors
|
||||
|
||||
The note's permission must allow us to write ("freely" for an anonymous run).
|
||||
"""
|
||||
|
||||
def __init__(self, url: str, timeout: int = 20):
|
||||
parts = urlsplit(url)
|
||||
path = parts.path.rstrip("/")
|
||||
if not parts.scheme or not parts.netloc or "/" not in path:
|
||||
raise ValueError(f"expected a note URL like https://host/<noteId>, got {url!r}")
|
||||
self.note = path.rsplit("/", 1)[-1]
|
||||
prefix = path[: -len(self.note)].rstrip("/")
|
||||
self.base = f"{parts.scheme}://{parts.netloc}{prefix}"
|
||||
self.timeout = timeout
|
||||
self.sid: str | None = None
|
||||
self.s = requests.Session()
|
||||
self.s.headers["Origin"] = f"{parts.scheme}://{parts.netloc}"
|
||||
|
||||
# -- transport ---------------------------------------------------------
|
||||
def _params(self) -> dict:
|
||||
p = {"EIO": "3", "transport": "polling", "noteId": self.note,
|
||||
"t": format(random.getrandbits(40), "x")}
|
||||
if self.sid:
|
||||
p["sid"] = self.sid
|
||||
return p
|
||||
|
||||
def _open(self) -> None:
|
||||
self.sid = None
|
||||
self.s.get(f"{self.base}/{self.note}", timeout=self.timeout).raise_for_status()
|
||||
r = self.s.get(f"{self.base}/socket.io/", params=self._params(), timeout=self.timeout)
|
||||
r.raise_for_status()
|
||||
pkts = decode_payload(r.text)
|
||||
if not pkts or pkts[0][:1] != EIO_OPEN:
|
||||
raise RuntimeError(f"unexpected engine.io handshake: {pkts[:1]}")
|
||||
self.sid = json.loads(pkts[0][1:])["sid"]
|
||||
|
||||
def _send(self, *packets: str) -> None:
|
||||
r = self.s.post(f"{self.base}/socket.io/", params=self._params(),
|
||||
data=encode_payload(packets),
|
||||
headers={"Content-Type": "text/plain;charset=UTF-8"},
|
||||
timeout=self.timeout)
|
||||
r.raise_for_status()
|
||||
|
||||
def _events(self):
|
||||
"""Poll once, answer keepalives, yield decoded socket.io events."""
|
||||
r = self.s.get(f"{self.base}/socket.io/", params=self._params(), timeout=self.timeout)
|
||||
r.raise_for_status()
|
||||
for p in decode_payload(r.text):
|
||||
if p == EIO_PING:
|
||||
self._send(EIO_PONG)
|
||||
elif p == EIO_CLOSE:
|
||||
raise RuntimeError("hedgedoc closed the connection")
|
||||
elif p[:1] == EIO_MSG:
|
||||
kind, data = p[1:2], p[2:]
|
||||
if kind == SIO_ERROR:
|
||||
raise RuntimeError(f"hedgedoc refused the socket: {data}")
|
||||
if kind == SIO_EVENT:
|
||||
yield json.loads(data)
|
||||
|
||||
def _wait(self, name: str, polls: int = 8):
|
||||
for _ in range(polls):
|
||||
for ev in self._events():
|
||||
if ev and ev[0] == name:
|
||||
return ev[1] if len(ev) > 1 else None
|
||||
raise RuntimeError(f"timed out waiting for {name!r} from hedgedoc")
|
||||
|
||||
def _close(self) -> None:
|
||||
try:
|
||||
self._send(EIO_MSG + SIO_DISCONNECT, EIO_CLOSE)
|
||||
except requests.RequestException:
|
||||
pass # we already got our ack; a tidy goodbye is a nicety
|
||||
|
||||
# -- api ---------------------------------------------------------------
|
||||
def download(self) -> str:
|
||||
r = self.s.get(f"{self.base}/{self.note}/download", timeout=self.timeout)
|
||||
r.raise_for_status()
|
||||
return r.content.decode("utf-8", "replace")
|
||||
|
||||
def replace(self, markdown: str) -> bool:
|
||||
"""Replace the note body. False if it already said exactly this."""
|
||||
self._open()
|
||||
doc = self._wait("doc")
|
||||
old, revision = doc["str"], doc["revision"]
|
||||
if old == markdown:
|
||||
self._close()
|
||||
return False
|
||||
# an OT operation is a list of: n>0 keep n chars, n<0 drop n, str insert
|
||||
ops = ([-u16len(old)] if old else []) + ([markdown] if markdown else [])
|
||||
self._send(EIO_MSG + SIO_EVENT
|
||||
+ json.dumps(["operation", revision, ops, None], ensure_ascii=False))
|
||||
self._wait("ack")
|
||||
time.sleep(2) # give the realtime server a moment to flush the note to its DB
|
||||
self._close()
|
||||
return True
|
||||
|
||||
|
||||
def update_hedgedoc(url: str, md: str, retries: int = 3, quiet: bool = False) -> bool:
|
||||
"""Write `md` into the existing note at `url`, then read it back to be sure."""
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
note = HedgeDocNote(url)
|
||||
changed = note.replace(md)
|
||||
if note.download() != md:
|
||||
raise RuntimeError("note does not match what we wrote")
|
||||
return changed
|
||||
except (requests.RequestException, RuntimeError, ValueError, KeyError) as e:
|
||||
if attempt == retries:
|
||||
raise
|
||||
if not quiet:
|
||||
print(f"hedgedoc update failed ({e}) — retry {attempt + 1}/{retries}",
|
||||
file=sys.stderr)
|
||||
time.sleep(3 * attempt)
|
||||
return False
|
||||
|
||||
|
||||
def push_hedgedoc(base: str, md: str) -> str:
|
||||
"""POST `md` as a *new* note; returns its URL."""
|
||||
r = requests.post(base.rstrip("/") + "/new", data=md.encode(),
|
||||
headers={"Content-Type": "text/markdown"}, allow_redirects=False)
|
||||
r.raise_for_status()
|
||||
loc = r.headers.get("Location", "")
|
||||
return loc if loc.startswith("http") else base.rstrip("/") + loc
|
||||
Reference in New Issue
Block a user