amfora/display/handlers.go
2021-12-28 17:35:08 -05:00

517 lines
14 KiB
Go

package display
import (
"errors"
"fmt"
"mime"
"net"
"net/url"
"os/exec"
"path"
"strings"
"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/rr"
"github.com/makeworld-the-better-one/amfora/structs"
"github.com/makeworld-the-better-one/amfora/subscriptions"
"github.com/makeworld-the-better-one/amfora/sysopen"
"github.com/makeworld-the-better-one/amfora/webbrowser"
"github.com/makeworld-the-better-one/go-gemini"
"github.com/spf13/viper"
)
// handleHTTP is used by handleURL.
// It opens HTTP links and displays Info and Error modals.
// 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 proc *exec.Cmd
if len(config.HTTPCommand) > 1 {
proc = exec.Command(config.HTTPCommand[0], append(config.HTTPCommand[1:], u)...)
} else {
proc = exec.Command(config.HTTPCommand[0], u)
}
err := proc.Start()
if err != nil {
Error("HTTP Error", "Error executing custom browser command: "+err.Error())
return false
}
//nolint:errcheck
go proc.Wait() // Prevent zombies, see #219
Info("Opened with: " + config.HTTPCommand[0])
App.Draw()
return true
}
// handleOther is used by handleURL.
// It opens links other than Gemini and HTTP and displays Error modals.
func handleOther(u string) {
// The URL should have a scheme due to a previous call to normalizeURL
parsed, _ := url.Parse(u)
// Search for a handler for the URL scheme
handler := viper.GetStringSlice("url-handlers." + parsed.Scheme)
if len(handler) == 0 {
// A string and not a list of strings, use old method of parsing
// #214
handler = strings.Fields(viper.GetString("url-handlers." + parsed.Scheme))
if len(handler) == 0 {
handler = viper.GetStringSlice("url-handlers.other")
if len(handler) == 0 {
handler = strings.Fields(viper.GetString("url-handlers.other"))
}
}
}
if len(handler) == 1 {
// Maybe special key
switch strings.TrimSpace(handler[0]) {
case "", "off":
Error("URL Error", "Opening "+parsed.Scheme+" URLs is turned off.")
return
case "default":
_, err := sysopen.Open(u)
if err != nil {
Error("Application Error", err.Error())
return
}
Info("Opened in default application")
return
}
}
// Custom application command
var proc *exec.Cmd
if len(handler) > 1 {
proc = exec.Command(handler[0], append(handler[1:], u)...)
} else {
proc = exec.Command(handler[0], u)
}
err := proc.Start()
if err != nil {
Error("URL Error", "Error executing custom command: "+err.Error())
}
//nolint:errcheck
go proc.Wait() // Prevent zombies, see #219
Info("Opened with: " + handler[0])
App.Draw()
}
// handleAbout can be called to deal with any URLs that start with
// 'about:'. It will display errors if the URL is not recognized,
// but not display anything if an 'about:' URL is not passed.
//
// It does not add the displayed page to history.
//
// It returns the URL displayed, and a bool indicating if the provided
// URL could be handled. The string returned will always be empty
// if the bool is false.
func handleAbout(t *tab, u string) (string, bool) {
if !strings.HasPrefix(u, "about:") {
return "", false
}
switch u {
case "about:bookmarks":
Bookmarks(t)
return u, true
case "about:newtab":
temp := newTabPage // Copy
setPage(t, &temp)
t.applyBottomBar()
return u, true
case "about:version":
temp := versionPage
setPage(t, &temp)
t.applyBottomBar()
return u, true
case "about:license":
temp := licensePage
setPage(t, &temp)
t.applyBottomBar()
return u, true
case "about:thanks":
temp := thanksPage
setPage(t, &temp)
t.applyBottomBar()
return u, true
case "about:about":
temp := aboutPage
setPage(t, &temp)
t.applyBottomBar()
return u, true
}
if u == "about:subscriptions" || (len(u) > 20 && u[:20] == "about:subscriptions?") {
// about:subscriptions?2 views page 2
return Subscriptions(t, u), true
}
if u == "about:manage-subscriptions" || (len(u) > 27 && u[:27] == "about:manage-subscriptions?") {
ManageSubscriptions(t, u)
// Don't count remove command in history
if u == "about:manage-subscriptions" {
return u, true
}
return "", false
}
Error("Error", "Not a valid 'about:' URL.")
return "", false
}
// handleURL displays whatever action is needed for the provided URL,
// and applies it to the current tab.
// It loads documents, handles errors, brings up a download prompt, etc.
//
// The string returned is the final URL, if redirects were involved.
// In most cases it will be the same as the passed URL.
// If there is some error, it will return "".
// The second returned item is a bool indicating if page content was displayed.
// It returns false for Errors, other protocols, etc.
//
// The bottomBar is not actually changed in this func, except during loading.
// The func that calls this one should apply the bottomBar values if necessary.
//
// numRedirects is the number of redirects that resulted in the provided URL.
// It should typically be 0.
func handleURL(t *tab, u string, numRedirects int) (string, bool) {
defer App.Draw() // Just in case
// Save for resetting on error
oldLable := t.barLabel
oldText := t.barText
// Custom return function
ret := func(s string, b bool) (string, bool) {
if !b {
// Reset bottomBar if page wasn't loaded
t.barLabel = oldLable
t.barText = oldText
}
t.mode = tabModeDone
t.preferURLHandler = false
go func(p *structs.Page) {
if b && t.hasContent() && !t.isAnAboutPage() && viper.GetBool("subscriptions.popup") {
// The current page might be an untracked feed, and the user wants
// to be notified in such cases.
feed, isFeed := getFeedFromPage(p)
if isFeed && isValidTab(t) && t.page == p {
// After parsing and track-checking time, the page is still being displayed
addFeedDirect(p.URL, feed, subscriptions.IsSubscribed(p.URL))
}
}
}(t.page)
return s, b
}
t.barLabel = ""
bottomBar.SetLabel("")
App.SetFocus(t.view)
if strings.HasPrefix(u, "about:") {
return ret(handleAbout(t, u))
}
u = client.NormalizeURL(u)
u = cache.Redirect(u)
parsed, err := url.Parse(u)
if err != nil {
Error("URL Error", err.Error())
return ret("", false)
}
proxy := strings.TrimSpace(viper.GetString("proxies." + parsed.Scheme))
usingProxy := false
proxyHostname, proxyPort, err := net.SplitHostPort(proxy)
if err != nil {
// Error likely means there's no port in the host
proxyHostname = proxy
proxyPort = "1965"
}
if strings.HasPrefix(u, "http") {
if proxy == "" || proxy == "off" || t.preferURLHandler {
// No proxy available
handleHTTP(u, true)
return ret("", false)
}
usingProxy = true
}
if strings.HasPrefix(u, "file") {
page, ok := handleFile(u)
if !ok {
return ret("", false)
}
setPage(t, page)
return ret(u, true)
}
if !strings.HasPrefix(u, "http") && !strings.HasPrefix(u, "gemini") && !strings.HasPrefix(u, "file") {
// Not a Gemini URL
if proxy == "" || proxy == "off" || t.preferURLHandler {
// No proxy available
handleOther(u)
return ret("", false)
}
usingProxy = true
}
// Gemini URL, or one with a Gemini proxy available
// 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...")
t.barText = "Loading..." // Save it too, in case the tab switches during loading
t.mode = tabModeLoading
App.Draw()
var res *gemini.Response
if usingProxy {
res, err = client.FetchWithProxy(proxyHostname, proxyPort, u)
} else {
res, err = client.Fetch(u)
}
// Loading may have taken a while, make sure tab is still valid
if !isValidTab(t) {
return ret("", false)
}
if errors.Is(err, client.ErrTofu) {
if usingProxy {
// They are using a proxy
if Tofu(proxy, client.GetExpiry(proxyHostname, proxyPort)) {
// They want to continue anyway
client.ResetTofuEntry(proxyHostname, proxyPort, res.Cert)
// Response can be used further down, no need to reload
} else {
// They don't want to continue
return ret("", false)
}
} else {
if Tofu(parsed.Host, client.GetExpiry(parsed.Hostname(), parsed.Port())) {
// They want to continue anyway
client.ResetTofuEntry(parsed.Hostname(), parsed.Port(), res.Cert)
// Response can be used further down, no need to reload
} else {
// They don't want to continue
return ret("", false)
}
}
} else if err != nil {
Error("URL Fetch Error", err.Error())
return ret("", false)
}
// Fetch happened successfully, use RestartReader to buffer read data
res.Body = rr.NewRestartReader(res.Body)
if renderer.CanDisplay(res) {
page, err := renderer.MakePage(u, res, textWidth(), usingProxy)
// Rendering may have taken a while, make sure tab is still valid
if !isValidTab(t) {
return ret("", false)
}
if errors.Is(err, renderer.ErrTooLarge) {
// Downloading now
// Disable read timeout and go back to start
res.SetReadTimeout(0) //nolint: errcheck
res.Body.(*rr.RestartReader).Restart()
dlChoice("That page is too large. What would you like to do?", u, res)
return ret("", false)
}
if errors.Is(err, renderer.ErrTimedOut) {
// Downloading now
// Disable read timeout and go back to start
res.SetReadTimeout(0) //nolint: errcheck
res.Body.(*rr.RestartReader).Restart()
dlChoice("Loading that page timed out. What would you like to do?", u, res)
return ret("", false)
}
if err != nil {
Error("Page Error", "Issuing creating page: "+err.Error())
return ret("", false)
}
page.TermWidth = termW
if !client.HasClientCert(parsed.Host, parsed.Path) {
// Don't cache pages with client certs
go cache.AddPage(page)
}
setPage(t, page)
return ret(u, true)
}
// Not displayable
// Could be a non 20 status code, or a different kind of document
// Handle each status code
// Except 20, that's handled after the switch
status := gemini.CleanStatus(res.Status)
switch status {
case 10, 11:
var userInput string
var ok bool
if status == 10 {
// Regular input
userInput, ok = Input(res.Meta, false)
} else {
// Sensitive input
userInput, ok = Input(res.Meta, true)
}
if ok {
// Make another request with the query string added
parsed.RawQuery = gemini.QueryEscape(userInput)
if len(parsed.String()) > gemini.URLMaxLength {
Error("Input Error", "URL for that input would be too long.")
return ret("", false)
}
return ret(handleURL(t, parsed.String(), 0))
}
return ret("", false)
case 30, 31:
parsedMeta, err := url.Parse(res.Meta)
if err != nil {
Error("Redirect Error", "Invalid URL: "+err.Error())
return ret("", false)
}
redir := parsed.ResolveReference(parsedMeta).String()
justAddsSlash := (redir == u+"/")
// Prompt before redirecting to non-Gemini protocol
redirect := false
if !justAddsSlash && !strings.HasPrefix(redir, "gemini") {
if YesNo("Follow redirect to non-Gemini URL?\n" + redir) {
redirect = true
} else {
return ret("", false)
}
}
// Prompt before redirecting
autoRedirect := justAddsSlash || viper.GetBool("a-general.auto_redirect")
if redirect || (autoRedirect && numRedirects < 5) || YesNo("Follow redirect?\n"+redir) {
if status == gemini.StatusRedirectPermanent {
go cache.AddRedir(u, redir)
}
return ret(handleURL(t, redir, numRedirects+1))
}
return ret("", false)
case 40:
Error("Temporary Failure", escapeMeta(res.Meta))
return ret("", false)
case 41:
Error("Server Unavailable", escapeMeta(res.Meta))
return ret("", false)
case 42:
Error("CGI Error", escapeMeta(res.Meta))
return ret("", false)
case 43:
Error("Proxy Failure", escapeMeta(res.Meta))
return ret("", false)
case 44:
Error("Slow Down", "You should wait "+escapeMeta(res.Meta)+" seconds before making another request.")
return ret("", false)
case 50:
Error("Permanent Failure", escapeMeta(res.Meta))
return ret("", false)
case 51:
Error("Not Found", escapeMeta(res.Meta))
return ret("", false)
case 52:
Error("Gone", escapeMeta(res.Meta))
return ret("", false)
case 53:
Error("Proxy Request Refused", escapeMeta(res.Meta))
return ret("", false)
case 59:
Error("Bad Request", escapeMeta(res.Meta))
return ret("", false)
case 60:
Error("Client Certificate Required", escapeMeta(res.Meta))
return ret("", false)
case 61:
Error("Certificate Not Authorised", escapeMeta(res.Meta))
return ret("", false)
case 62:
Error("Certificate Not Valid", escapeMeta(res.Meta))
return ret("", false)
default:
if !gemini.StatusInRange(status) {
// Status code not in a valid range
Error("Status Code Error", fmt.Sprintf("Out of range status code: %d", status))
return ret("", false)
}
}
// Status code 20, but not a document that can be displayed
// First see if it's a feed, and ask the user about adding it if it is
filename := path.Base(parsed.Path)
mediatype, _, _ := mime.ParseMediaType(res.Meta)
feed, ok := subscriptions.GetFeed(mediatype, filename, res.Body)
if ok {
go func() {
added := addFeedDirect(u, feed, subscriptions.IsSubscribed(u))
if !added {
// Otherwise offer download choices
// Disable read timeout and go back to start
res.SetReadTimeout(0) //nolint: errcheck
res.Body.(*rr.RestartReader).Restart()
dlChoice("That file could not be displayed. What would you like to do?", u, res)
}
}()
return ret("", false)
}
// Otherwise offer download choices
// Disable read timeout and go back to start
res.SetReadTimeout(0) //nolint: errcheck
res.Body.(*rr.RestartReader).Restart()
dlChoice("That file could not be displayed. What would you like to do?", u, res)
return ret("", false)
}