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 }}
{{- end -}} {{- with . | text }} {{- $isList = false }}

{{.}}

{{- end -}} {{- with . | li }} {{- if not $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}}
{{ if .Secret }} {{ else }} {{ end }}
`)) // 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)) }