Files
backlog/server/server.py
T

531 lines
20 KiB
Python

#!/usr/bin/env python3
"""Personal Backlog - lightweight file server + REST API.
Zero dependencies (Python 3 stdlib only).
Usage: python3 server.py --port 8080 --dir ./data
"""
import argparse
import hashlib
import json
import os
import re
import shutil
import sys
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from urllib.parse import parse_qs, urlparse
class Config:
def __init__(self, directory: Path, port: int, web_dir: Path | None = None):
self.dir = directory.resolve()
self.port = port
self.master = self.dir / "backlog.md"
self.archive = self.dir / "archive.md"
self.backups_dir = self.dir / "backups"
self.stats_file = self.dir / "stats.jsonl"
self.registry_file = self.dir / "projects.json"
self.web_dir = web_dir.resolve() if web_dir else Path(__file__).parent.parent / "webapp"
def ensure_dirs(self):
self.dir.mkdir(parents=True, exist_ok=True)
self.backups_dir.mkdir(exist_ok=True)
def load_registry(self) -> dict:
if not self.registry_file.exists():
return {}
try:
return json.loads(self.registry_file.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
print(f"[registry] projects.json unreadable, starting empty: {e}")
return {}
def save_registry(self, registry: dict):
tmp = self.registry_file.with_suffix(".json.tmp")
tmp.write_text(json.dumps(registry, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(self.registry_file)
CONFIG: Config = None # type: ignore[assignment]
# ---------------------------------------------------------------------------
# Markdown integrity helpers
# ---------------------------------------------------------------------------
def compute_checksum(entries_text: str, history_text: str) -> str:
payload = entries_text + "\n" + history_text
h = hashlib.sha256(payload.encode("utf-8")).hexdigest()
return f"sha256:{h}"
def parse_markdown_sections(text: str):
"""Split markdown into entries, history, and integrity sections.
Returns (entries_text, history_text, integrity_meta dict or None).
"""
# Find section markers
entries_start = text.find("<!-- SECTION: ENTRIES -->")
history_start = text.find("<!-- SECTION: HISTORY -->")
integrity_start = text.find("<!-- SECTION: INTEGRITY -->")
if entries_start == -1:
# No sections found — treat entire text as entries
return text, "", None
entries_text = text[entries_start:history_start if history_start != -1 else len(text)]
history_text = ""
integrity_meta = None
if history_start != -1:
end = integrity_start if integrity_start != -1 else len(text)
history_text = text[history_start:end]
if integrity_start != -1:
integrity_block = text[integrity_start:]
# Parse comment like <!-- saved: ... | checksum: ... | entries: ... | history: ... -->
m = re.search(r"saved:\s*([^|]+?)\s*\|\s*checksum:\s*([^|]+?)\s*\|\s*entries:\s*(\d+)\s*\|\s*history:\s*(\d+)", integrity_block)
if m:
integrity_meta = {
"saved": m.group(1).strip(),
"checksum": m.group(2).strip(),
"entries": int(m.group(3)),
"history": int(m.group(4)),
}
return entries_text, history_text, integrity_meta
def make_integrity_marker(entries_text: str, history_text: str) -> str:
checksum = compute_checksum(entries_text, history_text)
entry_count = len(re.findall(r"^[-*] \[", entries_text, re.MULTILINE))
history_count = len([l for l in history_text.splitlines() if l.startswith("|") and "Timestamp" not in l])
saved = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
return f"<!-- saved: {saved} | checksum: {checksum} | entries: {entry_count} | history: {history_count} -->"
def build_markdown(entries_text: str, history_text: str) -> str:
marker = make_integrity_marker(entries_text, history_text)
return f"# Backlog\n\n<!-- SECTION: ENTRIES -->\n\n{entries_text}\n\n<!-- SECTION: HISTORY -->\n\n{history_text}\n\n<!-- SECTION: INTEGRITY -->\n\n{marker}\n"
# ---------------------------------------------------------------------------
# File operations
# ---------------------------------------------------------------------------
def read_master() -> dict:
if not CONFIG.master.exists():
return {"content": build_markdown("", "| Timestamp | Item ID | Action | Details |\n|-----------|---------|--------|---------|"), "checksum": "", "size": 0}
text = CONFIG.master.read_text(encoding="utf-8")
entries, history, meta = parse_markdown_sections(text)
checksum = meta["checksum"] if meta else ""
return {"content": text, "checksum": checksum, "size": len(text.encode("utf-8"))}
def write_master(content: str) -> dict:
"""Atomic write with backup."""
tmp = CONFIG.master.with_suffix(".md.tmp")
# Write temp
tmp.write_text(content, encoding="utf-8")
# Verify it parses
parse_markdown_sections(content)
# Create backup (millis to avoid collisions)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S")
millis = datetime.now(timezone.utc).strftime("%f")[:3]
backup_name = f"backlog_{timestamp}-{millis}.md"
backup_path = CONFIG.backups_dir / backup_name
shutil.copy2(tmp, backup_path)
rotate_backups()
# Atomic rename
tmp.replace(CONFIG.master)
# Stats
append_stats({"t": datetime.now(timezone.utc).isoformat(), "e": "save_completed", "d": {"size": len(content.encode("utf-8"))}})
_, _, meta = parse_markdown_sections(content)
return {"ok": True, "checksum": meta["checksum"] if meta else "", "saved": meta["saved"] if meta else ""}
def read_archive() -> dict:
"""Read archive.md, return empty structure if not exists."""
if not CONFIG.archive.exists():
return {"content": "", "checksum": "", "size": 0, "exists": False}
text = CONFIG.archive.read_text(encoding="utf-8")
entries, history, meta = parse_markdown_sections(text)
checksum = meta["checksum"] if meta else ""
return {"content": text, "checksum": checksum, "size": len(text.encode("utf-8")), "exists": True}
def write_archive(content: str) -> dict:
"""Write archive.md (append-only semantics enforced at API level)."""
tmp = CONFIG.archive.with_suffix(".md.tmp")
tmp.write_text(content, encoding="utf-8")
parse_markdown_sections(content) # Verify it parses
tmp.replace(CONFIG.archive)
_, _, meta = parse_markdown_sections(content)
return {"ok": True, "checksum": meta["checksum"] if meta else "", "saved": meta["saved"] if meta else ""}
def rotate_backups():
files = sorted(CONFIG.backups_dir.glob("backlog_*.md"), key=lambda p: p.stat().st_mtime)
if not files:
return
now = datetime.now(timezone.utc)
for f in files[:-1]: # never delete the most recent
age_days = (now.timestamp() - f.stat().st_mtime) / 86400
if age_days <= 7:
continue
day = f.name.split("_")[1] # YYYY-MM-DD
same_day = [x for x in files if x.name.startswith(f"backlog_{day}")]
if f != max(same_day, key=lambda p: p.stat().st_mtime):
f.unlink()
def list_backups() -> list:
result = []
for f in sorted(CONFIG.backups_dir.glob("backlog_*.md"), key=lambda p: p.stat().st_mtime, reverse=True):
text = f.read_text(encoding="utf-8")
_, _, meta = parse_markdown_sections(text)
result.append({
"name": f.name,
"size": f.stat().st_size,
"timestamp": datetime.fromtimestamp(f.stat().st_mtime, tz=timezone.utc).isoformat(),
"valid": meta is not None,
})
return result
def restore_backup(name: str) -> dict:
src = CONFIG.backups_dir / name
if not src.exists():
return {"ok": False, "error": "Backup not found"}
text = src.read_text(encoding="utf-8")
parse_markdown_sections(text) # validate readable
shutil.copy2(src, CONFIG.master)
return {"ok": True}
# ---------------------------------------------------------------------------
# Stats
# ---------------------------------------------------------------------------
def append_stats(event: dict):
with open(CONFIG.stats_file, "a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
def read_stats(from_iso: str = None, to_iso: str = None) -> list:
if not CONFIG.stats_file.exists():
return []
events = []
with open(CONFIG.stats_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
t = ev.get("t", "")
if from_iso and t < from_iso:
continue
if to_iso and t > to_iso:
continue
events.append(ev)
except json.JSONDecodeError:
continue
return events
# ---------------------------------------------------------------------------
# Project registry
# ---------------------------------------------------------------------------
def safe_project_name(name: str) -> str:
"""Collapse to [a-zA-Z0-9_-]; empty result means the name is invalid."""
return re.sub(r"[^a-zA-Z0-9_-]", "-", name.strip())
def get_project_path(name: str) -> Path:
registry = CONFIG.load_registry()
if name not in registry:
raise KeyError(name)
return Path(registry[name]["path"]).resolve()
def auto_register_default():
registry = CONFIG.load_registry()
if registry:
return
if not CONFIG.master.exists():
return
path = str(CONFIG.master.resolve())
registry["default"] = {"path": path, "name": "default"}
CONFIG.save_registry(registry)
print(f"[init] Registered default project: {path}")
# ---------------------------------------------------------------------------
# HTTP Handler
# ---------------------------------------------------------------------------
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
# Suppress default logging
pass
def _json_response(self, data: dict, status: int = 200):
body = json.dumps(data).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
self.wfile.write(body)
def _text_response(self, text: str, status: int = 200, content_type: str = "text/plain"):
body = text.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def _file_response(self, path: Path, content_type: str = "application/octet-stream"):
if not path.exists():
self._json_response({"error": "Not found"}, 404)
return
data = path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
def _read_json_body(self) -> dict:
length = int(self.headers.get("Content-Length", 0))
if length == 0:
return {}
body = self.rfile.read(length).decode("utf-8")
return json.loads(body)
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
qs = parse_qs(parsed.query)
# Serve static files from web_dir for / and any non-API path
if not path.startswith("/api/"):
# Map / → index.html, otherwise strip leading /
rel = "index-style-v2.html" if path == "/" else path.lstrip("/")
target = (CONFIG.web_dir / rel).resolve()
# Safety: stay inside web_dir
try:
target.relative_to(CONFIG.web_dir)
except ValueError:
self._json_response({"error": "Forbidden"}, 403)
return
if target.exists() and target.is_file():
ext = target.suffix.lower()
mime = {
".html": "text/html", ".css": "text/css",
".js": "application/javascript", ".jsx": "application/javascript",
".json": "application/json", ".md": "text/markdown",
".png": "image/png", ".svg": "image/svg+xml",
}.get(ext, "application/octet-stream")
self._file_response(target, mime)
else:
self._json_response({"error": "Not found"}, 404)
return
if path == "/api/health":
info = read_master()
archive_info = read_archive()
backups = list_backups()
self._json_response({
"status": "ok",
"lastSave": info.get("meta", {}).get("saved", "") if isinstance(info, dict) else "",
"lastBackup": backups[0]["timestamp"] if backups else "",
"masterSize": CONFIG.master.stat().st_size if CONFIG.master.exists() else 0,
"backupCount": len(backups),
"masterPath": str(CONFIG.master),
"backupsPath": str(CONFIG.backups_dir),
"archiveExists": archive_info["exists"],
"archiveSize": archive_info["size"],
"archivePath": str(CONFIG.archive),
})
return
if path == "/api/backlog":
info = read_master()
client_checksum = qs.get("checksum", [None])[0]
if client_checksum and client_checksum == info["checksum"]:
self.send_response(304)
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
return
self._json_response({"content": info["content"], "checksum": info["checksum"]})
return
if path == "/api/archive":
info = read_archive()
if not info["exists"]:
self._json_response({"content": "", "checksum": "", "exists": False})
return
client_checksum = qs.get("checksum", [None])[0]
if client_checksum and client_checksum == info["checksum"]:
self.send_response(304)
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
return
self._json_response({"content": info["content"], "checksum": info["checksum"], "exists": True})
return
if path == "/api/backups":
self._json_response({"backups": list_backups()})
return
if path.startswith("/api/backups/"):
name = path[len("/api/backups/"):]
backup_path = CONFIG.backups_dir / name
self._file_response(backup_path, "text/markdown")
return
if path == "/api/stats":
events = read_stats(qs.get("from", [None])[0], qs.get("to", [None])[0])
self._json_response({"events": events})
return
self._json_response({"error": "Not found"}, 404)
def do_POST(self):
parsed = urlparse(self.path)
path = parsed.path
if path == "/api/backlog":
body = self._read_json_body()
content = body.get("content", "")
result = write_master(content)
self._json_response(result)
return
if path == "/api/backups/restore":
body = self._read_json_body()
result = restore_backup(body.get("name", ""))
self._json_response(result)
return
if path == "/api/archive":
# Archive items: save both backlog and archive atomically
body = self._read_json_body()
backlog_content = body.get("backlog", "")
archive_content = body.get("archive", "")
# Validate both parse correctly
parse_markdown_sections(backlog_content)
parse_markdown_sections(archive_content)
# Write archive first (append-only), then backlog
archive_result = write_archive(archive_content)
backlog_result = write_master(backlog_content)
self._json_response({"ok": True, "backlog": backlog_result, "archive": archive_result})
return
if path == "/api/archive/restore":
# Restore items from archive: save both backlog and archive atomically
body = self._read_json_body()
backlog_content = body.get("backlog", "")
archive_content = body.get("archive", "")
parse_markdown_sections(backlog_content)
parse_markdown_sections(archive_content)
backlog_result = write_master(backlog_content)
archive_result = write_archive(archive_content)
self._json_response({"ok": True, "backlog": backlog_result, "archive": archive_result})
return
if path == "/api/export":
body = self._read_json_body()
fmt = body.get("format", "json")
info = read_master()
if fmt == "json":
entries, history, meta = parse_markdown_sections(info["content"])
self._json_response({
"format": "json",
"exported_at": datetime.now(timezone.utc).isoformat(),
"entries_raw": entries,
"history_raw": history,
"integrity": meta,
})
else:
self._text_response(info["content"], content_type="text/markdown")
return
if path == "/api/import":
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length).decode("utf-8")
try:
data = json.loads(raw)
content = data.get("content", "")
except json.JSONDecodeError:
content = raw # assume markdown
parse_markdown_sections(content) # validate readable
result = write_master(content)
self._json_response(result)
return
if path == "/api/stats":
body = self._read_json_body()
append_stats(body)
self._json_response({"ok": True})
return
self._json_response({"error": "Not found"}, 404)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def initialize_storage():
CONFIG.ensure_dirs()
if not CONFIG.master.exists():
blank = build_markdown("", "| Timestamp | Item ID | Action | Details |\n|-----------|---------|--------|---------|")
CONFIG.master.write_text(blank, encoding="utf-8")
print(f"[init] Created blank {CONFIG.master}")
auto_register_default()
def main():
parser = argparse.ArgumentParser(description="Personal Backlog Server")
parser.add_argument("--port", type=int, default=8080, help="Port to listen on")
parser.add_argument("--dir", type=str, default=str(Path(__file__).parent),
help="Directory for backlog.md, backups/, stats.jsonl (default: same dir as server.py)")
parser.add_argument("--web-dir", type=str, default=None, help="Directory to serve static files from (default: webapp/)")
args = parser.parse_args()
global CONFIG
web_dir = Path(args.web_dir) if args.web_dir else None
CONFIG = Config(Path(args.dir), args.port, web_dir)
initialize_storage()
server = HTTPServer(("0.0.0.0", args.port), Handler)
print(f"[server] Listening on http://0.0.0.0:{args.port}")
print(f"[server] Data dir: {CONFIG.dir}")
print(f"[server] Web dir: {CONFIG.web_dir}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n[server] Shutting down.")
server.shutdown()
if __name__ == "__main__":
main()