Browse Source

Support bookmarks

pull/5/head v1.0.2
Trevor Slocum 10 months ago
parent
commit
1c986eb018
  1. 3
      CHANGELOG
  2. 12
      CONFIGURATION.md
  3. 33
      config.go
  4. 36
      main.go
  5. 130
      pkg/gmitohtml/assets.go
  6. 15
      pkg/gmitohtml/convert.go
  7. 167
      pkg/gmitohtml/daemon.go

3
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

12
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

33
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
}

36
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)

130
pkg/gmitohtml/assets.go

@ -12,22 +12,15 @@ const pageHeader = `
<body>
<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~>
</form>
<br>`
</form>`
const pageFooter = `
</body>
</html>
const navigationMenuText = `
<div style="padding-left: 84px;padding-bottom: 7px;">
<a href="/bookmarks" class="navlink">View bookmarks</a> &nbsp;-&nbsp; <a href="/bookmarks?add=~GEMINICURRENTURL~" class="navlink">Add bookmark</a>
</div>
`
const indexPage = pageHeader + `
<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 + `
const inputPrompt = `
<form method="post" action="~GEMINIINPUTFORM~">
<div style="padding-top: 25px;">
<span style="font-size: 1.5em;">~GEMINIINPUTPROMPT~</span><br><br>
@ -36,7 +29,12 @@ const inputPage = pageHeader + `
</div>
</div>
</form>
` + pageFooter
`
const pageFooter = `
</body>
</html>
`
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;
}
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);
}
input {
background-color: black;
color: white;
border-color: gray;
border-width: 0.3em;
border-style: solid;
padding: 0.5em;
}
body {
color: white;
background-color: black;
}
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.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)
}

15
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("</pre>\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)
}

167
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(`<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) {
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("<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.
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()
}
}
Loading…
Cancel
Save