Add Dockerfile, compose, README and server entrypoint
This commit is contained in:
1 parent
e7edf1aa29
commit
0c4d63bdea
4 files changed
+254
No files matched your search
+35
@@ -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"]
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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
|
||||
Reference in new issue
Block a user