Eine Zeichnung ist eine Datei, kein neues Ding – das ist die ganze Entscheidung dahinter. .excalidraw ist JSON; damit erbt sie Ordner, Gruppenordner, Papierkorb, Versionen, Freigabelinks, interne Freigaben und die Suche. Keine neue Tabelle, kein zweites Rechtemodell. Gespeichert wird über denselben Upload wie alles andere, wie beim Text-Editor in der Vorschau. Drei Sorgen, alle nachgemessen statt gehofft: Das Hauptbündel wächst von 484,11 auf 485,22 kB – das Zeichenbrett ist ein eigenes Stück (584 kB + 142 kB CSS) und wird nachgeladen wie der PDF-Betrachter. Wer nie zeichnet, holt es nie. Die Schriften: Die Richtlinie ist default-src 'self' ohne font-src, Excalidraw hätte seine handgezeichneten Schriften von aussen geladen und still eine Ersatzschrift gezeigt. Sie werden jetzt beim Bauen kopiert und über window.EXCALIDRAW_ASSET_PATH gefunden – gesetzt im Modul, nicht per Inline-Skript, das wäre von derselben Richtlinie blockiert. Xiaolai bleibt draussen: 13 der 14 MB für die chinesische Schrift. Die Reihenfolge der Dateiarten: .excalidraw wird vor code und text geprüft, sonst öffnete eine Zeichnung im Texteditor als Zahlenkolonne (json steht in codeExt). Kein gleichzeitiges Arbeiten. Die Komponente könnte es, aber den Transport bringt sie nicht mit – das wäre ein zweiter Dienst neben Aether. Solange es ihn nicht gibt, überschreibt der Zweite den Ersten. Zwei Dinge fangen das ab: Vor dem Speichern wird geprüft, ob sich die Fassung geändert hat, und dann gefragt statt überschrieben. Und Zeichnungen behalten ihre Fassungen von der ersten Minute an. Der zweite Punkt ist erst durch den Selbsttest aufgefallen: Versionierung ist sonst bewusst aus (Migration 000006), damit der Speicher nicht vollläuft. Für Zeichnungen stimmt die Rechnung nicht – sie werden in sich immer wieder bearbeitet und sind ein paar Kilobyte gross. Ohne sie wäre die Warnung im Editor ein Versprechen ohne Deckung gewesen. Neuer Selbsttest mit 12 Prüfungen: Er prüft die These nach, auf der alles beruht – wenn eine Zeichnung wirklich eine Datei ist, muss sie können, was Dateien können. Nebenbei: db-tests.sh liess je Lauf ein anonymes Postgres-Volume liegen (rund 49 MB). `docker rm -v` nimmt es jetzt mit; zehn verwaiste entfernt. Geprüft: go build/vet sauber, alle 15 Go-Pakete grün, 255 Prüfungen der acht Selbsttest-Suiten grün, 48 Vitest-Tests, tsc sauber. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
185 lines
7.7 KiB
Python
Executable File
185 lines
7.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Selbsttest der Zeichnungen (Excalidraw).
|
||
|
||
python3 scripts/zeichnung-selbsttest.py [BASIS-URL] [BENUTZER] [PASSWORT]
|
||
|
||
Eine Zeichnung ist bei uns **eine Datei** – das ist die ganze Entscheidung
|
||
hinter der Umsetzung. Dieser Test prueft genau das nach: Wenn sie wirklich
|
||
eine Datei ist, muss sie alles koennen, was Dateien koennen – Versionen,
|
||
Freigabelinks, Gruppenordner, Papierkorb. Und das ist nicht selbstverstaendlich,
|
||
sondern der Gewinn, den man sonst nur behauptet.
|
||
|
||
Dazu die eine Stelle, die ohne gleichzeitiges Arbeiten wehtut: Zwei Leute
|
||
speichern nacheinander, und der Zweite darf den Ersten nicht spurlos
|
||
ueberschreiben.
|
||
"""
|
||
import hashlib
|
||
import json
|
||
import sys
|
||
import urllib.error
|
||
import urllib.request
|
||
import uuid
|
||
import http.cookiejar
|
||
|
||
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8090").rstrip("/")
|
||
USER = sys.argv[2] if len(sys.argv) > 2 else "admin"
|
||
PASSWORT = sys.argv[3] if len(sys.argv) > 3 else "admin123"
|
||
|
||
ok_count, fail = 0, []
|
||
|
||
|
||
def check(name, cond, detail=""):
|
||
global ok_count
|
||
if cond:
|
||
ok_count += 1
|
||
print(f" \033[32mok\033[0m {name}")
|
||
else:
|
||
fail.append(name)
|
||
print(f" \033[31mFEHL\033[0m {name} {detail}")
|
||
|
||
|
||
def sitzung():
|
||
return urllib.request.build_opener(
|
||
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
|
||
|
||
|
||
def ruf(opener, method, path, body=None, headers=None, roh=False):
|
||
hdrs = dict(headers or {})
|
||
data = None
|
||
if body is not None:
|
||
data = json.dumps(body).encode()
|
||
hdrs["Content-Type"] = "application/json"
|
||
req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method=method)
|
||
try:
|
||
with (opener or urllib.request.build_opener()).open(req) as r:
|
||
payload = r.read()
|
||
if roh:
|
||
return r.status, payload
|
||
text = payload.decode()
|
||
return r.status, (json.loads(text).get("data") if text else None)
|
||
except urllib.error.HTTPError as e:
|
||
return e.code, None
|
||
|
||
|
||
def anmelden(user, passwort):
|
||
o = sitzung()
|
||
st, d = ruf(o, "POST", "/api/v1/auth/login", {"username": user, "password": passwort})
|
||
assert st == 200, f"Anmeldung {user} fehlgeschlagen: {st}"
|
||
return o, {"X-CSRF-Token": d["csrf_token"]}
|
||
|
||
|
||
def hochladen(opener, csrf, name, inhalt: bytes, folder_id=None):
|
||
grenze = "----aether" + uuid.uuid4().hex
|
||
teile = []
|
||
if folder_id:
|
||
teile.append(f'--{grenze}\r\nContent-Disposition: form-data; name="folder_id"\r\n\r\n{folder_id}\r\n')
|
||
kopf = (f'--{grenze}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
|
||
f'Content-Type: application/json\r\n\r\n')
|
||
leib = b"".join(t.encode() for t in teile) + kopf.encode() + inhalt + f"\r\n--{grenze}--\r\n".encode()
|
||
req = urllib.request.Request(BASE + "/api/v1/files", data=leib, method="POST",
|
||
headers={"Content-Type": f"multipart/form-data; boundary={grenze}", **csrf})
|
||
with opener.open(req) as r:
|
||
return json.loads(r.read().decode())["data"]
|
||
|
||
|
||
def zeichnung(elemente=0):
|
||
return json.dumps({
|
||
"type": "excalidraw", "version": 2, "source": "aether",
|
||
"elements": [{"id": f"e{i}", "type": "rectangle", "x": i * 10, "y": 0,
|
||
"width": 80, "height": 40} for i in range(elemente)],
|
||
"appState": {}, "files": {},
|
||
}).encode()
|
||
|
||
|
||
print(f"Zeichnungs-Selbsttest gegen {BASE}\n")
|
||
admin, CSRF = anmelden(USER, PASSWORT)
|
||
_, ich = ruf(admin, "GET", "/api/v1/auth/me")
|
||
CSRF = {"X-CSRF-Token": ich["csrf_token"]}
|
||
|
||
name = f"selbsttest-{uuid.uuid4().hex[:6]}.excalidraw"
|
||
angelegt, links, gruppen = [], [], []
|
||
|
||
|
||
def aufraeumen():
|
||
for lid in links:
|
||
ruf(admin, "DELETE", f"/api/v1/shares/{lid}", None, CSRF)
|
||
for fid in angelegt:
|
||
ruf(admin, "DELETE", f"/api/v1/files/{fid}", None, CSRF)
|
||
_, muell = ruf(admin, "GET", "/api/v1/trash")
|
||
for f in muell or []:
|
||
if f.get("name", "").startswith("selbsttest-"):
|
||
ruf(admin, "DELETE", f"/api/v1/trash/{f['id']}", None, CSRF)
|
||
for gid in gruppen:
|
||
ruf(admin, "DELETE", f"/api/v1/admin/groups/{gid}", None, CSRF)
|
||
|
||
|
||
try:
|
||
print("── Als Datei anlegen ──")
|
||
inhalt = zeichnung(3)
|
||
datei = hochladen(admin, CSRF, name, inhalt)
|
||
angelegt.append(datei["id"])
|
||
check("angelegt", datei["size"] == len(inhalt), datei["size"])
|
||
check("Pruefsumme stimmt", datei["checksum"] == hashlib.sha256(inhalt).hexdigest())
|
||
check("als JSON erkannt", datei["mime_type"] == "application/json", datei["mime_type"])
|
||
|
||
st, roh = ruf(admin, "GET", f"/api/v1/files/{datei['id']}/download?inline=1", None, None, True)
|
||
check("Inhalt kommt unveraendert zurueck", st == 200 and roh == inhalt, st)
|
||
|
||
print("\n── Aendern erzeugt eine Version ──")
|
||
# Speichern heisst: gleicher Name, gleicher Ordner, neuer Upload.
|
||
zweite = hochladen(admin, CSRF, name, zeichnung(7))
|
||
check("gleiche Datei, neue Fassung", zweite["id"] == datei["id"] and zweite["version"] > datei["version"],
|
||
f"{datei['version']} -> {zweite['version']}")
|
||
st, versionen = ruf(admin, "GET", f"/api/v1/files/{datei['id']}/versions")
|
||
check("die alte Fassung liegt in der Historie", st == 200 and len(versionen or []) >= 1,
|
||
len(versionen or []))
|
||
|
||
print("\n── Ueberschreiben faellt auf ──")
|
||
# Ohne gleichzeitiges Arbeiten ist die Fassungsnummer der einzige Hinweis
|
||
# darauf, dass jemand anders gespeichert hat. Sie MUSS sich aendern.
|
||
dritte = hochladen(admin, CSRF, name, zeichnung(9))
|
||
check("jede Speicherung hebt die Fassung", dritte["version"] > zweite["version"],
|
||
f"{zweite['version']} -> {dritte['version']}")
|
||
_, liste = ruf(admin, "GET", "/api/v1/files")
|
||
inliste = next((f for f in (liste or {}).get("files", []) if f["id"] == datei["id"]), None)
|
||
check("und die Liste zeigt sie – darauf beruht der Abgleich im Editor",
|
||
inliste is not None and inliste["version"] == dritte["version"],
|
||
inliste and inliste["version"])
|
||
|
||
print("\n── Erbt alles, was Dateien koennen ──")
|
||
st, link = ruf(admin, "POST", f"/api/v1/files/{datei['id']}/share", {}, CSRF)
|
||
check("Freigabelink darauf (201)", st == 201, st)
|
||
if st == 201:
|
||
links.append(link["id"])
|
||
st, geholt = ruf(None, "POST", f"/share/{link['token']}/download", None, None, True)
|
||
check("oeffentlich abrufbar, Inhalt stimmt", st == 200 and geholt == zeichnung(9), st)
|
||
|
||
gname = "selbsttest-zeichnung-" + uuid.uuid4().hex[:6]
|
||
st, gruppe = ruf(admin, "POST", "/api/v1/admin/groups", {"name": gname, "description": ""}, CSRF)
|
||
if st in (200, 201):
|
||
gruppen.append(gruppe["id"])
|
||
ruf(admin, "POST", f"/api/v1/admin/groups/{gruppe['id']}/members",
|
||
{"user_id": ich["user"]["id"]}, CSRF)
|
||
_, wurzeln = ruf(admin, "GET", "/api/v1/files")
|
||
gordner = next((f for f in (wurzeln or {}).get("folders", []) if f.get("group_id")), None)
|
||
if gordner:
|
||
gz = hochladen(admin, CSRF, "selbsttest-gruppe.excalidraw", zeichnung(2), gordner["id"])
|
||
angelegt.append(gz["id"])
|
||
check("Zeichnung im Gruppenordner", gz["folder_id"] == gordner["id"])
|
||
|
||
print("\n── Schriften werden selbst ausgeliefert ──")
|
||
# Ohne das laedt Excalidraw sie von aussen – und die Sicherheitsrichtlinie
|
||
# verbietet genau das. Man saehe eine Ersatzschrift und suchte den Grund
|
||
# im Zeichenbrett statt in der Richtlinie.
|
||
st, _ = ruf(None, "GET", "/excalidraw/fonts/Excalifont/"
|
||
"Excalifont-Regular-349fac6ca4700ffec595a7150a0d1e1d.woff2", None, None, True)
|
||
check("Excalifont liegt auf dem eigenen Server", st == 200, st)
|
||
|
||
finally:
|
||
aufraeumen()
|
||
|
||
print(f"\n{ok_count} bestanden, {len(fail)} fehlgeschlagen")
|
||
if fail:
|
||
print("Fehlgeschlagen: " + ", ".join(fail))
|
||
sys.exit(1 if fail else 0)
|