Compare commits

...

22 commits

Author SHA1 Message Date
Trevor Slocum a23ad13fd8 Fix connection issue affecting some sites 2021-07-10 11:30:00 -07:00
Trevor Slocum f933e9dd1b Update README 2021-07-10 11:19:49 -07:00
Trevor Slocum 6c7b8ec1d3 Document ConvertImages option 2021-07-09 20:16:11 -07:00
tslocum 8e8eef57e2 Merge pull request 'Convert images to images instead of links' (#5) from f/gmitohtml:image-output into master
Reviewed-on: https://code.rocketnine.space/tslocum/gmitohtml/pulls/5
2021-07-09 20:03:19 -07:00
Aaron Fischer 2fdec61b41 Move config to gmitohtml namespace 2021-07-09 23:19:30 +02:00
Aaron Fischer 0bec3c3eef Fix wrong package name and clean up tag-building
DRY the tag building for links and images.
2021-07-09 22:45:52 +02:00
Aaron Fischer 586b293bef Convert images to images instead of links
Image links will result in a `img` tag instead of a `a` tag. This
behaviour is disabled by default and can be enabled by adding the
following line to to config file:

convertimages: true

To get access to the config in the convert file, I had to move the
config file inside the gmitohtml namespace. This is specially handy
later on if the config file contains other settings which are useful for
the rest of the codebase.
2021-06-09 01:19:26 +02:00
Trevor Slocum e18a99b437 Migrate to code.rocketnine.space 2021-04-07 20:52:05 -07:00
Trevor Slocum b579de42ff Release v1.0.3 2021-01-05 19:02:33 -08:00
Trevor Slocum cfaca6678d Add hostname option
Resolves #1.
2021-01-05 19:00:14 -08:00
Trevor Slocum 2a1abe8efe Do not display address bar when running without daemon 2020-12-03 10:42:39 -08:00
Trevor Slocum dd70c77b18 Allow developers to reference CSS 2020-12-03 10:29:27 -08:00
Trevor Slocum f71136c1a5 Set address bar width to screen width 2020-11-28 11:53:31 -08:00
Trevor Slocum 1c986eb018 Support bookmarks 2020-11-26 20:52:17 -08:00
Trevor Slocum 7445d6c830 Always use lowercase hostname in requests 2020-11-25 21:59:12 -08:00
Trevor Slocum 8c9f7852bb Fix link parsing 2020-11-25 09:22:06 -08:00
Trevor Slocum 208beca154 Merge branch 'dark' into 'master'
Support dark theme using prefers-color-scheme

See merge request tslocum/gmitohtml!1
2020-11-25 16:18:11 +00:00
Paper 78b0228f49 Support dark theme using prefers-color-scheme 2020-11-25 11:20:52 +01:00
Trevor Slocum a8700abe29 Escape page content 2020-11-24 18:42:39 -08:00
Trevor Slocum e2232a8dc8 Add option to allow local file access 2020-11-24 18:18:16 -08:00
Trevor Slocum 11183c0c63 Fix link parsing 2020-11-24 14:31:30 -08:00
Trevor Slocum f8ae52eb7d Display navigation bar in pages 2020-11-24 14:29:16 -08:00
12 changed files with 560 additions and 168 deletions

View file

@ -1,21 +0,0 @@
image: golang:latest
stages:
- validate
- build
fmt:
stage: validate
script:
- gofmt -l -s -e .
- exit $(gofmt -l -s -e . | wc -l)
vet:
stage: validate
script:
- go vet -composites=false ./...
test:
stage: validate
script:
- go test -race -v ./...

View file

@ -1,2 +1,21 @@
1.0.5:
- Add option ConvertImages (thanks to @f)
- Fix connection issue affecting some sites
1.0.4:
- Migrate to code.rocketnine.space
1.0.3:
- Add hostname option
- Set address bar width to screen width
1.0.2:
- Support bookmarks
1.0.1:
- Add option to allow local file access
- Display navigation bar in pages
- Support dark themed pages via prefers-color-scheme
1.0.0:
- Initial release

View file

@ -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.
@ -19,9 +27,21 @@ openssl req -x509 -out localhost.crt -keyout localhost.key \
Files `localhost.crt` and `localhost.key` are generated. Rename these files to
match the domain where the certificate will be used.
## Allow file:// access
By default, local files are not served by gmitohtml. When executed with the
`--allow-file` argument, local files may be accessed via `file://`.
For example, to view `/home/dioscuri/sites/gemlog/index.gmi`, navigate to
`file:///home/dioscuri/sites/gemlog/index.gmi`.
# 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

View file

@ -1,6 +1,5 @@
# gmitohtml
[![GoDoc](https://gitlab.com/tslocum/godoc-static/-/raw/master/badge.svg)](https://docs.rocketnine.space/gitlab.com/tslocum/gmitohtml/pkg/gmitohtml)
[![CI status](https://gitlab.com/tslocum/gmitohtml/badges/master/pipeline.svg)](https://gitlab.com/tslocum/gmitohtml/commits/master)
[![GoDoc](https://code.rocketnine.space/tslocum/godoc-static/raw/branch/master/badge.svg)](https://docs.rocketnine.space/code.rocketnine.space/tslocum/gmitohtml/pkg/gmitohtml)
[![Donate](https://img.shields.io/liberapay/receives/rocketnine.space.svg?logo=liberapay)](https://liberapay.com/rocketnine.space)
[Gemini](https://gemini.circumlunar.space) to [HTML](https://en.wikipedia.org/wiki/HTML)
@ -8,22 +7,28 @@ conversion tool and daemon
## Download
### PC
[**Download gmitohtml**](https://gmitohtml.rocketnine.space/download/?sort=name&order=desc)
### Android
See [Xenia](https://code.rocketnine.space/tslocum/xenia).
## Compile
gmitohtml is written in [Go](https://golang.org). Run the following command to
download and build gmitohtml from source.
```bash
go get gitlab.com/tslocum/gmitohtml
go get code.rocketnine.space/tslocum/gmitohtml
```
The resulting binary is available as `~/go/bin/gmitohtml`.
## Configure
See [CONFIGURATION.md](https://gitlab.com/tslocum/gmitohtml/blob/master/CONFIGURATION.md)
See [CONFIGURATION.md](https://code.rocketnine.space/tslocum/twins/src/branch/master/CONFIGURATION.md)
## Usage
@ -41,4 +46,4 @@ gmitohtml < document.gmi
## Support
Please share issues and suggestions [here](https://gitlab.com/tslocum/gmitohtml/issues).
Please share issues and suggestions [here](https://code.rocketnine.space/tslocum/gmitohtml/issues).

View file

@ -1,44 +0,0 @@
package main
import (
"crypto/tls"
"errors"
"io/ioutil"
"gopkg.in/yaml.v3"
)
type certConfig struct {
Cert string
Key string
cert tls.Certificate
}
type appConfig struct {
Certs map[string]*certConfig
}
var config = &appConfig{
Certs: make(map[string]*certConfig),
}
func readconfig(configPath string) error {
if configPath == "" {
return errors.New("file unspecified")
}
configData, err := ioutil.ReadFile(configPath)
if err != nil {
return err
}
var newConfig *appConfig
err = yaml.Unmarshal(configData, &newConfig)
if err != nil {
return err
}
config = newConfig
return nil
}

4
go.mod
View file

@ -1,5 +1,5 @@
module gitlab.com/tslocum/gmitohtml
module code.rocketnine.space/tslocum/gmitohtml
go 1.15
require gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776
require code.rocketnine.space/tslocum/ez v0.0.0-20210506054357-569018bd037a

6
go.sum
View file

@ -1,4 +1,6 @@
code.rocketnine.space/tslocum/ez v0.0.0-20210506054357-569018bd037a h1:Ug5hgK5sM7bdK1gEl/pNLYTtBpFUxCvSCGYkEbUqdmw=
code.rocketnine.space/tslocum/ez v0.0.0-20210506054357-569018bd037a/go.mod h1:SQrM+bQ4eZdyAVTxuF2BNnyAnojHP6Kcmm2vMszoFWw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

63
main.go
View file

@ -8,10 +8,10 @@ import (
"net/url"
"os"
"os/exec"
"path"
"runtime"
"gitlab.com/tslocum/gmitohtml/pkg/gmitohtml"
"code.rocketnine.space/tslocum/ez"
"code.rocketnine.space/tslocum/gmitohtml/pkg/gmitohtml"
)
func openBrowser(url string) {
@ -32,33 +32,47 @@ func openBrowser(url string) {
}
func main() {
var view bool
var daemon string
var configFile string
var (
view bool
allowFile bool
daemon string
hostname string
configFile string
)
flag.BoolVar(&view, "view", false, "open web browser")
flag.BoolVar(&allowFile, "allow-file", false, "allow local file access via file://")
flag.StringVar(&daemon, "daemon", "", "start daemon on specified address")
flag.StringVar(&hostname, "hostname", "", "server hostname (e.g. rocketnine.space) (defaults to daemon address)")
flag.StringVar(&configFile, "config", "", "path to configuration file")
// TODO option to include response header in page
flag.Parse()
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
defaultConfig, err := ez.DefaultConfigPath("gmitohtml")
if err != nil {
log.Fatal(err)
} else if configFile == "" {
configFile = defaultConfig
}
if configFile != "" {
var configExists bool
if _, err := os.Stat(defaultConfig); !os.IsNotExist(err) {
configExists = true
}
if configExists || configFile != defaultConfig {
err := ez.Deserialize(gmitohtml.Config, 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 gmitohtml.Config.Bookmarks {
gmitohtml.AddBookmark(u, label)
}
}
}
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)
}
}
for domain, cc := range config.Certs {
for domain, cc := range gmitohtml.Config.Certs {
certData, err := ioutil.ReadFile(cc.Cert)
if err != nil {
log.Fatalf("failed to load client certificate for domain %s: %s", domain, err)
@ -76,7 +90,16 @@ func main() {
}
if daemon != "" {
err := gmitohtml.StartDaemon(daemon)
gmitohtml.SetOnBookmarksChanged(func() {
gmitohtml.Config.Bookmarks = gmitohtml.GetBookmarks()
err := ez.Serialize(gmitohtml.Config, configFile)
if err != nil {
log.Fatal(err)
}
})
err := gmitohtml.StartDaemon(daemon, hostname, allowFile)
if err != nil {
log.Fatal(err)
}

View file

@ -11,32 +11,40 @@ const pageHeader = `
</head>
<body>`
const navHeader = `
<div>
<form method="post" action="/" novalidate>
<input type="url" name="address" id="navigationaddress" placeholder="Address" size="40" value="~GEMINICURRENTURL~" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" ~GEMINIAUTOFOCUS~>
</form>
<div id="navigationbar">
<a href="/bookmarks" class="navlink">View bookmarks</a> &nbsp;-&nbsp; <a href="/bookmarks?add=~GEMINICURRENTURL~" class="navlink">Add bookmark</a>
</div>
</div>
`
const contentHeader = `
<div id="content">
`
const inputPrompt = `
<form method="post" action="~GEMINIINPUTFORM~">
<div style="padding-top: 25px;">
<span style="font-size: 1.5em;">~GEMINIINPUTPROMPT~</span><br><br>
<div>
<input type="~GEMINIINPUTTYPE~" name="input" placeholder="Input" size="40" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus>
</div>
</div>
</form>
`
const pageFooter = `
</div>
</body>
</html>
`
const indexPage = pageHeader + `
<form method="post" action="/" novalidate>
<input type="url" name="address" placeholder="Address" size="50" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus> <input type="submit" value="Go">
</form>
<br>
<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="GEMINICURRENTURL">
<b>GEMINICURRENTURL</b> requests input.<br><br><br>
<b>GEMINIINPUTPROMPT</b><br><br>
<input type="GEMINIINPUTTYPE" name="input" placeholder="Input" size="50" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" autofocus> <input type="submit" value="Go">
</form>
` + pageFooter
func loadAssets() {
fs["/assets/style.css"] = loadFile("style.css", `
// StyleCSS specifies page styling.
const StyleCSS = `
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
@ -60,9 +68,23 @@ html {
*/
body {
margin: 0;
width: 100%;
}
#content {
margin: 0.67em;
}
#navigationaddress {
box-sizing: border-box;
width: 100%
}
#navigationbar {
padding: 4px 21px 7px 21px;
}
/**
* Render the main element consistently in IE.
*/
@ -120,6 +142,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.
@ -390,5 +440,56 @@ template {
[hidden] {
display: none;
}
`, fs)
@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);
}
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;
}
}
`
func loadAssets() {
fs["/assets/style.css"] = loadFile("style.css", StyleCSS, fs)
}

27
pkg/gmitohtml/config.go Normal file
View file

@ -0,0 +1,27 @@
package gmitohtml
import (
"crypto/tls"
)
type CertConfig struct {
Cert string
Key string
cert tls.Certificate
}
type AppConfig struct {
Bookmarks map[string]string
// Convert image links to images instead of normal links
ConvertImages bool
Certs map[string]*CertConfig
}
var Config = &AppConfig{
Bookmarks: make(map[string]string),
Certs: make(map[string]*CertConfig),
}

View file

@ -5,6 +5,7 @@ import (
"bytes"
"errors"
"fmt"
"html"
"net/url"
"path"
"strings"
@ -18,22 +19,61 @@ var daemonAddress string
var assetLock sync.Mutex
var imageExtensions = []string{"png", "jpg", "jpeg", "gif", "svg", "webp"}
func rewriteURL(u string, loc *url.URL) string {
if daemonAddress != "" {
if strings.HasPrefix(u, "gemini://") {
return "http://" + daemonAddress + "/gemini/" + u[9:]
} else if strings.Contains(u, "://") {
return u
} else if loc != nil && len(u) > 0 && !strings.HasPrefix(u, "//") {
newPath := u
if u[0] != '/' {
newPath = path.Join(loc.Path, u)
}
return "http://" + daemonAddress + "/gemini/" + loc.Host + newPath
}
return "http://" + daemonAddress + "/gemini/" + u
if daemonAddress == "" {
return u
}
return u
if loc.Path == "" {
loc.Path = "/"
}
scheme := "gemini"
if strings.HasPrefix(loc.Path, "/file/") {
scheme = "file"
}
if strings.HasPrefix(u, "file://") {
if !allowFileAccess {
return "http://" + daemonAddress + "/?FileAccessNotAllowed"
}
return "http://" + daemonAddress + "/file/" + u[7:]
}
offset := 0
if strings.HasPrefix(u, "gemini://") {
offset = 9
}
firstSlash := strings.IndexRune(u[offset:], '/')
if firstSlash != -1 {
u = strings.ToLower(u[:firstSlash+offset]) + u[firstSlash+offset:]
}
if strings.HasPrefix(u, "gemini://") {
return "http://" + daemonAddress + "/gemini/" + u[9:]
} else if strings.Contains(u, "://") {
return u
} else if loc != nil && len(u) > 0 && !strings.HasPrefix(u, "//") {
if u[0] != '/' {
if loc.Path[len(loc.Path)-1] == '/' {
u = path.Join("/", loc.Path, u)
} else {
u = path.Join("/", path.Dir(loc.Path), u)
}
}
return "http://" + daemonAddress + "/" + scheme + "/" + strings.ToLower(loc.Host) + u
}
return "http://" + daemonAddress + "/" + scheme + "/" + u
}
func newPage() []byte {
data := []byte(pageHeader)
if daemonAddress != "" {
data = append(data, navHeader...)
}
return append(data, contentHeader...)
}
// Convert converts text/gemini to text/html.
@ -63,33 +103,55 @@ func Convert(page []byte, u string) []byte {
}
if preformatted {
result = append(result, line...)
result = append(result, html.EscapeString(string(line))...)
result = append(result, []byte("\n")...)
continue
}
if l >= 6 && bytes.HasPrefix(line, []byte("=>")) {
splitStart := 2
if line[splitStart+1] == ' ' || line[splitStart+1] == '\t' {
if line[splitStart] == ' ' || line[splitStart] == '\t' {
splitStart++
}
split := bytes.SplitN(line[splitStart:], []byte(" "), 2)
if len(split) != 2 {
var split [][]byte
firstSpace := bytes.IndexRune(line[splitStart:], ' ')
firstTab := bytes.IndexRune(line[splitStart:], '\t')
if firstSpace != -1 && (firstTab == -1 || firstSpace < firstTab) {
split = bytes.SplitN(line[splitStart:], []byte(" "), 2)
} else if firstTab != -1 {
split = bytes.SplitN(line[splitStart:], []byte("\t"), 2)
}
linkURL := line[splitStart:]
linkLabel := line[splitStart:]
var linkURL []byte
var linkLabel []byte
if len(split) == 2 {
linkURL = split[0]
linkLabel = split[1]
} else {
linkURL = line[splitStart:]
linkLabel = line[splitStart:]
}
link := append([]byte(`<a href="`), rewriteURL(string(linkURL), parsedURL)...)
link = append(link, []byte(`">`)...)
link = append(link, linkLabel...)
link = append(link, []byte(`</a>`)...)
result = append(result, link...)
result = append(result, []byte("<br>")...)
parts := strings.Split(string(linkURL), ".")
extension := parts[len(parts)-1]
isImage := false
for _, ext := range imageExtensions {
if extension == ext {
isImage = true
}
}
uri := html.EscapeString(rewriteURL(string(linkURL), parsedURL))
title := html.EscapeString(string(linkLabel))
// If link ends with gif/png/jpg, add a image instead of a link
if isImage && Config.ConvertImages {
result = append(result, []byte("<img src=\""+uri+"\" alt=\""+title+"\">")...)
} else {
result = append(result, []byte("<a href=\""+uri+"\">"+title+"</a><br>")...)
}
continue
}
@ -102,11 +164,11 @@ func Convert(page []byte, u string) []byte {
}
}
if heading > 0 {
result = append(result, []byte(fmt.Sprintf("<h%d>%s</h%d>", heading, line[heading:], heading))...)
result = append(result, []byte(fmt.Sprintf("<h%d>%s</h%d>", heading, html.EscapeString(string(line[heading:])), heading))...)
continue
}
result = append(result, line...)
result = append(result, html.EscapeString(string(line))...)
result = append(result, []byte("<br>")...)
}
@ -114,7 +176,8 @@ func Convert(page []byte, u string) []byte {
result = append(result, []byte("</pre>\n")...)
}
result = append([]byte(pageHeader), result...)
result = append(result, []byte(pageFooter)...)
return result
data := newPage()
data = append(data, result...)
data = append(data, []byte(pageFooter)...)
return fillTemplateVariables(data, u, false)
}

View file

@ -6,22 +6,48 @@ import (
"crypto/x509"
"errors"
"fmt"
"html"
"io/ioutil"
"log"
"net/http"
"net/url"
"path"
"sort"
"strings"
"time"
)
var lastRequestTime = time.Now().Unix()
var clientCerts = make(map[string]tls.Certificate)
var (
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(`<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
@ -89,33 +115,32 @@ func fetch(u string) ([]byte, []byte, error) {
if requestInput {
requestSensitiveInput := bytes.HasPrefix(header, []byte("11"))
data = []byte(inputPage)
data = newPage()
data = bytes.Replace(data, []byte("GEMINICURRENTURL"), []byte(rewriteURL(u, requestURL)), 1)
data = append(data, []byte(inputPrompt)...)
currentURL := u
if strings.HasPrefix(currentURL, "gemini://") {
currentURL = currentURL[9:]
}
data = bytes.Replace(data, []byte("GEMINICURRENTURL"), []byte(currentURL), 1)
data = bytes.Replace(data, []byte("~GEMINIINPUTFORM~"), []byte(html.EscapeString(rewriteURL(u, requestURL))), 1)
prompt := "(No input prompt)"
if len(header) > 3 {
prompt = string(header[3:])
}
data = bytes.Replace(data, []byte("GEMINIINPUTPROMPT"), []byte(prompt), 1)
data = bytes.Replace(data, []byte("~GEMINIINPUTPROMPT~"), []byte(prompt), 1)
inputType := "text"
if requestSensitiveInput {
inputType = "password"
}
data = bytes.Replace(data, []byte("GEMINIINPUTTYPE"), []byte(inputType), 1)
data = bytes.Replace(data, []byte("~GEMINIINPUTTYPE~"), []byte(inputType), 1)
return header, data, nil
return header, fillTemplateVariables(data, u, false), nil
}
if !bytes.HasPrefix(header, []byte("2")) {
return header, []byte(fmt.Sprintf(pageHeader+"Server sent unexpected header:<br><br><b>%s</b>", header) + pageFooter), nil
errorPage := newPage()
errorPage = append(errorPage, []byte(fmt.Sprintf("Server sent unexpected header:<br><br><b>%s</b>", header))...)
errorPage = append(errorPage, []byte(pageFooter)...)
return header, fillTemplateVariables(errorPage, u, false), nil
}
if bytes.HasPrefix(header, []byte("20 text/html")) {
@ -131,7 +156,29 @@ func handleIndex(writer http.ResponseWriter, request *http.Request) {
return
}
writer.Write([]byte(indexPage))
page := newPage()
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 {
if strings.HasPrefix(currentURL, "gemini://") {
currentURL = currentURL[9:]
}
if currentURL == "/" {
currentURL = ""
}
data = bytes.ReplaceAll(data, []byte("~GEMINICURRENTURL~"), []byte(currentURL))
autofocusValue := ""
if autofocus {
autofocusValue = "autofocus"
}
data = bytes.ReplaceAll(data, []byte("~GEMINIAUTOFOCUS~"), []byte(autofocusValue))
return data
}
func handleRequest(writer http.ResponseWriter, request *http.Request) {
@ -149,12 +196,17 @@ func handleRequest(writer http.ResponseWriter, request *http.Request) {
}
pathSplit := strings.Split(request.URL.Path, "/")
if len(pathSplit) < 2 || pathSplit[1] != "gemini" {
if len(pathSplit) < 2 || (pathSplit[1] != "gemini" && (!allowFileAccess || pathSplit[1] != "file")) {
writer.Write([]byte("Error: invalid protocol, only Gemini is supported"))
return
}
u, err := url.ParseRequestURI("gemini://" + strings.Join(pathSplit[2:], "/"))
scheme := "gemini://"
if pathSplit[1] == "file" {
scheme = "file://"
}
u, err := url.ParseRequestURI(scheme + strings.Join(pathSplit[2:], "/"))
if err != nil {
writer.Write([]byte("Error: invalid URL"))
return
@ -170,9 +222,24 @@ func handleRequest(writer http.ResponseWriter, request *http.Request) {
return
}
header, data, err := fetch(u.String())
if err != nil {
fmt.Fprintf(writer, "Error: failed to fetch %s: %s", u, err)
var header []byte
var data []byte
if scheme == "gemini://" {
header, data, err = fetch(u.String())
if err != nil {
fmt.Fprintf(writer, "Error: failed to fetch %s: %s", u, err)
return
}
} else if allowFileAccess && scheme == "file://" {
header = []byte("20 text/gemini; charset=utf-8")
data, err = ioutil.ReadFile(path.Join("/", strings.Join(pathSplit[2:], "/")))
if err != nil {
fmt.Fprintf(writer, "Error: failed to read file %s: %s", u, err)
return
}
data = Convert(data, u.String())
} else {
writer.Write([]byte("Error: invalid URL"))
return
}
@ -202,14 +269,100 @@ 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 = newPage()
data = append(data, []byte(fmt.Sprintf(`<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 = newPage()
addBookmark := request.FormValue("add")
addressFocus := "autofocus"
labelFocus := ""
if addBookmark != "" {
addressFocus = ""
labelFocus = "autofocus"
}
data = append(data, []byte(fmt.Sprintf(`<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) error {
func StartDaemon(address string, hostname string, allowFile bool) error {
daemonAddress = address
if hostname != "" {
daemonAddress = hostname
}
allowFileAccess = allowFile
loadAssets()
daemonAddress = address
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))
@ -243,3 +396,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()
}
}