2020-10-11 12:56:17 -04:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
2021-02-24 23:32:39 -05:00
|
|
|
"context"
|
2020-10-11 12:56:17 -04:00
|
|
|
"fmt"
|
|
|
|
"html/template"
|
2020-11-05 17:51:26 -05:00
|
|
|
"io"
|
2020-10-11 12:56:17 -04:00
|
|
|
"log"
|
|
|
|
"mime"
|
|
|
|
"net/http"
|
|
|
|
"net/url"
|
|
|
|
"os"
|
2025-01-17 13:35:08 -05:00
|
|
|
"path/filepath"
|
2020-10-11 12:56:17 -04:00
|
|
|
"strings"
|
2020-11-05 17:51:26 -05:00
|
|
|
"time"
|
2021-11-04 13:04:50 -04:00
|
|
|
"unicode"
|
2020-10-11 12:56:17 -04:00
|
|
|
|
2020-10-26 12:28:16 -04:00
|
|
|
"git.sr.ht/~adnano/go-gemini"
|
2020-10-11 12:56:17 -04:00
|
|
|
"git.sr.ht/~sircmpwn/getopt"
|
|
|
|
)
|
|
|
|
|
2025-01-17 13:35:08 -05:00
|
|
|
// 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"}
|
|
|
|
|
2020-10-11 12:56:17 -04:00
|
|
|
var gemtextPage = template.Must(template.
|
|
|
|
New("gemtext").
|
|
|
|
Funcs(template.FuncMap{
|
2020-10-26 12:28:16 -04:00
|
|
|
"heading": func(line gemini.Line) *GemtextHeading {
|
2020-10-11 12:56:17 -04:00
|
|
|
switch l := line.(type) {
|
2020-10-26 12:28:16 -04:00
|
|
|
case gemini.LineHeading1:
|
2021-11-04 13:04:50 -04:00
|
|
|
return &GemtextHeading{1, string(l), createAnchor(string(l))}
|
2020-10-26 12:28:16 -04:00
|
|
|
case gemini.LineHeading2:
|
2021-11-04 13:04:50 -04:00
|
|
|
return &GemtextHeading{2, string(l), createAnchor(string(l))}
|
2020-10-26 12:28:16 -04:00
|
|
|
case gemini.LineHeading3:
|
2021-11-04 13:04:50 -04:00
|
|
|
return &GemtextHeading{3, string(l), createAnchor(string(l))}
|
2020-10-11 12:56:17 -04:00
|
|
|
default:
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
},
|
2020-10-26 12:28:16 -04:00
|
|
|
"link": func(line gemini.Line) *gemini.LineLink {
|
2020-10-11 12:56:17 -04:00
|
|
|
switch l := line.(type) {
|
2020-10-26 12:28:16 -04:00
|
|
|
case gemini.LineLink:
|
2020-10-11 12:56:17 -04:00
|
|
|
return &l
|
|
|
|
default:
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
},
|
2020-10-26 12:28:16 -04:00
|
|
|
"li": func(line gemini.Line) *gemini.LineListItem {
|
2020-10-11 12:56:17 -04:00
|
|
|
switch l := line.(type) {
|
2020-10-26 12:28:16 -04:00
|
|
|
case gemini.LineListItem:
|
2020-10-11 12:56:17 -04:00
|
|
|
return &l
|
|
|
|
default:
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
},
|
2020-10-26 12:28:16 -04:00
|
|
|
"pre_toggle_on": func(ctx *GemtextContext, line gemini.Line) *gemini.LinePreformattingToggle {
|
2020-10-11 12:56:17 -04:00
|
|
|
switch l := line.(type) {
|
2020-10-26 12:28:16 -04:00
|
|
|
case gemini.LinePreformattingToggle:
|
2020-11-05 17:51:26 -05:00
|
|
|
if ctx.Pre%4 == 0 {
|
2020-10-11 12:56:17 -04:00
|
|
|
ctx.Pre += 1
|
|
|
|
return &l
|
|
|
|
}
|
|
|
|
ctx.Pre += 1
|
|
|
|
return nil
|
|
|
|
default:
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
},
|
2020-10-26 12:28:16 -04:00
|
|
|
"pre_toggle_off": func(ctx *GemtextContext, line gemini.Line) *gemini.LinePreformattingToggle {
|
2020-10-11 12:56:17 -04:00
|
|
|
switch l := line.(type) {
|
2020-10-26 12:28:16 -04:00
|
|
|
case gemini.LinePreformattingToggle:
|
2020-11-05 17:51:26 -05:00
|
|
|
if ctx.Pre%4 == 3 {
|
2020-10-11 12:56:17 -04:00
|
|
|
ctx.Pre += 1
|
|
|
|
return &l
|
|
|
|
}
|
|
|
|
ctx.Pre += 1
|
|
|
|
return nil
|
|
|
|
default:
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
},
|
2020-10-26 12:28:16 -04:00
|
|
|
"pre": func(line gemini.Line) *gemini.LinePreformattedText {
|
2020-10-11 12:56:17 -04:00
|
|
|
switch l := line.(type) {
|
2020-10-26 12:28:16 -04:00
|
|
|
case gemini.LinePreformattedText:
|
2020-10-11 12:56:17 -04:00
|
|
|
return &l
|
|
|
|
default:
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
},
|
2020-10-26 12:28:16 -04:00
|
|
|
"quote": func(line gemini.Line) *gemini.LineQuote {
|
2020-10-11 12:56:17 -04:00
|
|
|
switch l := line.(type) {
|
2020-10-26 12:28:16 -04:00
|
|
|
case gemini.LineQuote:
|
2020-10-11 12:56:17 -04:00
|
|
|
return &l
|
|
|
|
default:
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
},
|
2020-10-26 12:28:16 -04:00
|
|
|
"text": func(line gemini.Line) *gemini.LineText {
|
2020-10-11 12:56:17 -04:00
|
|
|
switch l := line.(type) {
|
2020-10-26 12:28:16 -04:00
|
|
|
case gemini.LineText:
|
2020-10-11 12:56:17 -04:00
|
|
|
return &l
|
|
|
|
default:
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
},
|
2025-01-17 13:35:08 -05:00
|
|
|
"isImage": func(s string) bool {
|
2025-01-11 05:19:22 -05:00
|
|
|
u, err := url.Parse(s)
|
|
|
|
if err != nil {
|
|
|
|
return false
|
|
|
|
}
|
2025-01-17 13:35:08 -05:00
|
|
|
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
|
2025-01-11 05:19:22 -05:00
|
|
|
},
|
2020-10-11 12:56:17 -04:00
|
|
|
"url": func(ctx *GemtextContext, s string) template.URL {
|
|
|
|
u, err := url.Parse(s)
|
2025-01-08 08:29:17 -05:00
|
|
|
|
2020-10-11 12:56:17 -04:00
|
|
|
if err != nil {
|
|
|
|
return template.URL("error")
|
|
|
|
}
|
2025-01-08 08:29:17 -05:00
|
|
|
|
2020-11-05 17:51:26 -05:00
|
|
|
u = ctx.URL.ResolveReference(u)
|
|
|
|
|
2025-01-10 07:24:45 -05:00
|
|
|
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")
|
2020-10-11 12:56:17 -04:00
|
|
|
}
|
2025-01-10 07:24:45 -05:00
|
|
|
return template.URL(r.String())
|
2020-10-11 12:56:17 -04:00
|
|
|
}
|
2025-01-08 08:29:17 -05:00
|
|
|
|
2025-01-10 07:24:45 -05:00
|
|
|
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 = ""
|
|
|
|
|
2020-11-05 17:51:26 -05:00
|
|
|
return template.URL(u.String())
|
2020-10-11 12:56:17 -04:00
|
|
|
},
|
|
|
|
"safeCSS": func(s string) template.CSS {
|
|
|
|
return template.CSS(s)
|
|
|
|
},
|
|
|
|
"safeURL": func(s string) template.URL {
|
|
|
|
return template.URL(s)
|
|
|
|
},
|
|
|
|
}).
|
|
|
|
Parse(`<!doctype html>
|
|
|
|
<html>
|
|
|
|
<meta charset="utf-8">
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
|
|
{{- if .CSS }}
|
2021-05-31 14:26:59 -04:00
|
|
|
{{- if .ExternalCSS }}
|
|
|
|
<link rel="stylesheet" type="text/css" href="{{.CSS | safeCSS}}">
|
|
|
|
{{- else }}
|
2020-10-11 12:56:17 -04:00
|
|
|
<style>
|
|
|
|
{{.CSS | safeCSS}}
|
|
|
|
</style>
|
|
|
|
{{- end }}
|
2021-05-31 14:26:59 -04:00
|
|
|
{{- end }}
|
2020-10-11 12:56:17 -04:00
|
|
|
<title>{{.Title}}</title>
|
2021-02-24 18:28:14 -05:00
|
|
|
<article{{if .Lang}} lang="{{.Lang}}"{{end}}>
|
2020-10-11 12:56:17 -04:00
|
|
|
{{ $ctx := . -}}
|
|
|
|
{{- $isList := false -}}
|
2025-01-11 05:19:22 -05:00
|
|
|
|
2020-10-11 12:56:17 -04:00
|
|
|
{{- range .Lines -}}
|
2025-01-11 05:19:22 -05:00
|
|
|
|
2020-10-11 12:56:17 -04:00
|
|
|
{{- if and $isList (not (. | li)) }}
|
|
|
|
</ul>
|
|
|
|
{{- $isList = false -}}
|
|
|
|
{{- end -}}
|
|
|
|
|
|
|
|
{{- with . | heading }}
|
|
|
|
{{- $isList = false -}}
|
2021-11-04 13:04:50 -04:00
|
|
|
<h{{.Level}} id="{{.Anchor}}">{{.Text}}</h{{.Level}}>
|
2020-10-11 12:56:17 -04:00
|
|
|
{{- end -}}
|
|
|
|
|
|
|
|
{{- with . | link }}
|
|
|
|
{{- $isList = false -}}
|
|
|
|
<p>
|
2025-01-17 13:35:08 -05:00
|
|
|
<a
|
|
|
|
href="{{.URL | url $ctx}}"
|
|
|
|
>
|
|
|
|
{{- if ( .URL | isImage ) -}}
|
2025-01-11 05:19:22 -05:00
|
|
|
<img
|
|
|
|
src="{{.URL | url $ctx}}"
|
|
|
|
{{if .Name}}alt="{{.Name}}" title="{{.Name}}"{{end}}
|
|
|
|
>
|
|
|
|
{{- else -}}
|
2025-01-17 13:35:08 -05:00
|
|
|
{{if .Name}}{{.Name}}{{else}}{{.URL}}{{end}}
|
2020-10-11 12:56:17 -04:00
|
|
|
{{- end -}}
|
2025-01-17 13:35:08 -05:00
|
|
|
</a>
|
2025-01-11 05:19:22 -05:00
|
|
|
</p>
|
|
|
|
{{- end -}}
|
2020-10-11 12:56:17 -04:00
|
|
|
|
|
|
|
{{- with . | quote }}
|
|
|
|
{{- $isList = false -}}
|
|
|
|
<blockquote>
|
|
|
|
{{slice .String 1}}
|
|
|
|
</blockquote>
|
|
|
|
{{- end -}}
|
|
|
|
|
|
|
|
{{- with . | pre_toggle_on $ctx }}
|
|
|
|
<div aria-label="{{slice .String 3}}">
|
|
|
|
<pre aria-hidden="true" alt="{{slice .String 3}}">
|
|
|
|
{{- $isList = false -}}
|
|
|
|
{{- end -}}
|
|
|
|
{{- with . | pre -}}
|
|
|
|
{{- $isList = false -}}
|
|
|
|
{{.}}
|
|
|
|
{{ end -}}
|
|
|
|
{{- with . | pre_toggle_off $ctx -}}
|
|
|
|
{{- $isList = false -}}
|
|
|
|
</pre>
|
|
|
|
</div>
|
|
|
|
{{- end -}}
|
|
|
|
|
|
|
|
{{- with . | text }}
|
|
|
|
{{- $isList = false }}
|
2025-01-11 05:19:22 -05:00
|
|
|
<p>{{.}}</p>
|
2020-10-11 12:56:17 -04:00
|
|
|
{{- end -}}
|
|
|
|
|
|
|
|
{{- with . | li }}
|
|
|
|
{{- if not $isList }}
|
|
|
|
<ul>
|
|
|
|
{{- end -}}
|
|
|
|
|
|
|
|
{{- $isList = true }}
|
|
|
|
<li>{{slice .String 1}}</li>
|
|
|
|
{{- end -}}
|
|
|
|
|
|
|
|
{{- end }}
|
|
|
|
{{- if $isList }}
|
|
|
|
</ul>
|
|
|
|
{{- end }}
|
|
|
|
</article>
|
|
|
|
<details>
|
|
|
|
<summary>
|
|
|
|
Proxied content from <a href="{{.URL.String | safeURL}}">{{.URL.String}}</a>
|
|
|
|
{{if .External}}
|
|
|
|
(external content)
|
|
|
|
{{end}}
|
|
|
|
</summary>
|
|
|
|
<p>Gemini request details:
|
|
|
|
<dl>
|
|
|
|
<dt>Original URL</dt>
|
|
|
|
<dd><a href="{{.URL.String | safeURL}}">{{.URL.String}}</a></dd>
|
|
|
|
<dt>Status code</dt>
|
|
|
|
<dd>{{.Resp.Status}}</dd>
|
|
|
|
<dt>Meta</dt>
|
|
|
|
<dd>{{.Resp.Meta}}</dd>
|
|
|
|
<dt>Proxied by</dt>
|
|
|
|
<dd><a href="https://sr.ht/~sircmpwn/kineto">kineto</a></dd>
|
|
|
|
</dl>
|
|
|
|
<p>Be advised that no attempt was made to verify the remote SSL certificate.
|
|
|
|
</details>
|
|
|
|
`))
|
|
|
|
|
2020-11-08 10:24:27 -05:00
|
|
|
var inputPage = template.Must(template.
|
|
|
|
New("input").
|
|
|
|
Funcs(template.FuncMap{
|
|
|
|
"safeCSS": func(s string) template.CSS {
|
|
|
|
return template.CSS(s)
|
|
|
|
},
|
|
|
|
}).
|
|
|
|
Parse(`<!doctype html>
|
|
|
|
<html>
|
|
|
|
<meta charset="utf-8">
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
|
|
{{- if .CSS }}
|
2021-05-31 14:26:59 -04:00
|
|
|
{{- if .ExternalCSS }}
|
|
|
|
<link rel="stylesheet" type="text/css" href="{{.CSS | safeCSS}}">
|
|
|
|
{{- else }}
|
2020-11-08 10:24:27 -05:00
|
|
|
<style>
|
|
|
|
{{.CSS | safeCSS}}
|
|
|
|
</style>
|
|
|
|
{{- end }}
|
2021-05-31 14:26:59 -04:00
|
|
|
{{- end }}
|
2020-11-08 10:24:27 -05:00
|
|
|
<title>{{.Prompt}}</title>
|
2020-11-08 12:41:56 -05:00
|
|
|
<form method="POST">
|
2020-11-08 10:24:27 -05:00
|
|
|
<label for="input">{{.Prompt}}</label>
|
|
|
|
{{ if .Secret }}
|
|
|
|
<input type="password" id="input" name="q" />
|
|
|
|
{{ else }}
|
|
|
|
<input type="text" id="input" name="q" />
|
|
|
|
{{ end }}
|
|
|
|
</form>
|
|
|
|
`))
|
|
|
|
|
2020-10-11 12:56:17 -04:00
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
|
2020-11-01 13:38:18 -05:00
|
|
|
li:not(:last-child) {
|
2020-11-01 13:39:28 -05:00
|
|
|
margin-bottom: 0.5rem;
|
2020-11-01 13:38:18 -05:00
|
|
|
}
|
|
|
|
|
2025-01-11 05:19:22 -05:00
|
|
|
img {
|
|
|
|
max-width: 100%;
|
|
|
|
}
|
|
|
|
|
2020-10-28 14:01:41 -04:00
|
|
|
a {
|
|
|
|
position: relative;
|
|
|
|
}
|
|
|
|
|
|
|
|
a:before {
|
|
|
|
content: '⇒';
|
|
|
|
color: #999;
|
|
|
|
text-decoration: none;
|
|
|
|
font-weight: bold;
|
|
|
|
position: absolute;
|
|
|
|
left: -1.25rem;
|
|
|
|
}
|
|
|
|
|
2025-01-17 13:35:08 -05:00
|
|
|
a:has(img):before {
|
|
|
|
content: '';
|
|
|
|
}
|
|
|
|
|
2020-10-11 12:56:17 -04:00
|
|
|
pre {
|
|
|
|
background-color: #eee;
|
|
|
|
margin: 0 -1rem;
|
|
|
|
padding: 1rem;
|
|
|
|
overflow-x: auto;
|
|
|
|
}
|
|
|
|
|
|
|
|
details:not([open]) summary,
|
|
|
|
details:not([open]) summary a {
|
|
|
|
color: gray;
|
|
|
|
}
|
|
|
|
|
2020-10-28 14:01:41 -04:00
|
|
|
details summary a:before {
|
|
|
|
display: none;
|
|
|
|
}
|
|
|
|
|
2020-10-11 12:56:17 -04:00
|
|
|
dl dt {
|
|
|
|
font-weight: bold;
|
|
|
|
}
|
|
|
|
|
|
|
|
dl dt:not(:first-child) {
|
|
|
|
margin-top: 0.5rem;
|
|
|
|
}
|
2020-11-02 01:15:58 -05:00
|
|
|
|
|
|
|
@media(prefers-color-scheme:dark) {
|
|
|
|
html {
|
|
|
|
background-color: #111;
|
|
|
|
color: #eee;
|
|
|
|
}
|
|
|
|
|
|
|
|
blockquote {
|
|
|
|
background-color: #000;
|
|
|
|
}
|
|
|
|
|
|
|
|
pre {
|
|
|
|
background-color: #222;
|
|
|
|
}
|
2021-02-14 17:42:51 -05:00
|
|
|
|
2020-11-02 01:15:58 -05:00
|
|
|
a {
|
|
|
|
color: #0087BD;
|
|
|
|
}
|
|
|
|
|
|
|
|
a:visited {
|
|
|
|
color: #333399;
|
|
|
|
}
|
|
|
|
}
|
2020-11-08 10:24:27 -05:00
|
|
|
|
|
|
|
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);
|
|
|
|
}
|
2020-10-11 12:56:17 -04:00
|
|
|
`
|
|
|
|
|
2025-01-10 07:24:45 -05:00
|
|
|
const defaultRobots = `
|
|
|
|
User-agent: *
|
|
|
|
Disallow: /
|
|
|
|
`
|
|
|
|
|
2020-10-11 12:56:17 -04:00
|
|
|
type GemtextContext struct {
|
2025-01-10 07:24:45 -05:00
|
|
|
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
|
2020-10-11 12:56:17 -04:00
|
|
|
}
|
|
|
|
|
2020-11-08 10:24:27 -05:00
|
|
|
type InputContext struct {
|
2021-05-31 14:26:59 -04:00
|
|
|
CSS string
|
|
|
|
ExternalCSS bool
|
|
|
|
Prompt string
|
|
|
|
Secret bool
|
|
|
|
URL *url.URL
|
2020-11-08 10:24:27 -05:00
|
|
|
}
|
|
|
|
|
2020-10-11 12:56:17 -04:00
|
|
|
type GemtextHeading struct {
|
2021-11-04 13:04:50 -04:00
|
|
|
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())
|
2020-10-11 12:56:17 -04:00
|
|
|
}
|
|
|
|
|
2025-01-10 07:24:45 -05:00
|
|
|
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) {
|
2020-11-05 17:51:26 -05:00
|
|
|
|
2021-02-24 23:32:39 -05:00
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
|
|
defer cancel()
|
2020-10-11 12:56:17 -04:00
|
|
|
|
2021-02-24 23:32:39 -05:00
|
|
|
client := gemini.Client{}
|
|
|
|
resp, err := client.Do(ctx, &req)
|
2020-10-11 12:56:17 -04:00
|
|
|
if err != nil {
|
|
|
|
w.WriteHeader(http.StatusBadGateway)
|
2020-11-05 17:51:27 -05:00
|
|
|
fmt.Fprintf(w, "Gateway error: %v", err)
|
2020-10-11 12:56:17 -04:00
|
|
|
return
|
|
|
|
}
|
2020-11-05 17:51:26 -05:00
|
|
|
defer resp.Body.Close()
|
2020-10-11 12:56:17 -04:00
|
|
|
|
|
|
|
switch resp.Status {
|
2020-11-08 10:24:27 -05:00
|
|
|
case 10, 11:
|
|
|
|
w.Header().Add("Content-Type", "text/html")
|
|
|
|
err = inputPage.Execute(w, &InputContext{
|
2021-05-31 14:26:59 -04:00
|
|
|
CSS: css,
|
|
|
|
ExternalCSS: externalCSS,
|
|
|
|
Prompt: resp.Meta,
|
|
|
|
Secret: resp.Status == 11,
|
|
|
|
URL: req.URL,
|
2020-11-08 10:24:27 -05:00
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
w.Write([]byte(fmt.Sprintf("%v", err)))
|
|
|
|
}
|
|
|
|
return
|
2020-10-11 12:56:17 -04:00
|
|
|
case 20:
|
|
|
|
break // OK
|
2020-11-05 17:51:27 -05:00
|
|
|
case 30, 31:
|
2020-11-08 10:24:27 -05:00
|
|
|
to, err := url.Parse(resp.Meta)
|
|
|
|
if err != nil {
|
|
|
|
w.WriteHeader(http.StatusBadGateway)
|
2021-02-24 23:18:41 -05:00
|
|
|
fmt.Fprintf(w, "Gateway error: bad redirect: %v", err)
|
2020-11-08 10:24:27 -05:00
|
|
|
}
|
|
|
|
next := req.URL.ResolveReference(to)
|
|
|
|
if next.Scheme != "gemini" {
|
|
|
|
w.WriteHeader(http.StatusOK)
|
2021-02-24 23:18:41 -05:00
|
|
|
fmt.Fprintf(w, "This page is redirecting you to %s", next)
|
2020-11-08 10:24:27 -05:00
|
|
|
return
|
|
|
|
}
|
|
|
|
if external {
|
|
|
|
next.Path = fmt.Sprintf("/x/%s/%s", next.Host, next.Path)
|
|
|
|
}
|
2021-02-24 17:50:31 -05:00
|
|
|
next.Host = r.URL.Host
|
2020-11-08 10:24:27 -05:00
|
|
|
next.Scheme = r.URL.Scheme
|
|
|
|
w.Header().Add("Location", next.String())
|
|
|
|
w.WriteHeader(http.StatusFound)
|
2021-02-24 23:18:41 -05:00
|
|
|
fmt.Fprintf(w, "Redirecting to %s", next)
|
2020-10-11 12:56:17 -04:00
|
|
|
return
|
2020-11-05 17:51:27 -05:00
|
|
|
case 40, 41, 42, 43, 44:
|
2020-10-11 12:56:17 -04:00
|
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
2020-11-05 17:51:27 -05:00
|
|
|
fmt.Fprintf(w, "The remote server returned %d: %s", resp.Status, resp.Meta)
|
2020-10-11 12:56:17 -04:00
|
|
|
return
|
2020-11-05 17:51:27 -05:00
|
|
|
case 50, 51:
|
2020-10-11 12:56:17 -04:00
|
|
|
w.WriteHeader(http.StatusNotFound)
|
2020-11-05 17:51:27 -05:00
|
|
|
fmt.Fprintf(w, "The remote server returned %d: %s", resp.Status, resp.Meta)
|
2020-10-11 12:56:17 -04:00
|
|
|
return
|
2020-11-05 17:51:27 -05:00
|
|
|
case 52, 53, 59:
|
2020-10-11 12:56:17 -04:00
|
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
2020-11-05 17:51:27 -05:00
|
|
|
fmt.Fprintf(w, "The remote server returned %d: %s", resp.Status, resp.Meta)
|
2020-10-11 12:56:17 -04:00
|
|
|
return
|
|
|
|
default:
|
|
|
|
w.WriteHeader(http.StatusNotImplemented)
|
2020-11-05 17:51:27 -05:00
|
|
|
fmt.Fprintf(w, "Proxy does not understand Gemini response status %d", resp.Status)
|
2020-10-11 12:56:17 -04:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2021-02-24 18:22:01 -05:00
|
|
|
m, params, err := mime.ParseMediaType(resp.Meta)
|
2020-10-11 12:56:17 -04:00
|
|
|
if err != nil {
|
|
|
|
w.WriteHeader(http.StatusBadGateway)
|
2021-02-24 23:18:41 -05:00
|
|
|
fmt.Fprintf(w, "Gateway error: %d %s: %v", resp.Status, resp.Meta, err)
|
2020-10-11 12:56:17 -04:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if m != "text/gemini" {
|
|
|
|
w.Header().Add("Content-Type", resp.Meta)
|
2020-11-05 17:51:26 -05:00
|
|
|
io.Copy(w, resp.Body)
|
2020-10-11 12:56:17 -04:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2021-02-24 18:22:01 -05:00
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-02-24 18:28:14 -05:00
|
|
|
lang := params["lang"]
|
|
|
|
|
2020-10-11 12:56:17 -04:00
|
|
|
w.Header().Add("Content-Type", "text/html")
|
2021-02-24 23:32:39 -05:00
|
|
|
gemctx := &GemtextContext{
|
2025-01-10 07:24:45 -05:00
|
|
|
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,
|
2020-10-11 12:56:17 -04:00
|
|
|
}
|
2020-11-05 17:51:26 -05:00
|
|
|
|
|
|
|
var title bool
|
|
|
|
gemini.ParseLines(resp.Body, func(line gemini.Line) {
|
2021-02-24 23:32:39 -05:00
|
|
|
gemctx.Lines = append(gemctx.Lines, line)
|
2020-11-05 17:51:26 -05:00
|
|
|
if !title {
|
|
|
|
if h, ok := line.(gemini.LineHeading1); ok {
|
2021-02-24 23:32:39 -05:00
|
|
|
gemctx.Title = string(h)
|
2020-11-05 17:51:26 -05:00
|
|
|
title = true
|
|
|
|
}
|
2020-10-11 12:56:17 -04:00
|
|
|
}
|
2020-11-05 17:51:26 -05:00
|
|
|
})
|
|
|
|
|
2021-02-24 23:32:39 -05:00
|
|
|
err = gemtextPage.Execute(w, gemctx)
|
2020-10-11 12:56:17 -04:00
|
|
|
if err != nil {
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
2020-11-05 17:51:27 -05:00
|
|
|
fmt.Fprintf(w, "%v", err)
|
2020-10-11 12:56:17 -04:00
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func main() {
|
|
|
|
var (
|
2025-01-10 07:24:45 -05:00
|
|
|
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
|
2020-10-11 12:56:17 -04:00
|
|
|
)
|
|
|
|
|
2025-01-10 07:24:45 -05:00
|
|
|
opts, optind, err := getopt.Getopts(os.Args, "PRb:c:e:p:r:s:")
|
|
|
|
|
2020-10-11 12:56:17 -04:00
|
|
|
if err != nil {
|
|
|
|
log.Fatal(err)
|
|
|
|
}
|
2025-01-10 07:24:45 -05:00
|
|
|
|
2020-10-11 12:56:17 -04:00
|
|
|
for _, opt := range opts {
|
|
|
|
switch opt.Option {
|
2025-01-10 07:24:45 -05:00
|
|
|
case 'P':
|
|
|
|
disableExternalProxy = true
|
|
|
|
case 'R':
|
|
|
|
disableRobots = true
|
2020-10-11 12:56:17 -04:00
|
|
|
case 'b':
|
|
|
|
bind = opt.Value
|
2025-01-10 07:24:45 -05:00
|
|
|
case 'e':
|
|
|
|
external = true
|
|
|
|
css = opt.Value
|
|
|
|
case 'p':
|
|
|
|
usePortal = true
|
|
|
|
portal = opt.Value
|
|
|
|
case 'r':
|
|
|
|
useRewrite = true
|
|
|
|
rewrite = opt.Value
|
2021-02-14 17:42:51 -05:00
|
|
|
case 's':
|
2021-05-31 14:26:59 -04:00
|
|
|
external = false
|
2025-01-10 07:19:12 -05:00
|
|
|
cssContent, err := os.ReadFile(opt.Value)
|
2021-02-24 23:18:41 -05:00
|
|
|
if err == nil {
|
2021-02-14 17:42:51 -05:00
|
|
|
css = string(cssContent)
|
|
|
|
} else {
|
|
|
|
log.Fatalf("Error opening custom CSS from '%s': %v", opt.Value, err)
|
|
|
|
}
|
2021-02-24 23:18:41 -05:00
|
|
|
}
|
2020-10-11 12:56:17 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
args := os.Args[optind:]
|
|
|
|
if len(args) != 1 {
|
|
|
|
log.Fatalf("Usage: %s <gemini root>", 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) {
|
2020-11-08 12:41:56 -05:00
|
|
|
if r.Method == "POST" {
|
|
|
|
r.ParseForm()
|
|
|
|
if q, ok := r.Form["q"]; !ok {
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
w.Write([]byte("Bad request"))
|
|
|
|
} else {
|
2021-01-15 10:36:39 -05:00
|
|
|
w.Header().Add("Location", "?"+q[0])
|
2020-11-08 12:41:56 -05:00
|
|
|
w.WriteHeader(http.StatusFound)
|
|
|
|
w.Write([]byte("Redirecting"))
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-10-11 12:56:17 -04:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2025-01-10 07:24:45 -05:00
|
|
|
if !disableRobots {
|
|
|
|
if r.URL.Path == "/robots.txt" {
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
w.Write([]byte(defaultRobots))
|
|
|
|
return
|
|
|
|
}
|
2021-08-25 06:08:56 -04:00
|
|
|
}
|
|
|
|
|
2020-10-26 12:28:16 -04:00
|
|
|
req := gemini.Request{}
|
2020-10-11 12:56:17 -04:00
|
|
|
req.URL = &url.URL{}
|
|
|
|
req.URL.Scheme = root.Scheme
|
|
|
|
req.URL.Host = root.Host
|
|
|
|
req.URL.Path = r.URL.Path
|
2020-11-08 12:41:56 -05:00
|
|
|
req.URL.RawQuery = r.URL.RawQuery
|
2025-01-10 07:24:45 -05:00
|
|
|
proxyGemini(req, false, root, w, r, css, external, usePortal, portal, disableExternalProxy, useRewrite, rewrite)
|
2020-10-11 12:56:17 -04:00
|
|
|
}))
|
|
|
|
|
|
|
|
http.Handle("/x/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
2025-01-08 08:29:17 -05:00
|
|
|
path := strings.SplitN(r.URL.Path, "/", 4)
|
|
|
|
if len(path) != 4 {
|
|
|
|
path = append(path, "")
|
|
|
|
}
|
|
|
|
|
2025-01-10 07:24:45 -05:00
|
|
|
if path[2] != root.Host && (usePortal || disableExternalProxy) {
|
2025-01-08 08:29:17 -05:00
|
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
|
|
w.Write([]byte("404 Not found"))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2021-02-24 23:29:15 -05:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2020-10-11 12:56:17 -04:00
|
|
|
if r.Method != "GET" {
|
|
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
|
|
w.Write([]byte("404 Not found"))
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-10-26 12:28:16 -04:00
|
|
|
req := gemini.Request{}
|
2021-02-24 23:29:15 -05:00
|
|
|
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)
|
2025-01-10 07:24:45 -05:00
|
|
|
proxyGemini(req, true, root, w, r, css, external, usePortal, portal, disableExternalProxy, useRewrite, rewrite)
|
2020-10-11 12:56:17 -04:00
|
|
|
}))
|
|
|
|
|
|
|
|
log.Printf("HTTP server listening on %s", bind)
|
|
|
|
log.Fatal(http.ListenAndServe(bind, nil))
|
|
|
|
}
|