Files
kokkoja-rel/kokkoja.py
T
ackandClaude Fable 5.1 95c80d04c9 Kokkoja: first public assembly
Assembled from the development repository by tools/make_release.sh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 15:58:02 +02:00

497 lines
20 KiB
Python

#!/usr/bin/env python3
"""Kokkoja local program.
Serves the Kokkoja client to a web browser on this computer, and reads
and writes the user's notebooks for it. A notebook is a directory; a page
is one file in it. It also lets the client's own file chooser look through
the user's directories for image files, and writes an exported picture of
a page where the user chooses, neither of which a web page can do itself.
The client must be loaded from this computer (or over HTTPS) because
browsers allow their built-in cryptography only there.
Only the user's own browser may use the notebooks. The program listens on
this computer alone, and every notebook request must carry a secret token.
The browser gets the token from the address this program opens or prints;
another user of the computer, or a web page from elsewhere, does not have it.
Uses only the Python standard library.
"""
import argparse
import functools
import hmac
import http.server
import json
import os
import pathlib
import re
import secrets
import subprocess
import sys
import threading
import time
import urllib.parse
import webbrowser
CLIENT_DIRECTORY = pathlib.Path(__file__).resolve().parent / "client"
MAX_PAGE_BYTES = 64 << 20
# The page being written in a notebook, and the pages kept from it.
CURRENT_PAGE = "Current.svg"
KEPT_PAGE = re.compile(r"^(\d{4}_\d{2}_\d{2})_(\d{2,})\.svg$")
# Notebook files are changed by one request at a time.
notebook_lock = threading.Lock()
# A notebook or page name: one path component, not hidden, no control
# characters, no characters that a file system might refuse.
NAME = re.compile(r'^[^\x00-\x1f/\\:*?"<>|.][^\x00-\x1f/\\:*?"<>|]{0,119}$')
# The image files the file chooser offers, and what a browser is told they
# are. The chooser reads these and nothing else.
IMAGE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp"}
MAX_IMAGE_FILE_BYTES = 64 << 20
# An exported page is a PNG file, and nothing else is written outside the
# notebooks: the name must end in .png and the bytes must begin like this.
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
LOCAL_HOST = re.compile(r"^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$")
LOCAL_ORIGIN = re.compile(r"^http://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$")
def config_directory():
"""Where Kokkoja keeps its own settings, by each system's convention."""
if sys.platform == "win32":
base = os.environ.get("APPDATA") or pathlib.Path.home() / "AppData" / "Roaming"
elif sys.platform == "darwin":
base = pathlib.Path.home() / "Library" / "Application Support"
else:
base = os.environ.get("XDG_CONFIG_HOME") or pathlib.Path.home() / ".config"
return pathlib.Path(base) / "kokkoja"
def default_notebooks_directory():
"""A Kokkoja directory in the user's documents directory."""
documents = pathlib.Path.home() / "Documents"
if sys.platform.startswith("linux"):
# The documents directory has another name in other languages.
try:
found = subprocess.run(["xdg-user-dir", "DOCUMENTS"], capture_output=True,
text=True, timeout=5).stdout.strip()
if found and pathlib.Path(found) != pathlib.Path.home():
documents = pathlib.Path(found)
except (OSError, subprocess.SubprocessError):
pass
return documents / "Kokkoja"
def load_token(directory):
"""The secret token, made on first use and kept in a private file."""
path = directory / "token"
try:
token = path.read_text().strip()
if token:
return token
except OSError:
pass
token = secrets.token_urlsafe(24)
path.parent.mkdir(parents=True, exist_ok=True)
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(descriptor, "w") as file:
file.write(token + "\n")
return token
def write_atomically(path, data):
"""Replace a file so that it is never seen half written."""
temporary = path.with_name("." + path.name + ".writing")
with open(temporary, "wb") as file:
file.write(data)
file.flush()
os.fsync(file.fileno())
os.replace(temporary, path)
def keep_page(notebook):
"""Keep a copy of the notebook's current page as a dated page.
The copy is named for the day the page was last changed, with a serial
number: 2026_10_02_01.svg. Nothing is copied if the page is blank or
if an identical page is already kept.
Returns (name, new): the name of the kept page, or None if there was
nothing to keep, and whether the copy was made just now.
"""
current = notebook / CURRENT_PAGE
try:
data = current.read_bytes()
changed = current.stat().st_mtime
except FileNotFoundError:
return None, False
if b"data-stroke=" not in data and b"<image" not in data:
return None, False
kept = [entry for entry in notebook.iterdir()
if KEPT_PAGE.match(entry.name) and entry.is_file()]
for entry in kept:
if entry.stat().st_size == len(data) and entry.read_bytes() == data:
return entry.name, False
date = time.strftime("%Y_%m_%d", time.localtime(changed))
serials = [int(match.group(2)) for match in map(KEPT_PAGE.match, (e.name for e in kept))
if match.group(1) == date]
name = f"{date}_{max(serials, default=0) + 1:02d}.svg"
write_atomically(notebook / name, data)
os.utime(notebook / name, (changed, changed))
return name, True
MAX_IDENTITY_BYTES = 4096
def site_of(relay, site):
"""The Kokkoja web site that invitations link to, or "".
Given explicitly, it is used as given. Otherwise it is taken from the
relay's address when the relay is a Kokkoja server's, at wss://<host>/relay:
such a server serves the client at https://<host>/ as well.
"""
if site:
return site.rstrip("/") + "/"
parts = urllib.parse.urlsplit(relay)
if parts.scheme == "wss" and parts.path.rstrip("/") == "/relay":
return f"https://{parts.netloc}/"
return ""
class Handler(http.server.SimpleHTTPRequestHandler):
relay = ""
site = ""
token = ""
notebooks = pathlib.Path()
config = pathlib.Path()
# Replies.
def send_json(self, value, status=200):
self.send_body(json.dumps(value).encode(), "application/json", status)
def send_body(self, body, content_type, status=200):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def refuse(self, status, reason):
self.send_json({"error": reason}, status)
def end_headers(self):
# The client files change during development; never serve stale ones.
self.send_header("Cache-Control", "no-store")
super().end_headers()
def log_message(self, format, *arguments):
pass
# Requests.
def do_GET(self):
path = self.path.split("?")[0]
if path == "/config.json":
# How the client was served: by this program, which keeps the
# notebooks as files. A web site serves its own config.json
# with "storage": "browser".
self.send_json({"relay": self.relay, "storage": "files", "site": self.site})
elif path == "/api/identity":
self.identity_request("GET")
elif path == "/api/files":
self.files_request()
elif path == "/api/file":
self.file_request("GET")
elif path == "/api" or path.startswith("/api/"):
self.notebook_request("GET", path)
else:
super().do_GET()
def do_PUT(self):
path = self.path.split("?")[0]
if path == "/api/identity":
self.identity_request("PUT")
elif path == "/api/file":
self.file_request("PUT")
else:
self.notebook_request("PUT", path)
def do_POST(self):
self.notebook_request("POST", self.path.split("?")[0])
def allowed(self):
"""Whether a notebook request comes from the user's own browser."""
if not LOCAL_HOST.match(self.headers.get("Host", "")):
self.refuse(403, "not addressed to this computer")
return False
origin = self.headers.get("Origin")
if origin is not None and not LOCAL_ORIGIN.match(origin):
self.refuse(403, "request from another web site")
return False
given = self.headers.get("Authorization", "")
if not hmac.compare_digest(given.encode(), ("Bearer " + self.token).encode()):
self.refuse(401, "missing or wrong token")
return False
return True
def identity_request(self, method):
"""
GET /api/identity the user's identity (signing keys), or 404
PUT /api/identity store it, once; a second PUT is refused
The identity is kept here rather than in the browser so that the
user is the same participant in every session, from any browser on
this computer, and their writing from an earlier lesson stays
theirs. The file is private to the user.
"""
if not self.allowed():
return
path = self.config / "identity.json"
try:
if method == "GET":
self.send_body(path.read_bytes(), "application/json")
return
length = int(self.headers.get("Content-Length", "-1"))
if not 0 < length <= MAX_IDENTITY_BYTES:
return self.refuse(413, "not an identity")
data = self.rfile.read(length)
try:
json.loads(data)
except ValueError:
return self.refuse(400, "not an identity")
with notebook_lock:
if path.exists():
return self.refuse(409, "an identity is already kept")
path.parent.mkdir(parents=True, exist_ok=True)
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "wb") as file:
file.write(data)
self.send_json({"kept": True})
except FileNotFoundError:
self.refuse(404, "no identity yet")
except (OSError, ValueError) as error:
self.refuse(500, str(error))
def notebook_request(self, method, path):
"""
GET /api/notebooks the notebooks
GET /api/notebooks/<notebook> the pages of a notebook
GET /api/notebooks/<notebook>/<page> a page
PUT /api/notebooks/<notebook>/<page> write a page
POST /api/notebooks/<notebook>/keep keep a copy of Current.svg
"""
if not self.allowed():
return
parts = [urllib.parse.unquote(part) for part in path.split("/")[2:]]
if not parts or parts[0] != "notebooks" or len(parts) > 3:
return self.refuse(404, "no such request")
names = parts[1:]
if not all(NAME.match(name) for name in names):
return self.refuse(400, "name not allowed")
keeping = method == "POST" and len(names) == 2 and names[1] == "keep"
if len(names) == 2 and not keeping and not names[1].endswith(".svg"):
return self.refuse(400, "a page name ends in .svg")
target = self.notebooks.joinpath(*names)
try:
if keeping:
with notebook_lock:
name, new = keep_page(target.parent) if target.parent.is_dir() else (None, False)
self.send_json({"kept": name, "new": new})
elif method == "POST":
self.refuse(405, "nothing to do")
elif method == "PUT":
if len(names) != 2:
return self.refuse(405, "only a page can be written")
length = int(self.headers.get("Content-Length", "-1"))
if not 0 <= length <= MAX_PAGE_BYTES:
return self.refuse(413, "page too large")
data = self.rfile.read(length)
with notebook_lock:
target.parent.mkdir(parents=True, exist_ok=True)
write_atomically(target, data)
self.send_json({"saved": names[1]})
elif len(names) == 0:
self.send_json({"directory": str(self.notebooks),
"notebooks": self.listing(self.notebooks, directories=True)})
elif len(names) == 1:
self.send_json({"pages": self.listing(target, directories=False)})
else:
self.send_body(target.read_bytes(), "image/svg+xml")
except FileNotFoundError:
self.refuse(404, "not found")
except (OSError, ValueError) as error:
self.refuse(500, str(error))
def query(self):
"""The named values after '?' in the request's address."""
values = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query,
keep_blank_values=True)
return {name: value[-1] for name, value in values.items()}
def files_request(self):
"""
GET /api/files?directory=<full path> the directories and the image
files in a directory
Without a directory: the notebooks directory, or the user's home
directory if there are no notebooks yet. Hidden entries are left
out. Each entry comes with its full path, so that the client never
has to put a path together.
"""
if not self.allowed():
return
given = self.query().get("directory", "")
try:
if given:
directory = pathlib.Path(given).expanduser()
if not directory.is_absolute():
return self.refuse(400, "not a full path")
else:
directory = self.notebooks if self.notebooks.is_dir() else pathlib.Path.home()
directory = pathlib.Path(os.path.abspath(directory))
directories = []
files = []
for entry in directory.iterdir():
if entry.name.startswith("."):
continue
try:
if entry.is_dir():
directories.append({"name": entry.name, "path": str(entry)})
elif entry.is_file() and entry.suffix.lower() in IMAGE_TYPES:
files.append({"name": entry.name, "path": str(entry),
"size": entry.stat().st_size})
except OSError:
continue
by_name = lambda entry: (entry["name"].casefold(), entry["name"])
self.send_json({
"directory": str(directory),
"parent": None if directory.parent == directory else str(directory.parent),
"directories": sorted(directories, key=by_name),
"files": sorted(files, key=by_name)})
except FileNotFoundError:
self.refuse(404, "no such directory")
except NotADirectoryError:
self.refuse(400, "not a directory")
except PermissionError:
self.refuse(403, "this directory may not be read")
except (OSError, ValueError) as error:
self.refuse(500, str(error))
def file_request(self, method):
"""
GET /api/file?path=<full path> an image file
PUT /api/file?directory=<full path>&name=<name>.png[&replace=1]
write an exported page
Only image files are read, by their names. Only PNG files are
written, into a directory that exists, and an existing file is
replaced only when the request says so (409 otherwise).
"""
if not self.allowed():
return
query = self.query()
try:
if method == "GET":
path = pathlib.Path(query.get("path", ""))
kind = IMAGE_TYPES.get(path.suffix.lower())
if not path.is_absolute() or kind is None:
return self.refuse(400, "not an image file")
if path.stat().st_size > MAX_IMAGE_FILE_BYTES:
return self.refuse(413, "image too large")
return self.send_body(path.read_bytes(), kind)
directory = pathlib.Path(query.get("directory", ""))
name = query.get("name", "")
if not directory.is_absolute() or not directory.is_dir():
return self.refuse(404, "no such directory")
if not NAME.match(name) or not name.lower().endswith(".png"):
return self.refuse(400, "the name must end in .png")
length = int(self.headers.get("Content-Length", "-1"))
if not 0 < length <= MAX_IMAGE_FILE_BYTES:
return self.refuse(413, "picture too large")
data = self.rfile.read(length)
if not data.startswith(PNG_SIGNATURE):
return self.refuse(400, "not a PNG picture")
target = directory / name
with notebook_lock:
if target.exists() and query.get("replace") != "1":
return self.refuse(409, "exists")
write_atomically(target, data)
self.send_json({"saved": str(target)})
except FileNotFoundError:
self.refuse(404, "not found")
except PermissionError:
self.refuse(403, "not permitted there")
except (OSError, ValueError) as error:
self.refuse(500, str(error))
@staticmethod
def listing(directory, directories):
"""Names of the notebooks in the notebooks directory, or of the
pages in a notebook. A notebook that does not exist yet is empty."""
if not directory.is_dir():
return []
return sorted(
entry.name for entry in directory.iterdir()
if NAME.match(entry.name) and
(entry.is_dir() if directories else entry.is_file() and entry.name.endswith(".svg")))
def make_server(port, relay, notebooks, token, config, site=""):
handler = type("ConfiguredHandler", (Handler,), {
"relay": relay.rstrip("/"), "site": site_of(relay, site), "token": token,
"notebooks": pathlib.Path(notebooks), "config": pathlib.Path(config)})
handler = functools.partial(handler, directory=str(CLIENT_DIRECTORY))
return http.server.ThreadingHTTPServer(("127.0.0.1", port), handler)
def main():
parser = argparse.ArgumentParser(description="Kokkoja local program")
parser.add_argument("--port", type=int, default=8760)
parser.add_argument("--relay", default="ws://127.0.0.1:8770",
help="relay used for sessions started here "
"(default ws://127.0.0.1:8770)")
parser.add_argument("--site", default="",
help="Kokkoja web site that invitations link to, so that "
"guests can join with nothing installed (default: "
"https://<host>/ when the relay is wss://<host>/relay)")
parser.add_argument("--notebooks", type=pathlib.Path, default=None,
help="directory that holds the notebooks "
"(default: Kokkoja in your documents directory)")
parser.add_argument("--config", type=pathlib.Path, default=None,
help="directory for Kokkoja's own settings "
"(default: your system's usual place)")
parser.add_argument("--no-browser", action="store_true",
help="do not open a browser window")
arguments = parser.parse_args()
notebooks = arguments.notebooks or default_notebooks_directory()
config = arguments.config or config_directory()
token = load_token(config)
server = make_server(arguments.port, arguments.relay, notebooks, token, config, arguments.site)
address = f"http://localhost:{arguments.port}/#token={token}"
print(f"Kokkoja is at {address}", flush=True)
print(f"Notebooks are in {notebooks}", flush=True)
print("Ctrl-C to stop.", flush=True)
if not arguments.no_browser:
webbrowser.open(address)
try:
server.serve_forever()
except KeyboardInterrupt:
print()
if __name__ == "__main__":
main()