Files
zwischenablage-server/server/netclip_server.py
T
duffyduck 5d68a673d3 Release v1.0.2
### Geändert
- Bauen und Releases über `./build.sh` (release, build, test): läuft komplett in temporären
  Docker-Containern, auf dem Rechner wird außer Docker nichts mehr benötigt.
- Android-Build-Image enthält die Platform-Tools fest (schnellere Builds).
2026-10-17 13:42:23 +00:00

308 lines
11 KiB
Python

"""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.2"
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()