diff --git a/CHANGELOG b/CHANGELOG index b06524c..c29fc4d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,6 @@ +1.0.2: +- Support bookmarks + 1.0.1: - Add option to allow local file access - Display navigation bar in pages diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 19139c7..df685f8 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -3,6 +3,14 @@ default. You may specify a different location via the `--config` argument. # Configuration options +## Bookmarks + +Bookmarks are defined as a list of URLs and corresponding label. + +Defining bookmarks manually via configuration file is possible, however it is +not required as the gmitohtml configuration file is updated when bookmarks are +modified using the web interface. + ## Client certificates Client certificates may be specified via the `Certs` option. @@ -30,6 +38,10 @@ For example, to view `/home/dioscuri/sites/gemlog/index.gmi`, navigate to # Example config.yaml ```yaml +bookmarks: + gemini://gemini.circumlunar.space/: Gemini protocol + gemini://gus.guru/: GUS - Gemini Universal Search + certs: astrobotany.mozz.us: cert: /home/dioscuri/.config/gmitohtml/astrobotany.mozz.us.crt diff --git a/config.go b/config.go index 0e19dcf..f750142 100644 --- a/config.go +++ b/config.go @@ -3,8 +3,12 @@ package main import ( "crypto/tls" "errors" + "fmt" "io/ioutil" + "os" + "path" + "gitlab.com/tslocum/gmitohtml/pkg/gmitohtml" "gopkg.in/yaml.v3" ) @@ -16,13 +20,25 @@ type certConfig struct { } type appConfig struct { + Bookmarks map[string]string + Certs map[string]*certConfig } var config = &appConfig{ + Bookmarks: make(map[string]string), + Certs: make(map[string]*certConfig), } +func defaultConfigPath() string { + homedir, err := os.UserHomeDir() + if err == nil && homedir != "" { + return path.Join(homedir, ".config", "gmitohtml", "config.yaml") + } + return "" +} + func readconfig(configPath string) error { if configPath == "" { return errors.New("file unspecified") @@ -42,3 +58,20 @@ func readconfig(configPath string) error { return nil } + +func saveConfig(configPath string) error { + config.Bookmarks = gmitohtml.GetBookmarks() + + out, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal configuration: %s", err) + } + + os.MkdirAll(path.Dir(configPath), 0755) // Ignore error + + err = ioutil.WriteFile(configPath, out, 0644) + if err != nil { + return fmt.Errorf("failed to save configuration to %s: %s", configPath, err) + } + return nil +} diff --git a/main.go b/main.go index da503f0..80fbf3f 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,6 @@ import ( "net/url" "os" "os/exec" - "path" "runtime" "gitlab.com/tslocum/gmitohtml/pkg/gmitohtml" @@ -43,20 +42,26 @@ func main() { // TODO option to include response header in page flag.Parse() + defaultConfig := defaultConfigPath() if configFile == "" { - homedir, err := os.UserHomeDir() - if err == nil && homedir != "" { - defaultConfig := path.Join(homedir, ".config", "gmitohtml", "config.yaml") - if _, err := os.Stat(defaultConfig); !os.IsNotExist(err) { - configFile = defaultConfig - } - } + configFile = defaultConfig } if configFile != "" { - err := readconfig(configFile) - if err != nil { - log.Fatalf("failed to read configuration file at %s: %v\nSee CONFIGURATION.md for information on configuring gmitohtml", configFile, err) + var configExists bool + if _, err := os.Stat(defaultConfig); !os.IsNotExist(err) { + configExists = true + } + + if configExists || configFile != defaultConfig { + err := readconfig(configFile) + if err != nil { + log.Fatalf("failed to read configuration file at %s: %v\nSee CONFIGURATION.md for information on configuring gmitohtml", configFile, err) + } + + for u, label := range config.Bookmarks { + gmitohtml.AddBookmark(u, label) + } } } @@ -78,6 +83,15 @@ func main() { } if daemon != "" { + gmitohtml.SetOnBookmarksChanged(func() { + config.Bookmarks = gmitohtml.GetBookmarks() + + err := saveConfig(configFile) + if err != nil { + log.Fatal(err) + } + }) + err := gmitohtml.StartDaemon(daemon, allowFile) if err != nil { log.Fatal(err) diff --git a/pkg/gmitohtml/assets.go b/pkg/gmitohtml/assets.go index dc0a1e5..33d55fe 100644 --- a/pkg/gmitohtml/assets.go +++ b/pkg/gmitohtml/assets.go @@ -12,22 +12,15 @@ const pageHeader = `
-
-
` +` -const pageFooter = ` - - +const navigationMenuText = ` +
+View bookmarks  -  Add bookmark +
` -const indexPage = pageHeader + ` - -` + pageFooter - -const inputPage = pageHeader + ` +const inputPrompt = `
~GEMINIINPUTPROMPT~

@@ -36,7 +29,12 @@ const inputPage = pageHeader + `
-` + pageFooter +` + +const pageFooter = ` + + +` func loadAssets() { fs["/assets/style.css"] = loadFile("style.css", ` @@ -123,6 +121,34 @@ a { background-color: transparent; } +a { + color: #0000EE; + text-decoration: none; +} +a:hover, +a:focus, +a:active { + color: #FF0000; + text-decoration: underline; +} +a:visited { + color: #551A8B; +} + +a.navlink { + color: #0000EE; + text-decoration: none; +} +a.navlink:hover, +a.navlink:focus, +a.navlink:active { + color: #FF0000; + text-decoration: underline; +} +a.navlink:visited { + color: #0000EE; +} + /** * 1. Remove the bottom border in Chrome 57- * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. @@ -395,37 +421,51 @@ template { } @media (prefers-color-scheme: dark) { - body { - color: white; - background-color: black; - } + body { + color: white; + background-color: black; + } - h1, h2, h3, h4, h5, h6 { - color: white; - } + h1, h2, h3, h4, h5, h6 { + color: white; + } - a { - color: rgb(26, 168, 245); - text-decoration: none; - } - a:hover, - a:focus, - a:active { - color: rgb(24, 151, 219); - text-decoration: underline; - } - a:visited { - color: rgb(200, 118, 255); - } + a { + color: rgb(26, 168, 245); + text-decoration: none; + } + a:hover, + a:focus, + a:active { + color: rgb(24, 151, 219); + text-decoration: underline; + } + a:visited { + color: rgb(200, 118, 255); + } - input { - background-color: black; - color: white; - border-color: gray; - border-width: 0.3em; - border-style: solid; - padding: 0.5em; - } + a.navlink { + color: rgb(26, 168, 245); + text-decoration: none; + } + a.navlink:hover, + a.navlink:focus, + a.navlink:active { + color: rgb(24, 151, 219); + text-decoration: underline; + } + a.navlink:visited { + color: rgb(26, 168, 245); + } + + input { + background-color: black; + color: white; + border-color: gray; + border-width: 0.3em; + border-style: solid; + padding: 0.5em; + } } `, fs) } diff --git a/pkg/gmitohtml/convert.go b/pkg/gmitohtml/convert.go index 36c1f24..1a76675 100644 --- a/pkg/gmitohtml/convert.go +++ b/pkg/gmitohtml/convert.go @@ -61,6 +61,13 @@ func rewriteURL(u string, loc *url.URL) string { return u } +func navigationMenu() []byte { + if daemonAddress == "" { + return nil + } + return []byte(navigationMenuText) +} + // Convert converts text/gemini to text/html. func Convert(page []byte, u string) []byte { var result []byte @@ -148,7 +155,9 @@ func Convert(page []byte, u string) []byte { result = append(result, []byte("\n")...) } - result = append([]byte(pageHeader), result...) - result = append(result, []byte(pageFooter)...) - return fillTemplateVariables(result, u, false) + data := []byte(pageHeader) + data = append(data, navigationMenu()...) + data = append(data, result...) + data = append(data, []byte(pageFooter)...) + return fillTemplateVariables(data, u, false) } diff --git a/pkg/gmitohtml/daemon.go b/pkg/gmitohtml/daemon.go index 61ccacd..0bc548d 100644 --- a/pkg/gmitohtml/daemon.go +++ b/pkg/gmitohtml/daemon.go @@ -12,6 +12,7 @@ import ( "net/http" "net/url" "path" + "sort" "strings" "time" ) @@ -19,14 +20,36 @@ import ( var lastRequestTime = time.Now().Unix() var ( - clientCerts = make(map[string]tls.Certificate) - allowFileAccess bool + clientCerts = make(map[string]tls.Certificate) + bookmarks = make(map[string]string) + bookmarksSorted []string + allowFileAccess bool + onBookmarksChanged func() ) +var defaultBookmarks = map[string]string{ + "gemini://gemini.circumlunar.space/": "Project Gemini", + "gemini://gus.guru/": "GUS - Gemini Universal Search", +} + // ErrInvalidCertificate is the error returned when an invalid certificate is provided. var ErrInvalidCertificate = errors.New("invalid certificate") -// Fetch downloads and converts a Gemini page. +func bookmarksList() []byte { + fakeURL, _ := url.Parse("/") // Always succeeds + + var b bytes.Buffer + b.Write([]byte(`
`)) + b.Write([]byte(`
Bookmarks
`)) + b.Write([]byte(`")) + return b.Bytes() +} + +// fetch downloads and converts a Gemini page. func fetch(u string) ([]byte, []byte, error) { if u == "" { return nil, nil, ErrInvalidURL @@ -94,7 +117,10 @@ func fetch(u string) ([]byte, []byte, error) { if requestInput { requestSensitiveInput := bytes.HasPrefix(header, []byte("11")) - data = []byte(inputPage) + data = []byte(pageHeader) + data = append(data, navigationMenu()...) + + data = append(data, []byte(inputPrompt)...) data = bytes.Replace(data, []byte("~GEMINIINPUTFORM~"), []byte(html.EscapeString(rewriteURL(u, requestURL))), 1) @@ -133,7 +159,12 @@ func handleIndex(writer http.ResponseWriter, request *http.Request) { return } - writer.Write(fillTemplateVariables([]byte(indexPage), request.URL.String(), true)) + var page []byte + page = append(page, pageHeader...) + page = append(page, bookmarksList()...) + page = append(page, pageFooter...) + + writer.Write(fillTemplateVariables(page, request.URL.String(), true)) } func fillTemplateVariables(data []byte, currentURL string, autofocus bool) []byte { @@ -242,6 +273,81 @@ func handleAssets(writer http.ResponseWriter, request *http.Request) { http.FileServer(fs).ServeHTTP(writer, request) } +func handleBookmarks(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/html; charset=utf-8") + var data []byte + + postAddress := request.PostFormValue("address") + postLabel := request.PostFormValue("label") + if postLabel == "" && postAddress != "" { + postLabel = postAddress + } + + editBookmark := request.FormValue("edit") + if editBookmark != "" { + if postLabel == "" { + label, ok := bookmarks[editBookmark] + if !ok { + writer.Write([]byte("

Error: bookmark not found

")) + return + } + + data = []byte(pageHeader) + + data = append(data, []byte(fmt.Sprintf(`

Edit bookmark





`, request.URL.Path+"?"+request.URL.RawQuery, html.EscapeString(editBookmark), html.EscapeString(label)))...) + + data = append(data, []byte(pageFooter)...) + + writer.Write(fillTemplateVariables(data, "", false)) + return + } + + if editBookmark != postAddress || bookmarks[editBookmark] != postLabel { + RemoveBookmark(editBookmark) + AddBookmark(postAddress, postLabel) + } + } else if postLabel != "" { + AddBookmark(postAddress, postLabel) + } + + deleteBookmark := request.FormValue("delete") + if deleteBookmark != "" { + RemoveBookmark(deleteBookmark) + } + + data = []byte(pageHeader) + + addBookmark := request.FormValue("add") + + addressFocus := "autofocus" + labelFocus := "" + if addBookmark != "" { + addressFocus = "" + labelFocus = "autofocus" + } + + data = append(data, []byte(fmt.Sprintf(`

Add bookmark





`, html.EscapeString(addBookmark), addressFocus, labelFocus))...) + + if len(bookmarks) > 0 && addBookmark == "" { + fakeURL, _ := url.Parse("/") // Always succeeds + + data = append(data, []byte(`

Bookmarks

`)...) + for _, u := range bookmarksSorted { + data = append(data, []byte(fmt.Sprintf(``, html.EscapeString(bookmarks[u]), html.EscapeString(rewriteURL(u, fakeURL)), html.EscapeString(u), html.EscapeString(url.PathEscape(u)), html.EscapeString(url.PathEscape(u))))...) + } + data = append(data, []byte(`
%s
%s
EditDelete
`)...) + } + + data = append(data, []byte(pageFooter)...) + + writer.Write(fillTemplateVariables(data, "", false)) +} + +// SetOnBookmarksChanged sets the function called when a bookmark is changed. +func SetOnBookmarksChanged(f func()) { + onBookmarksChanged = f +} + // StartDaemon starts the page conversion daemon. func StartDaemon(address string, allowFile bool) error { daemonAddress = address @@ -249,8 +355,15 @@ func StartDaemon(address string, allowFile bool) error { loadAssets() + if len(bookmarks) == 0 { + for u, label := range defaultBookmarks { + AddBookmark(u, label) + } + } + handler := http.NewServeMux() handler.HandleFunc("/assets/style.css", handleAssets) + handler.HandleFunc("/bookmarks", handleBookmarks) handler.HandleFunc("/", handleRequest) go func() { log.Fatal(http.ListenAndServe(address, handler)) @@ -284,3 +397,47 @@ func SetClientCertificate(domain string, certificate []byte, privateKey []byte) clientCerts[domain] = clientCert return nil } + +// AddBookmark adds a bookmark. +func AddBookmark(u string, label string) { + parsed, err := url.Parse(u) + if err != nil { + return + } + if parsed.Scheme == "" { + parsed.Scheme = "gemini" + } + parsed.Host = strings.ToLower(parsed.Host) + + bookmarks[parsed.String()] = label + + bookmarksUpdated() +} + +// GetBookmarks returns all bookmarks. +func GetBookmarks() map[string]string { + return bookmarks +} + +// RemoveBookmark removes a bookmark. +func RemoveBookmark(u string) { + delete(bookmarks, u) + + bookmarksUpdated() +} + +func bookmarksUpdated() { + var allURLs []string + for u := range bookmarks { + allURLs = append(allURLs, u) + } + sort.Slice(allURLs, func(i, j int) bool { + return strings.ToLower(bookmarks[allURLs[i]]) < strings.ToLower(bookmarks[allURLs[j]]) + }) + + bookmarksSorted = allURLs + + if onBookmarksChanged != nil { + onBookmarksChanged() + } +}