2026-09-19 14:17:27 +02:00
|
|
|
package web
|
|
|
|
|
|
|
|
|
|
import (
|
2026-10-07 15:10:10 +02:00
|
|
|
"bytes"
|
2026-09-19 17:33:44 +02:00
|
|
|
"context"
|
2026-09-19 14:17:27 +02:00
|
|
|
"encoding/json"
|
2026-09-19 14:56:49 +02:00
|
|
|
"fmt"
|
2026-09-19 17:33:44 +02:00
|
|
|
"io/fs"
|
2026-09-19 14:17:27 +02:00
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
2026-09-19 16:45:24 +02:00
|
|
|
|
2026-10-03 04:41:31 +02:00
|
|
|
goloose "github.com/danyel/go-loose/client"
|
|
|
|
|
|
|
|
|
|
"github.com/danyel/go-tell/internal/golooseauth"
|
|
|
|
|
"github.com/danyel/go-tell/internal/security"
|
|
|
|
|
"github.com/danyel/go-tell/internal/service"
|
2026-09-19 14:17:27 +02:00
|
|
|
)
|
|
|
|
|
|
2026-09-19 17:33:44 +02:00
|
|
|
// Handler serves the CMS API and the embedded React app from one binary.
|
2026-10-03 04:41:31 +02:00
|
|
|
// Reading content is anonymous; every mutation requires an authenticated
|
|
|
|
|
// editor: a Go Loose browser session, the identity asserted by the upstream
|
|
|
|
|
// SSO proxy, or a demo user.
|
2026-09-19 14:17:27 +02:00
|
|
|
type Handler struct {
|
2026-09-19 14:56:49 +02:00
|
|
|
content service.IContentService
|
2026-09-19 17:33:44 +02:00
|
|
|
assets fs.FS
|
2026-09-19 16:45:24 +02:00
|
|
|
sso security.ITokenVerifier
|
2026-10-03 04:41:31 +02:00
|
|
|
browser *golooseauth.Auth
|
2026-09-19 17:33:44 +02:00
|
|
|
demo *service.DemoAuth
|
2026-10-07 15:10:10 +02:00
|
|
|
// 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
|
2026-09-19 14:17:27 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-03 04:41:31 +02:00
|
|
|
func NewHandler(content service.IContentService, assets fs.FS, sso security.ITokenVerifier, browser *golooseauth.Auth, demo ...*service.DemoAuth) *Handler {
|
|
|
|
|
h := &Handler{content: content, assets: assets, sso: sso, browser: browser}
|
2026-09-19 17:33:44 +02:00
|
|
|
if len(demo) > 0 {
|
|
|
|
|
h.demo = demo[0]
|
|
|
|
|
}
|
|
|
|
|
return h
|
2026-09-19 14:56:49 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
|
2026-09-19 14:17:27 +02:00
|
|
|
func (h *Handler) Routes(origins string) http.Handler {
|
|
|
|
|
mux := http.NewServeMux()
|
2026-09-19 17:33:44 +02:00
|
|
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
jsonWrite(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
|
|
|
})
|
2026-10-03 04:41:31 +02:00
|
|
|
mux.HandleFunc(golooseauth.LoginPath, h.login)
|
|
|
|
|
mux.HandleFunc(golooseauth.CallbackPath, h.callback)
|
|
|
|
|
mux.HandleFunc(golooseauth.LogoutPath, h.logout)
|
2026-09-19 14:17:27 +02:00
|
|
|
mux.HandleFunc("/api/auth/session", h.session)
|
2026-09-19 17:33:44 +02:00
|
|
|
mux.HandleFunc("/api/auth/sudo", h.sudo)
|
|
|
|
|
mux.HandleFunc("/api/auth/users", h.users)
|
2026-09-19 14:56:49 +02:00
|
|
|
mux.HandleFunc("/api/protected", h.protected)
|
2026-10-07 15:10:10 +02:00
|
|
|
mux.HandleFunc("/api/content", h.contentCollection)
|
|
|
|
|
mux.HandleFunc("/api/dev/null", h.contentCollection)
|
2026-09-19 15:11:12 +02:00
|
|
|
mux.HandleFunc("/api/content/categories", h.contentCategories)
|
2026-09-19 15:14:16 +02:00
|
|
|
mux.HandleFunc("/api/content/badges", h.contentBadges)
|
2026-10-07 15:10:10 +02:00
|
|
|
mux.HandleFunc("/api/content/clear", h.adminReset)
|
2026-09-19 14:56:49 +02:00
|
|
|
mux.HandleFunc("/api/content/", h.contentDetail)
|
2026-10-07 15:10:10 +02:00
|
|
|
mux.HandleFunc("/api/dev/null/", h.contentDetail)
|
2026-10-03 12:25:10 +02:00
|
|
|
mux.HandleFunc("/api/admin/reset", h.adminReset)
|
2026-09-19 17:33:44 +02:00
|
|
|
mux.HandleFunc("/", h.singlePageApp)
|
2026-09-19 16:45:24 +02:00
|
|
|
return cors(mux, origins, h.sso.HeaderName())
|
2026-09-19 14:17:27 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
|
2026-10-03 04:41:31 +02:00
|
|
|
// session reports who the caller is. When Go Loose is configured the response
|
|
|
|
|
// also carries the login and logout endpoints so the UI can offer the browser
|
|
|
|
|
// login instead of a proxy redirect.
|
2026-09-19 14:17:27 +02:00
|
|
|
func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
|
2026-09-19 17:33:44 +02:00
|
|
|
username, authenticated := h.identity(r)
|
2026-10-03 04:41:31 +02:00
|
|
|
response := map[string]any{"authenticated": authenticated, "canEdit": authenticated, "username": username}
|
|
|
|
|
if user, ok := h.browserUser(r); ok {
|
|
|
|
|
response["email"] = user.Email
|
|
|
|
|
response["displayName"] = user.DisplayName
|
|
|
|
|
}
|
2026-10-07 15:10:10 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-03 04:41:31 +02:00
|
|
|
if h.browser != nil {
|
|
|
|
|
response["loginUrl"] = golooseauth.LoginPath
|
|
|
|
|
response["logoutUrl"] = golooseauth.LogoutPath
|
|
|
|
|
}
|
|
|
|
|
jsonWrite(w, http.StatusOK, response)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if h.browser == nil {
|
|
|
|
|
writeAuthUnavailable(w)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.browser.LoginHandler(w, r)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) callback(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if h.browser == nil {
|
|
|
|
|
writeAuthUnavailable(w)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.browser.CallbackHandler(w, r)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if h.browser == nil {
|
|
|
|
|
writeAuthUnavailable(w)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.browser.LogoutHandler(w, r)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeAuthUnavailable(w http.ResponseWriter) {
|
|
|
|
|
jsonWrite(w, http.StatusNotFound, map[string]string{
|
|
|
|
|
"error": "Go Loose login is not configured: set GO_LOOSE_CLIENT_ID and GO_LOOSE_CLIENT_SECRET, then restart the server",
|
|
|
|
|
})
|
2026-09-19 14:17:27 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
|
2026-09-19 14:17:27 +02:00
|
|
|
func (h *Handler) protected(w http.ResponseWriter, r *http.Request) {
|
2026-09-19 17:33:44 +02:00
|
|
|
if _, ok := h.identity(r); !ok {
|
|
|
|
|
jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
2026-09-19 14:17:27 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
jsonWrite(w, http.StatusOK, map[string]string{"status": "authenticated"})
|
2026-09-19 14:17:27 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
|
|
|
|
|
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"})
|
2026-09-19 14:56:49 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
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})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 15:10:10 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 17:33:44 +02:00
|
|
|
func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
|
2026-09-19 16:45:24 +02:00
|
|
|
if r.Method != http.MethodGet {
|
2026-09-19 14:56:49 +02:00
|
|
|
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
limit, offset := 20, 0
|
|
|
|
|
_, _ = fmt.Sscanf(r.URL.Query().Get("limit"), "%d", &limit)
|
|
|
|
|
_, _ = fmt.Sscanf(r.URL.Query().Get("offset"), "%d", &offset)
|
2026-09-19 15:11:12 +02:00
|
|
|
badges := r.URL.Query()["badge"]
|
|
|
|
|
if len(badges) == 0 {
|
2026-09-19 17:33:44 +02:00
|
|
|
badges = strings.Split(r.URL.Query().Get("badges"), ",")
|
2026-09-19 15:11:12 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
items, err := h.content.List(r.Context(), limit, offset,
|
|
|
|
|
r.URL.Query().Get("category"),
|
|
|
|
|
r.URL.Query().Get("status"),
|
|
|
|
|
badges,
|
2026-09-19 15:24:27 +02:00
|
|
|
)
|
2026-09-19 17:33:44 +02:00
|
|
|
if err != nil {
|
|
|
|
|
jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
2026-09-19 14:56:49 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
jsonWrite(w, http.StatusOK, map[string]any{"items": items, "limit": limit, "offset": offset})
|
2026-09-19 14:56:49 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
|
2026-10-03 12:25:10 +02:00
|
|
|
func (h *Handler) contentCreate(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodPost {
|
|
|
|
|
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-07 15:10:10 +02:00
|
|
|
username, authenticated := h.identity(r)
|
2026-10-03 12:25:10 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-10-07 15:10:10 +02:00
|
|
|
if strings.TrimSpace(item.Author) == "" {
|
|
|
|
|
item.Author = username
|
|
|
|
|
}
|
2026-10-03 12:25:10 +02:00
|
|
|
created, err := h.content.Create(r.Context(), item)
|
|
|
|
|
if err != nil {
|
|
|
|
|
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
jsonWrite(w, http.StatusCreated, created)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 15:11:12 +02:00
|
|
|
func (h *Handler) contentCategories(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodGet {
|
|
|
|
|
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
categories, err := h.content.ListCategories(r.Context())
|
|
|
|
|
if err != nil {
|
2026-09-19 17:33:44 +02:00
|
|
|
jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
2026-09-19 15:11:12 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
jsonWrite(w, http.StatusOK, map[string]any{"items": categories})
|
2026-09-19 15:11:12 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
|
2026-09-19 15:14:16 +02:00
|
|
|
func (h *Handler) contentBadges(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodGet {
|
|
|
|
|
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
badges, err := h.content.ListBadges(r.Context())
|
|
|
|
|
if err != nil {
|
2026-09-19 17:33:44 +02:00
|
|
|
jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
2026-09-19 15:14:16 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
jsonWrite(w, http.StatusOK, map[string]any{"items": badges})
|
2026-09-19 15:14:16 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
|
2026-09-19 14:56:49 +02:00
|
|
|
func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
slug := strings.TrimPrefix(r.URL.Path, "/api/content/")
|
2026-10-07 15:10:10 +02:00
|
|
|
slug = strings.TrimPrefix(slug, "/api/dev/null/")
|
2026-09-19 17:33:44 +02:00
|
|
|
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"})
|
2026-09-19 14:56:49 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
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)
|
2026-10-03 12:25:10 +02:00
|
|
|
case http.MethodDelete:
|
|
|
|
|
_, authenticated := h.identity(r)
|
|
|
|
|
if !authenticated {
|
|
|
|
|
jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := h.content.Delete(r.Context(), slug); err != nil {
|
|
|
|
|
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
jsonWrite(w, http.StatusNoContent, nil)
|
2026-09-19 17:33:44 +02:00
|
|
|
default:
|
|
|
|
|
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
2026-09-19 14:56:49 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-03 04:41:31 +02:00
|
|
|
// identity resolves the editor behind a request. The demo profile wins, then
|
|
|
|
|
// the SSO proxy, then the Go Loose browser session: the session is checked last
|
|
|
|
|
// because it costs a call to the identity provider.
|
2026-09-19 17:33:44 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-19 14:56:49 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
if h.sso.Authenticated(r) {
|
|
|
|
|
return "sso-owner", true
|
2026-09-19 14:56:49 +02:00
|
|
|
}
|
2026-10-03 04:41:31 +02:00
|
|
|
if user, ok := h.browserUser(r); ok {
|
|
|
|
|
return editorName(user), true
|
|
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 12:25:10 +02:00
|
|
|
func (h *Handler) adminReset(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodPost {
|
|
|
|
|
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_, authenticated := h.identity(r)
|
|
|
|
|
if !authenticated {
|
|
|
|
|
jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := h.content.Reset(r.Context()); err != nil {
|
|
|
|
|
jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
jsonWrite(w, http.StatusOK, map[string]string{"status": "reset"})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 04:41:31 +02:00
|
|
|
// browserUser returns the Go Loose user carried by the request session, if any.
|
|
|
|
|
func (h *Handler) browserUser(r *http.Request) (goloose.User, bool) {
|
|
|
|
|
if h.browser == nil {
|
|
|
|
|
return goloose.User{}, false
|
|
|
|
|
}
|
|
|
|
|
user, ok := h.browser.SessionUser(r)
|
|
|
|
|
if !ok {
|
|
|
|
|
return goloose.User{}, false
|
|
|
|
|
}
|
|
|
|
|
return user, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func editorName(user goloose.User) string {
|
|
|
|
|
if name := strings.TrimSpace(user.DisplayName); name != "" {
|
|
|
|
|
return name
|
|
|
|
|
}
|
|
|
|
|
return user.Email
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 17:33:44 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-09-19 14:56:49 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
index, err := fs.ReadFile(h.assets, "index.html")
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.NotFound(w, r)
|
2026-09-19 14:56:49 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-10-07 15:10:10 +02:00
|
|
|
body := applyThemeOrigin(index, h.themeOrigin)
|
2026-09-19 17:33:44 +02:00
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
|
w.Header().Set("Cache-Control", "no-cache")
|
2026-10-07 15:10:10 +02:00
|
|
|
_, _ = 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
|
2026-09-19 14:56:49 +02:00
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
|
2026-09-19 14:17:27 +02:00
|
|
|
func decode(r *http.Request, v any) bool { return json.NewDecoder(r.Body).Decode(v) == nil }
|
2026-09-19 17:33:44 +02:00
|
|
|
|
2026-09-19 14:17:27 +02:00
|
|
|
func jsonWrite(w http.ResponseWriter, status int, v any) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.WriteHeader(status)
|
|
|
|
|
if v != nil {
|
|
|
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-19 17:33:44 +02:00
|
|
|
|
2026-09-19 16:45:24 +02:00
|
|
|
func cors(next http.Handler, origin, ssoHeader string) http.Handler {
|
|
|
|
|
allowed := "Content-Type, Authorization"
|
|
|
|
|
if ssoHeader != "" && !strings.EqualFold(ssoHeader, "Authorization") {
|
|
|
|
|
allowed += ", " + ssoHeader
|
|
|
|
|
}
|
2026-09-19 14:17:27 +02:00
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
|
|
|
w.Header().Set("Vary", "Origin")
|
2026-09-19 16:45:24 +02:00
|
|
|
w.Header().Set("Access-Control-Allow-Headers", allowed)
|
2026-10-07 15:10:10 +02:00
|
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
2026-09-19 16:45:24 +02:00
|
|
|
if r.Method == http.MethodOptions {
|
2026-09-19 17:33:44 +02:00
|
|
|
w.WriteHeader(http.StatusNoContent)
|
2026-09-19 14:17:27 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
next.ServeHTTP(w, r)
|
|
|
|
|
})
|
|
|
|
|
}
|