go-tell/internal/web/handlers.go
Daniel Noulet fd2877da9e Add authenticated content editing
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-19 14:56:49 +02:00

260 lines
8.4 KiB
Go

package web
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/example/cms/internal/domain"
"github.com/example/cms/internal/identity"
"github.com/example/cms/internal/service"
"github.com/example/cms/internal/web/mapping"
"log"
"net/http"
"strings"
)
type Handler struct {
auth *service.AuthService
google identity.GoogleProvider
content service.IContentService
}
func NewHandler(a *service.AuthService, google identity.GoogleProvider) *Handler {
return &Handler{auth: a, google: google}
}
func NewHandlerWithContent(a *service.AuthService, google identity.GoogleProvider, content service.IContentService) *Handler {
return &Handler{auth: a, google: google, content: content}
}
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("/api/login", h.login)
mux.HandleFunc("/api/auth/google", h.googleLogin)
mux.HandleFunc("/api/auth/google/callback", h.googleCallback)
mux.HandleFunc("/api/auth/session", h.session)
mux.HandleFunc("/admin/login", h.adminLogin)
mux.HandleFunc("/api/test/protected", h.protected)
mux.HandleFunc("/api/protected", h.protected)
mux.HandleFunc("/api/content", h.contentList)
mux.HandleFunc("/api/content/", h.contentDetail)
return cors(mux, origins)
}
func (h *Handler) googleLogin(w http.ResponseWriter, r *http.Request) {
if h.google.ClientID == "" {
jsonWrite(w, http.StatusServiceUnavailable, map[string]string{"error": "google identity provider is not configured"})
return
}
stateBytes := make([]byte, 32)
if _, err := rand.Read(stateBytes); err != nil {
jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": "unable to create oauth state"})
return
}
state := base64.RawURLEncoding.EncodeToString(stateBytes)
http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, Path: "/", MaxAge: 600})
http.Redirect(w, r, h.google.OAuthConfig().AuthCodeURL(state), http.StatusFound)
}
func (h *Handler) googleCallback(w http.ResponseWriter, r *http.Request) {
stateCookie, err := r.Cookie("oauth_state")
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
http.Error(w, "invalid oauth state", http.StatusBadRequest)
return
}
id, err := h.google.FetchIdentity(r.Context(), r.URL.Query().Get("code"))
if err != nil {
log.Printf("Google OAuth callback failed: %v", err)
http.Error(w, "google authentication failed; start a new sign-in attempt", http.StatusUnauthorized)
return
}
user, token, err := h.auth.Login(r.Context(), id)
if err != nil {
http.Error(w, "unable to create session", http.StatusInternalServerError)
return
}
setSessionCookie(w, token, r.TLS != nil)
_ = user
http.Redirect(w, r, "/test", http.StatusFound)
}
func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
token := bearerOrCookie(r)
if token == "" {
jsonWrite(w, http.StatusUnauthorized, map[string]string{"authenticated": "false"})
return
}
s, err := h.auth.Authenticate(r.Context(), token)
if err != nil || s.UserID == nil {
jsonWrite(w, http.StatusUnauthorized, map[string]string{"authenticated": "false"})
return
}
u, err := h.auth.UserByID(r.Context(), *s.UserID)
if err != nil {
jsonWrite(w, http.StatusUnauthorized, map[string]string{"authenticated": "false"})
return
}
jsonWrite(w, http.StatusOK, map[string]any{"authenticated": true, "user": u, "canEdit": h.auth.CanEdit(r.Context(), s)})
}
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonWrite(w, 405, nil)
return
}
var req mapping.LoginRequest
if !decode(r, &req) {
jsonWrite(w, 400, map[string]string{"error": "invalid JSON"})
return
}
u, t, e := h.auth.Login(r.Context(), req.Identity())
if e != nil {
jsonWrite(w, 400, map[string]string{"error": e.Error()})
return
}
jsonWrite(w, 200, mapping.LoginResponse{Token: t, User: u})
}
func (h *Handler) adminLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonWrite(w, 405, nil)
return
}
var req struct {
Email string `json:"email"`
}
if !decode(r, &req) {
jsonWrite(w, 400, map[string]string{"error": "invalid JSON"})
return
}
a, t, e := h.auth.AdminLogin(r.Context(), req.Email)
if e != nil {
jsonWrite(w, 401, map[string]string{"error": e.Error()})
return
}
jsonWrite(w, 200, mapping.LoginResponse{Token: t, Admin: a})
}
func (h *Handler) protected(w http.ResponseWriter, r *http.Request) {
t := bearerOrCookie(r)
if t == "" {
jsonWrite(w, 401, map[string]string{"error": "missing bearer token"})
return
}
s, e := h.auth.Authenticate(r.Context(), t)
if e != nil || s.UserID == nil {
jsonWrite(w, 401, map[string]string{"error": "invalid session"})
return
}
jsonWrite(w, 200, map[string]string{"status": "authenticated"})
}
func (h *Handler) sessionForRequest(r *http.Request) (domain.Session, bool) {
t := bearerOrCookie(r)
if t == "" {
return domain.Session{}, false
}
if s, e := h.auth.Authenticate(r.Context(), t); e == nil {
return s, true
}
if s, e := h.auth.AdminAuthenticate(r.Context(), t); e == nil {
return s, true
}
return domain.Session{}, false
}
func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
if h.content == nil {
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
return
}
if _, ok := h.sessionForRequest(r); !ok {
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
return
}
if r.Method != "GET" {
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)
items, e := h.content.List(r.Context(), limit, offset)
if e != nil {
jsonWrite(w, 500, map[string]string{"error": e.Error()})
return
}
jsonWrite(w, 200, map[string]any{"items": items, "limit": limit, "offset": offset})
}
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 == "GET" {
if _, ok := h.sessionForRequest(r); !ok {
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
return
}
c, e := h.content.Get(r.Context(), slug)
if e != nil {
jsonWrite(w, 404, map[string]string{"error": "content not found"})
return
}
jsonWrite(w, 200, c)
return
}
if r.Method != "PUT" {
jsonWrite(w, 405, nil)
return
}
s, ok := h.sessionForRequest(r)
if !ok {
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
return
}
if !h.auth.CanEdit(r.Context(), s) {
jsonWrite(w, 403, map[string]string{"error": "forbidden"})
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, s)
if e != nil {
jsonWrite(w, 400, map[string]string{"error": e.Error()})
return
}
jsonWrite(w, 200, out)
}
func bearerOrCookie(r *http.Request) string {
header := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if header != "" {
return header
}
cookie, err := r.Cookie("cms_session")
if err != nil {
return ""
}
return cookie.Value
}
func setSessionCookie(w http.ResponseWriter, token string, secure bool) {
http.SetCookie(w, &http.Cookie{Name: "cms_session", Value: token, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, Path: "/", MaxAge: 86400})
}
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 string) http.Handler {
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", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
if r.Method == "OPTIONS" {
w.WriteHeader(204)
return
}
next.ServeHTTP(w, r)
})
}