diff --git a/VERSION b/VERSION index c043eea..276cbf9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.1 +2.3.0 diff --git a/bombadillo.1 b/bombadillo.1 index ef3e479..c8b19a0 100644 --- a/bombadillo.1 +++ b/bombadillo.1 @@ -59,7 +59,7 @@ Displaying web content directly in \fBbombadillo\fP requires lynx, w3m or elinks These commands work as a single keypress anytime \fBbombadillo\fP is not taking in a line based command or when the user is being prompted for action. This is the default command mode of \fBbombadillo\fP. .TP .B -b +b, h Navigate back one place in your document history. .TP .B @@ -71,7 +71,7 @@ d Scroll down an amount corresponding to 75% of your terminal window height in the current document. .TP .B -f +f, l Navigate forward one place in your document history. .TP .B @@ -95,6 +95,7 @@ n Jump to next found text item. .TP .B +N Jump to previous found text item. .TP .B @@ -232,6 +233,10 @@ defaultscheme The scheme that should be used when no scheme is present in a given URL. \fIgopher\fP, \fIgemini\fP, \fIhttp\fP, and \fIhttps\fP are valid values. .TP .B +geminiblocks +Determines how to treat preformatted text blocks in text/gemini documents. \fIblock\fP will show the contents of the block, \fIalt\fP will show any available alt text for the block, \fIboth\fP will show both the content and the alt text, and \fIneither\fP will show neither. Unlike other settings, a change to this value will require a fresh page load to see the change. +.TP +.B homeurl The url that \fBbombadillo\fP navigates to when the program loads or when the \fIhome\fP or \fIh\fP LINE COMMAND is issued. This should be a valid url. If a scheme/protocol is not included, gopher will be assumed. .TP diff --git a/client.go b/client.go index 5322c08..80210e6 100644 --- a/client.go +++ b/client.go @@ -4,7 +4,6 @@ import ( "fmt" "io/ioutil" "os" - "os/exec" "path/filepath" "regexp" "strconv" @@ -19,6 +18,7 @@ import ( "tildegit.org/sloum/bombadillo/http" "tildegit.org/sloum/bombadillo/local" "tildegit.org/sloum/bombadillo/telnet" + "tildegit.org/sloum/bombadillo/termios" ) //------------------------------------------------\\ @@ -43,14 +43,7 @@ type client struct { //--------------------------------------------------\\ func (c *client) GetSizeOnce() { - cmd := exec.Command("stty", "size") - cmd.Stdin = os.Stdin - out, err := cmd.Output() - if err != nil { - cui.Exit(5, "Fatal error: Unable to retrieve terminal size") - } - var h, w int - _, _ = fmt.Sscan(string(out), &h, &w) + var w, h = termios.GetWindowSize() c.Height = h c.Width = w } @@ -61,14 +54,7 @@ func (c *client) GetSize() { c.Draw() for { - cmd := exec.Command("stty", "size") - cmd.Stdin = os.Stdin - out, err := cmd.Output() - if err != nil { - cui.Exit(5, "Fatal error: Unable to retrieve terminal size") - } - var h, w int - _, _ = fmt.Sscan(string(out), &h, &w) + var w, h = termios.GetWindowSize() if h != c.Height || w != c.Width { c.Height = h c.Width = w @@ -168,15 +154,15 @@ func (c *client) TakeControlInput() { } else { c.goToLink(string(input)) } - case 'j', 'J': + case 'j': // scroll down one line c.ClearMessage() c.Scroll(1) - case 'k', 'K': + case 'k': // scroll up one line c.ClearMessage() c.Scroll(-1) - case 'q', 'Q': + case 'q': // quit bombadillo cui.Exit(0, "") case 'g': @@ -197,7 +183,7 @@ func (c *client) TakeControlInput() { c.ClearMessage() distance := c.Height - c.Height/4 c.Scroll(-distance) - case 'b': + case 'b', 'h': // go back c.ClearMessage() err := c.PageState.NavigateHistory(-1) @@ -222,7 +208,7 @@ func (c *client) TakeControlInput() { // open the bookmarks browser c.BookMarks.ToggleOpen() c.Draw() - case 'f', 'F': + case 'f', 'l': // go forward c.ClearMessage() err := c.PageState.NavigateHistory(1) @@ -470,6 +456,8 @@ func (c *client) doCommandAs(action string, values []string) { c.Options[values[0]] = lowerCaseOpt(values[0], val) if values[0] == "tlskey" || values[0] == "tlscertificate" { c.Certs.LoadCertificate(c.Options["tlscertificate"], c.Options["tlskey"]) + } else if values[0] == "geminiblocks" { + gemini.BlockBehavior = c.Options[values[0]] } else if values[0] == "configlocation" { c.SetMessage("Cannot set READ ONLY setting 'configlocation'", true) c.DrawMessage() @@ -676,7 +664,7 @@ func (c *client) doLinkCommand(action, target string) { } -func (c *client) search(query, url, question string) { +func (c *client) search(query, uri, question string) { var entry string var err error if query == "" { @@ -700,22 +688,32 @@ func (c *client) search(query, url, question string) { } else { entry = query } - if url == "" { - url = c.Options["searchengine"] + if uri == "" { + uri = c.Options["searchengine"] } - u, err := MakeUrl(url) + u, err := MakeUrl(uri) if err != nil { - c.SetMessage("The search url is not a valid url", true) + c.SetMessage("The search url is not valid", true) c.DrawMessage() return } + var rootUrl string switch u.Scheme { case "gopher": - go c.Visit(fmt.Sprintf("%s\t%s", u.Full, entry)) + if ind := strings.Index(entry, "\t"); ind >= 0 { + rootUrl = u.Full[:ind] + } else { + rootUrl = u.Full + } + c.Visit(fmt.Sprintf("%s\t%s", rootUrl, entry)) case "gemini": - // TODO url escape the entry variable - escapedEntry := entry - go c.Visit(fmt.Sprintf("%s?%s", u.Full, escapedEntry)) + if ind := strings.Index(entry, "?"); ind >= 0 { + rootUrl = u.Full[:ind] + } else { + rootUrl = u.Full + } + // escapedEntry := url.QueryEscape(entry) // TODO confirm expected behavior re: escaping + c.Visit(fmt.Sprintf("%s?%s", rootUrl, entry)) case "http", "https": c.Visit(u.Full) default: @@ -977,8 +975,10 @@ func (c *client) handleGemini(u Url) { go saveConfig() switch capsule.Status { case 1: + // Query c.search("", u.Full, capsule.Content) case 2: + // Success if capsule.MimeMaj == "text" || (c.Options["showimages"] == "true" && capsule.MimeMaj == "image") { pg := MakePage(u, capsule.Content, capsule.Links) pg.FileType = capsule.MimeMaj @@ -996,14 +996,21 @@ func (c *client) handleGemini(u Url) { c.saveFileFromData(capsule.Content, filename) } case 3: - c.SetMessage(fmt.Sprintf("Follow redirect (y/n): %s?", capsule.Content), false) - c.DrawMessage() - ch := cui.Getch() - if ch == 'y' || ch == 'Y' { + // Redirect + lowerRedirect := strings.ToLower(capsule.Content) + lowerOriginal := strings.ToLower(u.Full) + if strings.Replace(lowerRedirect, lowerOriginal, "", 1) == "/" { c.Visit(capsule.Content) } else { - c.SetMessage("Redirect aborted", false) + c.SetMessage(fmt.Sprintf("Follow redirect (y/n): %s?", capsule.Content), false) c.DrawMessage() + ch := cui.Getch() + if ch == 'y' || ch == 'Y' { + c.Visit(capsule.Content) + } else { + c.SetMessage("Redirect aborted", false) + c.DrawMessage() + } } } } diff --git a/cui/cui.go b/cui/cui.go index 676fcc6..c3ee14a 100644 --- a/cui/cui.go +++ b/cui/cui.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "os/exec" + + "tildegit.org/sloum/bombadillo/termios" ) var Shapes = map[string]string{ @@ -55,16 +57,17 @@ func Exit(exitCode int, msg string) { // InitTerm sets the terminal modes appropriate for Bombadillo func InitTerm() { - SetCharMode() - Tput("smcup") // use alternate screen - Tput("rmam") // turn off line wrapping + termios.SetCharMode() + Tput("smcup") // use alternate screen + Tput("rmam") // turn off line wrapping + fmt.Print("\033[?25l") // hide cursor } // CleanupTerm reverts changs to terminal mode made by InitTerm func CleanupTerm() { moveCursorToward("down", 500) moveCursorToward("right", 500) - SetLineMode() + termios.SetLineMode() fmt.Print("\n") fmt.Print("\033[?25h") // reenables cursor blinking @@ -98,7 +101,7 @@ func Getch() rune { } func GetLine(prefix string) (string, error) { - SetLineMode() + termios.SetLineMode() reader := bufio.NewReader(os.Stdin) fmt.Print(prefix) @@ -107,32 +110,10 @@ func GetLine(prefix string) (string, error) { return "", err } - SetCharMode() + termios.SetCharMode() return text[:len(text)-1], nil } -func SetCharMode() { - cmd := exec.Command("stty", "cbreak", "-echo") - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - err := cmd.Run() - if err != nil { - panic(err) - } - - fmt.Print("\033[?25l") -} - -func SetLineMode() { - cmd := exec.Command("stty", "-cbreak", "echo") - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - err := cmd.Run() - if err != nil { - panic(err) - } -} - func Tput(opt string) { cmd := exec.Command("tput", opt) cmd.Stdin = os.Stdin diff --git a/defaults.go b/defaults.go index c4ca227..fc47a64 100644 --- a/defaults.go +++ b/defaults.go @@ -47,6 +47,7 @@ var defaultOptions = map[string]string{ "configlocation": xdgConfigPath(), "defaultscheme": "gopher", // "gopher", "gemini", "http", "https" + "geminiblocks": "block", // "block", "alt", "neither", "both" "homeurl": "gopher://bombadillo.colorfield.space:70/1/user-guide.map", "savelocation": homePath(), "searchengine": "gopher://gopher.floodgap.com:70/7/v2/vs", diff --git a/gemini/gemini.go b/gemini/gemini.go index e13cb59..98cf059 100644 --- a/gemini/gemini.go +++ b/gemini/gemini.go @@ -24,6 +24,8 @@ type TofuDigest struct { ClientCert tls.Certificate } +var BlockBehavior = "block" + //------------------------------------------------\\ // + + + R E C E I V E R S + + + \\ //--------------------------------------------------\\ @@ -49,8 +51,8 @@ func (t *TofuDigest) Purge(host string) error { return fmt.Errorf("Invalid host %q", host) } -func (t *TofuDigest) Add(host, hash string) { - t.certs[strings.ToLower(host)] = hash +func (t *TofuDigest) Add(host, hash string, time int64) { + t.certs[strings.ToLower(host)] = fmt.Sprintf("%s|%d", hash, time) } func (t *TofuDigest) Exists(host string) bool { @@ -67,12 +69,11 @@ func (t *TofuDigest) Find(host string) (string, error) { return "", fmt.Errorf("Invalid hostname, no key saved") } -func (t *TofuDigest) Match(host string, cState *tls.ConnectionState) error { - host = strings.ToLower(host) +func (t *TofuDigest) Match(host, localCert string, cState *tls.ConnectionState) error { now := time.Now() for _, cert := range cState.PeerCertificates { - if t.certs[host] != hashCert(cert.Raw) { + if localCert != hashCert(cert.Raw) { continue } @@ -118,13 +119,40 @@ func (t *TofuDigest) newCert(host string, cState *tls.ConnectionState) error { continue } - t.Add(host, hashCert(cert.Raw)) + t.Add(host, hashCert(cert.Raw), cert.NotAfter.Unix()) return nil } return fmt.Errorf(reasons.String()) } +func (t *TofuDigest) GetCertAndTimestamp(host string) (string, int64, error) { + certTs, err := t.Find(host) + if err != nil { + return "", -1, err + } + certTsSplit := strings.SplitN(certTs, "|", -1) + if len(certTsSplit) < 2 { + _ = t.Purge(host) + return certTsSplit[0], -1, fmt.Errorf("Invalid certstring, no delimiter") + } + ts, err := strconv.ParseInt(certTsSplit[1], 10, 64) + if err != nil { + _ = t.Purge(host) + return certTsSplit[0], -1, err + } + now := time.Now() + if ts < now.Unix() { + // Ignore error return here since an error would indicate + // the host does not exist and we have already checked for + // that and the desired outcome of the action is that the + // host will no longer exist, so we are good either way + _ = t.Purge(host) + return "", -1, fmt.Errorf("Expired cert") + } + return certTsSplit[0], ts, nil +} + func (t *TofuDigest) IniDump() string { if len(t.certs) < 1 { return "" @@ -176,9 +204,11 @@ func Retrieve(host, port, resource string, td *TofuDigest) (string, error) { return "", fmt.Errorf("Insecure, no certificates offered by server") } - if td.Exists(host) { + localCert, localTs, err := td.GetCertAndTimestamp(host) + + if localTs > 0 { // See if we have a matching cert - err := td.Match(host, &connState) + err := td.Match(host, localCert, &connState) if err != nil && err.Error() != "EXP" { // If there is no match and it isnt because of an expiration // just return the error @@ -339,16 +369,23 @@ func parseGemini(b, rootUrl, currentUrl string) (string, []string) { splitContent := strings.Split(b, "\n") links := make([]string, 0, 10) + inPreBlock := false + outputIndex := 0 for i, ln := range splitContent { splitContent[i] = strings.Trim(ln, "\r\n") - if ln == "```" { - // By continuing we create a variance between i and outputIndex - // the other branches here will write to the outputIndex rather - // than i, thus removing these lines while itterating without - // needing mroe allocations. - continue - } else if len([]rune(ln)) > 3 && ln[:2] == "=>" { + isPreBlockDeclaration := strings.HasPrefix(ln, "```") + if isPreBlockDeclaration && !inPreBlock && (BlockBehavior == "both" || BlockBehavior == "alt") { + inPreBlock = !inPreBlock + alt := strings.TrimSpace(ln) + if len(alt) > 3 { + alt = strings.TrimSpace(alt[3:]) + splitContent[outputIndex] = fmt.Sprintf("[ %s ]", alt) + outputIndex++ + } + } else if isPreBlockDeclaration { + inPreBlock = !inPreBlock + } else if len([]rune(ln)) > 3 && ln[:2] == "=>" && !inPreBlock { var link, decorator string subLn := strings.Trim(ln[2:], "\r\n\t \a") splitPoint := strings.IndexAny(subLn, " \t") @@ -370,6 +407,9 @@ func parseGemini(b, rootUrl, currentUrl string) (string, []string) { splitContent[outputIndex] = fmt.Sprintf("%-5s %s", linknum, decorator) outputIndex++ } else { + if inPreBlock && (BlockBehavior == "alt" || BlockBehavior == "neither") { + continue + } splitContent[outputIndex] = ln outputIndex++ } @@ -381,18 +421,38 @@ func handleRelativeUrl(u, root, current string) string { if len(u) < 1 { return u } + currentIsDir := (current[len(current)-1] == '/') if u[0] == '/' { return fmt.Sprintf("%s%s", root, u) + } else if strings.HasPrefix(u, "../") { + currentDir := strings.LastIndex(current, "/") + if currentIsDir { + upOne := strings.LastIndex(current[:currentDir], "/") + dirRoot := current[:upOne] + return dirRoot + u[2:] + } + return current[:currentDir] + u[2:] + } + + if strings.HasPrefix(u, "./") { + if len(u) == 2 { + return current + } + u = u[2:] + } + + if currentIsDir { + indPrevDir := strings.LastIndex(current[:len(current)-1], "/") + if indPrevDir < 9 { + return current + u + } + return current[:indPrevDir+1] + u } ind := strings.LastIndex(current, "/") - if ind < 10 { - return fmt.Sprintf("%s/%s", root, u) - } - current = current[:ind+1] - return fmt.Sprintf("%s%s", current, u) + return current + u } func hashCert(cert []byte) string { diff --git a/main.go b/main.go index 53918f7..28fcf36 100644 --- a/main.go +++ b/main.go @@ -25,12 +25,14 @@ import ( "os" "os/signal" "path/filepath" + "strconv" "strings" "syscall" + "time" "tildegit.org/sloum/bombadillo/config" "tildegit.org/sloum/bombadillo/cui" - _ "tildegit.org/sloum/bombadillo/gemini" + "tildegit.org/sloum/bombadillo/gemini" ) var version string @@ -66,6 +68,7 @@ func validateOpt(opt, val string) bool { "theme": []string{"normal", "inverse", "color"}, "defaultscheme": []string{"gopher", "gemini", "http", "https"}, "showimages": []string{"true", "false"}, + "geminiblocks": []string{"block", "neither", "alt", "both"}, } opt = strings.ToLower(opt) @@ -84,7 +87,7 @@ func validateOpt(opt, val string) bool { func lowerCaseOpt(opt, val string) string { switch opt { - case "webmode", "theme", "defaultscheme", "showimages": + case "webmode", "theme", "defaultscheme", "showimages", "geminiblocks": return strings.ToLower(val) default: return val @@ -121,6 +124,9 @@ func loadConfig() { if _, ok := bombadillo.Options[lowerkey]; ok { if validateOpt(lowerkey, v.Value) { bombadillo.Options[lowerkey] = v.Value + if lowerkey == "geminiblocks" { + gemini.BlockBehavior = v.Value + } } else { bombadillo.Options[lowerkey] = defaultOptions[lowerkey] } @@ -132,7 +138,20 @@ func loadConfig() { } for _, v := range settings.Certs { - bombadillo.Certs.Add(v.Key, v.Value) + // Remove expired certs + vals := strings.SplitN(v.Value, "|", -1) + if len(vals) < 2 { + continue + } + ts, err := strconv.ParseInt(vals[1], 10, 64) + now := time.Now() + if err != nil || now.Unix() > ts { + continue + } + // Satisfied that the cert is not expired + // or malformed: add to the current client + // instance + bombadillo.Certs.Add(v.Key, vals[0], ts) } } diff --git a/page.go b/page.go index 4ca0842..6973a7c 100644 --- a/page.go +++ b/page.go @@ -70,7 +70,9 @@ func (p *Page) WrapContent(width int, color bool) { p.RenderImage(width) return } + width = min(width, 100) counter := 0 + spacer := " " var content strings.Builder var esc strings.Builder escape := false @@ -124,7 +126,6 @@ func (p *Page) WrapContent(width int, color bool) { content.WriteRune('\n') counter = 0 if p.Location.Mime == "1" { - spacer := " " content.WriteString(spacer) counter += len(spacer) } @@ -188,3 +189,10 @@ func MakePage(url Url, content string, links []string) Page { p := Page{make([]string, 0), content, links, url, 0, make([]int, 0), "", 0, "", 40, false} return p } + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/termios/consts_linux.go b/termios/consts_linux.go new file mode 100644 index 0000000..ae6e076 --- /dev/null +++ b/termios/consts_linux.go @@ -0,0 +1,10 @@ +// +build linux + +package termios + +import "syscall" + +const ( + getTermiosIoctl = syscall.TCGETS + setTermiosIoctl = syscall.TCSETS +) diff --git a/termios/consts_nonlinux.go b/termios/consts_nonlinux.go new file mode 100644 index 0000000..ca0daf7 --- /dev/null +++ b/termios/consts_nonlinux.go @@ -0,0 +1,10 @@ +// +build !linux + +package termios + +import "syscall" + +const ( + getTermiosIoctl = syscall.TIOCGETA + setTermiosIoctl = syscall.TIOCSETAF +) diff --git a/termios/termios.go b/termios/termios.go new file mode 100644 index 0000000..9e0a5e7 --- /dev/null +++ b/termios/termios.go @@ -0,0 +1,60 @@ +package termios + +import ( + "os" + "runtime" + "syscall" + "unsafe" +) + +type winsize struct { + Row uint16 + Col uint16 + Xpixel uint16 + Ypixel uint16 +} + +var fd = os.Stdin.Fd() + +func ioctl(fd, request, argp uintptr) error { + if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, fd, request, argp); e != 0 { + return e + } + return nil +} + +func GetWindowSize() (int, int) { + var value winsize + ioctl(fd, syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&value))) + return int(value.Col), int(value.Row) +} + +func getTermios() syscall.Termios { + var value syscall.Termios + err := ioctl(fd, getTermiosIoctl, uintptr(unsafe.Pointer(&value))) + if err != nil { + panic(err) + } + return value +} + +func setTermios(termios syscall.Termios) { + err := ioctl(fd, setTermiosIoctl, uintptr(unsafe.Pointer(&termios))) + if err != nil { + panic(err) + } + runtime.KeepAlive(termios) +} + +func SetCharMode() { + t := getTermios() + t.Lflag = t.Lflag ^ syscall.ICANON + t.Lflag = t.Lflag ^ syscall.ECHO + setTermios(t) +} + +func SetLineMode() { + var t = getTermios() + t.Lflag = t.Lflag | (syscall.ICANON | syscall.ECHO) + setTermios(t) +}