Barre de titre : mode clair/sombre, titre et logo ; explorateur redimensionnable #3
No files matched your search
+72
-1
@@ -11,6 +11,8 @@
|
|||||||
// DELETE /api/entry?path=x[&recursive=true] mise à la corbeille
|
// 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/thumb?path=x / PUT (image/png) miniature d'un schéma
|
||||||
// GET /api/library / PUT /api/library bibliothèque Excalidraw
|
// 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
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -44,6 +46,11 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("PUT /api/thumb", h.writeThumb)
|
mux.HandleFunc("PUT /api/thumb", h.writeThumb)
|
||||||
mux.HandleFunc("GET /api/library", h.readLibrary)
|
mux.HandleFunc("GET /api/library", h.readLibrary)
|
||||||
mux.HandleFunc("PUT /api/library", h.writeLibrary)
|
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) {
|
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeErr(w, http.StatusNotFound, "route inconnue")
|
writeErr(w, http.StatusNotFound, "route inconnue")
|
||||||
})
|
})
|
||||||
@@ -69,7 +76,8 @@ func fail(w http.ResponseWriter, err error) {
|
|||||||
writeErr(w, http.StatusConflict, err.Error())
|
writeErr(w, http.StatusConflict, err.Error())
|
||||||
case errors.Is(err, storage.ErrConflict):
|
case errors.Is(err, storage.ErrConflict):
|
||||||
writeErr(w, http.StatusPreconditionFailed, err.Error())
|
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())
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
case errors.As(err, &tooBig):
|
case errors.As(err, &tooBig):
|
||||||
writeErr(w, http.StatusRequestEntityTooLarge, "contenu trop volumineux")
|
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)
|
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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
func TestThumbs(t *testing.T) {
|
||||||
s := newStore(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)
|
t.Fatalf("miniature d'un schéma absent : %v", err)
|
||||||
}
|
}
|
||||||
if _, _, err := s.Write("d/a.excalidraw", []byte(doc), "", true); err != nil {
|
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) {
|
if _, _, err := s.ReadThumb("d/a.excalidraw"); !errors.Is(err, ErrNotFound) {
|
||||||
t.Fatalf("miniature inexistante : %v", err)
|
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)
|
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)
|
t.Fatalf("lecture : %q, %v", b, err)
|
||||||
}
|
}
|
||||||
// Suit le déplacement du dossier, la copie, puis la suppression.
|
// Suit le déplacement du dossier, la copie, puis la suppression.
|
||||||
|
|||||||
Reference in new issue
Block a user