build and ready for demo

login screen
This commit is contained in:
Daniel Noulet 2026-09-19 17:33:44 +02:00
parent 0ae2dc3888
commit 6af2853faa
33 changed files with 2020 additions and 1221 deletions

View file

@ -1,54 +1,96 @@
package web
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"strings"
"github.com/example/cms/internal/domain"
"github.com/example/cms/internal/security"
"github.com/example/cms/internal/service"
)
// Handler serves the CMS API. Reading content is anonymous; every mutation
// requires the identity asserted by the upstream SSO proxy.
// Handler serves the CMS API and the embedded React app from one binary.
// Reading content is anonymous; every mutation requires the identity asserted by
// the upstream SSO proxy.
type Handler struct {
content service.IContentService
assets fs.FS
sso security.ITokenVerifier
demo *service.DemoAuth
}
func NewHandler(content service.IContentService, sso security.ITokenVerifier) *Handler {
return &Handler{content: content, sso: sso}
func NewHandler(content service.IContentService, assets fs.FS, sso security.ITokenVerifier, demo ...*service.DemoAuth) *Handler {
h := &Handler{content: content, assets: assets, sso: sso}
if len(demo) > 0 {
h.demo = demo[0]
}
return h
}
func (h *Handler) Routes(origins string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { jsonWrite(w, 200, map[string]string{"status": "ok"}) })
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("/api/auth/session", h.session)
mux.HandleFunc("/api/auth/sudo", h.sudo)
mux.HandleFunc("/api/auth/users", h.users)
mux.HandleFunc("/api/protected", h.protected)
mux.HandleFunc("/api/test/protected", h.protected)
mux.HandleFunc("/api/content", h.contentList)
mux.HandleFunc("/api/content/categories", h.contentCategories)
mux.HandleFunc("/api/content/badges", h.contentBadges)
mux.HandleFunc("/api/content/", h.contentDetail)
mux.HandleFunc("/", h.singlePageApp)
return cors(mux, origins, h.sso.HeaderName())
}
func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
authenticated := h.sso.Authenticated(r)
jsonWrite(w, http.StatusOK, map[string]any{"authenticated": authenticated, "canEdit": authenticated})
username, authenticated := h.identity(r)
jsonWrite(w, http.StatusOK, map[string]any{"authenticated": authenticated, "canEdit": authenticated, "username": username})
}
func (h *Handler) protected(w http.ResponseWriter, r *http.Request) {
if !h.sso.Authenticated(r) {
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
if _, ok := h.identity(r); !ok {
jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
jsonWrite(w, 200, map[string]string{"status": "authenticated"})
jsonWrite(w, http.StatusOK, map[string]string{"status": "authenticated"})
}
func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
if h.content == nil {
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
func (h *Handler) users(w http.ResponseWriter, r *http.Request) {
if h.demo == nil || r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
jsonWrite(w, http.StatusOK, map[string]any{"items": h.demo.Users()})
}
func (h *Handler) sudo(w http.ResponseWriter, r *http.Request) {
if h.demo == nil || r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
var input struct {
Username string `json:"username"`
Password string `json:"password"`
}
if !decode(r, &input) {
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
token, user, err := h.demo.Login(input.Username, input.Password)
if err != nil {
jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "invalid demo credentials"})
return
}
jsonWrite(w, http.StatusOK, map[string]any{"token": token, "user": user})
}
func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonWrite(w, http.StatusMethodNotAllowed, nil)
return
@ -58,94 +100,128 @@ func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Sscanf(r.URL.Query().Get("offset"), "%d", &offset)
badges := r.URL.Query()["badge"]
if len(badges) == 0 {
badges = strings.Split(strings.TrimSpace(r.URL.Query().Get("badges")), ",")
badges = strings.Split(r.URL.Query().Get("badges"), ",")
}
filtered := badges[:0]
for _, badge := range badges {
if badge = strings.TrimSpace(strings.ToLower(badge)); badge != "" {
filtered = append(filtered, badge)
}
}
items, e := h.content.List(r.Context(), limit, offset,
strings.TrimSpace(strings.ToLower(r.URL.Query().Get("category"))),
strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))),
filtered,
items, err := h.content.List(r.Context(), limit, offset,
r.URL.Query().Get("category"),
r.URL.Query().Get("status"),
badges,
)
if e != nil {
jsonWrite(w, 500, map[string]string{"error": e.Error()})
if err != nil {
jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
jsonWrite(w, 200, map[string]any{"items": items, "limit": limit, "offset": offset})
jsonWrite(w, http.StatusOK, map[string]any{"items": items, "limit": limit, "offset": offset})
}
func (h *Handler) contentCategories(w http.ResponseWriter, r *http.Request) {
if h.content == nil {
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
return
}
if r.Method != http.MethodGet {
jsonWrite(w, http.StatusMethodNotAllowed, nil)
return
}
categories, err := h.content.ListCategories(r.Context())
if err != nil {
jsonWrite(w, 500, map[string]string{"error": err.Error()})
jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
jsonWrite(w, 200, map[string]any{"items": categories})
jsonWrite(w, http.StatusOK, map[string]any{"items": categories})
}
func (h *Handler) contentBadges(w http.ResponseWriter, r *http.Request) {
if h.content == nil {
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
return
}
if r.Method != http.MethodGet {
jsonWrite(w, http.StatusMethodNotAllowed, nil)
return
}
badges, err := h.content.ListBadges(r.Context())
if err != nil {
jsonWrite(w, 500, map[string]string{"error": err.Error()})
jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
jsonWrite(w, 200, map[string]any{"items": badges})
jsonWrite(w, http.StatusOK, map[string]any{"items": badges})
}
func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
if h.content == nil {
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
return
}
slug := strings.TrimPrefix(r.URL.Path, "/api/content/")
if r.Method == http.MethodGet {
c, e := h.content.Get(r.Context(), slug)
if e != nil {
jsonWrite(w, 404, map[string]string{"error": "content not found"})
switch r.Method {
case http.MethodGet:
item, err := h.content.Get(r.Context(), slug)
if err != nil {
jsonWrite(w, http.StatusNotFound, map[string]string{"error": "content not found"})
return
}
jsonWrite(w, 200, c)
return
jsonWrite(w, http.StatusOK, item)
case http.MethodPut:
username, authenticated := h.identity(r)
if !authenticated {
jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
var item service.Content
if !decode(r, &item) {
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
item.Slug = slug
var saved service.Content
var err error
if owned, ok := h.content.(interface {
UpdateAs(context.Context, service.Content, string) (service.Content, error)
}); ok {
saved, err = owned.UpdateAs(r.Context(), item, username)
} else {
saved, err = h.content.Update(r.Context(), item)
}
if err != nil {
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
jsonWrite(w, http.StatusOK, saved)
default:
jsonWrite(w, http.StatusMethodNotAllowed, nil)
}
if r.Method != http.MethodPut {
jsonWrite(w, 405, nil)
return
}
if !h.sso.Authenticated(r) {
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
return
}
var c domain.Content
if !decode(r, &c) {
jsonWrite(w, 400, map[string]string{"error": "invalid JSON"})
return
}
c.Slug = slug
out, e := h.content.Update(r.Context(), c)
if e != nil {
jsonWrite(w, 400, map[string]string{"error": e.Error()})
return
}
jsonWrite(w, 200, out)
}
func (h *Handler) identity(r *http.Request) (string, bool) {
if h.demo != nil {
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if username, ok := h.demo.Authenticated(token); ok {
return username, true
}
}
if h.sso.Authenticated(r) {
return "sso-owner", true
}
return "", false
}
// singlePageApp serves the embedded build and falls back to index.html so the
// client-side router owns every non-API path.
func (h *Handler) singlePageApp(w http.ResponseWriter, r *http.Request) {
if h.assets == nil {
http.NotFound(w, r)
return
}
name := strings.TrimPrefix(r.URL.Path, "/")
if name != "" {
if file, err := h.assets.Open(name); err == nil {
_ = file.Close()
http.FileServer(http.FS(h.assets)).ServeHTTP(w, r)
return
}
}
index, err := fs.ReadFile(h.assets, "index.html")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(index)
}
func decode(r *http.Request, v any) bool { return json.NewDecoder(r.Body).Decode(v) == nil }
func jsonWrite(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@ -153,6 +229,7 @@ func jsonWrite(w http.ResponseWriter, status int, v any) {
_ = json.NewEncoder(w).Encode(v)
}
}
func cors(next http.Handler, origin, ssoHeader string) http.Handler {
allowed := "Content-Type, Authorization"
if ssoHeader != "" && !strings.EqualFold(ssoHeader, "Authorization") {
@ -164,7 +241,7 @@ func cors(next http.Handler, origin, ssoHeader string) http.Handler {
w.Header().Set("Access-Control-Allow-Headers", allowed)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(204)
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)

View file

@ -6,28 +6,37 @@ import (
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"github.com/example/cms/internal/domain"
"github.com/example/cms/internal/security"
"github.com/example/cms/internal/service"
)
type fakeContent struct{ updated bool }
func (f *fakeContent) List(context.Context, int, int, string, string, []string) ([]domain.Content, error) {
return nil, nil
func (f *fakeContent) List(context.Context, int, int, string, string, []string) ([]service.Content, error) {
return []service.Content{{Slug: "post", Title: "Post"}}, nil
}
func (f *fakeContent) ListCategories(context.Context) ([]domain.Category, error) { return nil, nil }
func (f *fakeContent) ListBadges(context.Context) ([]domain.Badge, error) { return nil, nil }
func (f *fakeContent) Get(context.Context, string) (domain.Content, error) {
return domain.Content{Slug: "post", Title: "Post", Body: "body"}, nil
func (f *fakeContent) ListCategories(context.Context) ([]service.Category, error) {
return []service.Category{{Slug: "go", Name: "Go"}}, nil
}
func (f *fakeContent) Update(_ context.Context, c domain.Content) (domain.Content, error) {
func (f *fakeContent) ListBadges(context.Context) ([]service.Badge, error) {
return []service.Badge{{Name: "go"}}, nil
}
func (f *fakeContent) Get(context.Context, string) (service.Content, error) {
return service.Content{Slug: "post", Title: "Post", Body: "body"}, nil
}
func (f *fakeContent) Update(_ context.Context, c service.Content) (service.Content, error) {
f.updated = true
return c, nil
}
func newTestRoutes(content *fakeContent) http.Handler {
return NewHandler(content, security.NewSSOTokenVerifier("X-SSO-Token", "", "")).Routes("http://localhost:5173")
assets := fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<!doctype html><div id=\"root\"></div>")},
"assets/index.js": &fstest.MapFile{Data: []byte("console.log('cms')")},
}
return NewHandler(content, assets, security.NewSSOTokenVerifier("X-SSO-Token", "", "")).Routes("http://localhost:5173")
}
func TestAnonymousCanReadButNotEdit(t *testing.T) {
@ -79,3 +88,19 @@ func TestProxyIdentityCanEdit(t *testing.T) {
t.Fatalf("owner session = %s", sessionRec.Body.String())
}
}
func TestServesEmbeddedAppForClientRoutes(t *testing.T) {
routes := newTestRoutes(&fakeContent{})
asset := httptest.NewRecorder()
routes.ServeHTTP(asset, httptest.NewRequest(http.MethodGet, "/assets/index.js", nil))
if asset.Code != http.StatusOK || !strings.Contains(asset.Body.String(), "console.log") {
t.Fatalf("static asset status = %d body = %q", asset.Code, asset.Body.String())
}
page := httptest.NewRecorder()
routes.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/content/some-post", nil))
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), `id="root"`) {
t.Fatalf("spa fallback status = %d body = %q", page.Code, page.Body.String())
}
}