Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 })