diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cd1407a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +# --- 1. Frontend : React + @excalidraw/excalidraw, build Vite -> /src/web/dist +FROM node:22-alpine AS frontend +WORKDIR /src/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund +COPY frontend/ ./ +RUN npm run build + +# --- 2. Backend : binaire Go statique embarquant le frontend +FROM golang:1.24-alpine AS backend +WORKDIR /src +COPY go.mod ./ +COPY cmd ./cmd +COPY internal ./internal +COPY web/embed.go ./web/ +COPY --from=frontend /src/web/dist ./web/dist +RUN --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 go test ./internal/... && \ + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server && \ + mkdir -p /out/data + +# --- 3. Image finale : distroless, non-root (uid 65532) +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=backend /out/server /server +COPY --from=backend --chown=65532:65532 /out/data /data +ENV LISTEN_ADDR=:8080 \ + DATA_DIR=/data \ + MAX_BODY_MB=50 +VOLUME ["/data"] +EXPOSE 8080 +USER nonroot:nonroot +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["/server", "healthcheck"] +ENTRYPOINT ["/server"] diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..9edf3b6 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,183 @@ +// Commande server : API de gestion de schémas + frontend Excalidraw embarqué. +package main + +import ( + "context" + "crypto/subtle" + "errors" + "io/fs" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "path" + "strconv" + "syscall" + "time" + + "github.com/vblogio/excalidraw-go/internal/api" + "github.com/vblogio/excalidraw-go/internal/storage" + "github.com/vblogio/excalidraw-go/web" +) + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func main() { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) + + addr := env("LISTEN_ADDR", ":8080") + // `server healthcheck` : sonde pour l'image distroless (pas de curl/wget). + if len(os.Args) > 1 && os.Args[1] == "healthcheck" { + os.Exit(healthcheck(addr)) + } + dataDir := env("DATA_DIR", "./data") + maxMB, err := strconv.ParseInt(env("MAX_BODY_MB", "50"), 10, 64) + if err != nil || maxMB <= 0 { + slog.Error("MAX_BODY_MB invalide") + os.Exit(1) + } + + store, err := storage.New(dataDir) + if err != nil { + slog.Error("initialisation du stockage", "dir", dataDir, "err", err) + os.Exit(1) + } + + mux := http.NewServeMux() + (&api.Handler{Store: store, MaxBody: maxMB << 20}).Register(mux) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + mux.Handle("/", spa()) + + var h http.Handler = mux + if user, pass := os.Getenv("AUTH_USER"), os.Getenv("AUTH_PASSWORD"); user != "" && pass != "" { + h = basicAuth(h, user, pass) + slog.Info("authentification Basic activée", "user", user) + } + h = logRequests(securityHeaders(h)) + + srv := &http.Server{ + Addr: addr, + Handler: h, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + go func() { + slog.Info("démarrage", "addr", addr, "data", dataDir) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("serveur", "err", err) + os.Exit(1) + } + }() + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + slog.Info("arrêt") +} + +func healthcheck(addr string) int { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return 1 + } + if host == "" || host == "0.0.0.0" || host == "::" { + host = "127.0.0.1" + } + c := http.Client{Timeout: 3 * time.Second} + res, err := c.Get("http://" + net.JoinHostPort(host, port) + "/healthz") + if err != nil { + return 1 + } + res.Body.Close() + if res.StatusCode != http.StatusOK { + return 1 + } + return 0 +} + +// spa sert le build Vite embarqué ; toute route inconnue renvoie index.html. +func spa() http.Handler { + dist, _ := fs.Sub(web.Dist, "dist") + files := http.FileServerFS(dist) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := path.Clean(r.URL.Path) + if p != "/" { + if _, err := fs.Stat(dist, p[1:]); err == nil { + // Les assets Vite sont nommés par empreinte : cache long. + if path.Dir(p) == "/assets" { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } + files.ServeHTTP(w, r) + return + } + } + index, err := fs.ReadFile(dist, "index.html") + if err != nil { + http.Error(w, "frontend absent : lancer `npm run build` dans frontend/", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(index) + }) +} + +func basicAuth(next http.Handler, user, pass string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + next.ServeHTTP(w, r) + return + } + u, p, ok := r.BasicAuth() + if !ok || subtle.ConstantTimeCompare([]byte(u), []byte(user)) != 1 || + subtle.ConstantTimeCompare([]byte(p), []byte(pass)) != 1 { + w.Header().Set("WWW-Authenticate", `Basic realm="excalidraw", charset="UTF-8"`) + http.Error(w, "authentification requise", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "same-origin") + h.Set("X-Frame-Options", "SAMEORIGIN") + next.ServeHTTP(w, r) + }) +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (s *statusWriter) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +func logRequests(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(sw, r) + if r.URL.Path != "/healthz" { + slog.Info("http", "method", r.Method, "path", r.URL.Path, "status", sw.status, + "dur_ms", time.Since(start).Milliseconds()) + } + }) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8d8e62e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + excalidraw: + build: . + image: excalidraw-go:local + container_name: excalidraw-go + restart: unless-stopped + ports: + - "${EXCALIDRAW_PORT:-8080}:8080" + environment: + # Authentification Basic optionnelle (laisser vide pour la désactiver, + # par exemple derrière un reverse proxy qui gère déjà l'authentification). + AUTH_USER: ${AUTH_USER:-} + AUTH_PASSWORD: ${AUTH_PASSWORD:-} + # Taille max d'un schéma (images embarquées en base64 comprises). + MAX_BODY_MB: ${MAX_BODY_MB:-50} + volumes: + # Volume nommé : les droits (uid 65532) sont hérités de l'image. + # Pour un bind mount (ex. ./data:/data), faire `chown -R 65532:65532 ./data`. + - excalidraw-data:/data + read_only: true + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + +volumes: + excalidraw-data: diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..2cd76c2 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,10 @@ +// Package web embarque le frontend compilé (frontend/ -> web/dist) dans le binaire. +package web + +import "embed" + +// Dist contient le build Vite. En développement, dist/ peut ne contenir que +// .gitkeep : le serveur affiche alors un message invitant à lancer le build. +// +//go:embed all:dist +var Dist embed.FS