Files
enkly-ai/services/gateway/internal/handlers/core.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

280 lines
9.0 KiB
Go

// Package handlers is the gateway's net/http layer — the BFF (backend
// for frontend) that terminates the public HTTP/HTMX surface and
// composes it from gRPC calls to every other bounded context. It is
// the direct transplant of the monolith's internal/handlers package,
// with one systematic change: everywhere the original called h.DB,
// h.Agent, or h.Billing directly, this package calls the matching
// service's generated gRPC client instead. Rendering (html/template,
// pkg/renderkit's Markdown/syntax-highlighting) stays here — this is
// now the ONLY service that imports pkg/renderkit or parses *.html
// templates; see ARCHITECTURE.md's "Where rendering lives".
package handlers
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"log/slog"
"net"
"net/http"
"net/mail"
"path/filepath"
"strings"
"time"
agentv1 "enkly/gen/agent/v1"
billingv1 "enkly/gen/billing/v1"
conversationv1 "enkly/gen/conversation/v1"
iamv1 "enkly/gen/iam/v1"
knowledgev1 "enkly/gen/knowledge/v1"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/authsupport"
"enkly/pkg/i18n"
)
const (
sessionCookieName = "enkly_session"
csrfCookieName = "enkly_csrf"
csrfFormField = "csrf_token"
sessionTTL = 30 * 24 * time.Hour
maxFormBytes = 1 << 20 // 1 MiB
maxTitleChars = 120
minPasswordLen = 8
maxPasswordLen = 256
maxEmailLen = 254
)
// Handlers bundles every dependency an HTTP handler needs: the
// generated gRPC clients for each peer bounded context, the parsed
// templates, and the gateway's own edge-only state (CSRF — there is no
// server-side CSRF table, it's a pure double-submit cookie, so nothing
// about it belongs in any other service).
type Handlers struct {
IAM iamv1.IAMServiceClient
Billing billingv1.BillingServiceClient
Conversation conversationv1.ConversationServiceClient
Workspace workspacev1.WorkspaceServiceClient
Knowledge knowledgev1.KnowledgeServiceClient
Agent agentv1.AgentServiceClient
Templates *template.Template
Logger *slog.Logger
BaseURL string
StripePublishKey string
}
// templateFuncs are the template functions every *.html file can call
// regardless of which handler rendered it. Kept to pure functions of
// their arguments (never a closure over per-request state) because
// Templates is parsed once at startup and shared across every
// request — see LoadTemplates.
var templateFuncs = template.FuncMap{
// t looks up a translation key in the given locale, falling back
// to English and then to the key itself — see pkg/i18n.T's doc
// comment. Usage in a template: {{t .Lang "nav.new_chat"}}, or
// with Sprintf-style args: {{t .Lang "errors.password_min" 8}}.
"t": func(locale, key string, args ...any) string {
return i18n.T(i18n.Locale(locale), key, args...)
},
// supportedLanguages lists every locale enkly ships a catalog for,
// for the language-switcher partial to range over. A pure function
// of no arguments (the list is the same for every request), so —
// unlike the current locale — it needs no per-page field.
"supportedLanguages": func() []i18n.Info { return i18n.Supported },
// dict builds a map[string]any from alternating key/value
// arguments, so a page can pass its own CSRFToken/Lang/Next into a
// shared partial (e.g. "lang_switcher") that isn't handed the
// whole page's data value. Standard html/template idiom — the
// stdlib has no built-in equivalent.
"dict": func(pairs ...any) (map[string]any, error) {
if len(pairs)%2 != 0 {
return nil, fmt.Errorf("dict: odd number of arguments")
}
m := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
return nil, fmt.Errorf("dict: argument %d is not a string key", i)
}
m[key] = pairs[i+1]
}
return m, nil
},
}
// LoadTemplates parses every *.html file in dir, registering
// templateFuncs so every template can call {{t .Lang "..."}} and
// {{supportedLanguages}} — otherwise unchanged from the original
// handlers.LoadTemplates.
func LoadTemplates(dir string) (*template.Template, error) {
files, err := filepath.Glob(filepath.Join(dir, "*.html"))
if err != nil {
return nil, fmt.Errorf("gateway: glob templates: %w", err)
}
if len(files) == 0 {
return nil, fmt.Errorf("gateway: no *.html templates found in %s", dir)
}
t, err := template.New("enkly").Funcs(templateFuncs).ParseFiles(files...)
if err != nil {
return nil, fmt.Errorf("gateway: parse templates: %w", err)
}
return t, nil
}
func (h *Handlers) render(w http.ResponseWriter, status int, name string, data any) {
var buf bytes.Buffer
if err := h.Templates.ExecuteTemplate(&buf, name, data); err != nil {
h.Logger.Error("render template", "template", name, "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
func (h *Handlers) renderString(name string, data any) string {
var buf bytes.Buffer
if err := h.Templates.ExecuteTemplate(&buf, name, data); err != nil {
h.Logger.Error("render fragment", "template", name, "err", err)
return ""
}
return buf.String()
}
// ---------------------------------------------------------------------
// Request context: the authenticated user (now the IAM-service User,
// via the generated client, instead of models.User)
// ---------------------------------------------------------------------
type ctxKey int
const userCtxKey ctxKey = iota
func withUser(ctx context.Context, u *iamv1.User) context.Context {
return context.WithValue(ctx, userCtxKey, u)
}
func userFrom(r *http.Request) *iamv1.User {
u, _ := r.Context().Value(userCtxKey).(*iamv1.User)
return u
}
// ---------------------------------------------------------------------
// Cookies
// ---------------------------------------------------------------------
func (h *Handlers) secureCookies() bool {
return strings.HasPrefix(strings.ToLower(h.BaseURL), "https://")
}
func (h *Handlers) setCookie(w http.ResponseWriter, name, value string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: name, Value: value, Path: "/", MaxAge: maxAge,
HttpOnly: true, Secure: h.secureCookies(), SameSite: http.SameSiteLaxMode,
})
}
// csrfToken replaces Handlers.csrfToken.
func (h *Handlers) csrfToken(w http.ResponseWriter, r *http.Request) string {
if c, err := r.Cookie(csrfCookieName); err == nil && c.Value != "" {
return c.Value
}
tok, err := authsupport.NewCSRFToken()
if err != nil {
h.Logger.Error("csrf token", "err", err)
return ""
}
h.setCookie(w, csrfCookieName, tok, int((365 * 24 * time.Hour).Seconds()))
return tok
}
func sessionToken(r *http.Request) string {
if c, err := r.Cookie(sessionCookieName); err == nil {
return c.Value
}
return ""
}
// startSessionCookie sets the browser's session cookie from a token IAM
// already minted (via Login/SignUp) — replaces Handlers.startSession,
// which used to create the session row itself; now IAM owns that.
func (h *Handlers) startSessionCookie(w http.ResponseWriter, rawToken string, expiresAt time.Time) {
maxAge := int(time.Until(expiresAt).Seconds())
if maxAge <= 0 {
maxAge = int(sessionTTL.Seconds())
}
h.setCookie(w, sessionCookieName, rawToken, maxAge)
}
// ---------------------------------------------------------------------
// Small shared helpers — unchanged from the monolith
// ---------------------------------------------------------------------
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func normalizeEmail(raw string) (string, error) {
s := strings.ToLower(strings.TrimSpace(raw))
if s == "" || len(s) > maxEmailLen {
return "", errors.New("invalid email")
}
addr, err := mail.ParseAddress(s)
if err != nil || addr.Address != s {
return "", errors.New("invalid email")
}
return s, nil
}
func isSixDigitCode(raw string) bool {
if len(raw) != 6 {
return false
}
for _, c := range raw {
if c < '0' || c > '9' {
return false
}
}
return true
}
// Sentinel errors for validatePassword, so a caller (auth.go's
// translatePasswordError) can switch on *which* rule failed with
// errors.Is instead of matching against the English wording of an
// error string — the old approach, which silently breaks the moment
// either message or the min/max constants change.
var (
errPasswordTooShort = errors.New("password too short")
errPasswordTooLong = errors.New("password too long")
errPasswordMismatch = errors.New("passwords don't match")
)
func validatePassword(password, confirm string) error {
switch {
case len(password) < minPasswordLen:
return errPasswordTooShort
case len(password) > maxPasswordLen:
return errPasswordTooLong
case password != confirm:
return errPasswordMismatch
}
return nil
}
func parseForm(w http.ResponseWriter, r *http.Request) error {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
return r.ParseForm()
}
func isHTMX(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" }