/* tofuproxy -- flexible HTTP/HTTPS proxy, TLS terminator, X.509 TOFU manager, WARC/geminispace browser Copyright (C) 2021 Sergey Matveev This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package rounds import ( "bufio" "bytes" "context" "fmt" "html" "io" "log" "net/http" "strconv" "strings" "go.stargrave.org/tofuproxy/fifos" ttls "go.stargrave.org/tofuproxy/tls" ) const ( ContentTypeGemini = "text/gemini" SchemeGemini = "gemini://" GeminiEntrypoint = "https://gemini" GeminiPort = ":1965" ) var GemCodeName = map[int]string{ 10: "INPUT", 11: "SENSITIVE INPUT", 20: "SUCCESS", 30: "REDIRECT - TEMPORARY", 31: "REDIRECT - PERMANENT", 40: "TEMPORARY FAILURE", 41: "SERVER UNAVAILABLE", 42: "CGI ERROR", 43: "PROXY ERROR", 44: "SLOW DOWN", 50: "PERMANENT FAILURE", 51: "NOT FOUND", 52: "GONE", 53: "PROXY REQUEST REFUSED", 59: "BAD REQUEST", 60: "CLIENT CERTIFICATE REQUIRED", 61: "CERTIFICATE NOT AUTHORISED", 62: "CERTIFICATE NOT VALID", } func absolutizeURL(host, u string, paths ...string) string { host = strings.TrimSuffix(host, GeminiPort) if strings.Contains(u, "://") { return u } if strings.HasPrefix(u, "/") { return GeminiEntrypoint + "/" + host + u } paths = append([]string{GeminiEntrypoint, host}, paths...) paths = append(paths, u) return strings.Join(paths, "/") } func geminifyURL(host, u string, paths ...string) string { u = absolutizeURL(host, u, paths...) if !strings.HasPrefix(u, SchemeGemini) { return u } return GeminiEntrypoint + "/" + strings.TrimPrefix(u, SchemeGemini) } func RoundGemini( host string, resp *http.Response, w http.ResponseWriter, req *http.Request, ) (bool, error) { if host != "gemini" { return true, nil } paths := strings.Split(strings.TrimPrefix(req.URL.Path, "/"), "/") host, paths = paths[0], paths[1:] if host == "gemini:" { http.Redirect(w, req, strings.Join( append([]string{GeminiEntrypoint}, paths[1:]...), "/", ), http.StatusTemporaryRedirect) return false, nil } hostWithPort := host if !strings.Contains(hostWithPort, ":") { hostWithPort += GeminiPort } conn, err := ttls.DialTLS(context.TODO(), "tcp", hostWithPort) if err != nil { log.Printf("%s: can not dial: %+v\n", req.URL, err) return false, err } query := fmt.Sprintf("%s%s/%s", SchemeGemini, host, strings.Join(paths, "/")) if req.URL.RawQuery != "" { query += "?" + req.URL.RawQuery } if _, err = conn.Write([]byte(query + "\r\n")); err != nil { log.Printf("%s: can not send request: %+v\n", req.URL, err) return false, err } if len(paths) > 0 && paths[len(paths)-1] == "" { paths = paths[:len(paths)-1] } br := bufio.NewReader(conn) rawResp, err := br.ReadString('\n') if err != nil { log.Printf("%s: can not read response: %+v\n", req.URL, err) return false, err } cols := strings.SplitN(rawResp, " ", 2) if len(cols) < 2 { err = fmt.Errorf("invalid response format: %s", rawResp) log.Printf("%s: %s\n", req.URL, err) return false, err } code, err := strconv.Atoi(cols[0]) if err != nil { log.Printf("%s: can not parse response code: %+v\n", req.URL, err) return false, err } codeName := GemCodeName[code] if codeName == "" { codeName = "UNKNOWN" } if 10 <= code && code <= 19 { w.Header().Add("Content-Type", "text/plain") w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, "%s\n%d (%s): INPUT is not supported\n", cols[1], code, codeName) return false, nil } if 30 <= code && code <= 39 { w.Header().Add("Content-Type", "text/html") w.WriteHeader(http.StatusOK) u := geminifyURL(host, cols[1], paths...) w.Write([]byte( fmt.Sprintf( ` %d (%s) redirection Redirection to %s`, code, codeName, u, u, ))) fifos.LogRedir <- fmt.Sprintf( "%s %s\t%d\t%s", req.Method, req.URL, code, cols[1], ) return false, nil } if 40 <= code && code <= 49 { w.Header().Add("Content-Type", "text/plain") w.WriteHeader(http.StatusBadGateway) fmt.Fprintf(w, "%s\n%d (%s)\n", cols[1], code, codeName) return false, nil } if 50 <= code && code <= 59 { w.Header().Add("Content-Type", "text/plain") w.WriteHeader(http.StatusBadGateway) fmt.Fprintf(w, "%s\n%d (%s)\n", cols[1], code, codeName) return false, nil } if 60 <= code && code <= 69 { w.Header().Add("Content-Type", "text/plain") w.WriteHeader(http.StatusUnauthorized) fmt.Fprintf(w, "%s\n%d (%s)\n", cols[1], code, codeName) return false, nil } if !(20 <= code && code <= 29) { err = fmt.Errorf("unknown response code: %d", code) log.Printf("%s: %s\n", req.URL, err) return false, err } contentType := strings.Split(strings.TrimRight(cols[1], "\r\n"), ";")[0] if contentType == ContentTypeGemini && !strings.Contains(req.Header.Get("Accept"), ContentTypeGemini) { w.Header().Add("Content-Type", "text/html") w.WriteHeader(http.StatusOK) raw, err := io.ReadAll(br) if err != nil { log.Printf("%s: can not read response body: %+v\n", req.URL, err) return false, err } var buf bytes.Buffer fmt.Fprintf(&buf, ` %d (%s) `, code, codeName) pre := false for _, line := range strings.Split(string(raw), "\n") { if strings.HasPrefix(line, "```") { if pre { buf.WriteString("\n") } else { buf.WriteString("
" + line[3:] + "\n")
				}
				pre = !pre
				continue
			}
			if pre {
				fmt.Fprintf(&buf, "%s\n", line)
				continue
			}
			if strings.HasPrefix(line, "=> ") {
				cols = strings.Fields(line)
				u := geminifyURL(host, cols[1], paths...)
				switch len(cols) {
				case 2:
					fmt.Fprintf(
						&buf, "%s
\n", u, html.EscapeString(cols[1]), ) default: fmt.Fprintf( &buf, "%s (%s)
\n", u, html.EscapeString(strings.Join(cols[2:], " ")), cols[1], ) } continue } if strings.HasPrefix(line, "# ") { fmt.Fprintf(&buf, "

%s

\n", html.EscapeString(line[2:])) continue } if strings.HasPrefix(line, "## ") { fmt.Fprintf(&buf, "

%s

\n", html.EscapeString(line[3:])) continue } if strings.HasPrefix(line, "### ") { fmt.Fprintf(&buf, "

%s

\n", html.EscapeString(line[4:])) continue } if strings.HasPrefix(line, "* ") { fmt.Fprintf(&buf, "• %s\n", html.EscapeString(line[2:])) continue } if strings.HasPrefix(line, "> ") { fmt.Fprintf( &buf, "
%s
\n", html.EscapeString(line[2:]), ) continue } fmt.Fprintf(&buf, "%s
\n", html.EscapeString(line)) } buf.WriteString("\n") _, err = w.Write(buf.Bytes()) return false, err } w.Header().Add("Content-Type", contentType) w.WriteHeader(http.StatusOK) _, err = io.Copy(w, br) if err != nil { log.Printf("%s: can not read response body: %+v\n", req.URL, err) } return false, err }