v0.1.82 25e7da2

server.go RawHistoryFolder

1package auth
2
3import (
4	"context"
5	"fmt"
6	"html/template"
7	"net"
8	"net/http"
9	"net/url"
10	"strings"
11	"sync"
12	"time"
13)
14
15// LoginResult is delivered on the channel returned by Server.Result().
16type LoginResult struct {
17	Provider string
18	Method   string // "apikey" | "oauth"
19	APIKey   string // populated when Method == "apikey"
20	Code     string // populated when Method == "oauth"
21	State    string // OAuth state (caller should verify)
22	Err      error
23}
24
25// Server is a tiny local HTTP server used by the login flows. It binds
26// to 127.0.0.1 on a random free port and serves:
27//
28//	GET  /                      landing page (menu)
29//	GET  /apikey?provider=...   API key form
30//	POST /apikey                form submit -> probes -> stores via caller
31//	GET  /callback              OAuth callback (query: code, state)
32//	GET  /success               generic success page
33//	GET  /error                 generic error page
34//
35// The caller receives login events on Result(). The server stays up
36// until Shutdown() is called.
37type Server struct {
38	l        net.Listener
39	srv      *http.Server
40	baseURL  string
41	results  chan LoginResult
42	probeFn  func(ctx context.Context, provider, key string) error
43	mu       sync.Mutex
44	shutdown bool
45}
46
47// NewServer starts a new login server on a random free port bound to loopback.
48func NewServer() (*Server, error) {
49	l, err := net.Listen("tcp", "127.0.0.1:0")
50	if err != nil {
51		return nil, err
52	}
53	s := &Server{
54		l:       l,
55		baseURL: "http://" + l.Addr().String(),
56		results: make(chan LoginResult, 4),
57		probeFn: ProbeAPIKey,
58	}
59	mux := http.NewServeMux()
60	mux.HandleFunc("/", s.handleIndex)
61	mux.HandleFunc("/apikey", s.handleAPIKey)
62	mux.HandleFunc("/callback", s.handleCallback)
63	mux.HandleFunc("/success", s.handleSuccess)
64	mux.HandleFunc("/error", s.handleError)
65	mux.HandleFunc("/logo.png", serveLogo)
66	s.srv = &http.Server{
67		Handler:      mux,
68		ReadTimeout:  15 * time.Second,
69		WriteTimeout: 15 * time.Second,
70	}
71	go func() { _ = s.srv.Serve(l) }()
72	return s, nil
73}
74
75// URL returns the base URL the server is listening on.
76func (s *Server) URL() string { return s.baseURL }
77
78// Port returns the TCP port the server is bound to.
79func (s *Server) Port() int {
80	return s.l.Addr().(*net.TCPAddr).Port
81}
82
83// Result returns the channel receiving LoginResult events.
84func (s *Server) Result() <-chan LoginResult { return s.results }
85
86// Shutdown stops the server. It is safe to call multiple times.
87func (s *Server) Shutdown(ctx context.Context) error {
88	s.mu.Lock()
89	if s.shutdown {
90		s.mu.Unlock()
91		return nil
92	}
93	s.shutdown = true
94	s.mu.Unlock()
95	return s.srv.Shutdown(ctx)
96}
97
98// isKnownAPIKeyProvider reports whether the given provider supports
99// API-key login through the loopback flow. Kept centralized so adding a
100// provider only touches one place. OAuth-only paths are handled
101// elsewhere (manager.StartOAuth).
102func isKnownAPIKeyProvider(p string) bool {
103	switch p {
104	case "anthropic", "openai", "kimi", "google", "deepseek":
105		return true
106	}
107	return false
108}
109
110// ---- handlers ----
111
112func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
113	if r.URL.Path != "/" {
114		http.NotFound(w, r)
115		return
116	}
117	tpl.ExecuteTemplate(w, "index", map[string]any{
118		"Port": s.Port(),
119	})
120}
121
122func (s *Server) handleAPIKey(w http.ResponseWriter, r *http.Request) {
123	switch r.Method {
124	case http.MethodGet:
125		provider := r.URL.Query().Get("provider")
126		if !isKnownAPIKeyProvider(provider) {
127			http.Error(w, "provider must be anthropic, openai, kimi, deepseek, or google", http.StatusBadRequest)
128			return
129		}
130		tpl.ExecuteTemplate(w, "apikey", map[string]any{"Provider": provider})
131	case http.MethodPost:
132		if err := r.ParseForm(); err != nil {
133			http.Error(w, err.Error(), http.StatusBadRequest)
134			return
135		}
136		provider := strings.TrimSpace(r.FormValue("provider"))
137		key := strings.TrimSpace(r.FormValue("api_key"))
138		if provider == "" || key == "" {
139			s.errorPage(w, "missing provider or api key")
140			return
141		}
142		ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
143		defer cancel()
144		if err := s.probeFn(ctx, provider, key); err != nil {
145			s.errorPage(w, err.Error())
146			s.results <- LoginResult{Provider: provider, Method: "apikey", Err: err}
147			return
148		}
149		s.successPage(w, provider, "api key")
150		s.results <- LoginResult{Provider: provider, Method: "apikey", APIKey: key}
151	default:
152		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
153	}
154}
155
156func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
157	q := r.URL.Query()
158	provider := q.Get("provider")
159	if provider == "" {
160		// Some OAuth providers don't echo back custom params; try state-encoded form.
161		provider = decodeStateProvider(q.Get("state"))
162	}
163	if errParam := q.Get("error"); errParam != "" {
164		msg := errParam
165		if d := q.Get("error_description"); d != "" {
166			msg += ": " + d
167		}
168		s.errorPage(w, msg)
169		s.results <- LoginResult{Provider: provider, Method: "oauth", Err: fmt.Errorf(msg)}
170		return
171	}
172	code := q.Get("code")
173	state := q.Get("state")
174	if code == "" {
175		s.errorPage(w, "missing authorization code")
176		s.results <- LoginResult{Provider: provider, Method: "oauth", Err: fmt.Errorf("missing code")}
177		return
178	}
179	s.successPage(w, provider, "subscription")
180	s.results <- LoginResult{Provider: provider, Method: "oauth", Code: code, State: state}
181}
182
183func (s *Server) handleSuccess(w http.ResponseWriter, r *http.Request) {
184	q := r.URL.Query()
185	s.successPage(w, q.Get("provider"), q.Get("method"))
186}
187
188func (s *Server) handleError(w http.ResponseWriter, r *http.Request) {
189	s.errorPage(w, r.URL.Query().Get("message"))
190}
191
192func (s *Server) successPage(w http.ResponseWriter, provider, method string) {
193	w.Header().Set("content-type", "text/html; charset=utf-8")
194	tpl.ExecuteTemplate(w, "success", map[string]any{
195		"Provider": provider,
196		"Method":   method,
197	})
198}
199
200func (s *Server) errorPage(w http.ResponseWriter, msg string) {
201	w.Header().Set("content-type", "text/html; charset=utf-8")
202	w.WriteHeader(http.StatusBadRequest)
203	tpl.ExecuteTemplate(w, "error", map[string]any{"Message": msg})
204}
205
206// decodeStateProvider extracts the provider from a state string of the
207// form "<provider>:<nonce>".
208func decodeStateProvider(state string) string {
209	if i := strings.IndexByte(state, ':'); i > 0 {
210		return state[:i]
211	}
212	return ""
213}
214
215// BuildRedirectURI returns the callback URL the OAuth server should
216// redirect to.
217func (s *Server) BuildRedirectURI() string {
218	return s.baseURL + "/callback"
219}
220
221// Redirect sends an HTTP redirect to u. Used by the TUI to tell the
222// browser to bounce through our local server. Not currently used; kept
223// for future flows.
224func Redirect(w http.ResponseWriter, r *http.Request, u *url.URL) {
225	http.Redirect(w, r, u.String(), http.StatusFound)
226}
227
228// ---- templates ----
229//
230// All pages share the monochrome monoStyle defined in callback.go so
231// the browser tab looks like the tui: black on white, monospace, thin
232// rules, no rounded boxes, no color.
233
234var tpl = template.Must(template.New("index").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot login</title>` + monoStyle + `</head><body>
235` + logoTag + `
236<h1><span class="zot">zot</span> login</h1>
237<hr class="rule">
238<p>paste an api key for anthropic, openai, kimi, deepseek, or google. <span class="zot">zot</span> probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>
239<p>
240  <a href="/apikey?provider=anthropic">anthropic api key →</a><br>
241  <a href="/apikey?provider=openai">openai api key →</a><br>
242  <a href="/apikey?provider=kimi">kimi api key →</a><br>
243  <a href="/apikey?provider=deepseek">deepseek api key →</a><br>
244  <a href="/apikey?provider=google">google gemini api key →</a>
245</p>
246<hr class="rule">
247<p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro - kimi code), close this tab and run /login inside <span class="zot">zot</span>.</p>
248</body></html>`))
249
250func init() {
251	template.Must(tpl.New("apikey").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot login</title>` + monoStyle + `<style>
252  form { display: flex; flex-direction: column; gap: 0.75rem; }
253  label { font-size: 0.875rem; }
254</style></head><body>
255` + logoTag + `
256<h1><span class="zot">zot</span> login - {{.Provider}} api key</h1>
257<hr class="rule">
258<p>paste your {{.Provider}} api key. <span class="zot">zot</span> will probe the provider with it once, then save it if the key is accepted.</p>
259<form method="POST" action="/apikey">
260  <input type="hidden" name="provider" value="{{.Provider}}" />
261  <label for="api_key">api key</label>
262  <input id="api_key" name="api_key" type="password" autocomplete="off" autofocus />
263  <button type="submit">log in</button>
264</form>
265</body></html>`))
266
267	template.Must(tpl.New("success").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - logged in</title>` + monoStyle + `</head><body>
268` + logoTag + `
269<h1><span class="mark">✓</span> logged in to {{.Provider}}</h1>
270<hr class="rule">
271<p class="msg">method: {{.Method}}</p>
272<p class="muted"><span class="zot">zot</span> received the callback. you can close this tab and return to the terminal.</p>
273</body></html>`))
274
275	template.Must(tpl.New("error").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - error</title>` + monoStyle + `</head><body>
276` + logoTag + `
277<h1><span class="mark">✗</span> login failed</h1>
278<hr class="rule">
279<p class="msg mono">{{.Message}}</p>
280<p class="muted">go back to <span class="zot">zot</span> and try again.</p>
281</body></html>`))
282}

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