diff --git a/CHANGELOG.md b/CHANGELOG.md index e88751c..3c619c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed +- Update cview to `36671ba7d31c2287748e22966a92c5e94ff850cc` for large perf and feature updates (#107) +- Update to tcell v2 (depencency of cview) + +### Fixed + - More reliable start, no more flash of unindented text, or text that stays unindented (#107) + + +## [v1.6.0] - 2020-11-04 ### Added +- **Support client certificates** through config (#112) - `ansi` config setting, to disable ANSI colors in pages (#79, #86) - Edit current URL with e (#87) - If `emoji_favicons` is enabled, new bookmarks will have the domain's favicon prepended (#69, #90) - The `BROWSER` env var is now also checked when opening web links on Unix (#93) +- More accurate error messages based on server response code ### Changed - Disabling the `color` config setting also disables ANSI colors in pages (#79, #86) @@ -17,12 +28,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The web browser code doesn't check for Xorg anymore, just display variables (#93) - Bookmarks can be made to non-gemini URLs (#94) - Remove pointless directory fallbacks (#101) -- Update cview to `36671ba7d31c2287748e22966a92c5e94ff850cc` for large perf and feature updates (#107) -- Update to tcell v2 (depencency of cview) +- Don't load page from cache when redirected to it (#114) ### Fixed - XDG user dir file is parsed instead of looking for XDG env vars (#97, #100) -- More reliable start, no more flash of unindented text, or text that stays unindented (#107) +- Support paths with spaces in HTTP browser config setting (#77) +- Clicking "Change" on an existing bookmark without changing the text no longer removes it (#91) +- Display HTTP Error if "Open In Portal" fails (#81) +- Support ANSI color codes again, but only in preformatted blocks (#59) +- Make the `..` command work lke it used to in v1.4.0 ## [v1.5.0] - 2020-09-01 diff --git a/README.md b/README.md index dfd6434..84890c0 100644 --- a/README.md +++ b/README.md @@ -121,15 +121,16 @@ Features in *italics* are in the master branch, but not in the latest release. - Disabled by default, enable in config - [x] Proxying - Schemes like Gopher or HTTP can be proxied through a Gemini server +- [x] Client certificate support + - [ ] Full client certificate UX within the client + - Create transient and permanent certs within the client, per domain + - Manage and browse them + - Similar to [Kristall](https://github.com/MasterQ32/kristall) + - https://lists.orbitalfox.eu/archives/gemini/2020/001400.html - [ ] Subscribe to RSS and Atom feeds and display them - Subscribing to page changes, similar to how Spacewalk works, will also be supported - *In progress on `feeds` branch* - [ ] Stream support -- [ ] Full client certificate UX within the client - - Create transient and permanent certs within the client, per domain - - Manage and browse them - - Similar to [Kristall](https://github.com/MasterQ32/kristall) - - https://lists.orbitalfox.eu/archives/gemini/2020/001400.html - [ ] Table of contents for pages - [ ] Search in pages with Ctrl-F - [ ] Support Markdown rendering @@ -140,10 +141,19 @@ The config file is written in the intuitive [TOML](https://github.com/toml-lang/ On Windows, the file is in `%APPDATA%\amfora\config.toml`, which usually expands to `C:\Users\\AppData\Roaming\amfora\config.toml`. +## Client Certificates + +Amfora has early support for client certs. Eventually Amfora will be able to generate them itself, but for you can do it by using OpenSSL (not Windows friendly): + +```shell +openssl req -new -subj "/CN=username" -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -out cert.pem -keyout key.pem +``` + +This will create a certificate and key file, that can be renamed and moved as you like. See the configuration section above for how to edit your config file to tell Amfora about them. + ## Known Bugs - Pasting on Windows is truncated, the full paste content won't be added. ([#43](https://github.com/makeworld-the-better-one/amfora/issues/43)) -- ANSI codes aren't displaying properly ([#59](https://github.com/makeworld-the-better-one/amfora/issues/59)) You can also check out [all the issues with the bug label](https://github.com/makeworld-the-better-one/amfora/issues?q=is%3Aopen+is%3Aissue+label%3Abug). diff --git a/THANKS.md b/THANKS.md new file mode 100644 index 0000000..0e093f4 --- /dev/null +++ b/THANKS.md @@ -0,0 +1,13 @@ +# THANKS + +Thank you to the following contributors, who have helped make Amfora great. FOSS projects are a community effort, and we would be worse off without you. + +- Sotiris Papatheodorou (@sotpapathe) +- Chloe Kudryavtsev (@CosmicToast) +- Adrian Hesketh (@a-h) +- Jansen Price (@sumpygump) +- Alex Wennerberg (@alexwennerberg) +- Timur Ismagilov (@bouncepaw) +- Matt Caroll (@ohiolab) +- Patryk Niedźwiedziński (@pniedzwiedzinski) +- Trevor Slocum (@tsclocum) \ No newline at end of file diff --git a/amfora.go b/amfora.go index 1f39c82..6beb285 100644 --- a/amfora.go +++ b/amfora.go @@ -9,7 +9,7 @@ import ( ) var ( - version = "1.5.0" + version = "v1.6.0" commit = "unknown" builtBy = "unknown" ) diff --git a/client/client.go b/client/client.go index 4bc1557..2ec65d5 100644 --- a/client/client.go +++ b/client/client.go @@ -2,23 +2,74 @@ package client import ( + "io/ioutil" "net" "net/url" "github.com/makeworld-the-better-one/go-gemini" + "github.com/mitchellh/go-homedir" + "github.com/spf13/viper" ) +var certCache = make(map[string][][]byte) + +func clientCert(host string) ([]byte, []byte) { + if cert := certCache[host]; cert != nil { + return cert[0], cert[1] + } + + // Expand paths starting with ~/ + certPath, err := homedir.Expand(viper.GetString("auth.certs." + host)) + if err != nil { + certPath = viper.GetString("auth.certs." + host) + } + keyPath, err := homedir.Expand(viper.GetString("auth.keys." + host)) + if err != nil { + keyPath = viper.GetString("auth.keys." + host) + } + if certPath == "" && keyPath == "" { + certCache[host] = [][]byte{nil, nil} + return nil, nil + } + + cert, err := ioutil.ReadFile(certPath) + if err != nil { + certCache[host] = [][]byte{nil, nil} + return nil, nil + } + key, err := ioutil.ReadFile(keyPath) + if err != nil { + certCache[host] = [][]byte{nil, nil} + return nil, nil + } + + certCache[host] = [][]byte{cert, key} + return cert, key +} + +// HasClientCert returns whether or not a client certificate exists for a host. +func HasClientCert(host string) bool { + cert, _ := clientCert(host) + return cert != nil +} + // Fetch returns response data and an error. // The error text is human friendly and should be displayed. func Fetch(u string) (*gemini.Response, error) { + parsed, _ := url.Parse(u) + cert, key := clientCert(parsed.Host) - res, err := gemini.Fetch(u) + var res *gemini.Response + var err error + if cert != nil { + res, err = gemini.FetchWithCert(u, cert, key) + } else { + res, err = gemini.Fetch(u) + } if err != nil { return nil, err } - parsed, _ := url.Parse(u) - ok := handleTofu(parsed.Hostname(), parsed.Port(), res.Cert) if !ok { return res, ErrTofu @@ -29,7 +80,16 @@ func Fetch(u string) (*gemini.Response, error) { // FetchWithProxy is the same as Fetch, but uses a proxy. func FetchWithProxy(proxyHostname, proxyPort, u string) (*gemini.Response, error) { - res, err := gemini.FetchWithHost(net.JoinHostPort(proxyHostname, proxyPort), u) + parsed, _ := url.Parse(u) + cert, key := clientCert(parsed.Host) + + var res *gemini.Response + var err error + if cert != nil { + res, err = gemini.FetchWithHostAndCert(net.JoinHostPort(proxyHostname, proxyPort), u, cert, key) + } else { + res, err = gemini.FetchWithHost(net.JoinHostPort(proxyHostname, proxyPort), u) + } if err != nil { return nil, err } diff --git a/config/config.go b/config/config.go index 85b5c29..9da4c55 100644 --- a/config/config.go +++ b/config/config.go @@ -1,6 +1,7 @@ // Package config initializes all files required for Amfora, even those used by // other packages. It also reads in the config file and initializes a Viper and // the theme +//nolint:golint,goerr113 package config import ( @@ -38,7 +39,9 @@ var bkmkPath string var DownloadsDir string -//nolint:golint,goerr113 +// Command for opening HTTP(S) URLs in the browser, from "a-general.http" in config. +var HTTPCommand []string + func Init() error { // *** Set paths *** @@ -237,5 +240,14 @@ func Init() error { cview.Styles.PrimitiveBackgroundColor = GetColor("bg") } // Otherwise it's black by default + // Parse HTTP command + HTTPCommand = viper.GetStringSlice("a-general.http") + if len(HTTPCommand) == 0 { + // Not a string array, interpret as a string instead + // Split on spaces to maintain compatibility with old versions + // The new better way to is to just define a string array in config + HTTPCommand = strings.Fields(viper.GetString("a-general.http")) + } + return nil } diff --git a/config/default.go b/config/default.go index 11cc1ef..6329539 100644 --- a/config/default.go +++ b/config/default.go @@ -21,10 +21,20 @@ home = "gemini://gemini.circumlunar.space" # If set to false, a prompt will be shown before following redirects. auto_redirect = false -# What command to run to open a HTTP(S) URL. Set to "default" to try to guess the browser, -# or set to "off" to not open HTTP(S) URLs. +# What command to run to open a HTTP(S) URL. +# Set to "default" to try to guess the browser, or set to "off" to not open HTTP(S) URLs. # If a command is set, than the URL will be added (in quotes) to the end of the command. -# A space will be prepended if necessary. +# A space will be prepended to the URL. +# +# The best to define a command is using a string array. +# Examples: +# http = ["firefox"] +# http = ["custom-browser", "--flag", "--option=2"] +# http = ["/path/with spaces/in it/firefox"] +# +# Using just a string will also work, but it is deprecated, +# and will degrade if you use paths with spaces. + http = "default" # Any URL that will accept a query string can be put here @@ -33,7 +43,7 @@ search = "gemini://gus.guru/search" # Whether colors will be used in the terminal color = true -# Whether ANSI codes from the page content should be rendered +# Whether ANSI color codes from the page content should be rendered ansi = true # Whether to replace list asterisks with unicode bullets @@ -59,6 +69,20 @@ page_max_time = 10 emoji_favicons = false +[auth] +# Authentication settings + +[auth.certs] +# Client certificates +# Set domain name equal to path to client cert +# "example.com" = "mycert.crt" + +[auth.keys] +# Client certificate keys +# Set domain name equal to path to key for the client cert above +# "example.com" = "mycert.key" + + [keybindings] # In the future there will be more settings here. diff --git a/default-config.toml b/default-config.toml index 4242bb1..9ac0d21 100644 --- a/default-config.toml +++ b/default-config.toml @@ -18,10 +18,20 @@ home = "gemini://gemini.circumlunar.space" # If set to false, a prompt will be shown before following redirects. auto_redirect = false -# What command to run to open a HTTP(S) URL. Set to "default" to try to guess the browser, -# or set to "off" to not open HTTP(S) URLs. +# What command to run to open a HTTP(S) URL. +# Set to "default" to try to guess the browser, or set to "off" to not open HTTP(S) URLs. # If a command is set, than the URL will be added (in quotes) to the end of the command. -# A space will be prepended if necessary. +# A space will be prepended to the URL. +# +# The best to define a command is using a string array. +# Examples: +# http = ["firefox"] +# http = ["custom-browser", "--flag", "--option=2"] +# http = ["/path/with spaces/in it/firefox"] +# +# Using just a string will also work, but it is deprecated, +# and will degrade if you use paths with spaces. + http = "default" # Any URL that will accept a query string can be put here @@ -30,7 +40,7 @@ search = "gemini://gus.guru/search" # Whether colors will be used in the terminal color = true -# Whether ANSI codes from the page content should be rendered +# Whether ANSI color codes from the page content should be rendered ansi = true # Whether to replace list asterisks with unicode bullets @@ -56,6 +66,20 @@ page_max_time = 10 emoji_favicons = false +[auth] +# Authentication settings + +[auth.certs] +# Client certificates +# Set domain name equal to path to client cert +# "example.com" = "mycert.crt" + +[auth.keys] +# Client certificate keys +# Set domain name equal to path to key for the client cert above +# "example.com" = "mycert.key" + + [keybindings] # In the future there will be more settings here. diff --git a/display/bookmarks.go b/display/bookmarks.go index 035c5aa..2a6a9df 100644 --- a/display/bookmarks.go +++ b/display/bookmarks.go @@ -89,7 +89,7 @@ func openBkmkModal(name string, exists bool, favicon string) (string, int) { if favicon != "" && !exists { name = favicon + " " + name } - bkmkModalText = "" + bkmkModalText = name bkmkModal.GetForm().AddInputField("Name: ", name, 0, nil, func(text string) { // Store for use later diff --git a/display/display.go b/display/display.go index 01eb865..cc67bcc 100644 --- a/display/display.go +++ b/display/display.go @@ -114,6 +114,13 @@ func Init() { // This shouldn't occur return } + + if query == ".." && tabs[tab].page.URL[len(tabs[tab].page.URL)-1] != '/' { + // Support what ".." used to work like + // If on /dir/doc.gmi, got to /dir/ + query = "./" + } + target, err := current.Parse(query) if err != nil { // Invalid relative url diff --git a/display/download.go b/display/download.go index 53aba0b..8b84b3f 100644 --- a/display/download.go +++ b/display/download.go @@ -118,10 +118,12 @@ func dlChoice(text, u string, resp *gemini.Response) { portalURL = parsed.String() + "%3F" + query } portalURL = strings.TrimPrefix(portalURL, "gemini://") + "?raw=1" - handleHTTP("https://portal.mozz.us/gemini/"+portalURL, false) - browser.SetCurrentTab(strconv.Itoa(curTab)) - App.SetFocus(tabs[curTab].view) - App.Draw() + ok := handleHTTP("https://portal.mozz.us/gemini/"+portalURL, false) + if ok { + browser.SetCurrentTab(strconv.Itoa(curTab)) + App.SetFocus(tabs[curTab].view) + App.Draw() + } return } browser.SetCurrentTab(strconv.Itoa(curTab)) diff --git a/display/private.go b/display/private.go index b9d0a95..6ee915b 100644 --- a/display/private.go +++ b/display/private.go @@ -11,6 +11,7 @@ import ( "github.com/makeworld-the-better-one/amfora/cache" "github.com/makeworld-the-better-one/amfora/client" + "github.com/makeworld-the-better-one/amfora/config" "github.com/makeworld-the-better-one/amfora/renderer" "github.com/makeworld-the-better-one/amfora/structs" "github.com/makeworld-the-better-one/amfora/webbrowser" @@ -142,26 +143,42 @@ func setPage(t *tab, p *structs.Page) { // handleHTTP is used by handleURL. // It opens HTTP links and displays Info and Error modals. -func handleHTTP(u string, showInfo bool) { - switch strings.TrimSpace(viper.GetString("a-general.http")) { - case "", "off": - Error("HTTP Error", "Opening HTTP URLs is turned off.") - case "default": - s, err := webbrowser.Open(u) - if err != nil { - Error("Webbrowser Error", err.Error()) - } else if showInfo { - Info(s) - } - default: - // The config has a custom command to execute for HTTP URLs - fields := strings.Fields(viper.GetString("a-general.http")) - err := exec.Command(fields[0], append(fields[1:], u)...).Start() - if err != nil { - Error("HTTP Error", "Error executing custom browser command: "+err.Error()) +// Returns false if there was an error. +func handleHTTP(u string, showInfo bool) bool { + if len(config.HTTPCommand) == 1 { + // Possibly a non-command + + switch strings.TrimSpace(config.HTTPCommand[0]) { + case "", "off": + Error("HTTP Error", "Opening HTTP URLs is turned off.") + return false + case "default": + s, err := webbrowser.Open(u) + if err != nil { + Error("Webbrowser Error", err.Error()) + return false + } + if showInfo { + Info(s) + } + return true } } + + // Custom command + var err error = nil + if len(config.HTTPCommand) > 1 { + err = exec.Command(config.HTTPCommand[0], append(config.HTTPCommand[1:], u)...).Start() + } else { + err = exec.Command(config.HTTPCommand[0], u).Start() + } + if err != nil { + Error("HTTP Error", "Error executing custom browser command: "+err.Error()) + return false + } + App.Draw() + return true } // handleOther is used by handleURL. @@ -364,11 +381,14 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) { // Gemini URL, or one with a Gemini proxy available - // Load page from cache if possible - page, ok := cache.GetPage(u) - if ok { - setPage(t, page) - return ret(u, true) + // Load page from cache if it exists, + // and this isn't a page that was redirected to by the server (indicates dynamic content) + if numRedirects == 0 { + page, ok := cache.GetPage(u) + if ok { + setPage(t, page) + return ret(u, true) + } } // Otherwise download it bottomBar.SetText("Loading...") @@ -446,7 +466,12 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) { } page.Width = termW - go cache.AddPage(page) + + if !client.HasClientCert(parsed.Host) { + // Don't cache pages with client certs + go cache.AddPage(page) + } + setPage(t, page) return ret(u, true) } @@ -454,8 +479,8 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) { // Could be a non 20 (or 21) status code, or a different kind of document // Handle each status code - switch gemini.SimplifyStatus(res.Status) { - case 10: + switch res.Status { + case 10, 11: userInput, ok := Input(res.Meta) if ok { // Make another request with the query string added @@ -468,7 +493,7 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) { return ret(handleURL(t, parsed.String(), 0)) } return ret("", false) - case 30: + case 30, 31: parsedMeta, err := url.Parse(res.Meta) if err != nil { Error("Redirect Error", "Invalid URL: "+err.Error()) @@ -496,13 +521,44 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) { case 40: Error("Temporary Failure", cview.Escape(res.Meta)) return ret("", false) + case 41: + Error("Server Unavailable", cview.Escape(res.Meta)) + return ret("", false) + case 42: + Error("CGI Error", cview.Escape(res.Meta)) + return ret("", false) + case 43: + Error("Proxy Failure", cview.Escape(res.Meta)) + return ret("", false) + case 44: + Error("Slow Down", "You should wait "+cview.Escape(res.Meta)+" seconds before making another request.") + return ret("", false) case 50: Error("Permanent Failure", cview.Escape(res.Meta)) return ret("", false) + case 51: + Error("Not Found", cview.Escape(res.Meta)) + return ret("", false) + case 52: + Error("Gone", cview.Escape(res.Meta)) + return ret("", false) + case 53: + Error("Proxy Request Refused", cview.Escape(res.Meta)) + return ret("", false) + case 59: + Error("Bad Request", cview.Escape(res.Meta)) + return ret("", false) case 60: - Info("The server requested a certificate. Cert handling is coming to Amfora soon!") + Error("Client Certificate Required", cview.Escape(res.Meta)) + return ret("", false) + case 61: + Error("Certificate Not Authorised", cview.Escape(res.Meta)) + return ret("", false) + case 62: + Error("Certificate Not Valid", cview.Escape(res.Meta)) return ret("", false) } + // Status code 20, but not a document that can be displayed go dlChoice("That file could not be displayed. What would you like to do?", u, res) return ret("", false) diff --git a/renderer/renderer.go b/renderer/renderer.go index 3b1e6eb..8f41fdd 100644 --- a/renderer/renderer.go +++ b/renderer/renderer.go @@ -80,17 +80,6 @@ func wrapLine(line string, width int, prefix, suffix string, includeFirst bool) return ret } -// tagLines splits a string into lines and adds a the given -// string to the start and another to the end. -// It is used for adding cview color tags. -func tagLines(s, start, end string) string { - lines := strings.Split(s, "\n") - for i := range lines { - lines[i] = start + lines[i] + end - } - return strings.Join(lines, "\n") -} - // convertRegularGemini converts non-preformatted blocks of text/gemini // into a cview-compatible format. // Since this only works on non-preformatted blocks, RenderGemini @@ -283,11 +272,6 @@ func convertRegularGemini(s string, numLinks, width int, proxied bool) (string, // If it's not a gemini:// page, set this to true. func RenderGemini(s string, width, leftMargin int, proxied bool) (string, []string) { s = cview.Escape(s) - if viper.GetBool("a-general.color") && viper.GetBool("a-general.ansi") { - s = cview.TranslateANSI(s) - } else { - s = ansiRegex.ReplaceAllString(s, "") - } lines := strings.Split(s, "\n") @@ -302,13 +286,22 @@ func RenderGemini(s string, width, leftMargin int, proxied bool) (string, []stri if pre { // In a preformatted block, so add the text as is // Don't add the current line with backticks - rendered += tagLines( - buf, - fmt.Sprintf("[%s]", config.GetColorString("preformatted_text")), - "[-]", - ) + + // Support ANSI color codes in preformatted blocks - see #59 + if viper.GetBool("a-general.color") && viper.GetBool("a-general.ansi") { + buf = cview.TranslateANSI(buf) + } else { + buf = ansiRegex.ReplaceAllString(buf, "") + } + + rendered += fmt.Sprintf("[%s]", config.GetColorString("preformatted_text")) + + buf + "[-]" } else { // Not preformatted, regular text + + // ANSI not allowed in regular text - see #59 + buf = ansiRegex.ReplaceAllString(buf, "") + ren, lks := convertRegularGemini(buf, len(links), width, proxied) links = append(links, lks...) rendered += ren @@ -323,10 +316,21 @@ func RenderGemini(s string, width, leftMargin int, proxied bool) (string, []stri // Gone through all the lines, but there still is likely a block in the buffer if pre { // File ended without closing the preformatted block - rendered += buf + // Same code as in the loop above + + if viper.GetBool("a-general.color") && viper.GetBool("a-general.ansi") { + buf = cview.TranslateANSI(buf) + } else { + buf = ansiRegex.ReplaceAllString(buf, "") + } + rendered += fmt.Sprintf("[%s]", config.GetColorString("preformatted_text")) + + buf + "[-]" } else { // Not preformatted, regular text // Same code as in the loop above + + buf = ansiRegex.ReplaceAllString(buf, "") + ren, lks := convertRegularGemini(buf, len(links), width, proxied) links = append(links, lks...) rendered += ren