All checks were successful
Build and Test / build-and-test (push) Successful in 37s
Add a global, temporal ai_notes list appended to the classifier prompt (seeded once from no-PII defaults, documented in README), managed inline on a new AI tab with a read-only view of the assembled prompt. Every AI-run upload records the browser-round-tripped suggestion blob + model; misreads are derived (final field != AI guess) and reviewed one by one (image + per-field guess-vs-entered + notes-since), attributing which note fixed each or closing unresolved. Update SPEC (new section 10), DESIGN item 15, README, and changelog (0.0.3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
239 lines
8.2 KiB
Go
239 lines
8.2 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"golang.org/x/oauth2"
|
|
|
|
"maisym.com/hsa/internal/auth"
|
|
"maisym.com/hsa/internal/classify"
|
|
"maisym.com/hsa/internal/config"
|
|
"maisym.com/hsa/internal/storage"
|
|
)
|
|
|
|
const (
|
|
sessionCookie = "hsa_session"
|
|
loginCookie = "hsa_login"
|
|
)
|
|
|
|
// Server holds the wired dependencies for the HTTP handlers.
|
|
type Server struct {
|
|
cfg config.Config
|
|
provider *oidc.Provider
|
|
oauth *oauth2.Config
|
|
verifier *oidc.IDTokenVerifier
|
|
store *storage.Store
|
|
classifier *classify.Classifier // nil when classification is disabled
|
|
}
|
|
|
|
// NewServer performs OIDC discovery against the issuer and builds the server.
|
|
// This makes a network call to the issuer's /.well-known/openid-configuration.
|
|
// classifier may be nil to disable receipt auto-classification.
|
|
func NewServer(ctx context.Context, cfg config.Config, store *storage.Store, classifier *classify.Classifier) (*Server, error) {
|
|
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oidc discovery against %s: %w", cfg.IssuerURL, err)
|
|
}
|
|
|
|
oauthCfg := &oauth2.Config{
|
|
ClientID: cfg.ClientID,
|
|
ClientSecret: cfg.ClientSecret,
|
|
RedirectURL: cfg.RedirectURL,
|
|
Endpoint: provider.Endpoint(),
|
|
Scopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"},
|
|
}
|
|
|
|
return &Server{
|
|
cfg: cfg,
|
|
provider: provider,
|
|
oauth: oauthCfg,
|
|
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
|
store: store,
|
|
classifier: classifier,
|
|
}, nil
|
|
}
|
|
|
|
// Routes returns the configured HTTP handler.
|
|
func (s *Server) Routes() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /healthz", s.handleHealthz)
|
|
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
|
mux.HandleFunc("GET /login", s.handleLogin)
|
|
mux.HandleFunc("GET /callback", s.handleCallback)
|
|
mux.HandleFunc("GET /logout", s.handleLogout)
|
|
mux.Handle("GET /{$}", s.requireAuth(http.HandlerFunc(s.handleUploadForm)))
|
|
mux.Handle("POST /upload", s.requireAuth(http.HandlerFunc(s.handleUpload)))
|
|
mux.Handle("POST /classify", s.requireAuth(http.HandlerFunc(s.handleClassify)))
|
|
mux.Handle("GET /duplicates", s.requireAuth(http.HandlerFunc(s.handleDuplicates)))
|
|
mux.Handle("GET /tally", s.requireAuth(http.HandlerFunc(s.handleTally)))
|
|
mux.Handle("GET /recent", s.requireAuth(http.HandlerFunc(s.handleRecentUploads)))
|
|
mux.Handle("GET /recent/receipts", s.requireAuth(http.HandlerFunc(s.handleRecentReceipts)))
|
|
mux.Handle("GET /receipt/{id}/file", s.requireAuth(http.HandlerFunc(s.handleReceiptFile)))
|
|
mux.Handle("GET /attachment/{id}/file", s.requireAuth(http.HandlerFunc(s.handleAttachmentFile)))
|
|
mux.Handle("GET /ai", s.requireAuth(http.HandlerFunc(s.handleAI)))
|
|
mux.Handle("POST /ai/notes", s.requireAuth(http.HandlerFunc(s.handleAddNote)))
|
|
mux.Handle("POST /ai/notes/edit", s.requireAuth(http.HandlerFunc(s.handleEditNote)))
|
|
mux.Handle("POST /ai/notes/delete", s.requireAuth(http.HandlerFunc(s.handleDeleteNote)))
|
|
mux.Handle("GET /ai/review/{id}", s.requireAuth(http.HandlerFunc(s.handleReview)))
|
|
mux.Handle("POST /ai/review/{id}", s.requireAuth(http.HandlerFunc(s.handleReviewSubmit)))
|
|
mux.Handle("GET /manage", s.requireAuth(http.HandlerFunc(s.handleManage)))
|
|
mux.Handle("POST /manage/categories", s.requireAuth(http.HandlerFunc(s.handleAddCategory)))
|
|
mux.Handle("POST /manage/categories/rename", s.requireAuth(http.HandlerFunc(s.handleRenameCategory)))
|
|
mux.Handle("POST /manage/people", s.requireAuth(http.HandlerFunc(s.handleAddPerson)))
|
|
mux.Handle("POST /manage/people/rename", s.requireAuth(http.HandlerFunc(s.handleRenamePerson)))
|
|
mux.Handle("GET /export", s.requireAuth(http.HandlerFunc(s.handleExportPage)))
|
|
mux.Handle("GET /export/db", s.requireAuth(http.HandlerFunc(s.handleExportDB)))
|
|
return mux
|
|
}
|
|
|
|
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintln(w, "ok")
|
|
}
|
|
|
|
// handleLogin starts the OIDC flow: generate PKCE + state + nonce, stash them in
|
|
// a short-lived encrypted cookie, and redirect to Authelia's authorize endpoint.
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
verifier, err := auth.GenerateVerifier()
|
|
if err != nil {
|
|
s.serverError(w, "generate verifier", err)
|
|
return
|
|
}
|
|
state, err := auth.GenerateState()
|
|
if err != nil {
|
|
s.serverError(w, "generate state", err)
|
|
return
|
|
}
|
|
nonce, err := auth.GenerateState() // reuse the random generator for the nonce
|
|
if err != nil {
|
|
s.serverError(w, "generate nonce", err)
|
|
return
|
|
}
|
|
|
|
ls := auth.LoginState{State: state, Verifier: verifier, Nonce: nonce}
|
|
encoded, err := auth.EncodeLoginState(ls, s.cfg.SessionKey)
|
|
if err != nil {
|
|
s.serverError(w, "encode login state", err)
|
|
return
|
|
}
|
|
s.setCookie(w, loginCookie, encoded, 10*time.Minute)
|
|
|
|
challenge := auth.ChallengeS256(verifier)
|
|
url := auth.AuthorizeURL(s.oauth, state, challenge, oidc.Nonce(nonce))
|
|
http.Redirect(w, r, url, http.StatusFound)
|
|
}
|
|
|
|
// handleCallback completes the OIDC flow: validate state, exchange the code with
|
|
// PKCE, verify the ID token, check the group, and set the session cookie.
|
|
func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
|
|
// Recover the login state from the cookie.
|
|
c, err := r.Cookie(loginCookie)
|
|
if err != nil {
|
|
http.Error(w, "missing login state; start at /login", http.StatusBadRequest)
|
|
return
|
|
}
|
|
ls, err := auth.DecodeLoginState(c.Value, s.cfg.SessionKey)
|
|
if err != nil {
|
|
http.Error(w, "invalid login state", http.StatusBadRequest)
|
|
return
|
|
}
|
|
s.clearCookie(w, loginCookie)
|
|
|
|
// CSRF: the state we issued must match the one returned.
|
|
if r.URL.Query().Get("state") != ls.State {
|
|
http.Error(w, "state mismatch", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Exchange the authorization code, sending the PKCE verifier.
|
|
code := r.URL.Query().Get("code")
|
|
token, err := s.oauth.Exchange(r.Context(), code, oauth2.VerifierOption(ls.Verifier))
|
|
if err != nil {
|
|
s.serverError(w, "token exchange", err)
|
|
return
|
|
}
|
|
|
|
rawIDToken, ok := token.Extra("id_token").(string)
|
|
if !ok {
|
|
http.Error(w, "no id_token in response", http.StatusBadGateway)
|
|
return
|
|
}
|
|
idToken, err := s.verifier.Verify(r.Context(), rawIDToken)
|
|
if err != nil {
|
|
s.serverError(w, "verify id_token", err)
|
|
return
|
|
}
|
|
if idToken.Nonce != ls.Nonce {
|
|
http.Error(w, "nonce mismatch", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Authelia serves groups/email from the UserInfo endpoint rather than the ID
|
|
// token by default, so read claims from the ID token and merge in UserInfo.
|
|
type oidcClaims struct {
|
|
Email string `json:"email"`
|
|
PreferredUsername string `json:"preferred_username"`
|
|
Groups []string `json:"groups"`
|
|
}
|
|
var idClaims oidcClaims
|
|
if err := idToken.Claims(&idClaims); err != nil {
|
|
s.serverError(w, "parse id token claims", err)
|
|
return
|
|
}
|
|
|
|
var uiClaims oidcClaims
|
|
if userInfo, err := s.provider.UserInfo(r.Context(), oauth2.StaticTokenSource(token)); err == nil {
|
|
_ = userInfo.Claims(&uiClaims)
|
|
} else {
|
|
log.Printf("warning: userinfo fetch failed: %v", err)
|
|
}
|
|
|
|
groups := idClaims.Groups
|
|
if len(groups) == 0 {
|
|
groups = uiClaims.Groups
|
|
}
|
|
email := idClaims.Email
|
|
if email == "" {
|
|
email = uiClaims.Email
|
|
}
|
|
username := idClaims.PreferredUsername
|
|
if username == "" {
|
|
username = uiClaims.PreferredUsername
|
|
}
|
|
|
|
subject := email
|
|
if subject == "" {
|
|
subject = username
|
|
}
|
|
log.Printf("login attempt: subject=%q groups=%v", subject, groups)
|
|
|
|
// Authorization gate: must be in the required group.
|
|
if !auth.IsAuthorized(groups, s.cfg.RequiredGroup) {
|
|
http.Error(w, fmt.Sprintf("403: not a member of %q", s.cfg.RequiredGroup), http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
sess := auth.Session{Subject: subject, Groups: groups, IssuedAt: time.Now().UTC()}
|
|
encoded, err := auth.EncodeSession(sess, s.cfg.SessionKey)
|
|
if err != nil {
|
|
s.serverError(w, "encode session", err)
|
|
return
|
|
}
|
|
s.setCookie(w, sessionCookie, encoded, 12*time.Hour)
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
s.clearCookie(w, sessionCookie)
|
|
http.Redirect(w, r, "/login", http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) serverError(w http.ResponseWriter, what string, err error) {
|
|
log.Printf("error: %s: %v", what, err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
}
|