#!/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 shutil 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$") # What names a page file rather than describes the page: the session, # the host, the page's id and number on the root element, and the title # that repeats the number. A page loaded from a notebook is a new page # with new ones of these, so two files of the same page differ here and # nowhere else (2026-10-11, after loading a kept page and leaving it # unchanged kept it again at every change of page). They stand before # the paper's , and only there; typed text (in ) may contain # anything, so the search stops at the paper. VOLATILE = re.compile(rb' data-(?:session|host|page|number)="[^"]*"|[^<]*\n?') def page_content(data): """The page file without what names it, for telling the same page in two files.""" head, paper, rest = data.partition(b"|.][^\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" class Trash: """Where deleted notebooks and pages go, so that a deletion can be undone in the file manager (Andy, 2026-10-11: the Trash if possible). Two layouts: the freedesktop Trash on Linux, which wants an info file beside each entry, and a plain directory, which is what macOS's Trash is to a program and what --trash names. """ def __init__(self, directory, freedesktop=False): self.directory = pathlib.Path(directory) self.freedesktop = freedesktop @classmethod def system(cls): """The system's Trash, or None where this program knows none (Windows: the Recycle Bin needs the shell's help).""" if sys.platform == "darwin": return cls(pathlib.Path.home() / ".Trash") if sys.platform.startswith("linux"): base = os.environ.get("XDG_DATA_HOME") or pathlib.Path.home() / ".local" / "share" return cls(pathlib.Path(base) / "Trash", freedesktop=True) return None def take(self, path): """Move a file or directory into the Trash under a name not yet taken there. Raises OSError if that cannot be done; nothing is deleted then.""" files = self.directory / "files" if self.freedesktop else self.directory files.mkdir(parents=True, exist_ok=True) if self.freedesktop: info = self.directory / "info" info.mkdir(parents=True, exist_ok=True) self.directory.chmod(0o700) for serial in range(1, 1000): name = path.name if serial == 1 else f"{path.stem}.{serial}{path.suffix}" if self.freedesktop: # The info file reserves the name (the spec's way), and # tells the file manager where the entry came from. try: descriptor = os.open(info / f"{name}.trashinfo", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) except FileExistsError: continue with os.fdopen(descriptor, "w") as file: file.write("[Trash Info]\n" f"Path={urllib.parse.quote(str(path.resolve()))}\n" f"DeletionDate={time.strftime('%Y-%m-%dT%H:%M:%S')}\n") elif (files / name).exists(): continue shutil.move(str(path), str(files / name)) return raise OSError(f"no free name for {path.name} in the Trash") def discard(path, trash): """Move a notebook or a page to the Trash, or delete it outright where there is none. Returns whether the Trash took it.""" if trash is not None: trash.take(path) return True if path.is_dir(): shutil.rmtree(path) else: path.unlink() return False 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 remembered_relay(directory, given=""): """The relay to use: the one given, which is then remembered in the configuration directory, or else the one remembered from last time. Returns (address, remembered): the address, or None if none was given and none is remembered, and whether it came from memory. A relay address is typed once; afterwards the program is started bare (Andy, 2026-10-08, after a start without --relay sent an invitation to a relay on his own computer that was not running). """ path = directory / "relay" if given: given = given.strip() directory.mkdir(parents=True, exist_ok=True) if path.exists() and path.read_text().strip() == given: return given, False write_atomically(path, (given + "\n").encode()) return given, False try: kept = path.read_text().strip() except OSError: kept = "" return (kept, True) if kept else (None, False) 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 the same page is already kept (the same but for what names the file: see page_content). 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 # Blank: no stroke, no text block, no image. if not any(mark in data for mark in (b"data-stroke=", b"data-text=", b"/relay: such a server serves the client at https:/// 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 do_DELETE(self): self.notebook_request("DELETE", 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/ the pages of a notebook GET /api/notebooks// a page PUT /api/notebooks// write a page POST /api/notebooks//keep keep a copy of Current.svg DELETE /api/notebooks/ the notebook, pages and all DELETE /api/notebooks// a kept page A deletion moves the notebook or page to the Trash when the program has one; the listing of the notebooks says whether it has ("trash"), so that the client can say which it will be. """ 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 == "DELETE": if not names: return self.refuse(405, "the notebooks directory stays") if len(names) == 2 and names[1] == CURRENT_PAGE: return self.refuse(400, "the current page is being written") with notebook_lock: if not target.exists(): return self.refuse(404, "not found") basket = discard(target, self.trash) self.send_json({"discarded": names[-1], "trash": basket}) 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), "trash": self.trash is not None}) 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= 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= an image file PUT /api/file?directory=&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="", trash=None): """trash: a Trash for deleted notebooks and pages, or None to delete them outright.""" handler = type("ConfiguredHandler", (Handler,), { "relay": relay.rstrip("/"), "site": site_of(relay, site), "token": token, "notebooks": pathlib.Path(notebooks), "config": pathlib.Path(config), "trash": trash}) 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="", help="relay used for sessions started here, such as " "wss://kokkoja.example/relay; remembered, so it " "need be given only once") parser.add_argument("--site", default="", help="Kokkoja web site that invitations link to, so that " "guests can join with nothing installed (default: " "https:/// when the relay is wss:///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("--trash", default="", help="where deleted notebooks and pages are moved: a " "directory, or 'none' to delete them outright; by " "default the system's Trash, or " "outright where the program knows none") 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() relay, remembered = remembered_relay(config, arguments.relay) if relay is None: parser.exit(2, "Kokkoja needs the address of a relay the first time it is started:\n" " python3 kokkoja.py --relay wss://kokkoja.example/relay\n" "Afterwards the address is remembered and python3 kokkoja.py is enough.\n") token = load_token(config) trash = (None if arguments.trash == "none" else Trash(arguments.trash) if arguments.trash else Trash.system()) server = make_server(arguments.port, relay, notebooks, token, config, arguments.site, trash) address = f"http://localhost:{arguments.port}/#token={token}" print(f"Kokkoja is at {address}", flush=True) print(f"Relay: {relay}" + (" (remembered from last time)" if remembered else ""), 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()