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