feat(aether): initial commit – self-hosted Cloud, Phasen 1–7

Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
package admin
import (
"bytes"
"fmt"
"image"
"image/color"
_ "image/gif"
_ "image/jpeg"
"image/png"
"io"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp" // nur der Dekodierer – x/image ist schon da
)
// Aus einem einzigen hochgeladenen Bild entstehen alle Größen, die Browser und
// Android brauchen. Zwei Bilder zu pflegen (Tab-Symbol und App-Symbol) war der
// Grund, es zusammenzulegen.
const (
// Chrome verweigert die Installation, wenn im Manifest kein Symbol von
// mindestens dieser Kantenlänge steht.
minIconSide = 192
// Der maskable-Bereich, den Android garantiert nicht wegschneidet: 80 % in
// der Mitte. Der Rest verschwindet je nach Gerät im Kreis oder Squircle.
maskableSafeZone = 0.8
)
// iconVariant beschreibt eine der erzeugten Dateien.
type iconVariant struct {
// name ist der Dateiname im Symbol-Verzeichnis, ohne Endung.
name string
side int
// maskable legt das Motiv verkleinert auf einen farbigen Grund.
maskable bool
}
// renderAppIcon erzeugt aus der Vorlage die PNG-Dateien des Symbol-Satzes.
// Der Schlüssel der Rückgabe ist der Dateiname inklusive Endung.
//
// Hochskaliert wird nie: Aus einer 256er-Vorlage ein 512er zu rechnen ergibt
// ein matschiges Symbol, das schlechter aussieht als gar keins. Fehlt eine
// Größe, nennt das Manifest sie einfach nicht.
func renderAppIcon(src io.Reader, bg color.Color) (map[string][]byte, error) {
img, _, err := image.Decode(src)
if err != nil {
return nil, fmt.Errorf("decode image: %w", err)
}
// Nicht quadratische Vorlagen werden gerahmt, nicht beschnitten – ein
// Zuschnitt würde ein Logo anschneiden, und das fällt erst auf, wenn das
// Symbol schon überall klebt.
square := squareCanvas(img)
side := square.Bounds().Dx()
if side < minIconSide {
return nil, fmt.Errorf("image must be at least %dx%d pixels, got %dx%d",
minIconSide, minIconSide, img.Bounds().Dx(), img.Bounds().Dy())
}
variants := []iconVariant{
// 64 statt 32 fürs Tab: Auf feinen Bildschirmen wird das Symbol mit
// doppelter Pixeldichte gezeichnet.
{name: "64", side: 64},
{name: "192", side: 192},
}
if side >= 512 {
variants = append(variants, iconVariant{name: "512", side: 512})
}
maskSide := 192
if side >= 512 {
maskSide = 512
}
variants = append(variants, iconVariant{name: "maskable", side: maskSide, maskable: true})
out := make(map[string][]byte, len(variants))
for _, v := range variants {
buf, err := encodeVariant(square, v, bg)
if err != nil {
return nil, fmt.Errorf("render %s: %w", v.name, err)
}
out[v.name+".png"] = buf
}
return out, nil
}
func encodeVariant(square image.Image, v iconVariant, bg color.Color) ([]byte, error) {
dst := image.NewRGBA(image.Rect(0, 0, v.side, v.side))
target := dst.Bounds()
if v.maskable {
// Durchsichtig geht hier nicht: Android legt sonst eine weiße Kachel
// unter das Symbol, die im dunklen App-Menü leuchtet.
draw.Draw(dst, dst.Bounds(), image.NewUniform(bg), image.Point{}, draw.Src)
inset := int(float64(v.side) * (1 - maskableSafeZone) / 2)
target = image.Rect(inset, inset, v.side-inset, v.side-inset)
}
// CatmullRom statt des BiLinear aus internal/thumbnails: Vorschaubilder
// dürfen weich sein, ein Symbol soll scharfe Kanten behalten.
draw.CatmullRom.Scale(dst, target, square, square.Bounds(), draw.Over, nil)
var buf bytes.Buffer
// PNG, damit Transparenz erhalten bleibt – ein Symbol mit weißem Kasten
// drumherum sieht auf jedem farbigen Hintergrund falsch aus.
if err := png.Encode(&buf, dst); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// squareCanvas legt das Bild mittig auf eine quadratische, durchsichtige
// Fläche. Ist es schon quadratisch, bleibt es unverändert.
func squareCanvas(img image.Image) image.Image {
b := img.Bounds()
if b.Dx() == b.Dy() {
return img
}
side := b.Dx()
if b.Dy() > side {
side = b.Dy()
}
dst := image.NewRGBA(image.Rect(0, 0, side, side))
draw.Draw(dst, image.Rect(
(side-b.Dx())/2, (side-b.Dy())/2,
(side-b.Dx())/2+b.Dx(), (side-b.Dy())/2+b.Dy(),
), img, b.Min, draw.Src)
return dst
}
// parseHexColor liest "#rrggbb". Bei allem anderen kommt die Ersatzfarbe
// zurück – eine kaputte Einstellung soll keinen Upload scheitern lassen.
func parseHexColor(s string, fallback color.RGBA) color.RGBA {
if len(s) != 7 || s[0] != '#' {
return fallback
}
var r, g, b uint8
if _, err := fmt.Sscanf(s[1:], "%02x%02x%02x", &r, &g, &b); err != nil {
return fallback
}
return color.RGBA{R: r, G: g, B: b, A: 255}
}