mirror of
https://code.rocketnine.space/tslocum/gmitohtml.git
synced 2024-06-02 09:29:26 +02:00
Compare commits
22 commits
Author | SHA1 | Date | |
---|---|---|---|
a23ad13fd8 | |||
f933e9dd1b | |||
6c7b8ec1d3 | |||
8e8eef57e2 | |||
Aaron Fischer | 2fdec61b41 | ||
Aaron Fischer | 0bec3c3eef | ||
Aaron Fischer | 586b293bef | ||
e18a99b437 | |||
b579de42ff | |||
cfaca6678d | |||
2a1abe8efe | |||
dd70c77b18 | |||
f71136c1a5 | |||
1c986eb018 | |||
7445d6c830 | |||
8c9f7852bb | |||
208beca154 | |||
78b0228f49 | |||
a8700abe29 | |||
e2232a8dc8 | |||
11183c0c63 | |||
f8ae52eb7d |
|
@ -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 ./...
|
19
CHANGELOG
19
CHANGELOG
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
15
README.md
15
README.md
|
@ -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).
|
||||
|
|
44
config.go
44
config.go
|
@ -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
4
go.mod
|
@ -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
6
go.sum
|
@ -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
63
main.go
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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> - <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
27
pkg/gmitohtml/config.go
Normal 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),
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue