Files
challenge_status_tracker/tracker/hedgedoc.py
T
Casually9790andClaude Opus 5.5 e00f40eaf4 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>
2026-10-20 19:40:24 +02:00

181 lines
7.1 KiB
Python

"""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