detached 388d3be

web.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"context"
7	"errors"
8	"net/http"
9	"net/netip"
10	"net/url"
11	"strings"
12	"time"
13
14	"github.com/bnema/zerowrap"
15
16	"bnema.dev/ermite/internal/adapters/httpserver"
17	"bnema.dev/ermite/internal/adapters/web"
18	"bnema.dev/ermite/internal/clientlimit"
19	"bnema.dev/ermite/internal/config"
20	"bnema.dev/ermite/internal/domain"
21	"bnema.dev/ermite/internal/ports"
22	"bnema.dev/ermite/internal/usecase"
23)
24
25// newWeb builds the web pages on the use cases of this process, through the
26// ports (ADR 0020), like the API and Git over HTTP.
27//
28// The owner's session cookie turns a request into the owner's (web.Server
29// checks it with sessions); every other request is anonymous. The web passes
30// the principal of the request to each use case call itself.
31func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Handler, error) {
32	// A nil *usecase.Passkeys must not become a non-nil interface (the web
33	// tests for nil to leave the passkey routes out).
34	var passkeys ports.WebPasskeyService
35	if f.Passkeys != nil {
36		passkeys = webPasskeys{f.Passkeys}
37	}
38	// Without artifacts (ERMITE_CI_ARTIFACTS=0) the run page has no section
39	// for them and no download route.
40	var artifacts ports.ArtifactService
41	if cfg.CIArtifacts {
42		artifacts = f.CI
43	}
44	return web.New(web.Config{
45		Repos:         f.Repos,
46		Browse:        f.Browse,
47		MergeRequests: f.MergeRequests,
48		Issues:        f.Issues,
49		IssueNumbers:  f.Issues,
50		Inbox:         f.Inbox,
51		IssueForm:     f.IssueSubmissions,
52		Moderation:    f.IssueModeration,
53		Runs:          f.CI,
54		Artifacts:     artifacts,
55		Events:        f.EventLog,
56		Owner:         cfg.Owner,
57		PublicURL:     cfg.PublicURL,
58		SourceURL:     cfg.SourceURL,
59		Version:       serverVersion(),
60
61		Sessions:       webSessions{f.Sessions},
62		Passkeys:       passkeys,
63		Limiter:        limiter,
64		TrustedProxies: cfg.TrustedProxies,
65	})
66}
67
68// sessionsNeedHTTPS reports whether browser sign-in cannot work on this public
69// URL: the session cookie is Secure (and __Host-), which a browser keeps only
70// from https, except on a loopback host (localhost, 127.0.0.0/8, ::1), where
71// browsers treat http as secure.
72func sessionsNeedHTTPS(publicURL string) bool {
73	u, err := url.Parse(publicURL)
74	if err != nil || !strings.EqualFold(u.Scheme, "http") {
75		return false
76	}
77	host := strings.ToLower(u.Hostname())
78	if host == "localhost" {
79		return false
80	}
81	ip, err := netip.ParseAddr(host)
82	return err != nil || !ip.Unmap().IsLoopback()
83}
84
85// passkeysAvailable reports whether passkeys can work on this public URL, and
86// if not, why. WebAuthn needs a secure context (https, or localhost) and a
87// relying party ID that is a DNS name, not an IP address, so a server on
88// http://127.0.0.1 starts without passkeys. A sign-in needs the session
89// cookie, hence the same https rule as sessionsNeedHTTPS.
90func passkeysAvailable(publicURL string) (ok bool, reason string) {
91	if sessionsNeedHTTPS(publicURL) {
92		return false, "needs https"
93	}
94	u, err := url.Parse(publicURL)
95	if err != nil || u.Hostname() == "" {
96		return false, "public URL has no host"
97	}
98	if _, err := netip.ParseAddr(strings.ToLower(u.Hostname())); err == nil {
99		return false, "public URL host is an IP address"
100	}
101	return true, ""
102}
103
104// warnSessionsNeedHTTPS tells the operator, at startup, that web sign-in will
105// not work on an http public URL. It does not stop the server: the pages,
106// the API and Git do not need the cookie.
107func warnSessionsNeedHTTPS(ctx context.Context, publicURL string) {
108	if !sessionsNeedHTTPS(publicURL) {
109		return
110	}
111	log := zerowrap.FromCtx(ctx)
112	log.Warn().Str("public_url", publicURL).Msg(
113		"browser sign-in cannot work: " + config.EnvPublicURL + " is http:// on a host that is not loopback, " +
114			"and the session cookie is Secure (__Host-), so browsers drop it; serve the site over https")
115}
116
117// warnSharedClientKey tells the operator, at startup, that without trusted
118// proxies every request is attributed to the address of its TCP peer. Behind a
119// reverse proxy that is the proxy: all the clients then share one key, and so
120// one cap on the Git backends running at once (maxPerClient) and one budget of
121// failed authentications. The keying is unchanged; the fix is to list the
122// proxy networks.
123func warnSharedClientKey(ctx context.Context, trusted []netip.Prefix, maxPerClient int) {
124	if len(trusted) > 0 || maxPerClient <= 0 {
125		return
126	}
127	log := zerowrap.FromCtx(ctx)
128	log.Warn().Int("max_git_backends_per_client", maxPerClient).Msg(
129		config.EnvTrustedProxies + " is not set: clients are told apart by their TCP peer address only. " +
130			"Behind a reverse proxy, all clients share the proxy's address, so one cap of concurrent Git " +
131			"requests and one budget of failed authentications; set it to the proxy networks")
132}
133
134// webSessions adapts the session use cases to ports.WebSessionService: a valid
135// token gives the owner, the principal the web passes to the use cases for
136// that request.
137type webSessions struct{ *usecase.Sessions }
138
139var _ ports.WebSessionService = webSessions{}
140
141func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Duration, error) {
142	token, expiresAt, err := s.Sessions.Login(ctx, nonce)
143	if errors.Is(err, domain.ErrUnauthenticated) {
144		return "", 0, ports.ErrLinkRejected
145	}
146	if err != nil {
147		return "", 0, err
148	}
149	lifetime, ok := cookieLifetime(expiresAt)
150	if !ok {
151		return "", 0, ports.ErrLinkRejected
152	}
153	return token, lifetime, nil
154}
155
156// cookieLifetime is how long the session cookie lasts for a session that
157// expires at expiresAt: as long as the session, which the expiry of the token
158// that made a login link may cut short. The session token carries its own
159// expiry, so a cookie that outlives it by under a second is harmless. The
160// expiry is truncated to the second, hence the slack on the full TTL. ok is
161// false when under a second is left: there is no session to give.
162func cookieLifetime(expiresAt time.Time) (lifetime time.Duration, ok bool) {
163	lifetime = usecase.SessionTTL
164	if d := time.Until(expiresAt); d < usecase.SessionTTL-time.Second {
165		lifetime = d.Round(time.Second)
166	}
167	return lifetime, lifetime >= time.Second
168}
169
170func (s webSessions) Verify(ctx context.Context, token string) (domain.Principal, bool, error) {
171	p, err := s.Sessions.Verify(ctx, token)
172	if errors.Is(err, domain.ErrUnauthenticated) {
173		return domain.Anonymous(), false, nil
174	}
175	if err != nil {
176		return domain.Anonymous(), false, err
177	}
178	return p, true, nil
179}
180
181// webPasskeys adapts the passkey use cases to ports.WebPasskeyService. Only
182// FinishLogin differs from *usecase.Passkeys: the session a passkey sign-in
183// opens is given to the web as a token and a lifetime, like webSessions.Login
184// does for a link, and a refusal is ports.ErrLinkRejected.
185type webPasskeys struct{ *usecase.Passkeys }
186
187var _ ports.WebPasskeyService = webPasskeys{}
188
189func (p webPasskeys) FinishLogin(ctx context.Context, ceremony string, response []byte) (string, time.Duration, error) {
190	token, expiresAt, err := p.Passkeys.FinishLogin(ctx, ceremony, response)
191	if errors.Is(err, domain.ErrUnauthenticated) {
192		return "", 0, ports.ErrLinkRejected
193	}
194	if err != nil {
195		return "", 0, err
196	}
197	lifetime, ok := cookieLifetime(expiresAt)
198	if !ok {
199		return "", 0, ports.ErrLinkRejected
200	}
201	return token, lifetime, nil
202}
203
204// webRoutes mounts the web on the patterns it serves.
205func webRoutes(h http.Handler) []httpserver.Route {
206	routes := make([]httpserver.Route, 0, len(web.Patterns))
207	for _, p := range web.Patterns {
208		routes = append(routes, httpserver.Route{Pattern: p, Handler: h})
209	}
210	return routes
211}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close