// SPDX-FileCopyrightText: 2020 Paul Gorman
// SPDX-FileCopyrightText: 2021 Sotiris Papatheodorou
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"bufio"
"crypto/tls"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
)
var htmlEscaper = strings.NewReplacer(
`&`, "&",
`'`, "'",
`<`, "<",
`"`, """,
)
// geminiQueryEscape returns a URL query string with "+" replaced by "%2O",
// as requred in secion 1.2 Gemini URI scheme of the Gemini specification.
func geminiQueryEscape(q string) string {
return strings.ReplaceAll(url.PathEscape(q), "+", "%2B")
}
// geminiToHTML reads Gemini text from rd, and writes its HTML equivalent to w.
// The source URL is stored in u.
func geminiToHTML(w http.ResponseWriter, u *url.URL, rd *bufio.Reader, td templateData) error {
var err error
list := false
pre := false
if len(clientCerts) > 0 {
td.ManageCerts = true
}
err = tmpls.ExecuteTemplate(w, "header-only.html.tmpl", td)
if err != nil {
log.Println("geminiToHTML:", err)
http.Error(w, "Internal Server Error", 500)
}
var eof error
var line string
for eof == nil {
line, eof = rd.ReadString("\n"[0])
if optLogLevel > 2 {
fmt.Println(line)
}
line = htmlEscaper.Replace(line)
if reGemPre.MatchString(line) {
if list {
list = false
io.WriteString(w, "\n")
}
if pre == true {
pre = false
io.WriteString(w, "\n")
continue
} else {
pre = true
// How can we provide alt text from reGemPre.FindStringSubmatch(line)[1]?
io.WriteString(w, "
\n")
continue
}
} else {
if pre == true {
io.WriteString(w, line)
continue
}
}
if reGemBlank.MatchString(line) {
io.WriteString(w, "
\n")
} else if reGemH1.MatchString(line) {
if list == true {
list = false
io.WriteString(w, "\n")
}
io.WriteString(w, ""+reGemH1.FindStringSubmatch(line)[1]+"
\n")
} else if reGemH2.MatchString(line) {
if list == true {
list = false
io.WriteString(w, "\n")
}
io.WriteString(w, ""+reGemH2.FindStringSubmatch(line)[1]+"
\n")
} else if reGemH3.MatchString(line) {
if list == true {
list = false
io.WriteString(w, "\n")
}
io.WriteString(w, ""+reGemH3.FindStringSubmatch(line)[1]+"
\n")
} else if reGemLink.MatchString(line) {
if list == true {
list = false
io.WriteString(w, "\n")
}
link := reGemLink.FindStringSubmatch(line)
lineURL, err := absoluteURL(u, link[1])
if err != nil {
io.WriteString(w, ""+line+"
\n")
}
link[1] = lineURL.String()
if lineURL.Scheme == "gemini" {
if link[2] != "" {
io.WriteString(w, ``+link[2]+
` [`+lineURL.Scheme+`]
`+"\n")
} else {
io.WriteString(w, ``+link[1]+
` [`+lineURL.Scheme+`]
`+"\n")
}
} else {
if link[2] != "" {
io.WriteString(w, ``+link[2]+
` [`+lineURL.Scheme+`]
`+"\n")
} else {
io.WriteString(w, ``+link[1]+
` [`+lineURL.Scheme+`]
`+"\n")
}
}
} else if reGemList.MatchString(line) {
if list == false {
list = true
io.WriteString(w, "
")
}
io.WriteString(w, "- "+reGemList.FindStringSubmatch(line)[1]+"
\n")
} else if reGemQuote.MatchString(line) {
if list == true {
list = false
io.WriteString(w, "
")
}
io.WriteString(w, ""+reGemQuote.FindStringSubmatch(line)[1]+"
\n")
} else {
if list {
list = false
io.WriteString(w, "\n")
}
io.WriteString(w, line+"
\n")
}
}
err = tmpls.ExecuteTemplate(w, "footer-only.html.tmpl", td)
if err != nil {
log.Println("geminiToHTML:", err)
http.Error(w, "Internal Server Error", 500)
}
return err
}
// proxyGemini finds the Gemini content at u.
func proxyGemini(w http.ResponseWriter, r *http.Request, u *url.URL) (*url.URL, error) {
var err error
var rd *bufio.Reader
var warning string
// Section 1.2 of the Gemini spec forbids userinfo URL components.
u.User = nil
if homeFile != "" && u.Scheme == "file" {
if optLogLevel > 1 {
log.Println("proxyGemini: home:", u.String())
}
f, err := os.Open(u.Path)
if err != nil {
return u, fmt.Errorf("proxyGemini: failed to open home file: %v", err)
}
var td templateData
if u.Scheme == "file" {
td.Title = "Kindleto"
} else {
td.URL = u.String()
td.URLUp = parentURL(*u)
td.Warning = warning
td.Title = "Kindleto " + td.URL
}
geminiToHTML(w, u, bufio.NewReader(f), td)
}
var port string
if u.Port() != "" {
port = u.Port()
} else {
port = "1965"
}
var clientCert tls.Certificate
var tc *tls.Config
if optHours != 0 {
clientCert = matchClientCert(u)
}
if len(clientCert.Certificate) == 0 {
tc = &tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12,
}
} else {
tc = &tls.Config{
Certificates: []tls.Certificate{clientCert},
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12,
}
}
conn, err := tls.Dial("tcp", u.Hostname()+":"+port, tc)
if err != nil {
return u, fmt.Errorf("proxyGemini: tls.Dial error to %s: %v", u.String(), err)
}
defer conn.Close()
warning = checkServerCert(u, conn)
// Split the URL to avoid sending the fragment, if any, to the server.
fmt.Fprintf(conn, strings.SplitN(u.String(), "#", 2)[0]+"\r\n")
rd = bufio.NewReader(conn)
// Gemini specification section 3.1 forbids response headers not starting,
// with two digits, and a longer than 1024 bytes.
header, _ := rd.Peek(1029)
if !reGemResponseHeader.Match(header) {
if optLogLevel > 1 {
log.Printf("proxyGemini: server %s sent malformed header: %s", u.Host, string(header))
}
return u, fmt.Errorf("proxyGemini: first 1029 bytes from %s did not contain a valid response header", u.Host)
}
status, err := rd.ReadString("\n"[0])
status = strings.Trim(status, "\r\n")
if err != nil {
return u, fmt.Errorf("proxyGemini: failed to read status line from buffer: %v", err)
}
if optLogLevel > 1 {
log.Printf("proxyGemini: %s status: %s", u.String(), status)
}
if !reStatus.MatchString(status) {
return u, fmt.Errorf("proxyGemini: invalid status line: %s", status)
}
switch status[0] {
case "1"[0]: // Status: input
var td templateData
td.URL = u.String()
td.URLUp = parentURL(*u)
td.Warning = warning
td.Title = "Kindleto " + td.URL
if len(clientCerts) > 0 {
td.ManageCerts = true
}
td.Meta = status[3:]
switch status[1] {
case "1"[0]: // 11 == sensitive input/password
err = tmpls.ExecuteTemplate(w, "password.html.tmpl", td)
if err != nil {
err = fmt.Errorf("proxyGemini: failed to execute password template: %v", err)
break
}
default:
err = tmpls.ExecuteTemplate(w, "input.html.tmpl", td)
if err != nil {
err = fmt.Errorf("proxyGemini: failed to execute input template: %v", err)
break
}
}
case "2"[0]: // Status: success
if strings.Contains(status, " text/gemini") || len(strings.TrimSpace(status)) < 3 {
var td templateData
td.URL = u.String()
td.URLUp = parentURL(*u)
td.Title = "Kindleto " + td.URL
c := reCharset.FindStringSubmatch(status)
if len(c) > 1 {
td.Charset = c[1]
} else {
td.Charset = "utf-8"
}
l := reLang.FindStringSubmatch(status)
if len(l) > 1 {
td.Lang = l[1]
} else {
td.Lang = optLang
}
if r.URL.Query().Get("source") != "" {
err = textToHTML(w, u, rd, td)
} else {
err = geminiToHTML(w, u, rd, td)
}
if err != nil {
break
}
} else if strings.Contains(status, " text") {
var td templateData
td.URL = u.String()
td.URLUp = parentURL(*u)
td.Title = "Kindleto " + td.URL
err = textToHTML(w, u, rd, td)
if err != nil {
break
}
} else {
if optTextOnly {
err = fmt.Errorf("proxying of non-text types not allowed on this server")
} else {
err = serveFile(w, r, u, rd)
}
if err != nil {
break
}
}
case "3"[0]: // Status: redirect
var ru *url.URL
ru, err = url.Parse(strings.TrimSpace(strings.SplitAfterN(status, " ", 2)[1]))
if err != nil {
err = fmt.Errorf("proxyGemini: can't parse redirect URL %s: %v", strings.SplitAfterN(status, " ", 2)[1], err)
break
}
if ru.Host == "" {
ru.Host = u.Host
}
if ru.Scheme == "" {
ru.Scheme = u.Scheme
}
u = ru
errRedirect = errors.New(u.String())
err = errRedirect
case "6"[0]: // Status: Client certificate something
switch status[1] {
case "0"[0]: // 60 == Client certificate required
if optHours == 0 {
err = fmt.Errorf("proxyGemini: client certificated disabled by --hours option (status: %s)", status)
break
}
http.Redirect(w, r, "/certificate?url="+geminiQueryEscape(u.String()), http.StatusFound)
default: // Client certificat not autorized, not valid, etc.
err = fmt.Errorf("proxyGemini: %s", status)
}
default: // Statuses 40-59 indicate various failures.
err = fmt.Errorf("proxyGemini: status: %s", status)
}
return u, err
}
// serveFile saves a temporary file with the contents of rd, then serves it to w.
func serveFile(w http.ResponseWriter, r *http.Request, u *url.URL, rd *bufio.Reader) error {
var err error
fileName := u.String()[strings.LastIndex(u.String(), "/")+1:]
f, err := ioutil.TempFile("", "kindleto*-"+fileName)
if err != nil {
err = fmt.Errorf("serveFile: failed to create temp file: %v", err)
}
defer os.Remove(f.Name()) // clean up
if _, err := f.ReadFrom(rd); err != nil {
err = fmt.Errorf("serveFile: failed to write to temp file: %v", err)
}
// Note: If we ever want to serve images inline, we'll have to revisit
// this content disposition header value.
w.Header().Set("Content-Disposition", "attachment; filename="+fileName)
http.ServeContent(w, r, fileName, time.Time{}, f)
if err := f.Close(); err != nil {
err = fmt.Errorf("serveFile: failed to close temp file: %v", err)
}
return err
}
// textToHTML reads non-Gemini text from rd, and writes its HTML equivalent to w.
// The source URL is stored in u.
func textToHTML(w http.ResponseWriter, u *url.URL, rd *bufio.Reader, td templateData) error {
var err error
if len(clientCerts) > 0 {
td.ManageCerts = true
}
err = tmpls.ExecuteTemplate(w, "header-only.html.tmpl", td)
if err != nil {
log.Println("textToHTML:", err)
http.Error(w, "Internal Server Error", 500)
}
io.WriteString(w, ``+"\n")
var eof error
var line string
for eof == nil {
line, eof = rd.ReadString("\n"[0])
if optLogLevel > 2 {
fmt.Println(line)
}
line = htmlEscaper.Replace(line)
io.WriteString(w, line+"\n")
}
io.WriteString(w, "
\n")
err = tmpls.ExecuteTemplate(w, "footer-only.html.tmpl", td)
if err != nil {
log.Println("textToHTML:", err)
http.Error(w, "Internal Server Error", 500)
}
return err
}