feat(drawing): Vorschaubilder und ein Zeichenbrett, das zur Oberfläche passt

Vorschaubilder entstehen sonst serverseitig aus den hochgeladenen Bytes – bei
einer Zeichnung steht dort JSON, daraus wird ohne Browser kein Bild. Wer sie
gerade bearbeitet, hat aber genau den Browser, der sie ohnehin zeichnet.

Neu: POST /api/v1/files/{id}/thumbnail. Der Editor schickt nach dem Speichern
ein PNG hinterher.

Den Bytes wird dabei nicht geglaubt: Sie werden nicht abgelegt, sondern durch
denselben Weg geschickt wie jedes andere Vorschaubild – dekodieren,
verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die
übliche Grösse; nichts anderes kann im thumbs-Bereich landen. Dazu Schreibrecht
auf die Datei: Wer sie nicht ändern darf, bestimmt auch nicht, wie sie in der
Liste aussieht.

Das Hochladen läuft nach dem Speichern und ohne darauf zu warten. Ein
fehlgeschlagenes Vorschaubild ist ein Schönheitsfehler, kein Grund, das
Speichern scheitern zu lassen – und bleibt deshalb stumm.

Politur am Zeichenbrett: dunkles Thema (ein weisses Brett in dunkler Oberfläche
war der auffälligste Bruch), „Unsaved" als Marke statt eines Punktes hinter dem
Namen, Strg+S, Dateisymbol und Name ohne Endung.

„Open" und „Save to…" sind aus. Beide gehen am Browser vorbei: „Open" ersetzte
den Inhalt dieser Zeichnung durch eine fremde Datei, „Save to…" schriebe eine
zweite Fassung in den Download-Ordner, die niemand wiederfindet.

Selbsttest 12 → 18 Prüfungen, darunter die beiden Ablehnungen (kein Bild → 400,
fremder Typ → 415).

Geprüft: go build/vet sauber, alle Go-Pakete grün, 261 Prüfungen der acht
Selbsttest-Suiten grün, 53 Vitest-Tests, tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-29 20:13:04 +02:00
1 parent 2e1eaf9cbf
commit dd68bc594d
6 files changed
+277 -3

No files matched your search

