v0.38.0 58133ea

server.go RawHistoryFolder

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

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