package main import ( "bytes" "context" "crypto/tls" "errors" "fmt" "io" "net/url" "os" "os/exec" "path" "path/filepath" "strconv" "strings" "syscall" "time" "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") 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 ErrInvalidLink(invalidURL string) error { return invalidLinkErr(invalidURL) } type invalidLinkErr string func (ie invalidLinkErr) Error() string { return fmt.Sprintf("that doesn't look like a valid URL: %s", string(ie)) } 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) error { if state.Url == nil || target.String() != state.Url.String() { pushHistory(state, target, navIndex) } state.Modal = nil return Reload(state) } func pushHistory(state *BrowserState, target *url.URL, navIndex int) { hist := state.History state.History = &History{ Url: target, Depth: hist.Depth + 1, Back: hist, NavIndex: navIndex, } hist.Forward = state.History purgeOldHistory(state) } func purgeOldHistory(state *BrowserState) { if state.Depth <= state.SavedHistoryDepth { return } d := state.SavedHistoryDepth h := state.History for d > 0 { h = h.Back d -= 1 } h.Body = nil h.Formatted = "" h.Links = nil } 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) 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 } } if state.Url.Scheme == "gemini" { outer: for { 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.Url = returnedURL(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, state.Config) if err != nil { return err } return HandleResource(state) } func returnedURL(requested *url.URL, response *sliderule.Response) *url.URL { _, gopherType := gopherURL(requested) if gopherType == 0 { return response.Request.URL } u := *response.Request.URL u.Path = "/" + string([]byte{byte(gopherType)}) + u.Path return &u } func requestCtx(timeout time.Duration) (context.Context, context.CancelFunc) { ctx := context.Background() if timeout > 0 { return context.WithTimeout(ctx, timeout) } return ctx, func() {} } func fetch(state *BrowserState, u string, tlsConf *tls.Config) (*sliderule.Response, error) { ctx, cancel := requestCtx(state.Timeout.Duration) defer cancel() tlsConf.ClientSessionCache = nil response, err := sliderule.NewClient(tlsConf).Fetch(ctx, u) var tofuErr *TOFUViolation if errors.As(err, &tofuErr) { state.Printer.PrintError(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 } ctx, cancel = requestCtx(state.Timeout.Duration) defer cancel() return sliderule.NewClient(tlsConf).Fetch(ctx, 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) { ctx, cancel := requestCtx(state.Timeout.Duration) defer cancel() tlsConf.ClientSessionCache = nil response, err := sliderule.NewClient(tlsConf).Upload(ctx, u, body) var tofuErr *TOFUViolation if errors.As(err, &tofuErr) { state.Printer.PrintError(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 } ctx, cancel = requestCtx(state.Timeout.Duration) defer cancel() return sliderule.NewClient(tlsConf).Upload(ctx, 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.Modal = nil state.History = state.Back if state.Body == nil { return Reload(state) } 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 HandleResource(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 HandleResource(state) } func Next(state *BrowserState) 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) } func Previous(state *BrowserState) 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) } func Root(state *BrowserState, tilde bool) 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) } func Up(state *BrowserState) 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) } func Go(state *BrowserState, dest string) error { u, idx, err := parseURL(dest, state, state.DefaultScheme) if err != nil { return err } return Navigate(state, u, idx) } 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(str) } 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(str) } if i, err := strconv.Atoi(str[idx+2:]); err != nil { return nil, -1, ErrInvalidLink(str) } 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(str) } if u.Scheme == "" { u.Scheme = defaultScheme } } if state.Url != nil { u = state.Url.ResolveReference(u) } if u.Hostname() == "" { return nil, -1, ErrInvalidLink(u.String()) } return u, i, nil } func print(state *BrowserState) error { if state.Modal != nil { defer func() { state.Modal = nil }() return state.Printer.PrintModal(state, state.Modal) } if state.Body == nil { return ErrMustBeOnAPage } if state.Quiet { return nil } return state.Printer.PrintPage(state, state.Formatted) } func Print(state *BrowserState) error { q := state.Quiet defer func() { state.Quiet = q }() state.Quiet = false return print(state) } func HandleResource(state *BrowserState) error { if state.Modal != nil { return Print(state) } if handler, ok := state.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)) } func Outline(state *BrowserState) 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(), state.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) 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(), state.Config) 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) error { if state.Body == nil { return ErrMustBeOnAPage } if filename == "" { return ErrSaveNeedsFilename } p := filepath.Join(state.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) error { switch args[0] { case "add": return MarkAdd(state, args[1], args[2]) case "go": return MarkGo(state, args[1]) case "list": return MarkList(state) case "delete": return MarkDelete(state, args[1]) } return ErrInvalidMarkArgs } func TourCmd(state *BrowserState, args []string) error { switch args[0] { case "add": if args[1] == "next" { return TourAddNext(state, args[2:]) } return TourAdd(state, args[1:]) case "show": return TourShow(state) case "select": return TourSelect(state, args[1]) case "next": return TourNext(state) case "previous": return TourPrevious(state) case "clear": return TourClear(state) case "list": return TourList(state) case "go": return TourGo(state, 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() }