diff --git a/internal/api/api.go b/internal/api/api.go index 51fd628..2b769cf 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -11,6 +11,8 @@ // DELETE /api/entry?path=x[&recursive=true] mise à la corbeille // GET /api/thumb?path=x / PUT (image/png) miniature d'un schéma // GET /api/library / PUT /api/library bibliothèque Excalidraw +// GET /api/settings / PUT {"title":"..."} personnalisation (titre, logo) +// GET /api/logo / PUT (image/png) / DELETE logo 64x64 package api import ( @@ -44,6 +46,11 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("PUT /api/thumb", h.writeThumb) mux.HandleFunc("GET /api/library", h.readLibrary) mux.HandleFunc("PUT /api/library", h.writeLibrary) + mux.HandleFunc("GET /api/settings", h.readSettings) + mux.HandleFunc("PUT /api/settings", h.writeSettings) + mux.HandleFunc("GET /api/logo", h.readLogo) + mux.HandleFunc("PUT /api/logo", h.writeLogo) + mux.HandleFunc("DELETE /api/logo", h.deleteLogo) mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusNotFound, "route inconnue") }) @@ -69,7 +76,8 @@ func fail(w http.ResponseWriter, err error) { writeErr(w, http.StatusConflict, err.Error()) case errors.Is(err, storage.ErrConflict): writeErr(w, http.StatusPreconditionFailed, err.Error()) - case errors.Is(err, storage.ErrInvalidPath), errors.Is(err, storage.ErrInvalidJSON): + case errors.Is(err, storage.ErrInvalidPath), errors.Is(err, storage.ErrInvalidJSON), + errors.Is(err, storage.ErrInvalidImage): writeErr(w, http.StatusBadRequest, err.Error()) case errors.As(err, &tooBig): writeErr(w, http.StatusRequestEntityTooLarge, "contenu trop volumineux") @@ -278,3 +286,66 @@ func (h *Handler) writeLibrary(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusNoContent) } + +func (h *Handler) readSettings(w http.ResponseWriter, r *http.Request) { + st, err := h.Store.ReadSettings() + if err != nil { + fail(w, err) + return + } + w.Header().Set("Cache-Control", "no-cache") + writeJSON(w, http.StatusOK, st) +} + +type settingsReq struct { + Title string `json:"title"` +} + +func (h *Handler) writeSettings(w http.ResponseWriter, r *http.Request) { + var req settingsReq + if !h.decode(w, r, &req) { + return + } + st, err := h.Store.WriteTitle(req.Title) + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, st) +} + +func (h *Handler) readLogo(w http.ResponseWriter, r *http.Request) { + data, err := h.Store.ReadLogo() + if err != nil { + w.Header().Set("Cache-Control", "no-store") + fail(w, err) + return + } + // L'URL porte la version du logo (?v=...) : un nouveau logo change l'URL. + w.Header().Set("Cache-Control", "private, max-age=86400") + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(data) +} + +func (h *Handler) writeLogo(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, storage.MaxLogoUpload)) + if err != nil { + fail(w, err) + return + } + st, err := h.Store.WriteLogo(data) + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, st) +} + +func (h *Handler) deleteLogo(w http.ResponseWriter, r *http.Request) { + st, err := h.Store.DeleteLogo() + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, st) +} diff --git a/internal/storage/settings.go b/internal/storage/settings.go new file mode 100644 index 0000000..3ac0fab --- /dev/null +++ b/internal/storage/settings.go @@ -0,0 +1,185 @@ +package storage + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "image" + "image/draw" + "image/png" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "unicode/utf8" +) + +// Personnalisation de l'interface (titre, logo), partagée par tous les +// navigateurs : .settings.json et .logo.png à la racine. + +const ( + settingsFile = ".settings.json" + logoFile = ".logo.png" + + // LogoSize est le côté du logo enregistré (carré, affiché en rond). + LogoSize = 64 + // MaxLogoUpload borne la taille d'un PNG envoyé avant redimensionnement. + MaxLogoUpload = 5 << 20 + // MaxTitleLen borne la longueur du titre (en caractères). + MaxTitleLen = 80 + + maxLogoSide = 8192 // protège contre les « bombes » de décompression +) + +// ErrInvalidImage signale un logo illisible ou hors limites. +var ErrInvalidImage = errors.New("image invalide") + +// Settings est l'état exposé au client. Logo vaut "" sans logo, sinon une +// version (date) à ajouter à l'URL pour invalider le cache navigateur. +type Settings struct { + Title string `json:"title"` + Logo string `json:"logo"` +} + +type settingsDisk struct { + Title string `json:"title"` +} + +// ReadSettings renvoie la personnalisation courante (valeurs vides par défaut). +func (s *Store) ReadSettings() (Settings, error) { + var d settingsDisk + b, err := os.ReadFile(filepath.Join(s.root, settingsFile)) + switch { + case errors.Is(err, fs.ErrNotExist): + case err != nil: + return Settings{}, err + default: + if err := json.Unmarshal(b, &d); err != nil { + return Settings{}, fmt.Errorf("%s : %w", settingsFile, err) + } + } + st := Settings{Title: d.Title} + if fi, err := os.Stat(filepath.Join(s.root, logoFile)); err == nil { + st.Logo = strconv.FormatInt(fi.ModTime().UnixNano(), 36) + } + return st, nil +} + +// WriteTitle enregistre le titre affiché ("" = titre par défaut). +func (s *Store) WriteTitle(title string) (Settings, error) { + title = strings.TrimSpace(title) + if !utf8.ValidString(title) || strings.ContainsAny(title, "\r\n\x00") { + return Settings{}, fmt.Errorf("%w : titre", ErrInvalidPath) + } + if utf8.RuneCountInString(title) > MaxTitleLen { + return Settings{}, fmt.Errorf("%w : titre de %d caractères max", ErrInvalidPath, MaxTitleLen) + } + b, err := json.Marshal(settingsDisk{Title: title}) + if err != nil { + return Settings{}, err + } + if err := atomicWrite(filepath.Join(s.root, settingsFile), b); err != nil { + return Settings{}, err + } + return s.ReadSettings() +} + +// ReadLogo renvoie le logo PNG 64x64, ou ErrNotFound. +func (s *Store) ReadLogo() ([]byte, error) { + b, err := os.ReadFile(filepath.Join(s.root, logoFile)) + return b, mapErr(err) +} + +// WriteLogo décode un PNG, le recadre au centre en carré, le ramène à +// LogoSize x LogoSize et l'enregistre (ré-encodé : aucune donnée annexe du +// fichier d'origine n'est conservée). +func (s *Store) WriteLogo(data []byte) (Settings, error) { + cfg, err := png.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return Settings{}, fmt.Errorf("%w : PNG attendu", ErrInvalidImage) + } + if cfg.Width < 1 || cfg.Height < 1 || cfg.Width > maxLogoSide || cfg.Height > maxLogoSide { + return Settings{}, fmt.Errorf("%w : %dx%d px max", ErrInvalidImage, maxLogoSide, maxLogoSide) + } + img, err := png.Decode(bytes.NewReader(data)) + if err != nil { + return Settings{}, fmt.Errorf("%w : PNG illisible", ErrInvalidImage) + } + var buf bytes.Buffer + if err := png.Encode(&buf, squareResize(img, LogoSize)); err != nil { + return Settings{}, err + } + if err := atomicWrite(filepath.Join(s.root, logoFile), buf.Bytes()); err != nil { + return Settings{}, err + } + return s.ReadSettings() +} + +// DeleteLogo supprime le logo (sans erreur s'il est absent). +func (s *Store) DeleteLogo() (Settings, error) { + if err := os.Remove(filepath.Join(s.root, logoFile)); err != nil && !errors.Is(err, fs.ErrNotExist) { + return Settings{}, err + } + return s.ReadSettings() +} + +// squareResize recadre img au centre (carré) puis le rééchantillonne en n x n +// par moyenne de surface (filtre « boîte » pondéré), en alpha prémultiplié +// pour éviter les franges sombres sur les bords transparents. +func squareResize(img image.Image, n int) *image.RGBA { + b := img.Bounds() + side := min(b.Dx(), b.Dy()) + crop := image.Rect(0, 0, side, side).Add(b.Min). + Add(image.Pt((b.Dx()-side)/2, (b.Dy()-side)/2)) + src := image.NewRGBA(image.Rect(0, 0, side, side)) + draw.Draw(src, src.Bounds(), img, crop.Min, draw.Src) + + w := boxWeights(side, n) + dst := image.NewRGBA(image.Rect(0, 0, n, n)) + for dy := 0; dy < n; dy++ { + for dx := 0; dx < n; dx++ { + var acc [4]float64 + var total float64 + for _, wy := range w[dy] { + row := wy.i * src.Stride + for _, wx := range w[dx] { + k := wy.w * wx.w + p := src.Pix[row+wx.i*4:] + for c := range acc { + acc[c] += k * float64(p[c]) + } + total += k + } + } + o := dst.PixOffset(dx, dy) + for c := range acc { + dst.Pix[o+c] = uint8(acc[c]/total + 0.5) + } + } + } + return dst +} + +type weight struct { + i int + w float64 +} + +// boxWeights calcule, pour chaque pixel cible, les pixels source qu'il couvre +// et la fraction couverte. Fonctionne aussi en agrandissement (côté < n). +func boxWeights(side, n int) [][]weight { + scale := float64(side) / float64(n) + out := make([][]weight, n) + for d := range out { + lo, hi := float64(d)*scale, float64(d+1)*scale + for i := int(lo); i < side && float64(i) < hi; i++ { + w := min(hi, float64(i+1)) - max(lo, float64(i)) + if w > 0 { + out[d] = append(out[d], weight{i, w}) + } + } + } + return out +} diff --git a/internal/storage/settings_test.go b/internal/storage/settings_test.go new file mode 100644 index 0000000..06ed017 --- /dev/null +++ b/internal/storage/settings_test.go @@ -0,0 +1,95 @@ +package storage + +import ( + "bytes" + "errors" + "image" + "image/color" + "image/png" + "strings" + "testing" +) + +func encodePNG(t *testing.T, img image.Image) []byte { + t.Helper() + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestTitle(t *testing.T) { + s := newStore(t) + if st, err := s.ReadSettings(); err != nil || st != (Settings{}) { + t.Fatalf("défauts : %+v, %v", st, err) + } + st, err := s.WriteTitle(" Schémas vLab ") + if err != nil || st.Title != "Schémas vLab" { + t.Fatalf("titre : %+v, %v", st, err) + } + if st, _ := s.ReadSettings(); st.Title != "Schémas vLab" { + t.Fatalf("titre non persisté : %+v", st) + } + for _, bad := range []string{"a\nb", strings.Repeat("é", MaxTitleLen+1)} { + if _, err := s.WriteTitle(bad); !errors.Is(err, ErrInvalidPath) { + t.Errorf("titre %q accepté", bad) + } + } + // Les fichiers de réglages n'apparaissent pas dans l'explorateur. + if entries, _ := s.List(""); len(entries) != 0 { + t.Fatalf("réglages visibles : %+v", entries) + } +} + +func TestLogo(t *testing.T) { + s := newStore(t) + if _, err := s.ReadLogo(); !errors.Is(err, ErrNotFound) { + t.Fatalf("logo absent : %v", err) + } + if _, err := s.WriteLogo([]byte("pas une image")); !errors.Is(err, ErrInvalidImage) { + t.Fatalf("non-PNG accepté : %v", err) + } + + // 300x200 : bande rouge à gauche (hors recadrage), vert au centre. + src := image.NewNRGBA(image.Rect(0, 0, 300, 200)) + for y := 0; y < 200; y++ { + for x := 0; x < 300; x++ { + c := color.NRGBA{0, 200, 0, 255} + if x < 50 { + c = color.NRGBA{255, 0, 0, 255} + } + src.Set(x, y, c) + } + } + st, err := s.WriteLogo(encodePNG(t, src)) + if err != nil || st.Logo == "" { + t.Fatalf("écriture : %+v, %v", st, err) + } + b, err := s.ReadLogo() + if err != nil { + t.Fatal(err) + } + img, err := png.Decode(bytes.NewReader(b)) + if err != nil { + t.Fatal(err) + } + if sz := img.Bounds().Size(); sz != image.Pt(LogoSize, LogoSize) { + t.Fatalf("taille %v", sz) + } + if r, g, _, _ := img.At(0, 0).RGBA(); r>>8 != 0 || g>>8 != 200 { + t.Fatalf("recadrage centré attendu, coin = %v", img.At(0, 0)) + } + + // Agrandissement d'une petite image. + if _, err := s.WriteLogo(encodePNG(t, image.NewNRGBA(image.Rect(0, 0, 10, 10)))); err != nil { + t.Fatalf("petite image : %v", err) + } + + if st, err := s.DeleteLogo(); err != nil || st.Logo != "" { + t.Fatalf("suppression : %+v, %v", st, err) + } + if _, err := s.DeleteLogo(); err != nil { + t.Fatalf("double suppression : %v", err) + } +} diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 7b119ca..66968f6 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -145,11 +145,11 @@ func TestMoveMany(t *testing.T) { } } -var png = []byte("\x89PNG\r\n\x1a\nfake") +var fakePNG = []byte("\x89PNG\r\n\x1a\nfake") func TestThumbs(t *testing.T) { s := newStore(t) - if err := s.WriteThumb("d/a.excalidraw", png); !errors.Is(err, ErrNotFound) { + if err := s.WriteThumb("d/a.excalidraw", fakePNG); !errors.Is(err, ErrNotFound) { t.Fatalf("miniature d'un schéma absent : %v", err) } if _, _, err := s.Write("d/a.excalidraw", []byte(doc), "", true); err != nil { @@ -161,10 +161,10 @@ func TestThumbs(t *testing.T) { if _, _, err := s.ReadThumb("d/a.excalidraw"); !errors.Is(err, ErrNotFound) { t.Fatalf("miniature inexistante : %v", err) } - if err := s.WriteThumb("d/a.excalidraw", png); err != nil { + if err := s.WriteThumb("d/a.excalidraw", fakePNG); err != nil { t.Fatal(err) } - if b, _, err := s.ReadThumb("d/a.excalidraw"); err != nil || string(b) != string(png) { + if b, _, err := s.ReadThumb("d/a.excalidraw"); err != nil || string(b) != string(fakePNG) { t.Fatalf("lecture : %q, %v", b, err) } // Suit le déplacement du dossier, la copie, puis la suppression.