v0.0.15 3ff6d9e

callback.go RawHistoryFolder

1package auth
2
3import (
4	"context"
5	"fmt"
6	"net"
7	"net/http"
8	"strings"
9	"sync"
10	"time"
11
12	"github.com/patriceckhart/zot/internal/assets"
13)
14
15// CallbackResult is what an OAuth callback server returns once the
16// browser hits the redirect URI.
17type CallbackResult struct {
18	Code    string
19	State   string
20	Err     error
21	RawPath string
22}
23
24// CallbackServer is a single-shot OAuth callback listener bound to the
25// fixed port that the provider has whitelisted for its client.
26type CallbackServer struct {
27	l        net.Listener
28	srv      *http.Server
29	provider OAuthProvider
30	state    string
31	result   chan CallbackResult
32	once     sync.Once
33}
34
35// NewCallbackServer starts a listener on p.RedirectPort/p.RedirectPath.
36// Returns an error if that port is already in use (e.g. another login
37// flow already running, or the official CLI is running concurrently).
38func NewCallbackServer(p OAuthProvider, expectedState string) (*CallbackServer, error) {
39	addr := fmt.Sprintf("%s:%d", p.RedirectHost, p.RedirectPort)
40	l, err := net.Listen("tcp", addr)
41	if err != nil {
42		return nil, fmt.Errorf("bind %s: %w (is another zot/claude-code/codex login already running?)", addr, err)
43	}
44	cs := &CallbackServer{
45		l:        l,
46		provider: p,
47		state:    expectedState,
48		result:   make(chan CallbackResult, 1),
49	}
50	mux := http.NewServeMux()
51	mux.HandleFunc(p.RedirectPath, cs.handle)
52	mux.HandleFunc("/logo.png", serveLogo)
53	cs.srv = &http.Server{
54		Handler:      mux,
55		ReadTimeout:  15 * time.Second,
56		WriteTimeout: 15 * time.Second,
57	}
58	go func() { _ = cs.srv.Serve(l) }()
59	return cs, nil
60}
61
62// URL returns the full redirect URI this server is listening on.
63func (cs *CallbackServer) URL() string { return cs.provider.RedirectURI() }
64
65// Result blocks until the callback is received or ctx is cancelled.
66func (cs *CallbackServer) Result(ctx context.Context) (CallbackResult, error) {
67	select {
68	case r := <-cs.result:
69		return r, nil
70	case <-ctx.Done():
71		return CallbackResult{}, ctx.Err()
72	}
73}
74
75// Shutdown stops the server. Safe to call more than once.
76func (cs *CallbackServer) Shutdown() {
77	cs.once.Do(func() {
78		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
79		_ = cs.srv.Shutdown(ctx)
80		cancel()
81	})
82}
83
84func (cs *CallbackServer) handle(w http.ResponseWriter, r *http.Request) {
85	q := r.URL.Query()
86	w.Header().Set("content-type", "text/html; charset=utf-8")
87	if errParam := q.Get("error"); errParam != "" {
88		msg := errParam
89		if d := q.Get("error_description"); d != "" {
90			msg += ": " + d
91		}
92		w.WriteHeader(http.StatusBadRequest)
93		_, _ = w.Write([]byte(oauthErrorHTML(msg)))
94		cs.deliver(CallbackResult{Err: fmt.Errorf(msg), RawPath: r.URL.RequestURI()})
95		return
96	}
97	code := q.Get("code")
98	state := q.Get("state")
99	if code == "" {
100		w.WriteHeader(http.StatusBadRequest)
101		_, _ = w.Write([]byte(oauthErrorHTML("missing authorization code")))
102		cs.deliver(CallbackResult{Err: fmt.Errorf("missing code"), RawPath: r.URL.RequestURI()})
103		return
104	}
105	if cs.state != "" && state != cs.state {
106		w.WriteHeader(http.StatusBadRequest)
107		_, _ = w.Write([]byte(oauthErrorHTML("state mismatch")))
108		cs.deliver(CallbackResult{Err: fmt.Errorf("state mismatch"), RawPath: r.URL.RequestURI()})
109		return
110	}
111	w.WriteHeader(http.StatusOK)
112	_, _ = w.Write([]byte(oauthSuccessHTML(cs.provider.Name)))
113	cs.deliver(CallbackResult{Code: code, State: state, RawPath: r.URL.RequestURI()})
114}
115
116func (cs *CallbackServer) deliver(r CallbackResult) {
117	select {
118	case cs.result <- r:
119	default:
120	}
121}
122
123// ---- static HTML for the callback tab ----
124
125// All zot-served pages share a single style: white background, black
126// text, the cyan pixel-art `z` logo at the top, thin black rules,
127// monospace type.
128const monoStyle = `<style>
129  :root { color-scheme: light; }
130  * { box-sizing: border-box; }
131  body {
132    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
133    background: #ffffff;
134    color: #000000;
135    max-width: 44rem;
136    margin: 0 auto;
137    padding: 3rem 1.5rem;
138    line-height: 1.55;
139  }
140  .logo {
141    display: block;
142    width: 120px;
143    height: auto;
144    image-rendering: pixelated;
145    image-rendering: crisp-edges;
146    margin: 0 0 1.5rem;
147  }
148  h1 {
149    font-size: 1rem;
150    font-weight: 600;
151    margin: 0 0 0.25rem;
152    letter-spacing: 0.01em;
153  }
154  h1 .mark { display: inline-block; width: 1.25rem; }
155  .rule { border: 0; border-top: 1px solid #000000; margin: 1.5rem 0; }
156  .muted { color: #595959; }
157  .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; word-break: break-all; }
158  .msg { padding: 0.75rem 0; }
159  a { color: inherit; }
160  input[type=password], input[type=text] {
161    width: 100%; padding: 0.5rem 0.6rem;
162    border: 1px solid #000000; background: #ffffff; color: #000000;
163    font-family: inherit; font-size: 0.95rem;
164  }
165  button {
166    padding: 0.5rem 1.25rem;
167    background: #000000; color: #ffffff;
168    border: 1px solid #000000; font-family: inherit; font-size: 0.95rem;
169    cursor: pointer;
170  }
171  button:hover { background: #ffffff; color: #000000; }
172</style>`
173
174// logoTag is the <img> element used at the top of every zot-served
175// page. The image bytes are served from /logo.png by the same server.
176const logoTag = `<img class="logo" src="/logo.png" alt="zot" />`
177
178// serveLogo writes the embedded PNG with appropriate caching headers.
179// Shared between the oauth callback server and the api-key login server.
180func serveLogo(w http.ResponseWriter, r *http.Request) {
181	w.Header().Set("content-type", "image/png")
182	w.Header().Set("cache-control", "public, max-age=86400")
183	_, _ = w.Write(assets.LogoPNG)
184}
185
186func oauthSuccessHTML(provider string) string {
187	p := strings.ToLower(provider)
188	return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - logged in</title>` + monoStyle + `</head><body>
189` + logoTag + `
190<h1><span class="mark">✓</span> logged in to ` + p + `</h1>
191<hr class="rule">
192<p class="msg">zot received the callback. you can close this tab.</p>
193</body></html>`
194}
195
196func oauthErrorHTML(msg string) string {
197	return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot · error</title>` + monoStyle + `</head><body>
198` + logoTag + `
199<h1><span class="mark">✗</span> login failed</h1>
200<hr class="rule">
201<p class="msg mono">` + htmlEscape(msg) + `</p>
202<p class="muted">go back to zot and try again.</p>
203</body></html>`
204}
205
206func htmlEscape(s string) string {
207	r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;")
208	return r.Replace(s)
209}

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 1

Tags 476

↑↓ moveenter openesc close