+51
View File
@@ -1634,6 +1634,57 @@ ist, muss sie koennen, was Dateien koennen.
- [x] Zeichnung im Gruppenordner
- [x] Excalifont wird vom eigenen Server ausgeliefert
### Vorschaubild und Politur (29.09.2026)
**Vorschaubilder entstehen sonst serverseitig** aus den hochgeladenen Bytes –
bei einer Zeichnung steht dort JSON, daraus wird ohne Browser kein Bild. Wer
sie gerade bearbeitet, hat aber den Browser, der sie ohnehin zeichnet.
Neu: `POST /api/v1/files/{id}/thumbnail`. Nach dem Speichern schickt der Editor
ein PNG hinterher.
> **Den Bytes wird nicht geglaubt.** Sie werden nicht abgelegt, sondern durch
> denselben Weg geschickt wie jedes andere Vorschaubild – dekodieren,
> verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die
> uebliche Groesse; nichts anderes kann im `thumbs`-Bereich landen. Dazu
> Schreibrecht auf die Datei: Wer sie nicht aendern darf, bestimmt auch nicht,
> wie sie in der Liste aussieht.
Das Hochladen laeuft **nach** dem Speichern und ohne darauf zu warten. Ein
fehlgeschlagenes Vorschaubild ist ein Schoenheitsfehler, kein Grund, das
Speichern scheitern zu lassen – und bleibt deshalb auch stumm.
### Was am Zeichenbrett hueebscher wurde
* **Dunkles Thema.** Ein weisses Zeichenbrett in einer dunklen Oberflaeche war
der auffaelligste Bruch. Umschalten geht weiterhin im Menue – fuer einen
Ausdruck ist Hell die bessere Wahl.
* **„Unsaved" als Marke** statt eines Punktes hinter dem Namen. Das Uebersehene
war hier die ungespeicherte Arbeit.
* **Strg+S / Cmd+S** speichert.
* Dateisymbol und Name ohne die Endung in der Kopfzeile.
* **„Open" und „Save to…" sind aus.** Beide gehen am Browser vorbei: „Open"
ersetzte den Inhalt dieser Zeichnung durch eine fremde Datei, „Save to…"
schriebe eine zweite Fassung in den Download-Ordner, die niemand
wiederfindet. In Aether liegt die Datei da, wo sie liegt.
- [x] Vorschaubild setzen (204), Liste weiss davon, laesst sich abrufen
- [x] Was kein Bild ist -> 400. Fremder Typ -> 415.
- [ ] Zeichnung speichern -> nach kurzer Zeit zeigt die Kachel das Bild 🔎
- [ ] Zeichnung aendern und neu speichern -> das Bild zieht nach
- [ ] Strg+S speichert, „Unsaved" verschwindet
### Warum Excalidraw und nicht tldraw
tldraw sieht besser aus und ist angenehmer zu bedienen – aber es ist **nicht
quelloffen**: proprietaere Lizenz, Wasserzeichen, und Produktivbetrieb nur mit
bezahlter Lizenz. Fuer eine selbst betriebene Cloud, die laufen soll, ohne dass
jemand jaehrlich etwas verlaengert, faellt es damit aus.
Excalidraw ist MIT. draw.io waere die Alternative (Apache-2.0), zielt aber auf
Flussdiagramme und UML statt auf freies Zeichnen – und bringt eine deutlich
schwerere Oberflaeche mit.
### Beim zweiten Oeffnen blieb es leer (29.09.2026)
Eine Zeichnung liess sich anlegen und bearbeiten – **beim zweiten Oeffnen**
+1
View File
@@ -504,6 +504,7 @@ func main() {
r.Get("/search", fileHandler.Search)
r.Get("/files/{id}/download", fileHandler.Download)
r.Get("/files/{id}/thumbnail", fileHandler.GetThumbnail)
r.Post("/files/{id}/thumbnail", fileHandler.SetThumbnail)
// Erkannter Text, in Seiten zerlegt – fuer die Suche im PDF-Betrachter.
r.Get("/files/{id}/text", fileHandler.TextPages)
// Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im
@@ -0,0 +1,72 @@
package files
import (
"net/http"
"github.com/rs/zerolog/log"
"aether/internal/thumbnails"
"aether/pkg/response"
)
// maxThumbUpload begrenzt, was als Vorschaubild hereinkommen darf. Ein
// Vorschaubild ist ein paar Kilobyte gross; zwei Megabyte sind grosszuegig und
// halten trotzdem jeden Unfug vom Dekodierer fern.
const maxThumbUpload = 2 << 20
// SetThumbnail nimmt ein Vorschaubild entgegen, das nur der Browser erzeugen
// kann.
//
// POST /api/v1/files/{id}/thumbnail
//
// **Warum es das ueberhaupt gibt.** Vorschaubilder entstehen sonst
// serverseitig aus den hochgeladenen Bytes. Bei einer Zeichnung steht dort
// JSON – daraus laesst sich ohne Browser kein Bild rechnen. Wer die Zeichnung
// gerade bearbeitet, hat aber genau den Browser, der sie zeichnen kann.
//
// **Und warum man den Bytes trotzdem nicht glauben darf.** Was hier ankommt,
// kommt von einem Klienten. Es wird deshalb nicht abgelegt, sondern durch
// denselben Weg geschickt wie jedes andere Vorschaubild: dekodieren,
// verkleinern, neu kodieren. Danach ist es mit Sicherheit ein Bild und hat die
// uebliche Groesse – und nichts anderes kann im thumbs-Bereich landen.
func (h *Handler) SetThumbnail(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r, "id")
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid file id")
return
}
// Schreibrecht: Wer die Datei nicht aendern darf, bestimmt auch nicht, wie
// sie in der Liste aussieht.
owner, ok := h.resolveFile(w, r, id, true)
if !ok {
return
}
f, err := h.store.GetFile(r.Context(), id, owner.OwnerID)
if err != nil {
response.Error(w, http.StatusNotFound, "file not found")
return
}
typ := r.Header.Get("Content-Type")
if !thumbnails.SupportedMIME(typ) {
response.Error(w, http.StatusUnsupportedMediaType, "thumbnail must be a PNG, JPEG, GIF or WebP image")
return
}
// MaxBytesReader statt einer eigenen Zaehlung: Er bricht die Verbindung
// ab, statt erst alles zu lesen und dann abzulehnen.
rc := http.MaxBytesReader(w, r.Body, maxThumbUpload)
defer rc.Close()
if err := thumbnails.Generate(r.Context(), h.storage, f.ID.String(), typ, rc); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Str("file_id", f.ID.String()).
Msg("Vorschaubild vom Klienten liess sich nicht verarbeiten")
response.Error(w, http.StatusBadRequest, "could not read that image")
return
}
if err := h.store.UpdateThumbnail(r.Context(), f.ID, true); err != nil {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
response.NoContent(w)
}
+21
View File
@@ -671,6 +671,27 @@ export const filesApi = {
* Gemessen: dreimal dieselbe Datei hochgeladen ergibt dreimal dieselbe ID
* mit den Fassungen 1, 2, 3.
*/
/**
* Ein Vorschaubild setzen, das nur der Browser erzeugen kann.
*
* Der Server rechnet Vorschaubilder sonst selbst aus den hochgeladenen
* Bytes. Bei einer Zeichnung steht dort JSON – daraus wird ohne Browser kein
* Bild. Wer sie gerade bearbeitet, hat den Browser aber.
*
* Der Server legt die Bytes **nicht** ab, sondern schickt sie durch
* denselben Weg wie jedes andere Vorschaubild (dekodieren, verkleinern, neu
* kodieren). Was hier hochgeht, ist also ein Vorschlag, kein Befehl.
*/
setThumbnail: async (id: string, bild: Blob) => {
const res = await fetch(`/api/v1/files/${id}/thumbnail`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': bild.type || 'image/png', 'X-CSRF-Token': csrfToken },
body: bild,
})
if (!res.ok) throw new APIError(res.status, 'thumbnail upload failed')
},
thumbnail: (id: string, version?: number | string) =>
`/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`,
+88 -3
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Excalidraw, restore, serializeAsJSON } from '@excalidraw/excalidraw'
import { Excalidraw, exportToBlob, restore, serializeAsJSON } from '@excalidraw/excalidraw'
import '@excalidraw/excalidraw/index.css'
import { filesApi, type FileItem } from '../api/client'
import { useBackClose } from '../hooks/useBackClose'
import { FileTypeIcon } from './icons'
import { toast, fehlertext } from './Toasts'
/**
@@ -146,6 +147,7 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
setGeaendert(false)
onSaved?.(neu)
toast.info('Drawing saved')
void vorschaubild(neu.id)
} catch (err) {
toast.error(fehlertext(err, 'Could not save the drawing'))
} finally {
@@ -153,6 +155,52 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
}
}
/**
* Ein Vorschaubild erzeugen und hochladen.
*
* Der Server kann das nicht: In der Datei steht JSON, und daraus wird ohne
* Browser kein Bild. Hier ist der Browser, der die Zeichnung ohnehin gerade
* gezeichnet hat.
*
* **Nach** dem Speichern und ohne darauf zu warten: Ein fehlgeschlagenes
* Vorschaubild ist ein Schönheitsfehler, kein Grund, das Speichern
* scheitern zu lassen. Es bleibt deshalb auch stumm – die Zeichnung selbst
* ist heil, und eine rote Meldung dafür wäre verwirrend.
*/
async function vorschaubild(id: string) {
if (!aktuell.current) return
try {
const bild = await exportToBlob({
elements: aktuell.current.elements as never,
appState: { ...(aktuell.current.appState as object), exportBackground: true } as never,
files: aktuell.current.files as never,
mimeType: 'image/png',
exportPadding: 16,
// Der Server verkleinert ohnehin auf Kachelgröße. Grösser hochzuladen
// kostet nur Bandbreite.
getDimensions: (w: number, h: number) => {
const faktor = Math.min(1, 512 / Math.max(w, h))
return { width: Math.round(w * faktor), height: Math.round(h * faktor), scale: faktor }
},
})
await filesApi.setThumbnail(id, bild)
} catch {
/* Schönheitsfehler – die Zeichnung ist gespeichert. */
}
}
// Strg+S / Cmd+S, wie überall sonst auch.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
if (geaendert && !speichert) void speichern()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
})
// Die Zurück-Geste schließt das Zeichenbrett. Bei ungespeicherten Änderungen
// erst fragen – `false` hält die Ansicht offen (siehe useBackClose).
useBackClose({
@@ -174,22 +222,42 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
}}>
<span style={{ display: 'flex', flexShrink: 0 }}>
<FileTypeIcon mime={file.mime_type} name={file.name} size={18} />
</span>
<span style={{
flex: 1, minWidth: 0, fontSize: 14, color: 'var(--color-text)',
minWidth: 0, fontSize: 14, fontWeight: 500, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{file.name}{geaendert ? ' •' : ''}
{file.name.replace(/\.excalidraw$/i, '')}
</span>
{/* Ein Punkt hinter dem Namen ist leicht zu übersehen – und das
Übersehene ist hier die ungespeicherte Arbeit. */}
{geaendert && (
<span style={{
flexShrink: 0, padding: '2px 8px', borderRadius: 999,
background: 'rgba(var(--color-primary-rgb), 0.18)',
color: 'var(--color-primary)', fontSize: 11, fontWeight: 600,
}}>
Unsaved
</span>
)}
<span style={{ flex: 1 }} />
<button
onClick={speichern}
disabled={speichert || !geaendert}
title={geaendert ? 'Save (Ctrl+S)' : 'Nothing to save'}
style={{
padding: '6px 14px', borderRadius: 8, border: 'none',
background: geaendert ? 'var(--color-primary)' : 'var(--color-surface-2)',
color: geaendert ? 'var(--color-primary-fg)' : 'var(--color-text-muted)',
fontSize: 12, fontWeight: 600,
cursor: speichert || !geaendert ? 'default' : 'pointer',
transition: 'background 0.15s, color 0.15s',
}}
>
{speichert ? 'Saving…' : 'Save'}
@@ -236,6 +304,23 @@ export default function DrawingEditor({ file, onClose, onSaved }: Props) {
{szene && (
<Excalidraw
initialData={szene}
// Die Oberfläche ist dunkel; ein weisses Zeichenbrett darin war
// der auffälligste Bruch. Umschalten kann man es im Menü – für
// einen Ausdruck ist Hell die bessere Wahl.
theme="dark"
name={file.name.replace(/\.excalidraw$/i, '')}
autoFocus
UIOptions={{
canvasActions: {
// **Aus.** Beide laden oder speichern eine Datei am Browser
// vorbei: „Open" ersetzte den Inhalt dieser Zeichnung durch
// eine fremde, „Save to…" schriebe eine zweite Fassung in den
// Download-Ordner, die niemand mehr wiederfindet. In Aether
// gehört die Datei dorthin, wo sie liegt.
loadScene: false,
saveToActiveFile: false,
},
}}
onChange={(elements, appState, files) => {
// **Nicht in den Zustand schreiben.** onChange feuert bei jeder
// Mausbewegung; ein setState hier würde das Zeichenbrett bei
+44
View File
@@ -15,6 +15,8 @@ ueberschreiben.
"""
import hashlib
import json
import struct
import zlib
import sys
import urllib.error
import urllib.request
@@ -91,6 +93,26 @@ def zeichnung(elemente=0):
}).encode()
def winziges_png() -> bytes:
"""Ein gueltiges 1x1-PNG, von Hand gebaut – ohne Abhaengigkeit."""
def chunk(typ, daten):
c = typ + daten
return struct.pack(">I", len(daten)) + c + struct.pack(">I", zlib.crc32(c))
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
+ chunk(b"IDAT", zlib.compress(b"\x00\xff\x00\x00")) + chunk(b"IEND", b""))
def roh_senden(opener, path, daten: bytes, typ: str, csrf):
req = urllib.request.Request(BASE + path, data=daten, method="POST",
headers={"Content-Type": typ, **csrf})
try:
with opener.open(req) as r:
return r.status
except urllib.error.HTTPError as e:
return e.code
print(f"Zeichnungs-Selbsttest gegen {BASE}\n")
admin, CSRF = anmelden(USER, PASSWORT)
_, ich = ruf(admin, "GET", "/api/v1/auth/me")
@@ -167,6 +189,28 @@ try:
angelegt.append(gz["id"])
check("Zeichnung im Gruppenordner", gz["folder_id"] == gordner["id"])
print("\n── Vorschaubild ──")
# Der Server kann es nicht selbst rechnen: In der Datei steht JSON. Der
# Browser, der gerade zeichnet, kann es – deshalb darf er es schicken.
check("frische Zeichnung hat noch keines", not datei.get("has_thumbnail"))
st = roh_senden(admin, f"/api/v1/files/{datei['id']}/thumbnail", winziges_png(), "image/png", CSRF)
check("Vorschaubild setzen (204)", st == 204, st)
_, liste2 = ruf(admin, "GET", "/api/v1/files")
jetzt = next((f for f in (liste2 or {}).get("files", []) if f["id"] == datei["id"]), None)
check("und die Liste weiss davon", jetzt and jetzt.get("has_thumbnail"), jetzt)
st, _ = ruf(admin, "GET", f"/api/v1/files/{datei['id']}/thumbnail", None, None, True)
check("es laesst sich abrufen", st == 200, st)
# **Den Bytes wird nicht geglaubt.** Sie gehen durch denselben Weg wie
# jedes andere Vorschaubild – dekodieren, verkleinern, neu kodieren.
# Sonst liesse sich Beliebiges in den thumbs-Bereich legen.
st = roh_senden(admin, f"/api/v1/files/{datei['id']}/thumbnail", b"<html>kein bild</html>",
"image/png", CSRF)
check("was kein Bild ist, wird abgelehnt (400)", st == 400, st)
st = roh_senden(admin, f"/api/v1/files/{datei['id']}/thumbnail", winziges_png(),
"text/html", CSRF)
check("fremder Typ wird abgelehnt (415)", st == 415, st)
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