package web import ( "bytes" "context" "encoding/json" "fmt" "io/fs" "net/http" "strings" 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" ) // Handler serves the CMS API and the embedded React app from one binary. // 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. type Handler struct { content service.IContentService assets fs.FS 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 { h := &Handler{content: content, assets: assets, sso: sso, browser: browser} 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, http.StatusOK, map[string]string{"status": "ok"}) }) mux.HandleFunc(golooseauth.LoginPath, h.login) mux.HandleFunc(golooseauth.CallbackPath, h.callback) mux.HandleFunc(golooseauth.LogoutPath, h.logout) 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/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()) } // 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. func (h *Handler) session(w http.ResponseWriter, r *http.Request) { username, authenticated := h.identity(r) 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 } 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 } 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", }) } func (h *Handler) protected(w http.ResponseWriter, r *http.Request) { if _, ok := h.identity(r); !ok { jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) return } jsonWrite(w, http.StatusOK, map[string]string{"status": "authenticated"}) } 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) 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) return } limit, offset := 20, 0 _, _ = fmt.Sscanf(r.URL.Query().Get("limit"), "%d", &limit) _, _ = fmt.Sscanf(r.URL.Query().Get("offset"), "%d", &offset) badges := r.URL.Query()["badge"] if len(badges) == 0 { badges = strings.Split(r.URL.Query().Get("badges"), ",") } items, err := h.content.List(r.Context(), limit, offset, r.URL.Query().Get("category"), r.URL.Query().Get("status"), badges, ) if err != nil { jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } jsonWrite(w, http.StatusOK, map[string]any{"items": items, "limit": limit, "offset": offset}) } func (h *Handler) contentCreate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonWrite(w, http.StatusMethodNotAllowed, nil) return } 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 } 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()}) return } jsonWrite(w, http.StatusCreated, created) } 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 { jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } jsonWrite(w, http.StatusOK, map[string]any{"items": categories}) } 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 { jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } jsonWrite(w, http.StatusOK, map[string]any{"items": badges}) } 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) if err != nil { jsonWrite(w, http.StatusNotFound, map[string]string{"error": "content not found"}) 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) 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) default: jsonWrite(w, http.StatusMethodNotAllowed, nil) } } // 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. 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 } if user, ok := h.browserUser(r); ok { return editorName(user), true } return "", false } 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"}) } // 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 } // 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 } body := applyThemeOrigin(index, h.themeOrigin) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") _, _ = 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 ` + "\n" head := []byte("") 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 } 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) } } func cors(next http.Handler, origin, ssoHeader string) http.Handler { allowed := "Content-Type, Authorization" if ssoHeader != "" && !strings.EqualFold(ssoHeader, "Authorization") { allowed += ", " + ssoHeader } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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, DELETE, OPTIONS") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) }