package main import ( "astronaut/styles" "astronaut/ui" "bufio" "bytes" "context" "crypto/tls" "crypto/x509" "errors" "fmt" "io" "mime" "net" "net/url" "os" "os/exec" "path" "path/filepath" "strings" "sync" "sync/atomic" "time" "git.sr.ht/~adnano/go-gemini" "git.sr.ht/~adnano/go-gemini/certificate" "git.sr.ht/~adnano/go-gemini/tofu" "git.sr.ht/~adnano/go-xdg" "github.com/gdamore/tcell/v2" "github.com/mattn/go-runewidth" ) // Tab represents a browser tab. type Tab struct { view *ui.View status *ui.View busy int32 mu sync.RWMutex buf bytes.Buffer browser *Browser pages []Page // navigation history page int cancel func() spinner ui.Spinner } // Page represents a page. type Page struct { URL string MediaType string Text *Text Scroll ui.ScrollState Style tcell.Style } func (t *Tab) URL() string { t.mu.RLock() defer t.mu.RUnlock() if len(t.pages) == 0 { return "" } return t.pages[t.page].URL } func (t *Tab) MediaType() string { t.mu.RLock() defer t.mu.RUnlock() if len(t.pages) == 0 { return "" } return t.pages[t.page].MediaType } func (t *Tab) Style() tcell.Style { t.mu.RLock() defer t.mu.RUnlock() if len(t.pages) == 0 { return tcell.StyleDefault } return t.pages[t.page].Style } func (t *Tab) Text() *Text { t.mu.RLock() defer t.mu.RUnlock() if len(t.pages) == 0 { return nil } return t.pages[t.page].Text } func (t *Tab) textToDraw() *Text { t.mu.RLock() defer t.mu.RUnlock() if len(t.pages) == 0 { return nil } text := t.pages[t.page].Text // Draw previous page's text if the text hasn't loaded yet if text == nil && t.page > 0 && t.Busy() { text = t.pages[t.page-1].Text } return text } func (t *Tab) setURL(url string) { t.mu.Lock() defer t.mu.Unlock() t.pages[t.page].URL = url t.view.Invalidate() } func (t *Tab) setMediaType(mediatype string) { t.mu.Lock() defer t.mu.Unlock() t.pages[t.page].MediaType = mediatype t.view.Invalidate() } func (t *Tab) setStyle(style tcell.Style) { t.mu.Lock() defer t.mu.Unlock() t.pages[t.page].Style = style t.view.Invalidate() } func (t *Tab) setText(text *Text) { t.mu.Lock() defer t.mu.Unlock() page := &t.pages[t.page] if page.Text != nil { page.Text.Close() } page.Text = text t.view.Invalidate() // Remove duplicate pages if len(t.pages) < 2 { return } i, j := len(t.pages)-1, len(t.pages)-2 if t.pages[i].URL == t.pages[j].URL { t.pages[j] = t.pages[i] t.pages = t.pages[:i] t.page-- } } // Clone returns a new tab with the same URL and navigation history. func (t *Tab) Clone() *Tab { t.mu.RLock() defer t.mu.RUnlock() clone := &Tab{ view: t.view, status: t.status, pages: make([]Page, len(t.pages)), page: t.page, browser: t.browser, } for i := range clone.pages { clone.pages[i] = Page{ URL: t.pages[i].URL, } } return clone } // Busy reports whether the tab is currently performing a request. func (t *Tab) Busy() bool { return atomic.LoadInt32(&t.busy) != 0 } // Update updates the tab. func (t *Tab) Update() { if text := t.Text(); text != nil { text.Update() } t.spinner.Update(t.view, t.Busy()) } // Draw draws the tab. func (t *Tab) Draw() { t.view.Fill(' ', styles.Default) if text := t.textToDraw(); text != nil { text.Draw() } // Draw tab status url := t.URL() mediaType := t.MediaType() scroll := t.scroll() style := t.Style() if style == tcell.StyleDefault { style = styles.Status() } t.status.Fill(' ', style) w, _ := t.status.Size() mediaWidth := runewidth.StringWidth(mediaType) + 6 if runewidth.StringWidth(url) > w-mediaWidth-1 { url, _ = ui.Splice(url, w-mediaWidth-2) url += "…" } t.status.DrawText(0, 0, url, style) t.status.SetContent(w-5, 0, t.spinner.Rune(), nil, style) t.status.DrawText(w-mediaWidth, 0, mediaType, style) t.status.DrawText(w-runewidth.StringWidth(scroll), 0, scroll, style) } func (t *Tab) scroll() string { y := t.view.ScrollY() _, vh := t.view.Size() _, h := t.view.GetContentSize() // Remove extra padding h -= vh - 1 var scroll string switch { case y == 0: if h <= vh { scroll = "All" } else { scroll = "Top" } case y >= h-vh: scroll = "Bot" default: x := int(float64(y) / float64(h-vh) * 100) scroll = fmt.Sprintf("%d%%", x) } return scroll } // Title returns the tab title. func (t *Tab) Title() string { text := t.textToDraw() if text == nil { return t.URL() } title := text.Title() if title == "" { title = t.URL() } return title } // Reload reloads the tab. func (t *Tab) Reload() { url, err := url.Parse(t.URL()) if err != nil { t.Error(err) return } t.DoBackground(&gemini.Request{URL: url}) } // Cancel cancels the current request. func (t *Tab) Cancel() { if t.cancel != nil { t.cancel() t.cancel = nil } if text := t.Text(); text != nil { text.Close() } } // Back moves back in history. func (t *Tab) Back() bool { if t.Busy() { return false } t.mu.Lock() defer t.mu.Unlock() t.saveScroll() if t.page > 0 { t.page-- t.restoreScroll() return true } return false } // Forward moves forward in history. func (t *Tab) Forward() bool { if t.Busy() { return false } t.mu.Lock() defer t.mu.Unlock() t.saveScroll() if t.page < len(t.pages)-1 { t.page++ t.restoreScroll() return true } return false } func (t *Tab) saveScroll() { if len(t.pages) == 0 { return } t.pages[t.page].Scroll = t.view.ScrollState } func (t *Tab) restoreScroll() { t.view.ScrollState = t.pages[t.page].Scroll t.view.Invalidate() } func (t *Tab) resetScroll() { t.saveScroll() t.view.ScrollState = ui.ScrollState{} } func (t *Tab) Error(err error) { t.setStyle(styles.Error) var buf bytes.Buffer templates.ExecuteTemplate(&buf, "error.tmpl", err) t.setText(NewParsedGemText(t.view, t, io.NopCloser(&buf))) t.view.Invalidate() } func (t *Tab) ResolveReference(rawurl string) (*url.URL, error) { url, err := url.Parse(rawurl) if err != nil { return nil, err } rel, err := url.Parse(t.URL()) if err != nil { return nil, err } return rel.ResolveReference(url), nil } func (t *Tab) doContext(req *gemini.Request) context.Context { ctx, cancel := context.WithCancel(context.Background()) t.cancel = cancel t.resetScroll() // Get certificate hostname := req.URL.Hostname() if cert, ok := t.browser.certs.Lookup(hostname); ok { req.Certificate = &cert } t.newPage() return ctx } // Do performs the provided request. func (t *Tab) Do(req *gemini.Request) { ctx := t.doContext(req) err := t.do(ctx, req, nil) if err != nil { t.Error(err) } } // DoBackground performs the provided request in the background. func (t *Tab) DoBackground(req *gemini.Request) { // Don't perform a request if one is underway if !atomic.CompareAndSwapInt32(&t.busy, 0, 1) { return } ctx := t.doContext(req) go func() { defer atomic.StoreInt32(&t.busy, 0) err := t.do(ctx, req, nil) if err != nil { t.Error(err) } }() } // do performs the request. func (t *Tab) do(ctx context.Context, req *gemini.Request, via []*gemini.Request) error { t.setURL(req.URL.String()) resp, err := t.doRequest(ctx, req) if err != nil { return err } // Create a certificate if necessary if resp.Status == gemini.StatusCertificateRequired && req.Certificate == nil { cert, ok := t.createCertificate(req.URL.Hostname()) if !ok { return nil } req.Certificate = &cert return t.do(ctx, req, via) } switch resp.Status.Class() { case gemini.StatusInput: sensitive := resp.Status == gemini.StatusSensitiveInput input, ok := t.browser.Input(resp.Meta, "", sensitive) if !ok { return nil } req.URL.ForceQuery = true req.URL.RawQuery = gemini.QueryEscape(input) return t.do(ctx, req, via) case gemini.StatusRedirect: target, err := url.Parse(resp.Meta) if err != nil { return err } redirect := *req redirect.URL = req.URL.ResolveReference(target) via = append(via, req) if len(via) > 5 { return &ErrTooManyRedirects{ Req: &redirect, Via: via, } } if t.shouldRedirect(&redirect, via) { return t.do(ctx, &redirect, via) } t.setText(nil) return nil case gemini.StatusSuccess: t.buf.Reset() resp.Body = &teeReader{resp.Body, &t.buf} return t.handle(req, resp) default: return &ErrUnsuccessful{ Status: resp.Status, Meta: resp.Meta, } } } func (t *Tab) doRequest(ctx context.Context, req *gemini.Request) (*gemini.Response, error) { switch req.URL.Scheme { case "about": var w ResponseWriter t.handleAbout(&w, req) return w.Response(), nil case "file": var w ResponseWriter gemini.ServeFile(&w, os.DirFS(""), req.URL.Path) return w.Response(), nil case "gemini": client := &gemini.Client{ TrustCertificate: t.trustCertificate, } return client.Do(ctx, req) default: return nil, ErrUnsupportedScheme{} } } // handleAbout handles a request for an about: URI. func (t *Tab) handleAbout(w gemini.ResponseWriter, r *gemini.Request) { switch r.URL.Host { case "about": templates.ExecuteTemplate(w, "about.tmpl", &struct { Version string }{Version}) case "bookmarks": path := filepath.Join(xdg.DataHome(), "astronaut", "bookmarks.gmi") gemini.ServeFile(w, os.DirFS(""), path) case "certificates": templates.ExecuteTemplate(w, "certificates.tmpl", t.browser.certs.Entries()) case "knownhosts": templates.ExecuteTemplate(w, "knownhosts.tmpl", t.browser.hosts.Entries()) case "newtab": gemini.ServeFile(w, static, "about/newtab.gmi") case "welcome": gemini.ServeFile(w, static, "about/welcome.gmi") default: w.WriteHeader(gemini.StatusNotFound, "Not found") } } // createCertificate is called when the server requests a certificate. func (t *Tab) createCertificate(hostname string) (tls.Certificate, bool) { b := t.browser if cert, ok := b.certs.Lookup(hostname); ok { return cert, true } // TODO: Certificate creation page input, ok := b.Input("Certificate duration:", "", false) if !ok { return tls.Certificate{}, false } duration, err := time.ParseDuration(input) if err != nil { b.tabs[b.tab].Error(err) return tls.Certificate{}, false } cert, err := certificate.Create(certificate.CreateOptions{ Duration: duration, }) if err != nil { b.tabs[b.tab].Error(err) return cert, false } if err := b.certs.Add(hostname, cert); err != nil { b.tabs[b.tab].Error(err) return cert, false } return cert, true } // trustCertificate is called to determine if the client trusts the certificate. func (t *Tab) trustCertificate(hostname string, cert *x509.Certificate) error { b := t.browser // Check the known hosts host := tofu.NewHost(hostname, cert.Raw) knownHost, ok := b.hosts.Lookup(hostname) if ok { // Check fingerprint if host.Fingerprint != knownHost.Fingerprint { return &ErrFingerprintMismatch{ Expected: knownHost, Got: host, } } return nil } ch := make(chan int) const ( trustAlways = iota + 1 trustOnce ) { t.setStyle(styles.Warning) defer t.setStyle(tcell.StyleDefault) var buf bytes.Buffer templates.ExecuteTemplate(&buf, "trust.tmpl", host) gem := NewParsedGemText(b.tabView, b.tabs[b.tab], io.NopCloser(&buf)) gem.action = func(action string) { switch action { case "trust-always": ch <- trustAlways case "trust-once": ch <- trustOnce default: close(ch) } } t.setText(gem) } select { case trust := <-ch: switch trust { case trustAlways: b.hosts.Add(host) if b.hostsFile != nil { b.hostsFile.WriteHost(host) } return nil case trustOnce: b.hosts.Add(host) return nil } } return ErrNotTrusted } // shouldRedirect is called to determine whether we should redirect. func (t *Tab) shouldRedirect(req *gemini.Request, via []*gemini.Request) bool { if !t.shouldPromptRedirect(req, via[len(via)-1]) { return true } ch := make(chan bool) { t.setStyle(styles.Warning) defer t.setStyle(tcell.StyleDefault) ctx := struct { Req *gemini.Request Via []*gemini.Request }{req, via} var buf bytes.Buffer templates.ExecuteTemplate(&buf, "redirect.tmpl", ctx) gem := NewParsedGemText(t.view, t, io.NopCloser(&buf)) gem.action = func(action string) { switch action { case "proceed": ch <- true default: close(ch) } } t.setText(gem) } select { case b := <-ch: return b } } func (t *Tab) shouldPromptRedirect(req *gemini.Request, via *gemini.Request) bool { external := req.URL.Host != via.URL.Host nonGemini := via.URL.Scheme == "gemini" && req.URL.Scheme != "gemini" return external || nonGemini } func (t *Tab) newPage() { t.mu.Lock() defer t.mu.Unlock() if len(t.pages) != 0 { t.page++ if t.page != len(t.pages) { // Truncate history going forward t.pages = t.pages[:t.page] } } t.pages = append(t.pages, Page{}) } func (t *Tab) handle(req *gemini.Request, resp *gemini.Response) error { defer resp.Body.Close() mediatype, params, err := mime.ParseMediaType(resp.Meta) if err != nil { return err } t.setMediaType(mediatype) if strings.HasPrefix(mediatype, "text/") { return t.handleText(resp, mediatype, params) } return t.handleDownload(req, resp, mediatype) } func (t *Tab) handleText(resp *gemini.Response, mediatype string, params map[string]string) error { if charset, ok := params["charset"]; ok { charset = strings.ToLower(charset) if charset != "utf-8" && charset != "us-ascii" { return ErrUnsupportedCharset(charset) } } text := NewText(t.view, t, resp.Body, mediatype) t.setText(text) err := text.Parse() if errors.Is(err, net.ErrClosed) { // Ignore return nil } return err } func (t *Tab) handleDownload(req *gemini.Request, resp *gemini.Response, mediatype string) error { filename := path.Base(req.URL.Path) ch := make(chan int) const ( save = iota + 1 open ) { ctx := struct { Hostname string Filename string MediaType string }{ req.URL.Hostname(), filename, mediatype, } var buf bytes.Buffer templates.ExecuteTemplate(&buf, "open.tmpl", ctx) gem := NewParsedGemText(t.view, t, io.NopCloser(&buf)) gem.action = func(action string) { switch action { case "save": ch <- save case "open": ch <- open default: close(ch) } } t.setText(gem) t.view.Invalidate() } defer t.setText(nil) select { case choice := <-ch: switch choice { case save: return t.saveResponse(resp, filename) case open: return t.openResponse(resp, filename) } } return nil } func (t *Tab) saveResponse(resp *gemini.Response, filename string) error { name, ok := t.browser.Input("Save as:", filename, false) if !ok { return nil } f, err := os.Create(name) if err != nil { return err } defer f.Close() _, err = io.Copy(bufio.NewWriter(f), resp.Body) if err != nil { return err } return nil } func (t *Tab) openResponse(resp *gemini.Response, filename string) error { name, ok := t.browser.Input("Open with:", "xdg-open", false) if !ok { return nil } f, err := os.CreateTemp("", "astronaut-*-"+filename) if err != nil { return err } defer f.Close() _, err = io.Copy(bufio.NewWriter(f), resp.Body) if err != nil { return err } cmd := exec.Command(name, f.Name()) cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { return err } return nil }