x-1/actions.go

794 lines
18 KiB
Go

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()
}