package main
import (
"context"
"fmt"
"html/template"
"io"
"log"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"unicode"
"git.sr.ht/~adnano/go-gemini"
"git.sr.ht/~sircmpwn/getopt"
)
// Source: https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types
var imgexts = []string{".apng", ".png", ".avif", ".gif", ".jpg", ".jpeg", ".jfif", ".pjpeg", ".pjp", ".png", ".svg", ".webp", ".bmp", ".ico", ".cur", ".tif", ".tiff"}
var gemtextPage = template.Must(template.
New("gemtext").
Funcs(template.FuncMap{
"heading": func(line gemini.Line) *GemtextHeading {
switch l := line.(type) {
case gemini.LineHeading1:
return &GemtextHeading{1, string(l), createAnchor(string(l))}
case gemini.LineHeading2:
return &GemtextHeading{2, string(l), createAnchor(string(l))}
case gemini.LineHeading3:
return &GemtextHeading{3, string(l), createAnchor(string(l))}
default:
return nil
}
},
"link": func(line gemini.Line) *gemini.LineLink {
switch l := line.(type) {
case gemini.LineLink:
return &l
default:
return nil
}
},
"li": func(line gemini.Line) *gemini.LineListItem {
switch l := line.(type) {
case gemini.LineListItem:
return &l
default:
return nil
}
},
"pre_toggle_on": func(ctx *GemtextContext, line gemini.Line) *gemini.LinePreformattingToggle {
switch l := line.(type) {
case gemini.LinePreformattingToggle:
if ctx.Pre%4 == 0 {
ctx.Pre += 1
return &l
}
ctx.Pre += 1
return nil
default:
return nil
}
},
"pre_toggle_off": func(ctx *GemtextContext, line gemini.Line) *gemini.LinePreformattingToggle {
switch l := line.(type) {
case gemini.LinePreformattingToggle:
if ctx.Pre%4 == 3 {
ctx.Pre += 1
return &l
}
ctx.Pre += 1
return nil
default:
return nil
}
},
"pre": func(line gemini.Line) *gemini.LinePreformattedText {
switch l := line.(type) {
case gemini.LinePreformattedText:
return &l
default:
return nil
}
},
"quote": func(line gemini.Line) *gemini.LineQuote {
switch l := line.(type) {
case gemini.LineQuote:
return &l
default:
return nil
}
},
"text": func(line gemini.Line) *gemini.LineText {
switch l := line.(type) {
case gemini.LineText:
return &l
default:
return nil
}
},
"isImage": func(s string) bool {
u, err := url.Parse(s)
if err != nil {
return false
}
ext := strings.ToLower(filepath.Ext(u.Path))
log.Printf("Testing if %s is a known extension", ext)
for _, l := range imgexts {
log.Printf("Testing if %s == %s", ext, l)
if ext == l {
log.Printf("It is a known image!")
return true
}
}
log.Printf("Not an image")
return false
},
"url": func(ctx *GemtextContext, s string) template.URL {
u, err := url.Parse(s)
if err != nil {
return template.URL("error")
}
u = ctx.URL.ResolveReference(u)
if u.Scheme != "" && u.Scheme != "gemini" {
return template.URL(u.String())
}
if u.Host == ctx.Root.Host {
u.Scheme = ""
u.Host = ""
return template.URL(u.String())
}
if ctx.DisableExternalProxy {
return template.URL(u.String())
}
if ctx.UseRewrite {
r, err := url.Parse(ctx.Rewrite)
if err != nil {
return template.URL("error")
}
return template.URL(r.String())
}
if ctx.UsePortal {
p, err := url.Parse(ctx.Portal)
if err != nil {
return template.URL("error")
}
p = ctx.URL.ResolveReference(p)
u.Path = fmt.Sprintf("%s/%s/%s%s", p.Path, u.Scheme, u.Host, u.Path)
u.Scheme = p.Scheme
u.Host = p.Host
return template.URL(u.String())
}
u.Path = fmt.Sprintf("/x/%s%s", u.Host, u.Path)
u.Scheme = ""
u.Host = ""
return template.URL(u.String())
},
"safeCSS": func(s string) template.CSS {
return template.CSS(s)
},
"safeURL": func(s string) template.URL {
return template.URL(s)
},
}).
Parse(`
{{- if .CSS }}
{{- if .ExternalCSS }}
{{- else }}
{{- end }}
{{- end }}
{{.Title}}
{{ $ctx := . -}}
{{- $isList := false -}}
{{- range .Lines -}}
{{- if and $isList (not (. | li)) }}
{{- $isList = false -}}
{{- end -}}
{{- with . | heading }}
{{- $isList = false -}}
{{.Text}}
{{- end -}}
{{- with . | link }}
{{- $isList = false -}}
{{- if ( .URL | isImage ) -}}
{{- else -}}
{{if .Name}}{{.Name}}{{else}}{{.URL}}{{end}}
{{- end -}}
{{- end -}}
{{- with . | quote }}
{{- $isList = false -}}
{{slice .String 1}}
{{- end -}}
{{- with . | pre_toggle_on $ctx }}
{{- $isList = false -}}
{{- end -}}
{{- with . | pre -}}
{{- $isList = false -}}
{{.}}
{{ end -}}
{{- with . | pre_toggle_off $ctx -}}
{{- $isList = false -}}
{{- end -}}
{{- with . | text }}
{{- $isList = false }}
{{.}}
{{- end -}}
{{- with . | li }}
{{- if not $isList }}
{{- end -}}
{{- $isList = true }}
- {{slice .String 1}}
{{- end -}}
{{- end }}
{{- if $isList }}
{{- end }}
Proxied content from {{.URL.String}}
{{if .External}}
(external content)
{{end}}
Gemini request details:
- Original URL
- {{.URL.String}}
- Status code
- {{.Resp.Status}}
- Meta
- {{.Resp.Meta}}
- Proxied by
- kineto
Be advised that no attempt was made to verify the remote SSL certificate.
`))
var inputPage = template.Must(template.
New("input").
Funcs(template.FuncMap{
"safeCSS": func(s string) template.CSS {
return template.CSS(s)
},
}).
Parse(`
{{- if .CSS }}
{{- if .ExternalCSS }}
{{- else }}
{{- end }}
{{- end }}
{{.Prompt}}
`))
// TODO: let user customize this
const defaultCSS = `html {
font-family: sans-serif;
color: #080808;
}
body {
max-width: 920px;
margin: 0 auto;
padding: 1rem 2rem;
}
blockquote {
background-color: #eee;
border-left: 3px solid #444;
margin: 1rem -1rem 1rem calc(-1rem - 3px);
padding: 1rem;
}
ul {
margin-left: 0;
padding: 0;
}
li {
padding: 0;
}
li:not(:last-child) {
margin-bottom: 0.5rem;
}
img {
max-width: 100%;
}
a {
position: relative;
}
a:before {
content: '⇒';
color: #999;
text-decoration: none;
font-weight: bold;
position: absolute;
left: -1.25rem;
}
a:has(img):before {
content: '';
}
pre {
background-color: #eee;
margin: 0 -1rem;
padding: 1rem;
overflow-x: auto;
}
details:not([open]) summary,
details:not([open]) summary a {
color: gray;
}
details summary a:before {
display: none;
}
dl dt {
font-weight: bold;
}
dl dt:not(:first-child) {
margin-top: 0.5rem;
}
@media(prefers-color-scheme:dark) {
html {
background-color: #111;
color: #eee;
}
blockquote {
background-color: #000;
}
pre {
background-color: #222;
}
a {
color: #0087BD;
}
a:visited {
color: #333399;
}
}
label {
display: block;
font-weight: bold;
margin-bottom: 0.5rem;
}
input {
display: block;
border: 1px solid #888;
padding: .375rem;
line-height: 1.25rem;
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
width: 100%;
}
input:focus {
outline: 0;
border-color: #80bdff;
box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
}
`
const defaultRobots = `
User-agent: *
Disallow: /
`
type GemtextContext struct {
CSS string
ExternalCSS bool
External bool
Lines []gemini.Line
Pre int
Resp *gemini.Response
Title string
Lang string
URL *url.URL
Root *url.URL
UsePortal bool
Portal string
DisableExternalProxy bool
UseRewrite bool
Rewrite string
}
type InputContext struct {
CSS string
ExternalCSS bool
Prompt string
Secret bool
URL *url.URL
}
type GemtextHeading struct {
Level int
Text string
Anchor string
}
func createAnchor(heading string) string {
var anchor strings.Builder
prev := '-'
for _, c := range heading {
if unicode.IsLetter(c) || unicode.IsDigit(c) {
anchor.WriteRune(unicode.ToLower(c))
prev = c
} else if (unicode.IsSpace(c) || c == '-') && prev != '-' {
anchor.WriteRune('-')
prev = '-'
}
}
return strings.ToLower(anchor.String())
}
func proxyGemini(req gemini.Request, external bool, root *url.URL, w http.ResponseWriter,
r *http.Request, css string, externalCSS bool, usePortal bool, portal string, disableExternalProxy bool,
useRewrite bool, rewrite string) {
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
client := gemini.Client{}
resp, err := client.Do(ctx, &req)
if err != nil {
w.WriteHeader(http.StatusBadGateway)
fmt.Fprintf(w, "Gateway error: %v", err)
return
}
defer resp.Body.Close()
switch resp.Status {
case 10, 11:
w.Header().Add("Content-Type", "text/html")
err = inputPage.Execute(w, &InputContext{
CSS: css,
ExternalCSS: externalCSS,
Prompt: resp.Meta,
Secret: resp.Status == 11,
URL: req.URL,
})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(fmt.Sprintf("%v", err)))
}
return
case 20:
break // OK
case 30, 31:
to, err := url.Parse(resp.Meta)
if err != nil {
w.WriteHeader(http.StatusBadGateway)
fmt.Fprintf(w, "Gateway error: bad redirect: %v", err)
}
next := req.URL.ResolveReference(to)
if next.Scheme != "gemini" {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "This page is redirecting you to %s", next)
return
}
if external {
next.Path = fmt.Sprintf("/x/%s/%s", next.Host, next.Path)
}
next.Host = r.URL.Host
next.Scheme = r.URL.Scheme
w.Header().Add("Location", next.String())
w.WriteHeader(http.StatusFound)
fmt.Fprintf(w, "Redirecting to %s", next)
return
case 40, 41, 42, 43, 44:
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintf(w, "The remote server returned %d: %s", resp.Status, resp.Meta)
return
case 50, 51:
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "The remote server returned %d: %s", resp.Status, resp.Meta)
return
case 52, 53, 59:
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintf(w, "The remote server returned %d: %s", resp.Status, resp.Meta)
return
default:
w.WriteHeader(http.StatusNotImplemented)
fmt.Fprintf(w, "Proxy does not understand Gemini response status %d", resp.Status)
return
}
m, params, err := mime.ParseMediaType(resp.Meta)
if err != nil {
w.WriteHeader(http.StatusBadGateway)
fmt.Fprintf(w, "Gateway error: %d %s: %v", resp.Status, resp.Meta, err)
return
}
if m != "text/gemini" {
w.Header().Add("Content-Type", resp.Meta)
io.Copy(w, resp.Body)
return
}
if charset, ok := params["charset"]; ok {
charset = strings.ToLower(charset)
if charset != "utf-8" {
w.WriteHeader(http.StatusNotImplemented)
fmt.Fprintf(w, "Unsupported charset: %s", charset)
return
}
}
lang := params["lang"]
w.Header().Add("Content-Type", "text/html")
gemctx := &GemtextContext{
CSS: css,
ExternalCSS: externalCSS,
External: external,
Resp: resp,
Title: req.URL.Host + " " + req.URL.Path,
Lang: lang,
URL: req.URL,
Root: root,
UsePortal: usePortal,
Portal: portal,
DisableExternalProxy: disableExternalProxy,
UseRewrite: useRewrite,
Rewrite: rewrite,
}
var title bool
gemini.ParseLines(resp.Body, func(line gemini.Line) {
gemctx.Lines = append(gemctx.Lines, line)
if !title {
if h, ok := line.(gemini.LineHeading1); ok {
gemctx.Title = string(h)
title = true
}
}
})
err = gemtextPage.Execute(w, gemctx)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "%v", err)
return
}
}
func main() {
var (
bind string = ":8080"
css string = defaultCSS
external bool = false
usePortal bool = false
portal string = ""
useRewrite bool = false
rewrite string = ""
disableExternalProxy bool = false
disableRobots bool = false
)
opts, optind, err := getopt.Getopts(os.Args, "PRb:c:e:p:r:s:")
if err != nil {
log.Fatal(err)
}
for _, opt := range opts {
switch opt.Option {
case 'P':
disableExternalProxy = true
case 'R':
disableRobots = true
case 'b':
bind = opt.Value
case 'e':
external = true
css = opt.Value
case 'p':
usePortal = true
portal = opt.Value
case 'r':
useRewrite = true
rewrite = opt.Value
case 's':
external = false
cssContent, err := os.ReadFile(opt.Value)
if err == nil {
css = string(cssContent)
} else {
log.Fatalf("Error opening custom CSS from '%s': %v", opt.Value, err)
}
}
}
args := os.Args[optind:]
if len(args) != 1 {
log.Fatalf("Usage: %s ", os.Args[0])
}
root, err := url.Parse(args[0])
if err != nil {
log.Fatal(err)
}
http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
r.ParseForm()
if q, ok := r.Form["q"]; !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Bad request"))
} else {
w.Header().Add("Location", "?"+q[0])
w.WriteHeader(http.StatusFound)
w.Write([]byte("Redirecting"))
}
return
}
log.Printf("%s %s", r.Method, r.URL.Path)
if r.Method != "GET" {
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write([]byte("404 Not found"))
return
}
if r.URL.Path == "/favicon.ico" {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("404 Not found"))
return
}
if !disableRobots {
if r.URL.Path == "/robots.txt" {
w.WriteHeader(http.StatusOK)
w.Write([]byte(defaultRobots))
return
}
}
req := gemini.Request{}
req.URL = &url.URL{}
req.URL.Scheme = root.Scheme
req.URL.Host = root.Host
req.URL.Path = r.URL.Path
req.URL.RawQuery = r.URL.RawQuery
proxyGemini(req, false, root, w, r, css, external, usePortal, portal, disableExternalProxy, useRewrite, rewrite)
}))
http.Handle("/x/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.SplitN(r.URL.Path, "/", 4)
if len(path) != 4 {
path = append(path, "")
}
if path[2] != root.Host && (usePortal || disableExternalProxy) {
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write([]byte("404 Not found"))
return
}
if r.Method == "POST" {
r.ParseForm()
if q, ok := r.Form["q"]; !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Bad request"))
} else {
w.Header().Add("Location", "?"+q[0])
w.WriteHeader(http.StatusFound)
w.Write([]byte("Redirecting"))
}
return
}
if r.Method != "GET" {
w.WriteHeader(http.StatusMethodNotAllowed)
w.Write([]byte("404 Not found"))
return
}
req := gemini.Request{}
req.URL = &url.URL{}
req.URL.Scheme = "gemini"
req.URL.Host = path[2]
req.URL.Path = "/" + path[3]
req.URL.RawQuery = r.URL.RawQuery
log.Printf("%s (external) %s%s", r.Method, r.URL.Host, r.URL.Path)
proxyGemini(req, true, root, w, r, css, external, usePortal, portal, disableExternalProxy, useRewrite, rewrite)
}))
log.Printf("HTTP server listening on %s", bind)
log.Fatal(http.ListenAndServe(bind, nil))
}