fixing style
Some checks failed
Build, test, and publish / Build and test (push) Has been cancelled
Build, test, and publish / Build and push Docker image (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Daniel Noulet 2026-10-07 15:10:10 +02:00
parent e454940dfa
commit f40485842d
35 changed files with 16000 additions and 286 deletions

View file

@ -1,6 +1,7 @@
package web
import (
"bytes"
"context"
"encoding/json"
"fmt"
@ -25,6 +26,18 @@ type Handler struct {
sso security.ITokenVerifier
browser *golooseauth.Auth
demo *service.DemoAuth
// themeOrigin is the go-bananas origin supplying the shared styling contract.
// Empty means the shell keeps its own fallbacks, which is the default.
themeOrigin string
}
// WithThemeOrigin sets the origin the shared styling contract is served from.
//
// A setter rather than a constructor argument, so that adding this does not change
// the signature every existing caller and test passes through.
func (h *Handler) WithThemeOrigin(origin string) *Handler {
h.themeOrigin = origin
return h
}
func NewHandler(content service.IContentService, assets fs.FS, sso security.ITokenVerifier, browser *golooseauth.Auth, demo ...*service.DemoAuth) *Handler {
@ -47,11 +60,13 @@ func (h *Handler) Routes(origins string) http.Handler {
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", h.contentCollection)
mux.HandleFunc("/api/dev/null", h.contentCollection)
mux.HandleFunc("/api/content/categories", h.contentCategories)
mux.HandleFunc("/api/content/badges", h.contentBadges)
mux.HandleFunc("/api/content/clear", h.adminReset)
mux.HandleFunc("/api/content/", h.contentDetail)
mux.HandleFunc("/api/dev/null/", h.contentDetail)
mux.HandleFunc("/api/admin/reset", h.adminReset)
mux.HandleFunc("/", h.singlePageApp)
return cors(mux, origins, h.sso.HeaderName())
@ -67,6 +82,18 @@ func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
response["email"] = user.Email
response["displayName"] = user.DisplayName
}
if h.demo != nil {
response["demo"] = true
if authenticated {
for _, user := range h.demo.Users() {
if user.Username == username {
response["displayName"] = user.Name
response["email"] = user.Username
break
}
}
}
}
if h.browser != nil {
response["loginUrl"] = golooseauth.LoginPath
response["logoutUrl"] = golooseauth.LogoutPath
@ -141,6 +168,17 @@ func (h *Handler) sudo(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, http.StatusOK, map[string]any{"token": token, "user": user})
}
func (h *Handler) contentCollection(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.contentList(w, r)
case http.MethodPost:
h.contentCreate(w, r)
default:
jsonWrite(w, http.StatusMethodNotAllowed, nil)
}
}
func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonWrite(w, http.StatusMethodNotAllowed, nil)
@ -170,7 +208,7 @@ func (h *Handler) contentCreate(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, http.StatusMethodNotAllowed, nil)
return
}
_, authenticated := h.identity(r)
username, authenticated := h.identity(r)
if !authenticated {
jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
@ -180,6 +218,9 @@ func (h *Handler) contentCreate(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if strings.TrimSpace(item.Author) == "" {
item.Author = username
}
created, err := h.content.Create(r.Context(), item)
if err != nil {
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
@ -216,6 +257,7 @@ func (h *Handler) contentBadges(w http.ResponseWriter, r *http.Request) {
func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimPrefix(r.URL.Path, "/api/content/")
slug = strings.TrimPrefix(slug, "/api/dev/null/")
switch r.Method {
case http.MethodGet:
item, err := h.content.Get(r.Context(), slug)
@ -342,9 +384,52 @@ func (h *Handler) singlePageApp(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
body := applyThemeOrigin(index, h.themeOrigin)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(index)
_, _ = w.Write(body)
}
// applyThemeOrigin adds the shared styling contract to the shell when an origin is
// configured, and adds nothing when one is not.
//
// Appending rather than substituting, which is the opposite of the obvious approach
// and the reason it is worth a comment. A placeholder in index.html has to survive
// two things that both fail quietly:
//
// - The bundler resolves a <script src> at build time. A placeholder is not a
// resolvable module, so the build fails with an error that points at the HTML
// rather than at the cause.
// - An unset or whitespace-only environment variable substitutes an empty or blank
// origin, producing a relative URL. The browser then requests this application's
// own origin, where neither path exists, and every page load logs a 404 that
// reads as a styling bug in the wrong service.
//
// Adding nothing in the unset case is correct rather than degraded: the stylesheet's
// every colour is written as var(--bn-role, <the colour it used to be>), so the
// application renders exactly as it always did.
func applyThemeOrigin(index []byte, base string) []byte {
origin := strings.TrimRight(strings.TrimSpace(base), "/")
if origin == "" {
return index
}
// Injected just before </head> so the stylesheet is parsed before the body
// renders: a contract that arrives late produces a flash of the fallback palette.
tag := "\n " +
`<link rel="stylesheet" href="` + origin + `/rt/v1/contract.css" data-theme-contract>` + "\n " +
`<script type="module" src="` + origin + `/rt/v1/components.js"></script>` + "\n"
head := []byte("</head>")
at := bytes.Index(index, head)
if at < 0 {
return index
}
out := make([]byte, 0, len(index)+len(tag))
out = append(out, index[:at]...)
out = append(out, tag...)
out = append(out, index[at:]...)
return out
}
func decode(r *http.Request, v any) bool { return json.NewDecoder(r.Body).Decode(v) == nil }
@ -366,7 +451,7 @@ func cors(next http.Handler, origin, ssoHeader string) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Headers", allowed)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return

View file

@ -32,6 +32,15 @@ func (f *fakeContent) Update(_ context.Context, c service.Content) (service.Cont
f.updated = true
return c, nil
}
func (f *fakeContent) Create(_ context.Context, c service.Content) (service.Content, error) {
return c, nil
}
func (f *fakeContent) Delete(_ context.Context, _ string) error {
return nil
}
func (f *fakeContent) Reset(_ context.Context) error {
return nil
}
func newTestRoutes(content *fakeContent, browser ...*golooseauth.Auth) http.Handler {
assets := fstest.MapFS{
@ -171,4 +180,48 @@ func TestServesEmbeddedAppForClientRoutes(t *testing.T) {
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())
}
devNull := httptest.NewRecorder()
routes.ServeHTTP(devNull, httptest.NewRequest(http.MethodGet, "/dev/null", nil))
if devNull.Code != http.StatusOK || !strings.Contains(devNull.Body.String(), `id="root"`) {
t.Fatalf("dev/null fallback status = %d body = %q", devNull.Code, devNull.Body.String())
}
backlogRc := httptest.NewRecorder()
routes.ServeHTTP(backlogRc, httptest.NewRequest(http.MethodGet, "/etc/backlog.rc", nil))
if backlogRc.Code != http.StatusOK || !strings.Contains(backlogRc.Body.String(), `id="root"`) {
t.Fatalf("backlog.rc fallback status = %d body = %q", backlogRc.Code, backlogRc.Body.String())
}
}
func TestContentCRUDAndClear(t *testing.T) {
content := &fakeContent{}
routes := newTestRoutes(content)
// Create with SSO header
postReq := httptest.NewRequest(http.MethodPost, "/api/content", strings.NewReader(`{"title":"New Post","body":"Content body"}`))
postReq.Header.Set("X-SSO-Token", "valid")
postRec := httptest.NewRecorder()
routes.ServeHTTP(postRec, postReq)
if postRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d", postRec.Code, http.StatusCreated)
}
// Delete with SSO header
delReq := httptest.NewRequest(http.MethodDelete, "/api/content/post", nil)
delReq.Header.Set("X-SSO-Token", "valid")
delRec := httptest.NewRecorder()
routes.ServeHTTP(delRec, delReq)
if delRec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, want %d", delRec.Code, http.StatusNoContent)
}
// Reset with SSO header
resetReq := httptest.NewRequest(http.MethodPost, "/api/admin/reset", nil)
resetReq.Header.Set("X-SSO-Token", "valid")
resetRec := httptest.NewRecorder()
routes.ServeHTTP(resetRec, resetReq)
if resetRec.Code != http.StatusOK {
t.Fatalf("reset status = %d, want %d", resetRec.Code, http.StatusOK)
}
}

View file

@ -0,0 +1,75 @@
package web
import (
"strings"
"testing"
)
const shell = `<!doctype html>
<html lang="en">
<head>
<title>Urpi's backlog</title>
</head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>`
// TestThemeLinesAreAddedWhenConfigured is the whole feature: the contract reaches
// the page.
func TestThemeLinesAreAddedWhenConfigured(t *testing.T) {
got := string(applyThemeOrigin([]byte(shell), "https://bananas-dev.urpi.be"))
if !strings.Contains(got, `href="https://bananas-dev.urpi.be/rt/v1/contract.css"`) {
t.Errorf("the contract stylesheet was not added:\n%s", got)
}
if !strings.Contains(got, `src="https://bananas-dev.urpi.be/rt/v1/components.js"`) {
t.Errorf("the control module was not added:\n%s", got)
}
// Injected before </head>, so the stylesheet is parsed before the body renders. A
// contract that arrives late shows a flash of the fallback palette.
headClose := strings.Index(got, "</head>")
link := strings.Index(got, "contract.css")
if link < 0 || link > headClose {
t.Errorf("the contract link is not inside the head (link=%d, head=%d)", link, headClose)
}
// The application script must survive untouched.
if !strings.Contains(got, `src="/src/main.tsx"`) {
t.Error("the application script was altered")
}
}
func TestThemeOriginTrailingSlashIsTrimmed(t *testing.T) {
for _, base := range []string{
"https://bananas-dev.urpi.be/",
"https://bananas-dev.urpi.be///",
} {
got := string(applyThemeOrigin([]byte(shell), base))
if strings.Contains(got, "be//") {
t.Errorf("base %q produced a doubled slash:\n%s", base, got)
}
}
}
// TestNothingIsAddedWhenNoOriginIsConfigured is the branch that matters most: an
// unset or blank origin must leave the page byte-identical, because the stylesheet's
// var() fallbacks are what carry the application in that case.
func TestNothingIsAddedWhenNoOriginIsConfigured(t *testing.T) {
for _, base := range []string{"", " ", "\t", "/"} {
got := applyThemeOrigin([]byte(shell), base)
if string(got) != shell {
t.Errorf("base %q modified the shell:\n%s", base, got)
}
if strings.Contains(string(got), "rt/v1") {
t.Errorf("base %q left a contract reference behind:\n%s", base, got)
}
}
}
// TestShellWithoutHeadIsUnchanged covers a malformed shell. Returning the input
// unchanged is better than corrupting it, and better than a 500 on a page that would
// otherwise have rendered.
func TestShellWithoutHeadIsUnchanged(t *testing.T) {
odd := []byte("<html><body>no head</body></html>")
if got := applyThemeOrigin(odd, "https://bananas-dev.urpi.be"); string(got) != string(odd) {
t.Errorf("a shell with no head was modified:\n%s", got)
}
}