vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Build out the forge web UI
f09ef39efe44de693748053a7b596ef328fcf06b · Keenan Nemetz · 15d ago · parent 70b70264
Build out the forge web UI Turn the web surface into a compact, server-rendered forge with HTMX as the progressive dynamic layer. - Split templates and assets into embedded files; add a tokenised dark theme, Lucide icons, and vendored HTMX. - Add the repository Overview page and a separate chronological Activity feed with type filters and HTMX fragment updates. - Finish the issue and change workflows: create pages, comments, close/reopen, labels and assignees, approval, discussion, line-anchored review comments, and HTMX list filters. - Add syntax-highlighted blobs, contributor/statistics derivation, branch-aware code browsing, and a repository Runs list. - Add a loopback-only development login for local write testing, with explicit configuration and listener checks. - Extend tests for the new pages, fragments, labels, assignees, anchors, and development login.
server/web/auth.go +152−10
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "encoding/json" | |
| 15 | 15 | "errors" | |
| 16 | 16 | "fmt" | |
| 17 | "net" | ||
| 17 | 18 | "net/http" | |
| 18 | 19 | "net/url" | |
| 19 | 20 | "strings" | |
| @@ -33,6 +34,12 @@ const ( | |||
| 33 | 34 | sessionCookieName = "__Host-vole_session" | |
| 34 | 35 | ceremonyCookieName = "__Host-vole_ceremony" | |
| 35 | 36 | ceremonyTTL = 5 * time.Minute | |
| 37 | |||
| 38 | // Development login uses its own cookie because __Host- requires Secure, | ||
| 39 | // and a loopback HTTP preview cannot set a Secure cookie. The name is | ||
| 40 | // deliberately not a __Host- name: the browser must be willing to store it. | ||
| 41 | devSessionCookieName = "vole_dev_session" | ||
| 42 | devOffCookieName = "vole_dev_login_off" | ||
| 36 | 43 | ) | |
| 37 | 44 | ||
| 38 | 45 | type Auth struct { | |
| @@ -95,16 +102,24 @@ func (w *Web) Middleware(next http.Handler) http.Handler { | |||
| 95 | 102 | return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { | |
| 96 | 103 | w.securityHeaders(rw) | |
| 97 | 104 | if w.Sessions != nil { | |
| 98 | if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" { | ||
| 99 | sess, serr := w.Sessions.Lookup(c.Value) | ||
| 100 | if serr == nil { | ||
| 101 | if acct, aerr := w.Ident.Find(sess.AccountID); aerr == nil && acct.Active() { | ||
| 102 | r = r.WithContext(context.WithValue(r.Context(), principalKey{}, &principal{account: acct, session: sess})) | ||
| 103 | } else { | ||
| 104 | w.clearCookie(rw, sessionCookieName) | ||
| 105 | } | ||
| 105 | sess, cookieName := w.sessionFromCookie(r) | ||
| 106 | if sess != nil { | ||
| 107 | if acct, err := w.Ident.Find(sess.AccountID); err == nil && acct.Active() { | ||
| 108 | r = w.withPrincipal(r, acct, sess) | ||
| 106 | 109 | } else { | |
| 107 | w.clearCookie(rw, sessionCookieName) | ||
| 110 | w.clearCookieFor(rw, cookieName, cookieName != devSessionCookieName) | ||
| 111 | } | ||
| 112 | } else if cookieName != "" { | ||
| 113 | w.clearCookieFor(rw, cookieName, cookieName != devSessionCookieName) | ||
| 114 | } | ||
| 115 | // Development login: only when configured, only from loopback, and | ||
| 116 | // only after an explicit logout has not opted out for this browser. | ||
| 117 | if w.principal(r) == nil && w.devLoginRequest(r) { | ||
| 118 | if sess := w.ensureDevSession(); sess != nil { | ||
| 119 | if acct, err := w.Ident.Find(sess.AccountID); err == nil && acct.Active() { | ||
| 120 | w.setDevSessionCookie(rw, sess) | ||
| 121 | r = w.withPrincipal(r, acct, sess) | ||
| 122 | } | ||
| 108 | 123 | } | |
| 109 | 124 | } | |
| 110 | 125 | } | |
| @@ -112,6 +127,112 @@ func (w *Web) Middleware(next http.Handler) http.Handler { | |||
| 112 | 127 | }) | |
| 113 | 128 | } | |
| 114 | 129 | ||
| 130 | // sessionFromCookie returns the first valid session cookie and the name it came | ||
| 131 | // from. The dev cookie is only considered when dev login is enabled, so a stray | ||
| 132 | // cookie on a normal instance is never a credential. | ||
| 133 | func (w *Web) sessionFromCookie(r *http.Request) (*session.Session, string) { | ||
| 134 | names := []string{sessionCookieName} | ||
| 135 | if w.devLoginRequest(r) { | ||
| 136 | names = append(names, devSessionCookieName) | ||
| 137 | } | ||
| 138 | for _, name := range names { | ||
| 139 | c, err := r.Cookie(name) | ||
| 140 | if err != nil || c.Value == "" { | ||
| 141 | continue | ||
| 142 | } | ||
| 143 | sess, err := w.Sessions.Lookup(c.Value) | ||
| 144 | if err == nil { | ||
| 145 | return sess, name | ||
| 146 | } | ||
| 147 | return nil, name | ||
| 148 | } | ||
| 149 | return nil, "" | ||
| 150 | } | ||
| 151 | |||
| 152 | func (w *Web) withPrincipal(r *http.Request, acct *identity.Account, sess *session.Session) *http.Request { | ||
| 153 | return r.WithContext(context.WithValue(r.Context(), principalKey{}, &principal{account: acct, session: sess})) | ||
| 154 | } | ||
| 155 | |||
| 156 | // enableDevLogin resolves the configured account once. It refuses to start when | ||
| 157 | // the listener is not loopback: a typo must not turn a shared instance into one | ||
| 158 | // that signs everybody in as the same person. | ||
| 159 | func (w *Web) enableDevLogin(cfg *config.Config) error { | ||
| 160 | name := strings.TrimSpace(cfg.DevLogin) | ||
| 161 | if name == "" { | ||
| 162 | return nil | ||
| 163 | } | ||
| 164 | if !loopbackListen(cfg.HTTPAddr) { | ||
| 165 | return fmt.Errorf("dev_login requires http_addr on a loopback address, got %q", cfg.HTTPAddr) | ||
| 166 | } | ||
| 167 | acct, err := w.Ident.Find(name) | ||
| 168 | if err != nil { | ||
| 169 | return fmt.Errorf("dev_login %q: %w", name, err) | ||
| 170 | } | ||
| 171 | if !acct.Active() { | ||
| 172 | return fmt.Errorf("dev_login %q: account is not active", name) | ||
| 173 | } | ||
| 174 | w.devAccount = acct | ||
| 175 | return nil | ||
| 176 | } | ||
| 177 | |||
| 178 | func (w *Web) devLoginRequest(r *http.Request) bool { | ||
| 179 | if w.devAccount == nil || !isLoopbackRequest(r) { | ||
| 180 | return false | ||
| 181 | } | ||
| 182 | if c, err := r.Cookie(devOffCookieName); err == nil && c.Value != "" { | ||
| 183 | return false | ||
| 184 | } | ||
| 185 | return true | ||
| 186 | } | ||
| 187 | |||
| 188 | func (w *Web) ensureDevSession() *session.Session { | ||
| 189 | w.devMu.Lock() | ||
| 190 | defer w.devMu.Unlock() | ||
| 191 | if w.devSess != nil { | ||
| 192 | if sess, err := w.Sessions.Lookup(w.devSess.Token); err == nil { | ||
| 193 | return sess | ||
| 194 | } | ||
| 195 | } | ||
| 196 | sess, err := w.Sessions.Create(w.devAccount.ID, nil, "dev-login") | ||
| 197 | if err != nil { | ||
| 198 | w.Logger("dev login: create session: %v", err) | ||
| 199 | return nil | ||
| 200 | } | ||
| 201 | w.devSess = sess | ||
| 202 | return sess | ||
| 203 | } | ||
| 204 | |||
| 205 | func loopbackListen(addr string) bool { | ||
| 206 | host, _, err := net.SplitHostPort(addr) | ||
| 207 | return err == nil && loopbackHost(host) | ||
| 208 | } | ||
| 209 | |||
| 210 | func loopbackHost(host string) bool { | ||
| 211 | if strings.EqualFold(host, "localhost") { | ||
| 212 | return true | ||
| 213 | } | ||
| 214 | ip := net.ParseIP(host) | ||
| 215 | return ip != nil && ip.IsLoopback() | ||
| 216 | } | ||
| 217 | |||
| 218 | // isLoopbackRequest checks both ends. A loopback listener behind a reverse proxy | ||
| 219 | // sees loopback as RemoteAddr, so the Host check is what stops a public request | ||
| 220 | // from inheriting a development session. | ||
| 221 | func isLoopbackRequest(r *http.Request) bool { | ||
| 222 | remote, _, err := net.SplitHostPort(r.RemoteAddr) | ||
| 223 | if err != nil || !loopbackHost(remote) { | ||
| 224 | return false | ||
| 225 | } | ||
| 226 | if r.Host == "" { | ||
| 227 | return true | ||
| 228 | } | ||
| 229 | host := r.Host | ||
| 230 | if h, _, err := net.SplitHostPort(host); err == nil { | ||
| 231 | host = h | ||
| 232 | } | ||
| 233 | return loopbackHost(host) | ||
| 234 | } | ||
| 235 | |||
| 115 | 236 | func (w *Web) securityHeaders(rw http.ResponseWriter) { | |
| 116 | 237 | rw.Header().Set("X-Content-Type-Options", "nosniff") | |
| 117 | 238 | rw.Header().Set("X-Frame-Options", "DENY") | |
| @@ -156,6 +277,9 @@ func (w *Web) page(r *http.Request, title string) Page { | |||
| 156 | 277 | p.AccountID = pr.account.ID | |
| 157 | 278 | p.CSRF = pr.session.CSRF | |
| 158 | 279 | } | |
| 280 | if w.devAccount != nil { | ||
| 281 | p.DevLogin = w.devAccount.Name | ||
| 282 | } | ||
| 159 | 283 | return p | |
| 160 | 284 | } | |
| 161 | 285 | ||
| @@ -172,13 +296,31 @@ func (w *Web) setSessionCookie(rw http.ResponseWriter, sess *session.Session) { | |||
| 172 | 296 | }) | |
| 173 | 297 | } | |
| 174 | 298 | ||
| 299 | func (w *Web) setDevSessionCookie(rw http.ResponseWriter, sess *session.Session) { | ||
| 300 | http.SetCookie(rw, &http.Cookie{ | ||
| 301 | Name: devSessionCookieName, | ||
| 302 | Value: sess.Token, | ||
| 303 | Path: "/", | ||
| 304 | HttpOnly: true, | ||
| 305 | // Secure is false on purpose: dev login exists for a loopback HTTP | ||
| 306 | // preview. It is never set unless dev_login is configured. | ||
| 307 | SameSite: http.SameSiteLaxMode, | ||
| 308 | Expires: sess.Expires, | ||
| 309 | MaxAge: int(time.Until(sess.Expires).Seconds()), | ||
| 310 | }) | ||
| 311 | } | ||
| 312 | |||
| 175 | 313 | func (w *Web) clearCookie(rw http.ResponseWriter, name string) { | |
| 314 | w.clearCookieFor(rw, name, true) | ||
| 315 | } | ||
| 316 | |||
| 317 | func (w *Web) clearCookieFor(rw http.ResponseWriter, name string, secure bool) { | ||
| 176 | 318 | http.SetCookie(rw, &http.Cookie{ | |
| 177 | 319 | Name: name, | |
| 178 | 320 | Value: "", | |
| 179 | 321 | Path: "/", | |
| 180 | 322 | HttpOnly: true, | |
| 181 | Secure: true, | ||
| 323 | Secure: secure, | ||
| 182 | 324 | SameSite: http.SameSiteLaxMode, | |
| 183 | 325 | MaxAge: -1, | |
| 184 | 326 | }) | |