1package daemon23import (4 "context"5 "crypto/rand"6 "crypto/sha256"7 "database/sql"8 "encoding/hex"9 "errors"10 "net/http"11 "net/url"12 "strings"13 "time"14)1516const sessionCookie = "tiny_session"1718// requestURL binds signatures to the original external request, including a19// tenant path stripped by the router. Forwarded headers are never trusted.20func (t *Tenant) requestURL(r *http.Request) string {21 scheme := "http"22 if r.TLS != nil {23 scheme = "https"24 } else if base, err := url.Parse(t.app.cfg.PublicURL); err == nil && base.Scheme == "https" {25 scheme = "https"26 }27 requestURI := r.RequestURI28 if requestURI == "" {29 requestURI = r.URL.RequestURI()30 }31 if strings.HasPrefix(requestURI, "http://") || strings.HasPrefix(requestURI, "https://") {32 if u, err := url.Parse(requestURI); err == nil {33 requestURI = u.RequestURI()34 }35 }36 return scheme + "://" + r.Host + requestURI37}3839func (t *Tenant) initSessions(ctx context.Context) error {40 _, err := t.store.DB().ExecContext(ctx, `CREATE TABLE IF NOT EXISTS browser_sessions(token_hash TEXT PRIMARY KEY,pubkey TEXT NOT NULL,expires_at INTEGER NOT NULL)`)41 return err42}4344func sessionHash(token string) string {45 sum := sha256.Sum256([]byte(token))46 return hex.EncodeToString(sum[:])47}4849func (t *Tenant) cookieActor(r *http.Request) (string, error) {50 cookie, err := r.Cookie(sessionCookie)51 if errors.Is(err, http.ErrNoCookie) {52 return "", nil53 }54 if err != nil {55 return "", err56 }57 var actor string58 err = t.store.DB().QueryRowContext(r.Context(), "SELECT pubkey FROM browser_sessions WHERE token_hash=? AND expires_at>?", sessionHash(cookie.Value), time.Now().Unix()).Scan(&actor)59 if errors.Is(err, sql.ErrNoRows) {60 return "", nil61 }62 if err != nil {63 return "", err64 }65 banned, err := t.community.IsBanned(r.Context(), actor)66 if banned {67 return "", errors.New("blocked: this pubkey is banned")68 }69 return actor, err70}7172func (t *Tenant) sessionHTTP(w http.ResponseWriter, r *http.Request) {73 if r.Method != http.MethodPost {74 w.Header().Set("Allow", "POST")75 w.WriteHeader(http.StatusMethodNotAllowed)76 return77 }78 base, err := url.Parse(t.requestURL(r))79 if err != nil {80 http.Error(w, "invalid relay URL", 500)81 return82 }83 // Cookie-only requests may change session state only from this origin.84 if origin := r.Header.Get("Origin"); origin != "" && origin != base.Scheme+"://"+base.Host {85 http.Error(w, "foreign origin", http.StatusForbidden)86 return87 }88 cookiePath := strings.TrimSuffix(base.Path, r.URL.Path) + "/"89 cookie := &http.Cookie{Name: sessionCookie, Path: cookiePath, HttpOnly: true, Secure: base.Scheme == "https", SameSite: http.SameSiteStrictMode}90 if r.URL.Path == "/session/logout" {91 if r.Header.Get("Origin") == "" && r.Header.Get("Authorization") == "" {92 http.Error(w, "origin or signature required", http.StatusForbidden)93 return94 }95 if r.Header.Get("Origin") == "" {96 if _, err := t.resolveUIActor(r); err != nil {97 http.Error(w, "invalid logout signature", http.StatusUnauthorized)98 return99 }100 }101 if prior, err := r.Cookie(sessionCookie); err == nil {102 if _, err := t.store.DB().ExecContext(r.Context(), "DELETE FROM browser_sessions WHERE token_hash=?", sessionHash(prior.Value)); err != nil {103 http.Error(w, "logout failed", 500)104 return105 }106 }107 cookie.MaxAge = -1108 http.SetCookie(w, cookie)109 writeJSON(w, 200, map[string]any{"ok": true})110 return111 }112 body, err := t.requestBody(w, r)113 if err != nil {114 http.Error(w, err.Error(), 400)115 return116 }117 session, err := t.session(r, body)118 if err != nil {119 http.Error(w, err.Error(), 401)120 return121 }122 actor := first(session.PubKeys)123 banned, err := t.community.IsBanned(r.Context(), actor)124 if err != nil {125 http.Error(w, "login failed", 500)126 return127 }128 if banned {129 http.Error(w, "blocked: this pubkey is banned", 403)130 return131 }132 var token [32]byte133 if _, err := rand.Read(token[:]); err != nil {134 http.Error(w, "login failed", 500)135 return136 }137 cookie.Value = hex.EncodeToString(token[:])138 cookie.MaxAge = int((24 * time.Hour) / time.Second)139 cookie.Expires = time.Now().Add(24 * time.Hour)140 if _, err := t.store.DB().ExecContext(r.Context(), "INSERT INTO browser_sessions(token_hash,pubkey,expires_at) VALUES(?,?,?)", sessionHash(cookie.Value), actor, cookie.Expires.Unix()); err != nil {141 http.Error(w, "login failed", 500)142 return143 }144 http.SetCookie(w, cookie)145 if actor == t.Policy().Owner {146 if err := t.records.Heartbeat(r.Context(), actor, time.Now().Unix()); err != nil {147 t.app.telemetry.Logger().Error("owner heartbeat", "error", err)148 }149 }150 writeJSON(w, 200, map[string]any{"pubkey": actor})151}