package main import ( "bytes" "crypto/tls" "errors" "fmt" "io" "net/url" "os" "os/exec" "path" "path/filepath" "strconv" "strings" "syscall" "tildegit.org/tjp/sliderule" "tildegit.org/tjp/sliderule/gemini" "tildegit.org/tjp/sliderule/gemini/gemtext" "tildegit.org/tjp/sliderule/gopher" ) var ( ErrMustBeOnAPage = errors.New("you must be on a page to do that, use the \"go\" command first") ErrNoPreviousHistory = errors.New("there is no previous page in the history") ErrNoNextHistory = errors.New("there is no page to go forward to") ErrOnLastLink = errors.New("already on the last link") ErrOnFirstLink = errors.New("already on the first link") ErrCantMoveRelative = errors.New("next/previous only work after navigating to a link on a page") ErrAlreadyAtTop = errors.New("already at the site root") ErrInvalidNumericLink = errors.New("no link with that number") ErrInvalidLink = errors.New("that doesn't look like a valid URL") ErrSaveNeedsFilename = errors.New("save requires a filename argument") ErrInvalidMarkArgs = errors.New("mark what?") ErrInvalidTourArgs = errors.New("tour what?") ErrOnlyTextGemini = errors.New("that is only supported for text/gemini pages") ) func About(_ *BrowserState) error { _, err := fmt.Println(` ... *=:::. ---======- -+-..... .#@@@@@@@= .+=-:....:++ +@@@@@@@@# :-::.. ...*+ :%@@@@@@@@@. =*+++==:.... .*@@@@@@@@@@= -#%###**++-:.. -@@@@@@@@@@@% .::-=*#@@#*#@@@@@@@%-. ..:::----=====++#@@@@@@@@@@@@%%%@@@@@@@@@@%%#**@@@@@@@= .-+#%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@- =*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##%@@*%@@@@@@@@@@- .. -+*. .#@@@@@@@@@@@@@@@@@@@@@@@@@@@%@@@@@@%%%%###- =- .=.==----==--:... ...:::::=#@@@@#**+*##@@@#*%%%%%%%%##############******+==-:-=-:.-==-:.. ... .:.:-==++++====::-----::---:-+%@@@@@@@@@@@@@#-... . ...............=@@@@@@@@@@@@@@+.... -@@@@@@@@@@@@@#. -@@@@@@@@@@@@%- -@@%%##@@@@@@= =%++*=-=%@@@*. =--*%%%::#@#: *@+=#-+*%@%- .*%****###@+. ::..:::.::*@@@%%%%%#: x-1 is a browser for small web protocols gemini, gopher, finger, spartan, and nex. It also has very limited support for HTTP[S]. It was written by TJP and released to the public domain. `[1:]) return err } func Navigate(state *BrowserState, target *url.URL, navIndex int, conf *Config) error { hist := state.History if hist.Url == nil || target.String() != hist.Url.String() { state.History = &History{ Url: target, Depth: hist.Depth + 1, Back: hist, NavIndex: navIndex, } hist.Forward = state.History } state.Modal = nil return Reload(state, conf) } func gopherURL(u *url.URL) (string, sliderule.Status) { if u.Scheme != "gopher" || len(u.Path) < 2 || !strings.HasPrefix(u.Path, "/") { return u.String(), 0 } itemType := u.Path[1] clone := *u clone.Path = u.Path[2:] return clone.String(), sliderule.Status(itemType) } func Reload(state *BrowserState, conf *Config) error { if state.Url == nil { return ErrMustBeOnAPage } urlStr, itemType := gopherURL(state.Url) if itemType == gopher.SearchType && state.Url.RawQuery == "" { state.Readline.SetPrompt("query: ") line, err := state.Readline.Readline() if err != nil { return err } state.Url.RawQuery = url.QueryEscape(strings.TrimRight(line, "\n")) urlStr, _ = gopherURL(state.Url) } tlsConf := tlsConfig(state) var response *sliderule.Response var err error if state.Url.Scheme == "spartan" && state.Url.Fragment == "prompt" { input, err := externalMessage() if err != nil { return err } body := io.LimitReader(bytes.NewBuffer(input), int64(len(input))) state.Url.Fragment = "" response, err = upload(state, state.Url.String(), body, tlsConf) state.Url.Fragment = "prompt" if err != nil { return err } } else { response, err = fetch(state, urlStr, tlsConf) if err != nil { return err } } outer: for { if state.Url.Scheme == "gemini" { switch response.Status { case gemini.StatusInput: state.Readline.SetPrompt(response.Meta.(string) + " ") line, err := state.Readline.Readline() if err != nil { return err } state.Url = response.Request.URL state.Url.RawQuery = url.QueryEscape(strings.TrimRight(line, "\n")) response, err = fetch(state, state.Url.String(), tlsConf) if err != nil { return err } case gemini.StatusSensitiveInput: line, err := state.Readline.ReadPassword(response.Meta.(string) + " ") if err != nil { return err } state.Url = response.Request.URL state.Url.RawQuery = url.QueryEscape(strings.TrimRight(string(line), "\n")) response, err = fetch(state, state.Url.String(), tlsConf) if err != nil { return err } case gemini.StatusSuccess: break outer default: return fmt.Errorf("gemini response %s: %s", gemini.StatusName(response.Status), response.Meta.(string)) } } } state.DocType = docType(state.Url, response) state.Body, err = io.ReadAll(response.Body) if err != nil { return err } state.Formatted, state.Links, err = parseDoc(state.DocType, state.Body, conf) if err != nil { return err } return HandleResource(state, conf) } func fetch(state *BrowserState, u string, tlsConf *tls.Config) (*sliderule.Response, error) { tlsConf.ClientSessionCache = nil response, err := sliderule.NewClient(tlsConf).Fetch(u) var tofuErr *TOFUViolation if errors.As(err, &tofuErr) { writeError(err.Error()) state.Readline.SetPrompt("Trust new certificate instead (y/n)? [n] ") line, err := state.Readline.Readline() if err != nil { return nil, err } if line != "y" { return nil, tofuErr } tofuStore[tofuErr.domain] = tofuErr.got if err := saveTofuStore(tofuStore); err != nil { return nil, err } return sliderule.NewClient(tlsConf).Fetch(u) } else if err != nil { return nil, err } return response, nil } func upload(state *BrowserState, u string, body io.Reader, tlsConf *tls.Config) (*sliderule.Response, error) { tlsConf.ClientSessionCache = nil response, err := sliderule.NewClient(tlsConf).Upload(u, body) var tofuErr *TOFUViolation if errors.As(err, &tofuErr) { writeError(err.Error()) state.Readline.SetPrompt("Trust new certificate instead (y/n)? [n] ") line, err := state.Readline.Readline() if err != nil { return nil, err } if line != "y" { return nil, tofuErr } tofuStore[tofuErr.domain] = tofuErr.got if err := saveTofuStore(tofuStore); err != nil { return nil, err } return sliderule.NewClient(tlsConf).Upload(u, body) } else if err != nil { return nil, err } return response, nil } func externalMessage() ([]byte, error) { tmpf, err := os.CreateTemp("", "*") if err != nil { return nil, err } defer func() { _ = os.Remove(tmpf.Name()) }() prompt := []byte("# enter input below (this line will be ignored)\n") err = (func() error { defer func() { _ = tmpf.Close() }() if _, err := tmpf.Write(prompt); err != nil { return err } return nil }()) if err != nil { return nil, err } editor := os.Getenv("EDITOR") if editor == "" { editor = "vi" } editor, err = exec.LookPath(editor) if err != nil { return nil, err } cmd := exec.Command(editor, tmpf.Name()) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return nil, err } tmpf, err = os.Open(tmpf.Name()) if err != nil { return nil, err } defer func() { _ = tmpf.Close() }() buf, err := io.ReadAll(tmpf) if err != nil { return nil, err } return bytes.TrimPrefix(buf, prompt), nil } func back(state *BrowserState) error { if state.Back == nil { return ErrNoPreviousHistory } state.History = state.Back state.Modal = nil return nil } func Back(state *BrowserState, num int) error { for i := 0; i < num; i += 1 { if err := back(state); err != nil { return err } } return print(state) } func Forward(state *BrowserState, num int) error { for i := 0; i < num; i += 1 { if state.Forward == nil { return ErrNoNextHistory } state.History = state.Forward } state.Modal = nil return print(state) } func Next(state *BrowserState, conf *Config) error { switch state.NavIndex { case -1: return ErrCantMoveRelative case len(state.Back.Links) - 1: return ErrOnLastLink } index := state.NavIndex + 1 if err := back(state); err != nil { return err } u := state.Url.ResolveReference(state.Links[index].Target) return Navigate(state, u, index, conf) } func Previous(state *BrowserState, conf *Config) error { switch state.NavIndex { case -1: return ErrCantMoveRelative case 0: return ErrOnFirstLink } index := state.NavIndex - 1 if err := back(state); err != nil { return err } u := state.Url.ResolveReference(state.Links[index].Target) return Navigate(state, u, index, conf) } func Root(state *BrowserState, tilde bool, conf *Config) error { if state.Url == nil { return ErrMustBeOnAPage } u := *state.Url u.RawQuery = "" u.Fragment = "" base := "/" if u.Scheme == "gopher" { base = "/1/" } if tilde && strings.HasPrefix(u.Path, "/~") { u.Path = base + strings.SplitN(u.Path, "/", 3)[1] + "/" } else { u.Path = base } return Navigate(state, &u, -1, conf) } func Up(state *BrowserState, conf *Config) error { if state.Url == nil { return ErrMustBeOnAPage } u := *state.Url u.Path = strings.TrimSuffix(u.Path, "/") if u.Path == "" { return ErrAlreadyAtTop } u.Path = u.Path[:strings.LastIndex(u.Path, "/")+1] u.RawQuery = "" u.Fragment = "" return Navigate(state, &u, -1, conf) } func Go(state *BrowserState, dest string, conf *Config) error { u, idx, err := parseURL(dest, state, conf.DefaultScheme) if err != nil { return err } return Navigate(state, u, idx, conf) } func parseURL(str string, state *BrowserState, defaultScheme string) (*url.URL, int, error) { if str == "." { if state.Url == nil { return nil, -1, ErrMustBeOnAPage } return state.Url, -1, nil } if strings.HasPrefix(str, "m:") { _, target, err := findMark(state, str[2:]) if err != nil { return nil, -1, err } str = target } else if strings.HasPrefix(str, "t:") { i, err := strconv.Atoi(str[2:]) if err != nil { return nil, -1, ErrInvalidLink } if i < 0 || i >= len(state.CurrentTour.Links) { return nil, -1, ErrInvalidTourPos } return state.CurrentTour.Links[i], -1, nil } else if strings.HasPrefix(str, "t[") { idx := strings.IndexByte(str, ']') if idx < 0 || idx >= len(str)-2 || str[idx+1] != ':' { return nil, -1, ErrInvalidLink } if i, err := strconv.Atoi(str[idx+2:]); err != nil { return nil, -1, ErrInvalidLink } else { _, tour, err := findTour(state, str[2:idx]) if err != nil { return nil, -1, err } if i < 0 || i >= len(tour.Links) { return nil, -1, ErrInvalidTourPos } return tour.Links[i], -1, nil } } var u *url.URL i, err := strconv.Atoi(str) if err == nil { if len(state.Links) <= i { return nil, -1, ErrInvalidNumericLink } u = state.Links[i].Target if state.Links[i].Prompt { u.Fragment = "prompt" } } else { i = -1 u, err = url.Parse(str) if err != nil { return nil, -1, ErrInvalidLink } if u.Scheme == "" { u.Scheme = defaultScheme } } if state.Url != nil { u = state.Url.ResolveReference(u) } if u.Hostname() == "" { return nil, -1, ErrInvalidLink } return u, i, nil } func print(state *BrowserState) error { if state.Quiet { return nil } defer func() { state.Modal = nil }() if state.Body == nil && state.Modal == nil { return ErrMustBeOnAPage } out := []byte(state.Formatted) if state.Modal != nil { out = state.Modal } if state.Modal != nil || state.Pager == "never" { _, err := os.Stdout.Write(out) return err } lessarg := []string{} switch state.Pager { case "auto": lessarg = []string{"-F"} fallthrough case "always": less, err := exec.LookPath("less") if err != nil { return err } cmd := exec.Command(less, lessarg...) cmd.Stdin = bytes.NewBuffer(out) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } return errors.New("invalid 'pager' value in configuration") } func Print(state *BrowserState) error { q := state.Quiet defer func() { state.Quiet = q }() state.Quiet = false return print(state) } func HandleResource(state *BrowserState, conf *Config) error { if state.Modal != nil { return Print(state) } if handler, ok := conf.Handlers[state.DocType]; ok { return Pipe(state, handler) } switch state.DocType { case "text/gemini", "text/x-gophermap", "text/plain": return print(state) } return Save(state, path.Base(state.Url.Path), conf) } func Outline(state *BrowserState, conf *Config) error { if state.Body == nil { return ErrMustBeOnAPage } if state.DocType != "text/gemini" { return ErrOnlyTextGemini } gemdoc, err := gemtext.Parse(bytes.NewBuffer(state.Body)) if err != nil { return err } b := &bytes.Buffer{} for _, line := range gemdoc { switch line.Type() { case gemtext.LineTypeHeading3, gemtext.LineTypeHeading2, gemtext.LineTypeHeading1: if _, err := b.Write(line.Raw()); err != nil { return err } } } formatted, _, err := parseGemtextDoc(b.Bytes(), conf.SoftWrap) if err != nil { return err } state.Modal = []byte(formatted) if len(state.Modal) == 0 { state.Modal = []byte("No headers on the current page\n") } return Print(state) } func Links(state *BrowserState, conf *Config) error { if state.Links == nil { return ErrMustBeOnAPage } buf := &bytes.Buffer{} for _, link := range state.Links { fmt.Fprintf(buf, "=> %s %s\n", link.Target.String(), link.Text) } formatted, _, err := parseDoc("text/gemini", buf.Bytes(), conf) if err != nil { return err } state.Modal = []byte(formatted) if len(state.Links) == 0 { state.Modal = []byte("There are no links on the current page\n") } return Print(state) } func HistoryCmd(state *BrowserState) error { if state.History == nil { return ErrMustBeOnAPage } h := state.History i := 0 for h.Forward != nil { h = h.Forward i += 1 } buf := &bytes.Buffer{} j := 0 for h.Url != nil { mark := "" if j == i { mark = "* " } fmt.Fprintf(buf, "%s%s\n", mark, h.Url.String()) j += 1 h = h.Back } state.Modal = buf.Bytes() return Print(state) } func Save(state *BrowserState, filename string, conf *Config) error { if state.Body == nil { return ErrMustBeOnAPage } if filename == "" { return ErrSaveNeedsFilename } p := filepath.Join(conf.DownloadFolder, filename) _, err := os.Stat(p) pbase := p i := 1 for !errors.Is(err, syscall.ENOENT) { p = pbase + "-" + strconv.Itoa(i) _, err = os.Stat(p) i += 1 } if err := os.WriteFile(p, state.Body, 0o644); err != nil { return err } state.Modal = []byte(fmt.Sprintf("Saved page to %s\n", p)) return Print(state) } func Mark(state *BrowserState, args []string, conf *Config) error { switch args[0] { case "add": return MarkAdd(state, conf, args[1], args[2]) case "go": return MarkGo(state, conf, args[1]) case "list": return MarkList(state) case "delete": return MarkDelete(state, args[1]) } return ErrInvalidMarkArgs } func TourCmd(state *BrowserState, args []string, conf *Config) error { switch args[0] { case "add": if args[1] == "next" { return TourAddNext(state, conf, args[2:]) } return TourAdd(state, conf, args[1:]) case "show": return TourShow(state) case "select": return TourSelect(state, args[1]) case "next": return TourNext(state, conf) case "previous": return TourPrevious(state, conf) case "clear": return TourClear(state) case "list": return TourList(state) case "go": return TourGo(state, conf, args[1]) } return ErrInvalidTourArgs } func IdentityCmd(state *BrowserState, args []string) error { switch args[0] { case "create": return IdentityCreate(state, args[1]) case "list": return IdentityList(state) case "delete": return IdentityDelete(state, args[1]) case "use": switch args[2] { case "domain": return IdentityUseDomain(state, args[1], args[3]) case "folder": return IdentityUseFolder(state, args[1], args[3]) case "page": return IdentityUsePage(state, args[1], args[3]) } } return ErrInvalidArgs } func Pipe(state *BrowserState, cmdStr string) error { if state.Body == nil { return ErrMustBeOnAPage } sh, err := exec.LookPath("sh") if err != nil { return err } cmd := exec.Command(sh, "-c", cmdStr) cmd.Stdin = bytes.NewBuffer(state.Body) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() }