280 lines
9.0 KiB
Go
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" }
|