"""NetClip-Server: reines Relay fuer verschluesselte Zwischenablage-Eintraege. Der Server kennt weder Token noch Inhalte. Clients leiten aus ihrem Token eine Raum-ID (64 Hex-Zeichen) und einen Schluessel ab; der Server sieht nur die Raum-ID und speichert/verteilt verschluesselte Blobs an alle Clients im Raum. """ import asyncio import json import logging import os import re import secrets import shutil import struct import time from pathlib import Path from aiohttp import WSMsgType, web VERSION = "1.0.0" DATA_DIR = Path(os.environ.get("DATA_DIR", "/data")) PORT = int(os.environ.get("PORT", "8080")) SERVER_KEY = os.environ.get("SERVER_KEY", "") MAX_CLIP_MB = int(os.environ.get("MAX_CLIP_MB", "1024")) HISTORY_MAX = int(os.environ.get("HISTORY_MAX", "100")) HISTORY_DAYS = float(os.environ.get("HISTORY_DAYS", "14")) ROOM_QUOTA_MB = int(os.environ.get("ROOM_QUOTA_MB", "5120")) MAX_META_BYTES = 256 * 1024 ROOM_RE = re.compile(r"^[0-9a-f]{64}$") CLIP_RE = re.compile(r"^[0-9a-f]{32}$") DEVICE_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") log = logging.getLogger("netclip") class Room: def __init__(self, room_id: str): self.id = room_id self.dir = DATA_DIR / "rooms" / room_id self.index_file = self.dir / "index.json" self.clips: list[dict] = [] # neueste zuerst self.sockets: dict[web.WebSocketResponse, dict] = {} self.lock = asyncio.Lock() if self.index_file.exists(): try: self.clips = json.loads(self.index_file.read_text()) except (OSError, ValueError): log.warning("Index von Raum %s defekt, starte leer", room_id[:8]) def blob_path(self, clip_id: str) -> Path: return self.dir / f"{clip_id}.bin" def save_index(self): self.dir.mkdir(parents=True, exist_ok=True) tmp = self.index_file.with_suffix(".tmp") tmp.write_text(json.dumps(self.clips)) os.replace(tmp, self.index_file) def prune(self) -> list[str]: """Entfernt Eintraege ueber Anzahl-, Alters- und Groessenlimit. Gibt geloeschte IDs zurueck.""" cutoff = time.time() * 1000 - HISTORY_DAYS * 86400_000 quota = ROOM_QUOTA_MB * 1024 * 1024 keep, removed, total = [], [], 0 for clip in self.clips: total += clip["size"] if len(keep) >= HISTORY_MAX or clip["ts"] < cutoff or (keep and total > quota): removed.append(clip["id"]) else: keep.append(clip) for clip_id in removed: self.blob_path(clip_id).unlink(missing_ok=True) if removed: self.clips = keep self.save_index() return removed async def broadcast(self, msg: dict): data = json.dumps(msg) for ws in list(self.sockets): try: await ws.send_str(data) except (ConnectionError, RuntimeError): pass async def broadcast_devices(self): devices = [{"device": i["device"], "label": i["label"]} for i in self.sockets.values()] await self.broadcast({"type": "devices", "devices": devices}) rooms: dict[str, Room] = {} def get_room(request: web.Request) -> Room: room_id = request.match_info["room"] if not ROOM_RE.match(room_id): raise web.HTTPBadRequest(text="invalid room") room = rooms.get(room_id) if room is None: room = rooms[room_id] = Room(room_id) return room def public(clip: dict) -> dict: return {k: clip[k] for k in ("id", "ts", "device", "size", "meta")} @web.middleware async def auth_middleware(request: web.Request, handler): if SERVER_KEY and request.path.startswith("/api/"): auth = request.headers.get("Authorization", "") if not secrets.compare_digest(auth.encode(), f"Bearer {SERVER_KEY}".encode()): raise web.HTTPUnauthorized(text="invalid server key") return await handler(request) async def health(_request): return web.json_response({"ok": True, "version": VERSION}) async def info(_request): return web.json_response({"version": VERSION, "max_clip_mb": MAX_CLIP_MB, "history_max": HISTORY_MAX, "history_days": HISTORY_DAYS}) async def list_clips(request): room = get_room(request) return web.json_response({"clips": [public(c) for c in room.clips]}) async def upload_clip(request: web.Request): """Body: 4 Byte Laenge (big endian) + Meta (Base64, verschluesselt) + verschluesselter Blob.""" room = get_room(request) device = request.headers.get("X-Device", "") if not DEVICE_RE.match(device): raise web.HTTPBadRequest(text="invalid device") max_bytes = MAX_CLIP_MB * 1024 * 1024 if request.content_length and request.content_length > max_bytes + MAX_META_BYTES: raise web.HTTPRequestEntityTooLarge(max_size=max_bytes, actual_size=request.content_length) body = request.content try: (meta_len,) = struct.unpack(">I", await body.readexactly(4)) if meta_len > MAX_META_BYTES: raise web.HTTPBadRequest(text="meta too large") meta = (await body.readexactly(meta_len)).decode("ascii") except asyncio.IncompleteReadError: raise web.HTTPBadRequest(text="truncated body") clip_id = secrets.token_hex(16) room.dir.mkdir(parents=True, exist_ok=True) path = room.blob_path(clip_id) tmp = path.with_suffix(".part") size = 0 try: with open(tmp, "wb") as f: async for chunk in body.iter_chunked(1024 * 1024): size += len(chunk) if size > max_bytes: raise web.HTTPRequestEntityTooLarge(max_size=max_bytes, actual_size=size) f.write(chunk) os.replace(tmp, path) finally: tmp.unlink(missing_ok=True) clip = {"id": clip_id, "ts": int(time.time() * 1000), "device": device, "size": size, "meta": meta} async with room.lock: room.clips.insert(0, clip) room.save_index() removed = room.prune() log.info("Raum %s: Clip %s von %s (%d Bytes)", room.id[:8], clip_id[:8], device, size) await room.broadcast({"type": "clip", "clip": public(clip)}) for clip_id_removed in removed: await room.broadcast({"type": "deleted", "id": clip_id_removed}) return web.json_response(public(clip), status=201) async def get_blob(request): room = get_room(request) clip_id = request.match_info["clip"] if not CLIP_RE.match(clip_id) or not any(c["id"] == clip_id for c in room.clips): raise web.HTTPNotFound() path = room.blob_path(clip_id) if not path.exists(): raise web.HTTPNotFound() return web.FileResponse(path, headers={"Content-Type": "application/octet-stream"}) async def delete_clip(request): room = get_room(request) clip_id = request.match_info["clip"] if not CLIP_RE.match(clip_id): raise web.HTTPBadRequest() async with room.lock: room.clips = [c for c in room.clips if c["id"] != clip_id] room.blob_path(clip_id).unlink(missing_ok=True) room.save_index() await room.broadcast({"type": "deleted", "id": clip_id}) return web.json_response({"ok": True}) async def clear_clips(request): room = get_room(request) async with room.lock: for clip in room.clips: room.blob_path(clip["id"]).unlink(missing_ok=True) room.clips = [] room.save_index() await room.broadcast({"type": "cleared"}) return web.json_response({"ok": True}) async def websocket(request): room = get_room(request) device = request.query.get("device", "") if not DEVICE_RE.match(device): raise web.HTTPBadRequest(text="invalid device") ws = web.WebSocketResponse(heartbeat=30, max_msg_size=64 * 1024) await ws.prepare(request) room.sockets[ws] = {"device": device, "label": ""} log.info("Raum %s: %s verbunden (%d online)", room.id[:8], device, len(room.sockets)) try: await ws.send_json({"type": "hello", "version": VERSION, "max_clip_mb": MAX_CLIP_MB, "clips": [public(c) for c in room.clips]}) await room.broadcast_devices() async for msg in ws: if msg.type != WSMsgType.TEXT: continue try: data = json.loads(msg.data) except ValueError: continue if data.get("type") == "hello": # label = vom Client verschluesselter Geraetename, fuer den Server undurchsichtig room.sockets[ws]["label"] = str(data.get("label", ""))[:1024] await room.broadcast_devices() elif data.get("type") == "ping": await ws.send_json({"type": "pong"}) finally: room.sockets.pop(ws, None) log.info("Raum %s: %s getrennt (%d online)", room.id[:8], device, len(room.sockets)) await room.broadcast_devices() if not room.sockets and not room.clips: rooms.pop(room.id, None) return ws async def cleanup_loop(_app): while True: await asyncio.sleep(3600) rooms_dir = DATA_DIR / "rooms" if not rooms_dir.exists(): continue for d in rooms_dir.iterdir(): if not ROOM_RE.match(d.name): continue room = rooms.get(d.name) or Room(d.name) async with room.lock: removed = room.prune() for clip_id in removed: await room.broadcast({"type": "deleted", "id": clip_id}) if not room.clips and not room.sockets: rooms.pop(d.name, None) shutil.rmtree(d, ignore_errors=True) async def start_background(app): app["cleanup"] = asyncio.create_task(cleanup_loop(app)) async def stop_background(app): app["cleanup"].cancel() for room in list(rooms.values()): for ws in list(room.sockets): await ws.close() def make_app() -> web.Application: app = web.Application(middlewares=[auth_middleware]) r = "/api/v1/rooms/{room}" app.add_routes([ web.get("/healthz", health), web.get("/api/v1/info", info), web.get(f"{r}/clips", list_clips), web.post(f"{r}/clips", upload_clip), web.delete(f"{r}/clips", clear_clips), web.get(f"{r}/clips/{{clip}}/blob", get_blob), web.delete(f"{r}/clips/{{clip}}", delete_clip), web.get(f"{r}/ws", websocket), ]) app.on_startup.append(start_background) app.on_shutdown.append(stop_background) return app def main(): logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(message)s") DATA_DIR.mkdir(parents=True, exist_ok=True) log.info("NetClip-Server %s auf Port %d, Daten in %s, Server-Key %s", VERSION, PORT, DATA_DIR, "aktiv" if SERVER_KEY else "AUS") web.run_app(make_app(), port=PORT, access_log=None, print=None) if __name__ == "__main__": main()