// SPDX-FileCopyrightText: 2020 Paul Gorman
// SPDX-FileCopyrightText: 2021 Sotiris Papatheodorou
// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"git.sr.ht/~sotirisp/kindleto/util"
)
var DefaultLang string = "en-US"
var TextOnly bool = false
var reCharset *regexp.Regexp = regexp.MustCompile(`\bcharset=([\w-]+)`)
var reGemResponseHeader *regexp.Regexp = regexp.MustCompile(`^\d{2} (.*)\r\n`)
var reLang *regexp.Regexp = regexp.MustCompile(`\blang=([\w-]+)`)
var reStatus *regexp.Regexp = regexp.MustCompile(`\d\d .*`)
// 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")
}
// 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.ParentURL = util.ParentURL(*u)
td.Warning = warning
td.Title = "Kindleto " + td.URL
}
if len(clientCerts) > 0 {
td.ManageCerts = true
}
gemtextToHTML(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 <META> 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.ParentURL = util.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, "gemini-sensitive-input.html.tmpl", td)
if err != nil {
err = fmt.Errorf("proxyGemini: failed to execute password template: %v", err)
break
}
default:
err = tmpls.ExecuteTemplate(w, "gemini-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.ParentURL = util.ParentURL(*u)
td.Title = "Kindleto " + td.URL
if len(clientCerts) > 0 {
td.ManageCerts = true
}
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 = DefaultLang
}
if r.URL.Query().Get("source") != "" {
err = textToHTML(w, u, rd, td)
} else {
err = gemtextToHTML(w, u, rd, td)
}
if err != nil {
break
}
} else if strings.Contains(status, " text") {
var td TemplateData
td.URL = u.String()
td.ParentURL = util.ParentURL(*u)
td.Title = "Kindleto " + td.URL
if len(clientCerts) > 0 {
td.ManageCerts = true
}
err = textToHTML(w, u, rd, td)
if err != nil {
break
}
} else {
if TextOnly {
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 creates a buffer 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 {
const maxSizeBytes int64 = 10 * 1024 * 1024
var buf bytes.Buffer
_, err := io.CopyN(&buf, rd, maxSizeBytes + 1)
if err == nil {
return fmt.Errorf("serveFile: file exceeding maximum size of %v MiB", maxSizeBytes / 1024 / 1024)
} else if err.Error() != "EOF" {
return fmt.Errorf("serveFile: failed to read file: %v", err)
}
bufReader := bytes.NewReader(buf.Bytes())
fileName := u.String()[strings.LastIndex(u.String(), "/")+1:]
w.Header().Set("Content-Disposition", `attachment; filename="`+fileName+`"`)
http.ServeContent(w, r, fileName, time.Time{}, bufReader)
return nil
}