Files
kokkoja-rel/kokkoja.py
T

651 lines
28 KiB
Python
Raw Normal View History

2026-10-06 15:58:02 +02:00
#!/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
2026-10-06 15:58:02 +02:00
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 <rect>, and only there; typed text (in <desc>) may contain
# anything, so the search stops at the paper.
VOLATILE = re.compile(rb' data-(?:session|host|page|number)="[^"]*"|<title>[^<]*</title>\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"<rect ")
return VOLATILE.sub(b"", head) + paper + rest
2026-10-06 15:58:02 +02:00
# 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"
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
2026-10-06 15:58:02 +02:00
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)
2026-10-06 15:58:02 +02:00
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).
2026-10-06 15:58:02 +02:00
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"<image")):
2026-10-06 15:58:02 +02:00
return None, False
kept = [entry for entry in notebook.iterdir()
if KEPT_PAGE.match(entry.name) and entry.is_file()]
content = page_content(data)
2026-10-06 15:58:02 +02:00
for entry in kept:
# The ids are of fixed length, so files of the same page differ
# in size by the digits of the page number at most; a notebook
# of large pages is not read through for every keep.
if abs(entry.stat().st_size - len(data)) <= 32 and page_content(entry.read_bytes()) == content:
2026-10-06 15:58:02 +02:00
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 do_DELETE(self):
self.notebook_request("DELETE", self.path.split("?")[0])
2026-10-06 15:58:02 +02:00
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
2026-10-11 18:05:49 +02:00
GET /api/notebooks/<notebook> the pages of a notebook, and
when each was last changed
2026-10-06 15:58:02 +02:00
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
DELETE /api/notebooks/<notebook> the notebook, pages and all
DELETE /api/notebooks/<notebook>/<page> 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.
2026-10-06 15:58:02 +02:00
"""
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})
2026-10-06 15:58:02 +02:00
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})
2026-10-06 15:58:02 +02:00
elif len(names) == 1:
2026-10-11 18:05:49 +02:00
pages = self.listing(target, directories=False)
self.send_json({"pages": pages,
"changed": {name: int((target / name).stat().st_mtime * 1000) for name in pages}})
2026-10-06 15:58:02 +02:00
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="", trash=None):
"""trash: a Trash for deleted notebooks and pages, or None
to delete them outright."""
2026-10-06 15:58:02 +02:00
handler = type("ConfiguredHandler", (Handler,), {
"relay": relay.rstrip("/"), "site": site_of(relay, site), "token": token,
"notebooks": pathlib.Path(notebooks), "config": pathlib.Path(config),
"trash": trash})
2026-10-06 15:58:02 +02:00
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")
2026-10-06 15:58:02 +02:00
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("--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")
2026-10-06 15:58:02 +02:00
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")
2026-10-06 15:58:02 +02:00
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)
2026-10-06 15:58:02 +02:00
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)
2026-10-06 15:58:02 +02:00
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()