Mediatypes support (#134)

Co-authored-by: makeworld <25111343+makeworld-the-better-one@users.noreply.github.com>
Co-authored-by: Stephen Robinson <stephen@drsudo.com>
This commit is contained in:
Stephen Robinson 2020-12-14 11:28:07 -08:00 committed by GitHub
parent 39290b09c6
commit 0df5effdcf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 339 additions and 47 deletions

View File

@ -116,6 +116,8 @@ 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] *Configure applications to open particular mediatypes*
- [ ] Allow piping/streaming content instead of downloading it first
- [x] Client certificate support
- [ ] Full client certificate UX within the client
- Create transient and permanent certs within the client, per domain

View File

@ -38,6 +38,7 @@ var bkmkDir string
var bkmkPath string
var DownloadsDir string
var TempDownloadsDir string
// Subscriptions
var subscriptionDir string
@ -46,6 +47,13 @@ var SubscriptionPath string
// Command for opening HTTP(S) URLs in the browser, from "a-general.http" in config.
var HTTPCommand []string
type MediaHandler struct {
Cmd []string
NoPrompt bool
}
var MediaHandlers = make(map[string]MediaHandler)
func Init() error {
// *** Set paths ***
@ -194,6 +202,36 @@ func Init() error {
DownloadsDir = dDir
}
// Setup temporary downloads dir
if viper.GetString("a-general.temp_downloads") == "" {
TempDownloadsDir = filepath.Join(os.TempDir(), "amfora_temp")
// Make sure it exists
err = os.MkdirAll(TempDownloadsDir, 0755)
if err != nil {
return fmt.Errorf("temp downloads path could not be created: %s", TempDownloadsDir)
}
} else {
// Validate path
dDir := viper.GetString("a-general.temp_downloads")
di, err := os.Stat(dDir)
if err == nil {
if !di.IsDir() {
return fmt.Errorf("temp downloads path specified is not a directory: %s", dDir)
}
} else if os.IsNotExist(err) {
// Try to create path
err = os.MkdirAll(dDir, 0755)
if err != nil {
return fmt.Errorf("temp downloads path could not be created: %s", dDir)
}
} else {
// Some other error
return fmt.Errorf("couldn't access temp downloads directory: %s", dDir)
}
TempDownloadsDir = dDir
}
// *** Setup vipers ***
TofuStore.SetConfigFile(tofuDBPath)
@ -228,6 +266,7 @@ func Init() error {
viper.SetDefault("a-general.left_margin", 0.15)
viper.SetDefault("a-general.max_width", 100)
viper.SetDefault("a-general.downloads", "")
viper.SetDefault("a-general.temp_downloads", "")
viper.SetDefault("a-general.page_max_size", 2097152)
viper.SetDefault("a-general.page_max_time", 10)
viper.SetDefault("a-general.emoji_favicons", false)
@ -279,5 +318,26 @@ func Init() error {
HTTPCommand = strings.Fields(viper.GetString("a-general.http"))
}
var rawMediaHandlers []struct {
Cmd []string `mapstructure:"cmd"`
Types []string `mapstructure:"types"`
NoPrompt bool `mapstructure:"no_prompt"`
}
err = viper.UnmarshalKey("mediatype-handlers", &rawMediaHandlers)
if err != nil {
return fmt.Errorf("couldn't parse mediatype-handlers section in config: %w", err)
}
for _, rawMediaHandler := range rawMediaHandlers {
for _, typ := range rawMediaHandler.Types {
if _, ok := MediaHandlers[typ]; ok {
return fmt.Errorf("multiple mediatype-handlers defined for %v", typ)
}
MediaHandlers[typ] = MediaHandler{
Cmd: rawMediaHandler.Cmd,
NoPrompt: rawMediaHandler.NoPrompt,
}
}
}
return nil
}

View File

@ -115,6 +115,54 @@ shift_numbers = "!@#$%^&*()"
other = 'off'
# [[mediatype-handlers]]
# Specify what applications will open certain media types.
# By default your default application will be used to open the file when you select "Open".
# You only need to configure this section if you want to override your default application,
# or do special things like streaming.
#
# To open jpeg files with the feh command:
# [[mediatype-handlers]]
# cmd = ["feh"]
# types = ["image/jpeg"]
#
# Each command that you specify must come under its own [[mediatype-handlers]]. You may
# specify as many [[mediatype-handlers]] as you want to setup multiple commands.
#
# If the subtype is omitted then the specified command will be used for the
# entire type:
# [[mediatype-handlers]]
# command = ["vlc", "--flag"]
# types = ["audio", "video"]
#
# A catch-all handler can by specified with "*".
# Note that there are already catch-all handlers in place for all OSes,
# that open the file using your default application. This is only if you
# want to override that.
# [[mediatype-handlers]]
# cmd = ["some-command"]
# types = [
# "application/pdf",
# "*",
# ]
#
# If you want to always open a type in its viewer without the download or open
# prompt appearing, you can add no_prompt = true
#
# [[mediatype-handlers]]
# cmd = ["feh"]
# types = ["image"]
# no_prompt = true
#
# Note: Multiple handlers cannot be defined for the same full media type, but
# still there needs to be an order for which handlers are used. The following
# order applies regardless of the order written in the config:
#
# 1. Full media type: "image/jpeg"
# 2. Just type: "image"
# 3. Catch-all: "*"
[cache]
# Options for page cache - which is only for text/gemini pages
# Increase the cache size to speed up browsing at the expense of memory

View File

@ -1,6 +1,10 @@
#!/usr/bin/env bash
head -n 3 default.go | tee default.go > /dev/null
cat > default.go <<-EOF
package config
//go:generate ./default.sh
EOF
echo -n 'var defaultConf = []byte(`' >> default.go
cat ../default-config.toml >> default.go
echo '`)' >> default.go
echo '`)' >> default.go

View File

@ -112,6 +112,54 @@ shift_numbers = "!@#$%^&*()"
other = 'off'
# [[mediatype-handlers]]
# Specify what applications will open certain media types.
# By default your default application will be used to open the file when you select "Open".
# You only need to configure this section if you want to override your default application,
# or do special things like streaming.
#
# To open jpeg files with the feh command:
# [[mediatype-handlers]]
# cmd = ["feh"]
# types = ["image/jpeg"]
#
# Each command that you specify must come under its own [[mediatype-handlers]]. You may
# specify as many [[mediatype-handlers]] as you want to setup multiple commands.
#
# If the subtype is omitted then the specified command will be used for the
# entire type:
# [[mediatype-handlers]]
# command = ["vlc", "--flag"]
# types = ["audio", "video"]
#
# A catch-all handler can by specified with "*".
# Note that there are already catch-all handlers in place for all OSes,
# that open the file using your default application. This is only if you
# want to override that.
# [[mediatype-handlers]]
# cmd = ["some-command"]
# types = [
# "application/pdf",
# "*",
# ]
#
# If you want to always open a type in its viewer without the download or open
# prompt appearing, you can add no_prompt = true
#
# [[mediatype-handlers]]
# cmd = ["feh"]
# types = ["image"]
# no_prompt = true
#
# Note: Multiple handlers cannot be defined for the same full media type, but
# still there needs to be an order for which handlers are used. The following
# order applies regardless of the order written in the config:
#
# 1. Full media type: "image/jpeg"
# 2. Just type: "image"
# 3. Catch-all: "*"
[cache]
# Options for page cache - which is only for text/gemini pages
# Increase the cache size to speed up browsing at the expense of memory

View File

@ -4,8 +4,10 @@ import (
"fmt"
"io"
"io/ioutil"
"mime"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"strconv"
@ -15,15 +17,16 @@ import (
"github.com/gdamore/tcell"
"github.com/makeworld-the-better-one/amfora/config"
"github.com/makeworld-the-better-one/amfora/structs"
"github.com/makeworld-the-better-one/amfora/sysopen"
"github.com/makeworld-the-better-one/go-gemini"
"github.com/makeworld-the-better-one/progressbar/v3"
"github.com/spf13/viper"
"gitlab.com/tslocum/cview"
)
// For choosing between download and the portal - copy of YesNo basically
// For choosing between download and opening - copy of YesNo basically
var dlChoiceModal = cview.NewModal().
AddButtons([]string{"Download", "Open in portal", "Cancel"})
AddButtons([]string{"Open", "Download", "Cancel"})
// Channel to indicate what choice they made using the button text
var dlChoiceCh = make(chan string)
@ -83,46 +86,62 @@ func dlInit() {
})
}
func getMediaHandler(resp *gemini.Response) config.MediaHandler {
def := config.MediaHandler{
Cmd: nil,
NoPrompt: false,
}
mediatype, _, err := mime.ParseMediaType(resp.Meta)
if err != nil {
return def
}
if ret, ok := config.MediaHandlers[mediatype]; ok {
return ret
}
splitType := strings.Split(mediatype, "/")[0]
if ret, ok := config.MediaHandlers[splitType]; ok {
return ret
}
if ret, ok := config.MediaHandlers["*"]; ok {
return ret
}
return def
}
// dlChoice displays the download choice modal and acts on the user's choice.
// It should run in a goroutine.
func dlChoice(text, u string, resp *gemini.Response) {
defer resp.Body.Close()
parsed, err := url.Parse(u)
if err != nil {
Error("URL Error", err.Error())
return
mediaHandler := getMediaHandler(resp)
var choice string
if mediaHandler.NoPrompt {
choice = "Open"
} else {
dlChoiceModal.SetText(text)
tabPages.ShowPage("dlChoice")
tabPages.SendToFront("dlChoice")
App.SetFocus(dlChoiceModal)
App.Draw()
choice = <-dlChoiceCh
}
dlChoiceModal.SetText(text)
tabPages.ShowPage("dlChoice")
tabPages.SendToFront("dlChoice")
App.SetFocus(dlChoiceModal)
App.Draw()
choice := <-dlChoiceCh
if choice == "Download" {
tabPages.HidePage("dlChoice")
App.Draw()
downloadURL(u, resp)
downloadURL(config.DownloadsDir, u, resp)
return
}
if choice == "Open in portal" {
// Open in mozz's proxy
portalURL := u
if parsed.RawQuery != "" {
// Remove query and add encoded version on the end
query := parsed.RawQuery
parsed.RawQuery = ""
portalURL = parsed.String() + "%3F" + query
}
portalURL = strings.TrimPrefix(portalURL, "gemini://") + "?raw=1"
ok := handleHTTP("https://portal.mozz.us/gemini/"+portalURL, false)
if ok {
tabPages.SwitchToPage(strconv.Itoa(curTab))
App.SetFocus(tabs[curTab].view)
App.Draw()
}
if choice == "Open" {
tabPages.HidePage("dlChoice")
App.Draw()
open(u, resp)
return
}
tabPages.SwitchToPage(strconv.Itoa(curTab))
@ -130,9 +149,43 @@ func dlChoice(text, u string, resp *gemini.Response) {
App.Draw()
}
// open performs the same actions as downloadURL except it also opens the file.
// If there is no system viewer configured for the particular mediatype, it opens it
// with the default system viewer.
func open(u string, resp *gemini.Response) {
mediaHandler := getMediaHandler(resp)
path := downloadURL(config.TempDownloadsDir, u, resp)
if path == "" {
return
}
tabPages.SwitchToPage(strconv.Itoa(curTab))
App.SetFocus(tabs[curTab].view)
App.Draw()
if mediaHandler.Cmd == nil {
// Open with system default viewer
_, err := sysopen.Open(path)
if err != nil {
Error("System Viewer Error", err.Error())
return
}
Info("Opened in default system viewer")
} else {
cmd := mediaHandler.Cmd
err := exec.Command(cmd[0], append(cmd[1:], path)...).Start()
if err != nil {
Error("File Opening Error", "Error executing custom command: "+err.Error())
return
}
Info("Opened with " + cmd[0])
}
App.SetFocus(dlModal)
App.Draw()
}
// downloadURL pulls up a modal to show download progress and saves the URL content.
// downloadPage should be used for Page content.
func downloadURL(u string, resp *gemini.Response) {
// Returns location downloaded to or an empty string on error.
func downloadURL(dir, u string, resp *gemini.Response) string {
_, _, width, _ := dlModal.GetInnerRect()
// Copy of progressbar.DefaultBytesSilent with custom width
bar := progressbar.NewOptions64(
@ -146,15 +199,15 @@ func downloadURL(u string, resp *gemini.Response) {
)
bar.RenderBlank() //nolint:errcheck
savePath, err := downloadNameFromURL(u, "")
savePath, err := downloadNameFromURL(dir, u, "")
if err != nil {
Error("Download Error", "Error deciding on file name: "+err.Error())
return
return ""
}
f, err := os.OpenFile(savePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
Error("Download Error", "Error creating download file: "+err.Error())
return
return ""
}
defer f.Close()
@ -184,7 +237,7 @@ func downloadURL(u string, resp *gemini.Response) {
Error("Download Error", err.Error())
f.Close()
os.Remove(savePath) // Remove partial file
return
return ""
}
dlModal.SetText(fmt.Sprintf("Download complete! File saved to %s.", savePath))
dlModal.ClearButtons()
@ -192,6 +245,8 @@ func downloadURL(u string, resp *gemini.Response) {
dlModal.GetForm().SetFocus(100)
App.SetFocus(dlModal)
App.Draw()
return savePath
}
// downloadPage saves the passed Page to a file.
@ -202,9 +257,9 @@ func downloadPage(p *structs.Page) (string, error) {
var err error
if p.Mediatype == structs.TextGemini {
savePath, err = downloadNameFromURL(p.URL, ".gmi")
savePath, err = downloadNameFromURL(config.DownloadsDir, p.URL, ".gmi")
} else {
savePath, err = downloadNameFromURL(p.URL, ".txt")
savePath, err = downloadNameFromURL(config.DownloadsDir, p.URL, ".txt")
}
if err != nil {
return "", err
@ -221,13 +276,13 @@ func downloadPage(p *structs.Page) (string, error) {
// downloadNameFromURL takes a URl and returns a safe download path that will not overwrite any existing file.
// ext is an extension that will be added if the file has no extension, and for domain only URLs.
// It should include the dot.
func downloadNameFromURL(u string, ext string) (string, error) {
func downloadNameFromURL(dir, u, ext string) (string, error) {
var name string
var err error
parsed, _ := url.Parse(u)
if parsed.Path == "" || path.Base(parsed.Path) == "/" {
// No file, just the root domain
name, err = getSafeDownloadName(parsed.Hostname()+ext, true, 0)
name, err = getSafeDownloadName(dir, parsed.Hostname()+ext, true, 0)
if err != nil {
return "", err
}
@ -238,23 +293,23 @@ func downloadNameFromURL(u string, ext string) (string, error) {
// No extension
name += ext
}
name, err = getSafeDownloadName(name, false, 0)
name, err = getSafeDownloadName(dir, name, false, 0)
if err != nil {
return "", err
}
}
return filepath.Join(config.DownloadsDir, name), nil
return filepath.Join(dir, name), nil
}
// getSafeDownloadName is used by downloads.go only.
// It returns a modified name that is unique for the downloads folder.
// It returns a modified name that is unique for the specified folder.
// This way duplicate saved files will not overwrite each other.
//
// lastDot should be set to true if the number added to the name should come before
// the last dot in the filename instead of the first.
//
// n should be set to 0, it is used for recursiveness.
func getSafeDownloadName(name string, lastDot bool, n int) (string, error) {
func getSafeDownloadName(dir, name string, lastDot bool, n int) (string, error) {
// newName("test.txt", 3) -> "test(3).txt"
newName := func() string {
if n <= 0 {
@ -271,7 +326,7 @@ func getSafeDownloadName(name string, lastDot bool, n int) (string, error) {
return name[:idx] + "(" + strconv.Itoa(n) + ")" + name[idx:]
}
d, err := os.Open(config.DownloadsDir)
d, err := os.Open(dir)
if err != nil {
return "", err
}
@ -285,7 +340,7 @@ func getSafeDownloadName(name string, lastDot bool, n int) (string, error) {
for i := range files {
if nn == files[i] {
d.Close()
return getSafeDownloadName(name, lastDot, n+1)
return getSafeDownloadName(dir, name, lastDot, n+1)
}
}
d.Close()

View File

@ -0,0 +1,14 @@
// +build darwin
package sysopen
import "os/exec"
// Open opens `path` in default system viewer.
func Open(path string) (string, error) {
err := exec.Command("open", path).Start()
if err != nil {
return "", err
}
return "Opened in default system viewer", nil
}

View File

@ -0,0 +1,11 @@
// +build !linux,!darwin,!windows,!freebsd,!netbsd,!openbsd
package sysopen
import "fmt"
// Open opens `path` in default system viewer, but not on this OS.
func Open(path string) (string, error) {
return "", fmt.Errorf("unsupported OS for default system viewer. " +
"Set a catch-all [[mediatype-handlers]] command in the config")
}

View File

@ -0,0 +1,35 @@
// +build linux freebsd netbsd openbsd
//nolint:goerr113
package sysopen
import (
"fmt"
"os"
"os/exec"
)
// Open opens `path` in default system viewer. It tries to do so using
// xdg-open. It only works if there is a display server working.
func Open(path string) (string, error) {
var (
xorgDisplay = os.Getenv("DISPLAY")
waylandDisplay = os.Getenv("WAYLAND_DISPLAY")
xdgOpenPath, xdgOpenNotFoundErr = exec.LookPath("xdg-open")
)
switch {
case xorgDisplay == "" && waylandDisplay == "":
return "", fmt.Errorf("no display server was found. " +
"You may set a default [[mediatype-handlers]] command in the config")
case xdgOpenNotFoundErr == nil:
// Use start rather than run or output in order
// to make application run in background.
if err := exec.Command(xdgOpenPath, path).Start(); err != nil {
return "", err
}
return "Opened in default system viewer", nil
default:
return "", fmt.Errorf("could not determine default system viewer. " +
"Set a catch-all [[mediatype-handlers]] command in the config")
}
}

View File

@ -0,0 +1,15 @@
// +build windows
// +build !linux !darwin !freebsd !netbsd !openbsd
package sysopen
import "os/exec"
// Open opens `path` in default system vierwer.
func Open(path string) (string, error) {
err := exec.Command("rundll32", "url.dll,FileProtocolHandler", path).Start()
if err != nil {
return "", err
}
return "Opened in default system viewer", nil
}