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>
143 lines
4.4 KiB
Go
143 lines
4.4 KiB
Go
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}
|
||
}
|