]> Sergey Matveev's repositories - tofuproxy.git/blob - rounds/gemini.go
Download link for 0.6.0 release
[tofuproxy.git] / rounds / gemini.go
1 // tofuproxy -- flexible HTTP/HTTPS proxy, TLS terminator, X.509 TOFU
2 //              manager, WARC/geminispace browser
3 // Copyright (C) 2021-2024 Sergey Matveev <stargrave@stargrave.org>
4 //
5 // This program is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, version 3 of the License.
8 //
9 // This program is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17 package rounds
18
19 import (
20         "bufio"
21         "bytes"
22         "context"
23         "fmt"
24         "html"
25         "io"
26         "log"
27         "net/http"
28         "strconv"
29         "strings"
30
31         "go.stargrave.org/tofuproxy/fifos"
32         ttls "go.stargrave.org/tofuproxy/tls"
33 )
34
35 const (
36         ContentTypeGemini = "text/gemini"
37         SchemeGemini      = "gemini://"
38         GeminiEntrypoint  = "https://gemini"
39         GeminiPort        = ":1965"
40 )
41
42 var GemCodeName = map[int]string{
43         10: "INPUT",
44         11: "SENSITIVE INPUT",
45         20: "SUCCESS",
46         30: "REDIRECT - TEMPORARY",
47         31: "REDIRECT - PERMANENT",
48         40: "TEMPORARY FAILURE",
49         41: "SERVER UNAVAILABLE",
50         42: "CGI ERROR",
51         43: "PROXY ERROR",
52         44: "SLOW DOWN",
53         50: "PERMANENT FAILURE",
54         51: "NOT FOUND",
55         52: "GONE",
56         53: "PROXY REQUEST REFUSED",
57         59: "BAD REQUEST",
58         60: "CLIENT CERTIFICATE REQUIRED",
59         61: "CERTIFICATE NOT AUTHORISED",
60         62: "CERTIFICATE NOT VALID",
61 }
62
63 func absolutizeURL(host, u string, paths ...string) string {
64         host = strings.TrimSuffix(host, GeminiPort)
65         if strings.Contains(u, "://") {
66                 return u
67         }
68         if strings.HasPrefix(u, "/") {
69                 return GeminiEntrypoint + "/" + host + u
70         }
71         paths = append([]string{GeminiEntrypoint, host}, paths...)
72         paths = append(paths, u)
73         return strings.Join(paths, "/")
74 }
75
76 func geminifyURL(host, u string, paths ...string) string {
77         u = absolutizeURL(host, u, paths...)
78         if !strings.HasPrefix(u, SchemeGemini) {
79                 return u
80         }
81         return GeminiEntrypoint + "/" + strings.TrimPrefix(u, SchemeGemini)
82 }
83
84 func RoundGemini(
85         host string,
86         resp *http.Response,
87         w http.ResponseWriter,
88         req *http.Request,
89 ) (bool, error) {
90         if host != "gemini" {
91                 return true, nil
92         }
93         paths := strings.Split(strings.TrimPrefix(req.URL.Path, "/"), "/")
94         host, paths = paths[0], paths[1:]
95         if host == "gemini:" {
96                 http.Redirect(w, req, strings.Join(
97                         append([]string{GeminiEntrypoint}, paths[1:]...), "/",
98                 ), http.StatusTemporaryRedirect)
99                 return false, nil
100         }
101         hostWithPort := host
102         if !strings.Contains(hostWithPort, ":") {
103                 hostWithPort += GeminiPort
104         }
105         conn, err := ttls.DialTLS(context.TODO(), "tcp", hostWithPort)
106         if err != nil {
107                 log.Printf("%s: can not dial: %+v\n", req.URL, err)
108                 return false, err
109         }
110         query := fmt.Sprintf("%s%s/%s", SchemeGemini, host, strings.Join(paths, "/"))
111         if req.URL.RawQuery != "" {
112                 query += "?" + req.URL.RawQuery
113         }
114         if _, err = conn.Write([]byte(query + "\r\n")); err != nil {
115                 log.Printf("%s: can not send request: %+v\n", req.URL, err)
116                 return false, err
117         }
118         if len(paths) > 0 && paths[len(paths)-1] == "" {
119                 paths = paths[:len(paths)-1]
120         }
121         br := bufio.NewReader(conn)
122         rawResp, err := br.ReadString('\n')
123         if err != nil {
124                 log.Printf("%s: can not read response: %+v\n", req.URL, err)
125                 return false, err
126         }
127         cols := strings.SplitN(strings.TrimRight(rawResp, "\r\n"), " ", 2)
128         if len(cols) < 2 {
129                 err = fmt.Errorf("invalid response format: %s", rawResp)
130                 log.Printf("%s: %s\n", req.URL, err)
131                 return false, err
132         }
133         code, err := strconv.Atoi(cols[0])
134         if err != nil {
135                 log.Printf("%s: can not parse response code: %+v\n", req.URL, err)
136                 return false, err
137         }
138         codeName := GemCodeName[code]
139         if codeName == "" {
140                 codeName = "UNKNOWN"
141         }
142         if 10 <= code && code <= 19 {
143                 w.Header().Add("Content-Type", "text/plain")
144                 w.WriteHeader(http.StatusBadRequest)
145                 fmt.Fprintf(w, "%s\n%d (%s): INPUT is not supported\n", cols[1], code, codeName)
146                 return false, nil
147         }
148         if 30 <= code && code <= 39 {
149                 w.Header().Add("Content-Type", "text/html")
150                 w.WriteHeader(http.StatusOK)
151                 u := geminifyURL(host, cols[1], paths...)
152                 fmt.Fprintf(w, `<!DOCTYPE html>
153 <html><head><title>%d (%s) redirection</title></head>
154 <body><a href="%s">%s</a></body></html>`, code, codeName, u, u)
155                 fifos.LogRedir <- fmt.Sprintf(
156                         "%s %s\t%d\t%s", req.Method, req.URL, code, cols[1],
157                 )
158                 return false, nil
159         }
160         msg := fmt.Sprintf(
161                 "%s %s\t%d (%s)\t%s",
162                 req.Method, req.URL,
163                 code, codeName,
164                 cols[1],
165         )
166         if 40 <= code && code <= 49 {
167                 w.Header().Add("Content-Type", "text/plain")
168                 w.WriteHeader(http.StatusBadGateway)
169                 fmt.Fprintf(w, "%s\n%d (%s)\n", cols[1], code, codeName)
170                 fifos.LogNonOK <- msg
171                 return false, nil
172         }
173         if 50 <= code && code <= 59 {
174                 w.Header().Add("Content-Type", "text/plain")
175                 w.WriteHeader(http.StatusBadGateway)
176                 fmt.Fprintf(w, "%s\n%d (%s)\n", cols[1], code, codeName)
177                 fifos.LogNonOK <- msg
178                 return false, nil
179         }
180         if 60 <= code && code <= 69 {
181                 w.Header().Add("Content-Type", "text/plain")
182                 w.WriteHeader(http.StatusUnauthorized)
183                 fmt.Fprintf(w, "%s\n%d (%s)\n", cols[1], code, codeName)
184                 fifos.LogNonOK <- msg
185                 return false, nil
186         }
187         if !(20 <= code && code <= 29) {
188                 err = fmt.Errorf("unknown response code: %d", code)
189                 log.Printf("%s: %s\n", req.URL, err)
190                 fifos.LogNonOK <- msg
191                 return false, err
192         }
193         contentType := strings.Split(strings.TrimRight(cols[1], "\r\n"), ";")[0]
194         if contentType == ContentTypeGemini &&
195                 !strings.Contains(req.Header.Get("Accept"), ContentTypeGemini) {
196                 w.Header().Add("Content-Type", "text/html")
197                 w.WriteHeader(http.StatusOK)
198                 raw, err := io.ReadAll(br)
199                 if err != nil {
200                         log.Printf("%s: can not read response body: %+v\n", req.URL, err)
201                         return false, err
202                 }
203                 var buf bytes.Buffer
204                 fmt.Fprintf(&buf, `<!DOCTYPE html>
205 <html><head><title>%d (%s)</title></head><body>
206 `, code, codeName)
207                 pre := false
208                 for _, line := range strings.Split(string(raw), "\n") {
209                         if strings.HasPrefix(line, "```") {
210                                 if pre {
211                                         buf.WriteString("</pre>\n")
212                                 } else {
213                                         buf.WriteString("<pre>" + line[3:] + "\n")
214                                 }
215                                 pre = !pre
216                                 continue
217                         }
218                         if pre {
219                                 fmt.Fprintf(&buf, "%s\n", line)
220                                 continue
221                         }
222                         if strings.HasPrefix(line, "=>") {
223                                 line = strings.TrimLeft(line[2:], " ")
224                                 cols = strings.Fields(line)
225                                 u := geminifyURL(host, cols[0], paths...)
226                                 switch len(cols) {
227                                 case 1:
228                                         fmt.Fprintf(
229                                                 &buf, "<a href=\"%s\">%s</a><br/>\n",
230                                                 u, html.EscapeString(cols[0]),
231                                         )
232                                 default:
233                                         fmt.Fprintf(
234                                                 &buf, "<a href=\"%s\">%s</a> (<tt>%s</tt>)<br/>\n",
235                                                 u, html.EscapeString(strings.Join(cols[1:], " ")), cols[0],
236                                         )
237                                 }
238                                 continue
239                         }
240                         if strings.HasPrefix(line, "# ") {
241                                 fmt.Fprintf(&buf, "<h1>%s</h1>\n", html.EscapeString(line[2:]))
242                                 continue
243                         }
244                         if strings.HasPrefix(line, "## ") {
245                                 fmt.Fprintf(&buf, "<h2>%s</h2>\n", html.EscapeString(line[3:]))
246                                 continue
247                         }
248                         if strings.HasPrefix(line, "### ") {
249                                 fmt.Fprintf(&buf, "<h3>%s</h3>\n", html.EscapeString(line[4:]))
250                                 continue
251                         }
252                         if strings.HasPrefix(line, "> ") {
253                                 fmt.Fprintf(
254                                         &buf, "<blockquote><tt>%s</tt></blockquote>\n",
255                                         html.EscapeString(line[2:]),
256                                 )
257                                 continue
258                         }
259                         fmt.Fprintf(&buf, "%s<br/>\n", html.EscapeString(line))
260                 }
261                 buf.WriteString("</body></html>\n")
262                 _, err = w.Write(buf.Bytes())
263                 fifos.LogOK <- msg
264                 return false, err
265         }
266         w.Header().Add("Content-Type", contentType)
267         w.WriteHeader(http.StatusOK)
268         _, err = io.Copy(w, br)
269         if err != nil {
270                 log.Printf("%s: can not read response body: %+v\n", req.URL, err)
271         }
272         fifos.LogOK <- msg
273         return false, err
274 }