Support bookmarks

This commit is contained in:
Trevor Slocum 2020-11-26 20:43:03 -08:00
parent 7445d6c830
commit 1c986eb018
7 changed files with 329 additions and 61 deletions

View file

@ -1,3 +1,6 @@
1.0.2:
- Support bookmarks
1.0.1: 1.0.1:
- Add option to allow local file access - Add option to allow local file access
- Display navigation bar in pages - Display navigation bar in pages

View file

@ -3,6 +3,14 @@ default. You may specify a different location via the `--config` argument.
# Configuration options # 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
Client certificates may be specified via the `Certs` option. 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 # Example config.yaml
```yaml ```yaml
bookmarks:
gemini://gemini.circumlunar.space/: Gemini protocol
gemini://gus.guru/: GUS - Gemini Universal Search
certs: certs:
astrobotany.mozz.us: astrobotany.mozz.us:
cert: /home/dioscuri/.config/gmitohtml/astrobotany.mozz.us.crt cert: /home/dioscuri/.config/gmitohtml/astrobotany.mozz.us.crt

View file

@ -3,8 +3,12 @@ package main
import ( import (
"crypto/tls" "crypto/tls"
"errors" "errors"
"fmt"
"io/ioutil" "io/ioutil"
"os"
"path"
"gitlab.com/tslocum/gmitohtml/pkg/gmitohtml"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@ -16,13 +20,25 @@ type certConfig struct {
} }
type appConfig struct { type appConfig struct {
Bookmarks map[string]string
Certs map[string]*certConfig Certs map[string]*certConfig
} }
var config = &appConfig{ var config = &appConfig{
Bookmarks: make(map[string]string),
Certs: make(map[string]*certConfig), 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 { func readconfig(configPath string) error {
if configPath == "" { if configPath == "" {
return errors.New("file unspecified") return errors.New("file unspecified")
@ -42,3 +58,20 @@ func readconfig(configPath string) error {
return nil 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
}

36
main.go
View file

@ -8,7 +8,6 @@ import (
"net/url" "net/url"
"os" "os"
"os/exec" "os/exec"
"path"
"runtime" "runtime"
"gitlab.com/tslocum/gmitohtml/pkg/gmitohtml" "gitlab.com/tslocum/gmitohtml/pkg/gmitohtml"
@ -43,20 +42,26 @@ func main() {
// TODO option to include response header in page // TODO option to include response header in page
flag.Parse() flag.Parse()
defaultConfig := defaultConfigPath()
if configFile == "" { if configFile == "" {
homedir, err := os.UserHomeDir() configFile = defaultConfig
if err == nil && homedir != "" {
defaultConfig := path.Join(homedir, ".config", "gmitohtml", "config.yaml")
if _, err := os.Stat(defaultConfig); !os.IsNotExist(err) {
configFile = defaultConfig
}
}
} }
if configFile != "" { if configFile != "" {
err := readconfig(configFile) var configExists bool
if err != nil { if _, err := os.Stat(defaultConfig); !os.IsNotExist(err) {
log.Fatalf("failed to read configuration file at %s: %v\nSee CONFIGURATION.md for information on configuring gmitohtml", configFile, 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 != "" { if daemon != "" {
gmitohtml.SetOnBookmarksChanged(func() {
config.Bookmarks = gmitohtml.GetBookmarks()
err := saveConfig(configFile)
if err != nil {
log.Fatal(err)
}
})
err := gmitohtml.StartDaemon(daemon, allowFile) err := gmitohtml.StartDaemon(daemon, allowFile)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)

View file

@ -12,22 +12,15 @@ const pageHeader = `
<body> <body>
<form method="post" action="/" novalidate> <form method="post" action="/" novalidate>
<input type="url" name="address" placeholder="Address" size="40" value="~GEMINICURRENTURL~" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" css="width: 100%;" ~GEMINIAUTOFOCUS~> <input type="url" name="address" placeholder="Address" size="40" value="~GEMINICURRENTURL~" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" css="width: 100%;" ~GEMINIAUTOFOCUS~>
</form> </form>`
<br>`
const pageFooter = ` const navigationMenuText = `
</body> <div style="padding-left: 84px;padding-bottom: 7px;">
</html> <a href="/bookmarks" class="navlink">View bookmarks</a> &nbsp;-&nbsp; <a href="/bookmarks?add=~GEMINICURRENTURL~" class="navlink">Add bookmark</a>
</div>
` `
const indexPage = pageHeader + ` const inputPrompt = `
<ul>
<li><a href="/gemini/gus.guru/">GUS - Gemini Universal Search</a></li>
<li><a href="/gemini/gemini.circumlunar.space/">Gemini protocol</a></li>
</ul>
` + pageFooter
const inputPage = pageHeader + `
<form method="post" action="~GEMINIINPUTFORM~"> <form method="post" action="~GEMINIINPUTFORM~">
<div style="padding-top: 25px;"> <div style="padding-top: 25px;">
<span style="font-size: 1.5em;">~GEMINIINPUTPROMPT~</span><br><br> <span style="font-size: 1.5em;">~GEMINIINPUTPROMPT~</span><br><br>
@ -36,7 +29,12 @@ const inputPage = pageHeader + `
</div> </div>
</div> </div>
</form> </form>
` + pageFooter `
const pageFooter = `
</body>
</html>
`
func loadAssets() { func loadAssets() {
fs["/assets/style.css"] = loadFile("style.css", ` fs["/assets/style.css"] = loadFile("style.css", `
@ -123,6 +121,34 @@ a {
background-color: transparent; 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- * 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
@ -395,37 +421,51 @@ template {
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { body {
color: white; color: white;
background-color: black; background-color: black;
} }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
color: white; color: white;
} }
a { a {
color: rgb(26, 168, 245); color: rgb(26, 168, 245);
text-decoration: none; text-decoration: none;
} }
a:hover, a:hover,
a:focus, a:focus,
a:active { a:active {
color: rgb(24, 151, 219); color: rgb(24, 151, 219);
text-decoration: underline; text-decoration: underline;
} }
a:visited { a:visited {
color: rgb(200, 118, 255); color: rgb(200, 118, 255);
} }
input { a.navlink {
background-color: black; color: rgb(26, 168, 245);
color: white; text-decoration: none;
border-color: gray; }
border-width: 0.3em; a.navlink:hover,
border-style: solid; a.navlink:focus,
padding: 0.5em; 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) `, fs)
} }

View file

@ -61,6 +61,13 @@ func rewriteURL(u string, loc *url.URL) string {
return u return u
} }
func navigationMenu() []byte {
if daemonAddress == "" {
return nil
}
return []byte(navigationMenuText)
}
// Convert converts text/gemini to text/html. // Convert converts text/gemini to text/html.
func Convert(page []byte, u string) []byte { func Convert(page []byte, u string) []byte {
var result []byte var result []byte
@ -148,7 +155,9 @@ func Convert(page []byte, u string) []byte {
result = append(result, []byte("</pre>\n")...) result = append(result, []byte("</pre>\n")...)
} }
result = append([]byte(pageHeader), result...) data := []byte(pageHeader)
result = append(result, []byte(pageFooter)...) data = append(data, navigationMenu()...)
return fillTemplateVariables(result, u, false) data = append(data, result...)
data = append(data, []byte(pageFooter)...)
return fillTemplateVariables(data, u, false)
} }

View file

@ -12,6 +12,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"sort"
"strings" "strings"
"time" "time"
) )
@ -19,14 +20,36 @@ import (
var lastRequestTime = time.Now().Unix() var lastRequestTime = time.Now().Unix()
var ( var (
clientCerts = make(map[string]tls.Certificate) clientCerts = make(map[string]tls.Certificate)
allowFileAccess bool 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. // ErrInvalidCertificate is the error returned when an invalid certificate is provided.
var ErrInvalidCertificate = errors.New("invalid certificate") 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(`<div style="padding-left: 12px;">`))
b.Write([]byte(`<br><a href="/bookmarks" class="navlink">Bookmarks</a><br></div>`))
b.Write([]byte(`<ul>`))
for _, u := range bookmarksSorted {
b.Write([]byte(fmt.Sprintf(`<li><a href="%s">%s</a></li>`, rewriteURL(u, fakeURL), bookmarks[u])))
}
b.Write([]byte("</ul>"))
return b.Bytes()
}
// fetch downloads and converts a Gemini page.
func fetch(u string) ([]byte, []byte, error) { func fetch(u string) ([]byte, []byte, error) {
if u == "" { if u == "" {
return nil, nil, ErrInvalidURL return nil, nil, ErrInvalidURL
@ -94,7 +117,10 @@ func fetch(u string) ([]byte, []byte, error) {
if requestInput { if requestInput {
requestSensitiveInput := bytes.HasPrefix(header, []byte("11")) 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) 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 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 { 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) 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("<h1>Error: bookmark not found</h1>"))
return
}
data = []byte(pageHeader)
data = append(data, []byte(fmt.Sprintf(`<br><form method="post" action="%s"><h3>Edit bookmark</h3><input type="text" size="40" name="address" placeholder="Address" value="%s" autofocus><br><br><input type="text" size="40" name="label" placeholder="Label" value="%s"><br><br><input type="submit" value="Update"></form>`, 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(`<br><form method="post" action="/bookmarks"><h3>Add bookmark</h3><input type="text" size="40" name="address" placeholder="Address" value="%s" %s><br><br><input type="text" size="40" name="label" placeholder="Label" %s><br><br><input type="submit" value="Add"></form>`, html.EscapeString(addBookmark), addressFocus, labelFocus))...)
if len(bookmarks) > 0 && addBookmark == "" {
fakeURL, _ := url.Parse("/") // Always succeeds
data = append(data, []byte(`<br><h3>Bookmarks</h3><table border="1" cellpadding="5">`)...)
for _, u := range bookmarksSorted {
data = append(data, []byte(fmt.Sprintf(`<tr><td>%s<br><a href="%s">%s</a></td><td><a href="/bookmarks?edit=%s" class="navlink">Edit</a></td><td><a href="/bookmarks?delete=%s" onclick="return confirm('Are you sure you want to delete this bookmark?')" class="navlink">Delete</a></td></tr>`, 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(`</table>`)...)
}
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. // StartDaemon starts the page conversion daemon.
func StartDaemon(address string, allowFile bool) error { func StartDaemon(address string, allowFile bool) error {
daemonAddress = address daemonAddress = address
@ -249,8 +355,15 @@ func StartDaemon(address string, allowFile bool) error {
loadAssets() loadAssets()
if len(bookmarks) == 0 {
for u, label := range defaultBookmarks {
AddBookmark(u, label)
}
}
handler := http.NewServeMux() handler := http.NewServeMux()
handler.HandleFunc("/assets/style.css", handleAssets) handler.HandleFunc("/assets/style.css", handleAssets)
handler.HandleFunc("/bookmarks", handleBookmarks)
handler.HandleFunc("/", handleRequest) handler.HandleFunc("/", handleRequest)
go func() { go func() {
log.Fatal(http.ListenAndServe(address, handler)) log.Fatal(http.ListenAndServe(address, handler))
@ -284,3 +397,47 @@ func SetClientCertificate(domain string, certificate []byte, privateKey []byte)
clientCerts[domain] = clientCert clientCerts[domain] = clientCert
return nil 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()
}
}