Files
DoGaMa-serv/internal/web/server.go
T

440 lines
15 KiB
Go

// Package web serves DoGaMa's embedded, server-rendered interface.
package web
import (
"crypto/rand"
"crypto/subtle"
"embed"
"encoding/base64"
"encoding/json"
"errors"
"html/template"
"io"
"log/slog"
"net/http"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/instance"
)
const (
sessionCookie = "dogama_session"
csrfCookie = "dogama_csrf"
maxFormBytes = 64 << 10
)
//go:embed templates/*.html static/*.css
var assets embed.FS
var englishMessages = map[string]string{
"brand": "DoGaMa",
"setup.title": "Create administrator",
"setup.heading": "Welcome to DoGaMa",
"setup.introduction": "Create the first administrator to finish setup.",
"setup.submit": "Create administrator",
"login.title": "Sign in",
"login.heading": "Sign in to DoGaMa",
"login.submit": "Sign in",
"logout.submit": "Sign out",
"dashboard.title": "Dashboard",
"dashboard.ready": "The initial application foundation is ready.",
"field.username": "Username",
"field.password": "Password",
"error.csrf": "Request verification failed.",
"error.form": "Invalid form submission.",
"error.internal": "The request could not be completed.",
"error.credentials": "Invalid username or password.",
"dashboard.signed_in": "Signed in as",
}
type server struct {
auth *auth.Service
templates *template.Template
logger *slog.Logger
repository repository
}
type repository interface {
catalog.Repository
instance.Repository
}
type pageData struct {
Title string
CSRFToken string
Error string
User auth.User
}
// NewHandler constructs the complete HTTP application.
func NewHandler(authService *auth.Service, logger *slog.Logger) (http.Handler, error) {
return newHandler(authService, nil, logger)
}
// NewHandlerWithRepository enables the authenticated catalog and draft APIs.
func NewHandlerWithRepository(authService *auth.Service, repository repository, logger *slog.Logger) (http.Handler, error) {
return newHandler(authService, repository, logger)
}
func newHandler(authService *auth.Service, repository repository, logger *slog.Logger) (http.Handler, error) {
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message}).ParseFS(assets, "templates/*.html")
if err != nil {
return nil, err
}
s := &server{auth: authService, templates: templates, logger: logger, repository: repository}
mux := http.NewServeMux()
if repository != nil {
mux.HandleFunc("GET /api/v1/catalog", s.catalogList)
mux.HandleFunc("POST /api/v1/instances/preview", s.instancePreview)
mux.HandleFunc("POST /api/v1/instances/drafts", s.instanceDraft)
}
mux.HandleFunc("GET /static/app.v1.css", s.stylesheet)
mux.HandleFunc("GET /setup", s.setupForm)
mux.HandleFunc("POST /setup", s.setupSubmit)
mux.HandleFunc("GET /login", s.loginForm)
mux.HandleFunc("POST /login", s.loginSubmit)
mux.HandleFunc("POST /logout", s.logout)
mux.HandleFunc("GET /", s.home)
return s.securityHeaders(mux), nil
}
type previewAPIRequest struct {
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
DisplayName string `json:"display_name"`
Slug string `json:"slug"`
HostPorts map[string]int `json:"host_ports"`
MountPaths map[string]string `json:"mount_paths"`
Resources catalog.Resources `json:"resources"`
DataOrigin string `json:"data_origin"`
BackupRetention int `json:"backup_retention"`
}
func (s *server) catalogList(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, false); !ok {
return
}
templates, err := s.repository.List(r.Context())
if err != nil {
s.apiProblem(w, http.StatusInternalServerError, "catalog_unavailable", "The catalog is unavailable.")
return
}
s.apiJSON(w, http.StatusOK, struct {
Templates []catalog.Summary `json:"templates"`
}{Templates: templates})
}
func (s *server) instancePreview(w http.ResponseWriter, r *http.Request) {
request, preview, ok := s.buildAPIPreview(w, r)
_ = request
if !ok {
return
}
s.apiJSON(w, http.StatusOK, preview)
}
func (s *server) instanceDraft(w http.ResponseWriter, r *http.Request) {
_, preview, ok := s.buildAPIPreview(w, r)
if !ok {
return
}
id := randomToken()
if err := s.repository.CreateDraft(r.Context(), instance.Draft{ID: id, Preview: preview}); err != nil {
s.apiProblem(w, http.StatusConflict, "draft_conflict", "The draft instance could not be created.")
return
}
s.apiJSON(w, http.StatusCreated, map[string]string{"id": id, "state": "draft", "plan_digest": preview.PlanDigest})
}
func (s *server) buildAPIPreview(w http.ResponseWriter, r *http.Request) (previewAPIRequest, instance.Preview, bool) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return previewAPIRequest{}, instance.Preview{}, false
}
var request previewAPIRequest
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
s.apiProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
return request, instance.Preview{}, false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
s.apiProblem(w, http.StatusBadRequest, "invalid_request", "The request is invalid.")
return request, instance.Preview{}, false
}
snapshot, err := s.repository.Get(r.Context(), request.TemplateID, request.TemplateVersion)
if err != nil {
s.apiProblem(w, http.StatusNotFound, "template_not_found", "The template version was not found.")
return request, instance.Preview{}, false
}
preview, err := instance.BuildPreview(snapshot, instance.PreviewRequest{
DisplayName: request.DisplayName, Slug: request.Slug, HostPorts: request.HostPorts,
MountPaths: request.MountPaths, Resources: request.Resources, DataOrigin: request.DataOrigin,
BackupRetention: request.BackupRetention,
})
if err != nil {
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_preview", "The deployment preview is invalid.")
return request, instance.Preview{}, false
}
return request, preview, true
}
func (s *server) requireAPIUser(w http.ResponseWriter, r *http.Request, admin bool) (auth.User, bool) {
user, err := s.currentUser(r)
if err != nil {
s.apiProblem(w, http.StatusUnauthorized, "authentication_required", "Authentication is required.")
return auth.User{}, false
}
if admin && user.Role != "admin" {
s.apiProblem(w, http.StatusForbidden, "permission_denied", "Permission denied.")
return auth.User{}, false
}
if r.Method != http.MethodGet {
session, err := r.Cookie(sessionCookie)
if err != nil || !s.auth.ValidateCSRF(r.Context(), session.Value, r.Header.Get("X-CSRF-Token")) {
s.apiProblem(w, http.StatusForbidden, "csrf_failed", "Request verification failed.")
return auth.User{}, false
}
}
return user, true
}
func (s *server) apiJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func (s *server) apiProblem(w http.ResponseWriter, status int, code, message string) {
s.apiJSON(w, status, map[string]string{"code": code, "message": message})
}
func (s *server) setupForm(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, true) {
return
}
token := s.anonymousCSRF(w, r)
s.render(w, http.StatusOK, "setup.html", pageData{Title: message("setup.title"), CSRFToken: token})
}
func (s *server) setupSubmit(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, true) {
return
}
if !s.parseForm(w, r) || !validAnonymousCSRF(r) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
err := s.auth.BootstrapAdmin(r.Context(), r.FormValue("username"), r.FormValue("password"))
if err != nil {
if errors.Is(err, auth.ErrBootstrapComplete) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
token := s.anonymousCSRF(w, r)
s.render(w, http.StatusUnprocessableEntity, "setup.html", pageData{Title: message("setup.title"), CSRFToken: token, Error: err.Error()})
return
}
clearCookie(w, csrfCookie)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *server) loginForm(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, false) {
return
}
if _, err := s.currentUser(r); err == nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
token := s.anonymousCSRF(w, r)
s.render(w, http.StatusOK, "login.html", pageData{Title: message("login.title"), CSRFToken: token})
}
func (s *server) loginSubmit(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, false) {
return
}
if !s.parseForm(w, r) || !validAnonymousCSRF(r) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
session, err := s.auth.Login(r.Context(), r.FormValue("username"), r.FormValue("password"), r.RemoteAddr)
if err != nil {
status := http.StatusUnauthorized
if errors.Is(err, auth.ErrRateLimited) {
status = http.StatusTooManyRequests
w.Header().Set("Retry-After", "60")
}
token := s.anonymousCSRF(w, r)
s.render(w, status, "login.html", pageData{Title: message("login.title"), CSRFToken: token, Error: message("error.credentials")})
return
}
if cookie, cookieErr := r.Cookie(sessionCookie); cookieErr == nil {
if revokeErr := s.auth.Revoke(r.Context(), cookie.Value); revokeErr != nil {
_ = s.auth.Revoke(r.Context(), session.Token)
s.logger.Error("session rotation failed", "event", "auth.session.rotation.failed", "error", revokeErr)
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
}
setCookie(w, sessionCookie, session.Token, session.ExpiresAt, true)
setCookie(w, csrfCookie, session.CSRFToken, session.ExpiresAt, true)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *server) logout(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, false) {
return
}
session, err := r.Cookie(sessionCookie)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !s.parseForm(w, r) || !s.auth.ValidateCSRF(r.Context(), session.Value, r.FormValue("csrf_token")) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
if err := s.auth.Revoke(r.Context(), session.Value); err != nil {
s.logger.Error("session revocation failed", "event", "auth.logout.failed", "error", err)
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return
}
clearCookie(w, sessionCookie)
clearCookie(w, csrfCookie)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *server) home(w http.ResponseWriter, r *http.Request) {
if !s.requireBootstrap(w, r, false) {
return
}
user, err := s.currentUser(r)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
csrf, err := r.Cookie(csrfCookie)
if err != nil {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
s.render(w, http.StatusOK, "home.html", pageData{Title: message("dashboard.title"), User: user, CSRFToken: csrf.Value})
}
func (s *server) currentUser(r *http.Request) (auth.User, error) {
cookie, err := r.Cookie(sessionCookie)
if err != nil {
return auth.User{}, auth.ErrInvalidSession
}
return s.auth.Authenticate(r.Context(), cookie.Value)
}
func (s *server) requireBootstrap(w http.ResponseWriter, r *http.Request, setupRoute bool) bool {
required, err := s.auth.BootstrapRequired(r.Context())
if err != nil {
s.logger.Error("bootstrap state failed", "event", "bootstrap.state.failed", "error", err)
s.problem(w, http.StatusInternalServerError, message("error.internal"))
return false
}
if required != setupRoute {
if required {
http.Redirect(w, r, "/setup", http.StatusSeeOther)
} else {
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
return false
}
return true
}
func (s *server) anonymousCSRF(w http.ResponseWriter, r *http.Request) string {
if cookie, err := r.Cookie(csrfCookie); err == nil && cookie.Value != "" {
return cookie.Value
}
token := randomToken()
setCookie(w, csrfCookie, token, time.Now().Add(time.Hour), true)
return token
}
func validAnonymousCSRF(r *http.Request) bool {
cookie, err := r.Cookie(csrfCookie)
provided := r.FormValue("csrf_token")
if err != nil || cookie.Value == "" || provided == "" || len(cookie.Value) != len(provided) {
return false
}
return subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(provided)) == 1
}
func (s *server) parseForm(w http.ResponseWriter, r *http.Request) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
s.problem(w, http.StatusBadRequest, message("error.form"))
return false
}
return true
}
func (s *server) render(w http.ResponseWriter, status int, name string, data pageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
s.logger.Error("template rendering failed", "event", "http.render.failed", "template", name, "error", err)
}
}
func (s *server) stylesheet(w http.ResponseWriter, _ *http.Request) {
body, err := assets.Open("static/app.v1.css")
if err != nil {
http.Error(w, "Not found.", http.StatusNotFound)
return
}
defer body.Close()
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
_, _ = io.Copy(w, body)
}
func (s *server) problem(w http.ResponseWriter, status int, message string) {
http.Error(w, message, status)
}
func (s *server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
next.ServeHTTP(w, r)
})
}
func setCookie(w http.ResponseWriter, name, value string, expires time.Time, httpOnly bool) {
http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", Expires: expires, MaxAge: int(time.Until(expires).Seconds()), HttpOnly: httpOnly, Secure: true, SameSite: http.SameSiteStrictMode})
}
func clearCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode})
}
func randomToken() string {
value := make([]byte, 32)
if _, err := rand.Read(value); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(value)
}
func message(key string) string {
if value, ok := englishMessages[key]; ok {
return value
}
return key
}