v0.1.13 d53fbf4

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 dark style matching the TUI:
126// near-black background (#0a0a0a), white text, Geist Mono type, cyan
127// accent on the word "zot". Serves every step of both the api-key
128// flow and the oauth callback flow.
129const monoStyle = `<style>
130  @import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;600&display=swap');
131  :root { color-scheme: dark; }
132  * { box-sizing: border-box; }
133  body {
134    font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
135    background: #0a0a0a;
136    color: #ffffff;
137    max-width: 44rem;
138    margin: 0 auto;
139    padding: 3rem 1.5rem;
140    line-height: 1.55;
141  }
142  .logo {
143    display: block;
144    width: 120px;
145    height: auto;
146    image-rendering: pixelated;
147    image-rendering: crisp-edges;
148    margin: 0 0 1.5rem;
149  }
150  h1 {
151    font-size: 1rem;
152    font-weight: 600;
153    margin: 0 0 0.25rem;
154    letter-spacing: 0.01em;
155  }
156  h1 .mark { display: inline-block; width: 1.25rem; }
157  .zot { color: #7ed3fc; }
158  .rule { border: 0; border-top: 1px solid #ffffff; margin: 1.5rem 0; }
159  .muted { color: #9ca3af; }
160  .mono { font-family: inherit; word-break: break-all; }
161  .msg { padding: 0.75rem 0; }
162  a { color: #7ed3fc; }
163  input[type=password], input[type=text] {
164    width: 100%; padding: 0.5rem 0.6rem;
165    background: #0a0a0a; color: #ffffff;
166    border: 1px solid #ffffff;
167    font-family: inherit; font-size: 0.95rem;
168  }
169  input[type=password]:focus, input[type=text]:focus {
170    outline: none; border-color: #7ed3fc;
171  }
172  button {
173    padding: 0.5rem 1.25rem;
174    background: #ffffff; color: #0a0a0a;
175    border: 1px solid #ffffff; font-family: inherit; font-size: 0.95rem;
176    cursor: pointer;
177  }
178  button:hover { background: #0a0a0a; color: #ffffff; }
179</style>`
180
181// logoTag is the <img> element used at the top of every zot-served
182// page. The image bytes are served from /logo.png by the same server.
183const logoTag = `<img class="logo" src="/logo.png" alt="zot" />`
184
185// serveLogo writes the embedded PNG with appropriate caching headers.
186// Shared between the oauth callback server and the api-key login server.
187func serveLogo(w http.ResponseWriter, r *http.Request) {
188	w.Header().Set("content-type", "image/png")
189	w.Header().Set("cache-control", "public, max-age=86400")
190	_, _ = w.Write(assets.LogoPNG)
191}
192
193func oauthSuccessHTML(provider string) string {
194	p := strings.ToLower(provider)
195	return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - logged in</title>` + monoStyle + `</head><body>
196` + logoTag + `
197<h1><span class="mark">✓</span> logged in to ` + p + `</h1>
198<hr class="rule">
199<p class="msg"><span class="zot">zot</span> received the callback. you can close this tab.</p>
200</body></html>`
201}
202
203func oauthErrorHTML(msg string) string {
204	return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - error</title>` + monoStyle + `</head><body>
205` + logoTag + `
206<h1><span class="mark">✗</span> login failed</h1>
207<hr class="rule">
208<p class="msg mono">` + htmlEscape(msg) + `</p>
209<p class="muted">go back to <span class="zot">zot</span> and try again.</p>
210</body></html>`
211}
212
213func htmlEscape(s string) string {
214	r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;")
215	return r.Replace(s)
216}

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