// 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, "
"+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 }