Something went wrong. Try again.
An ATProto Powered Reputation Network
Something went wrong. Try again.
12 kB · 353 lines
Go
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354package ui
import ( "context" "crypto/subtle" _ "embed" "errors" "fmt" "html/template" "log/slog" "net/http" "net/url" "strconv" "strings" "time"
"github.com/bluesky-social/indigo/atproto/atclient"
"provenance/internal/atproto" "provenance/internal/domain" "provenance/internal/httpx" "provenance/internal/secure" "provenance/internal/trust")
const ( sessionCookie = "provenance-session" flowCookie = "provenance-flow" csrfHeader = "X-CSRF-Token" accountSearchTimeout = 5 * time.Second)
//go:embed web/index.htmlvar page string
//go:embed web/app.jsvar script string
//go:embed web/style.cssvar style string
type sessionKey struct{}
type HTTP struct { service *Service origin string page *template.Template}
func Handler(service *Service, origin string) http.Handler { h := &HTTP{service, strings.TrimRight(origin, "/"), template.Must(template.New("page").Parse(page))} mux := http.NewServeMux() mux.HandleFunc("GET /{$}", h.home) mux.HandleFunc("GET /check", h.home) mux.HandleFunc("GET /reputation", h.home) mux.HandleFunc("GET /app.js", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/javascript") w.Write([]byte(script)) }) mux.HandleFunc("GET /style.css", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css") w.Write([]byte(style)) }) mux.HandleFunc("GET /oauth-client-metadata.json", func(w http.ResponseWriter, r *http.Request) { httpx.JSON(w, http.StatusOK, h.service.Metadata()) }) mux.HandleFunc("POST /oauth/login", h.login) mux.HandleFunc("GET /oauth/callback", h.callback) mux.HandleFunc("POST /api/logout", h.protect(h.logout)) mux.HandleFunc("GET /api/me", h.protect(h.me)) // Account suggestions contain only public data and also serve the login form. mux.HandleFunc("GET /api/accounts", h.accounts) mux.HandleFunc("GET /api/tags", h.protect(h.tags)) mux.HandleFunc("GET /api/vouches", h.protect(h.vouches)) mux.HandleFunc("PUT /api/vouch", h.protect(h.put)) mux.HandleFunc("DELETE /api/vouch", h.protect(h.delete)) mux.HandleFunc("PUT /api/threshold", h.protect(h.threshold)) mux.HandleFunc("GET /api/reputation", h.protect(h.reputation)) mux.HandleFunc("GET /api/aggregate", h.protect(h.aggregate)) return mux}
func (h *HTTP) setCookie(w http.ResponseWriter, name, value string, age int) { http.SetCookie(w, &http.Cookie{Name: h.cookieName(name), Value: value, Path: "/", HttpOnly: true, Secure: strings.HasPrefix(h.origin, "https://"), SameSite: http.SameSiteLaxMode, MaxAge: age})}
// Host-only prefixes prevent sibling domains from substituting production cookies.func (h *HTTP) cookieName(name string) string { if strings.HasPrefix(h.origin, "https://") { return "__Host-" + name } return name}
func (h *HTTP) home(w http.ResponseWriter, r *http.Request) { logged := false if c, err := r.Cookie(h.cookieName(sessionCookie)); err == nil { _, err = h.service.Session(r.Context(), c.Value) logged = err == nil } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") if err := h.page.Execute(w, map[string]any{"Logged": logged, "Check": r.URL.Path == "/check", "Aggregate": r.URL.Path == "/reputation"}); err != nil { slog.Error("page template failed", "error", err) }}
func (h *HTTP) login(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Origin") != h.origin { h.fail(w, r, &requestError{http.StatusForbidden, "origin_mismatch", "Open Provenance at its configured address, then try login again.", nil}) return } r.Body = http.MaxBytesReader(w, r.Body, httpx.MaxBodyBytes) if err := r.ParseForm(); err != nil { h.fail(w, r, invalid("The login form is invalid. Reload this page and try again.", err)) return } nonce := secure.Token() redirect, err := h.service.Begin(r.Context(), r.Form.Get("identifier"), nonce) if err != nil { h.fail(w, r, err) return } h.setCookie(w, flowCookie, nonce, int(atproto.AuthTTL.Seconds())) w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, redirect, http.StatusSeeOther)}
func (h *HTTP) callback(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(h.cookieName(flowCookie)) if err != nil { h.fail(w, r, invalid("The login session is missing. Start login again in this browser.", err)) return } h.setCookie(w, flowCookie, "", -1) token, err := h.service.Finish(r.Context(), r.URL.Query(), cookie.Value) if err != nil { h.fail(w, r, err) return } h.setCookie(w, sessionCookie, token, int(atproto.SessionTTL.Seconds())) w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, "/", http.StatusSeeOther)}
func (h *HTTP) protect(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(h.cookieName(sessionCookie)) if err != nil { h.fail(w, r, &requestError{http.StatusUnauthorized, "login_required", "Log in to use this feature.", err}) return } browser, err := h.service.Session(r.Context(), cookie.Value) if err != nil { h.fail(w, r, &requestError{http.StatusUnauthorized, "session_expired", "Your session expired. Log in again.", err}) return } if r.Method != http.MethodGet && (r.Header.Get("Origin") != h.origin || subtle.ConstantTimeCompare([]byte(r.Header.Get(csrfHeader)), []byte(browser.CSRF)) != 1) { h.fail(w, r, &requestError{http.StatusForbidden, "csrf_failed", "Reload this page before you try again.", nil}) return } next(w, r.WithContext(context.WithValue(r.Context(), sessionKey{}, browser))) }}
func browser(r *http.Request) Browser { return r.Context().Value(sessionKey{}).(Browser) }
func (h *HTTP) me(w http.ResponseWriter, r *http.Request) { b := browser(r) p, err := h.service.Config(r.Context(), b) if err != nil { h.fail(w, r, err) return } httpx.JSON(w, http.StatusOK, map[string]any{"did": p.DID, "threshold": p.Threshold, "syncedAt": p.SyncedAt, "csrf": b.CSRF})}
func (h *HTTP) logout(w http.ResponseWriter, r *http.Request) { c, _ := r.Cookie(h.cookieName(sessionCookie)) if err := h.service.Logout(r.Context(), c.Value, browser(r)); err != nil { h.fail(w, r, err) return } h.setCookie(w, sessionCookie, "", -1) httpx.JSON(w, http.StatusOK, map[string]bool{"loggedOut": true})}
func (h *HTTP) accounts(w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("q") if len(query) > atproto.MaxSearchQuery { h.fail(w, r, invalid("The account search is too long. Enter a handle or DID.", nil)) return } ctx, cancel := context.WithTimeout(r.Context(), accountSearchTimeout) defer cancel() actors, err := h.service.Search(ctx, query) if err != nil { h.fail(w, r, err) return } httpx.JSON(w, http.StatusOK, actors)}
func (h *HTTP) tags(w http.ResponseWriter, r *http.Request) { tags, err := h.service.Tags(r.Context(), r.URL.Query().Get("prefix")) if err != nil { h.fail(w, r, err) return } httpx.JSON(w, http.StatusOK, tags)}
func (h *HTTP) vouches(w http.ResponseWriter, r *http.Request) { offset := 0 if raw := r.URL.Query().Get("offset"); raw != "" { var err error offset, err = strconv.Atoi(raw) if err != nil || offset < 0 { h.fail(w, r, invalid("The page number is invalid. Return to the first page.", err)) return } } vouches, err := h.service.Vouches(r.Context(), browser(r), offset) if err != nil { h.fail(w, r, err) return } httpx.JSON(w, http.StatusOK, vouches)}
func (h *HTTP) put(w http.ResponseWriter, r *http.Request) { var body struct { Subject string `json:"subject"` Tag string `json:"tag"` Score *int `json:"score"` Comment string `json:"comment"` } if err := httpx.Decode(w, r, &body); err != nil || body.Score == nil { h.fail(w, r, invalid("Enter an account and choose a trust level.", err)) return } result, err := h.service.Put(r.Context(), browser(r), domain.Vouch{Subject: body.Subject, Tag: body.Tag, Score: float64(*body.Score), Comment: body.Comment}) if err != nil { h.fail(w, r, err) return } httpx.JSON(w, http.StatusOK, result)}
func (h *HTTP) delete(w http.ResponseWriter, r *http.Request) { var body struct { Subject string `json:"subject"` Tag string `json:"tag"` } if err := httpx.Decode(w, r, &body); err != nil { h.fail(w, r, invalid("Choose a vouch to remove.", err)) return } result, err := h.service.Delete(r.Context(), browser(r), body.Subject, body.Tag) if err != nil { h.fail(w, r, err) return } httpx.JSON(w, http.StatusOK, result)}
func (h *HTTP) threshold(w http.ResponseWriter, r *http.Request) { var body struct { Threshold *float64 `json:"threshold"` } if err := httpx.Decode(w, r, &body); err != nil || body.Threshold == nil { h.fail(w, r, invalid("Enter a minimum trust score between -2 and 2.", err)) return } if err := h.service.SetThreshold(r.Context(), browser(r), *body.Threshold); err != nil { h.fail(w, r, err) return } httpx.JSON(w, http.StatusOK, map[string]float64{"threshold": *body.Threshold})}
func (h *HTTP) reputation(w http.ResponseWriter, r *http.Request) { q, err := url.ParseQuery(r.URL.RawQuery) if err != nil { h.fail(w, r, invalid("The query encoding is invalid.", err)) return } mode := domain.Mode(q.Get("mode")) if mode == "" { mode = domain.Default } options := trust.Accounts{} for name, values := range q { switch name { case "target", "tag", "mode", "include_path": continue } options[name] = values } result, err := h.service.Evaluate(r.Context(), browser(r), q.Get("target"), q.Get("tag"), mode, q.Get("include_path"), options) if err != nil { h.fail(w, r, err) return } httpx.JSON(w, http.StatusOK, result)}
func (h *HTTP) fail(w http.ResponseWriter, r *http.Request, err error) { problem := describeError(err, r.URL.Path) reference := secure.Token() upstreamStatus := 0 var api *atclient.APIError if errors.As(err, &api) { upstreamStatus = api.StatusCode } // Correlate failures without logging tokens, query strings, or remote error bodies. slog.Warn("UI request failed", "component", "http", "request_id", reference, "method", r.Method, "path", r.URL.Path, "status", problem.status, "code", problem.code, "upstream_status", upstreamStatus, "error_type", fmt.Sprintf("%T", err)) w.Header().Set("X-Request-ID", reference) if strings.HasPrefix(r.URL.Path, "/oauth/") && !strings.Contains(r.Header.Get("Accept"), "application/json") { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(problem.status) if err := h.page.Execute(w, map[string]any{"Error": problem.message, "Reference": reference}); err != nil { slog.Error("error page failed", "request_id", reference) } return } httpx.JSON(w, problem.status, map[string]string{"error": problem.message, "code": problem.code, "request_id": reference})}
func (h *HTTP) aggregate(w http.ResponseWriter, r *http.Request) { q, err := url.ParseQuery(r.URL.RawQuery) if err != nil { h.fail(w, r, invalid("The query encoding is invalid.", err)) return }
for name, values := range q { if (name != "target" && name != "tag" && name != "mode") || len(values) != 1 { h.fail(w, r, invalid("Use only one target, topic, and mode.", nil)) return } }
mode := domain.Mode(q.Get("mode")) if mode == "" { mode = domain.Default } result, err := h.service.Aggregate(r.Context(), browser(r), q.Get("target"), q.Get("tag"), mode) if err != nil { h.fail(w, r, err) return } httpx.JSON(w, http.StatusOK, result)}