### 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).
308 lines
11 KiB
Python
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()
|