local.go raw
1 package io
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strconv"
9 "strings"
10 "time"
11
12 "git.smesh.lol/torly/pkg/config"
13 "git.smesh.lol/torly/pkg/httpsrv"
14 "git.smesh.lol/torly/pkg/wire"
15 )
16
17 // localResponse answers the requests a reader can serve without involving the
18 // network domain: NIP-11, the static tree, and 404s. Health and Blossom
19 // requests return false and are forwarded.
20 func localResponse(cfg config.Config, req *httpsrv.Request) (wire.HTTPResponse, bool) {
21 if req.Path == "/health" || isBlossomPath(req.Path) {
22 return wire.HTTPResponse{}, false
23 }
24 accept := strings.ToLower(req.Header["accept"])
25 if (req.Path == "/" || req.Path == "") && strings.Contains(accept, "application/nostr+json") {
26 return nip11Response(cfg, req.Method), true
27 }
28 if req.Path == "/" || req.Path == "" {
29 if cfg.StaticDir != "" {
30 if resp, ok := fileResponse(cfg.StaticDir, "/index.html", req.Method); ok {
31 return resp, true
32 }
33 }
34 body := cfg.AppName + " relay\n"
35 return wire.HTTPResponse{
36 Status: 200,
37 Header: [][2]string{{"Content-Type", "text/plain; charset=utf-8"}, {"Content-Length", strconv.Itoa(len(body))}},
38 Body: []byte(body),
39 }, true
40 }
41 if cfg.StaticDir != "" {
42 if resp, ok := fileResponse(cfg.StaticDir, req.Path, req.Method); ok {
43 return resp, true
44 }
45 }
46 return wire.HTTPResponse{
47 Status: 404,
48 Header: [][2]string{{"Content-Type", "text/plain"}, {"Content-Length", "10"}},
49 Body: []byte("not found\n"),
50 }, true
51 }
52
53 func isBlossomPath(path string) bool {
54 // Clients and reverse proxies mount Blossom at both / and /blossom.
55 if path == "/blossom" {
56 path = "/"
57 } else if strings.HasPrefix(path, "/blossom/") {
58 path = strings.TrimPrefix(path, "/blossom")
59 }
60 if path == "/upload" || strings.HasPrefix(path, "/list/") {
61 return true
62 }
63 h := strings.TrimPrefix(path, "/")
64 if len(h) < 64 {
65 return false
66 }
67 // BUD-01 also permits an extension: /<sha256>.<ext>.
68 if len(h) > 64 && h[64] != '.' {
69 return false
70 }
71 return isHex(h[:64])
72 }
73
74 func isHex(s string) bool {
75 for i := 0; i < len(s); i++ {
76 c := s[i]
77 if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') {
78 return false
79 }
80 }
81 return true
82 }
83
84 func nip11Response(cfg config.Config, method string) wire.HTTPResponse {
85 doc := config.NIP11{
86 Name: cfg.AppName,
87 Description: cfg.Description,
88 Contact: cfg.Contact,
89 Icon: cfg.Icon,
90 PubKey: cfg.RelayPubKey,
91 SupportedNIPs: config.SupportedNIPs,
92 Software: "https://git.smesh.lol/torly",
93 Version: config.Version,
94 Limitation: config.LimitDoc{
95 MaxMessageLength: cfg.MaxEventBytes,
96 MaxSubscriptions: cfg.MaxSubsPerConn,
97 MaxFilters: cfg.MaxFilters,
98 MaxLimit: cfg.MaxLimit,
99 MaxSubIDLength: 64,
100 MinPrefix: 64,
101 MaxEventTags: 2000,
102 MaxContentLength: cfg.MaxEventBytes,
103 AuthRequired: cfg.AuthRequired,
104 },
105 }
106 body, _ := json.Marshal(doc)
107 headers := [][2]string{{"Content-Type", "application/nostr+json"}, {"Content-Length", strconv.Itoa(len(body))}}
108 if method == "HEAD" {
109 body = nil
110 }
111 return wire.HTTPResponse{Status: 200, Header: headers, Body: body}
112 }
113
114 func fileResponse(root, urlPath, method string) (wire.HTTPResponse, bool) {
115 if method != "GET" && method != "HEAD" {
116 return wire.HTTPResponse{}, false
117 }
118 if strings.Contains(urlPath, "..") {
119 return wire.HTTPResponse{}, false
120 }
121 full := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(urlPath, "/")))
122 info, err := os.Stat(full)
123 if err == nil && info.IsDir() {
124 full = filepath.Join(full, "index.html")
125 info, err = os.Stat(full)
126 }
127 if err != nil || info.IsDir() {
128 return wire.HTTPResponse{}, false
129 }
130 data, err := os.ReadFile(full)
131 if err != nil {
132 return wire.HTTPResponse{}, false
133 }
134 headers := [][2]string{
135 {"Content-Type", contentType(full)},
136 {"Content-Length", strconv.Itoa(len(data))},
137 {"Cache-Control", "no-cache"},
138 }
139 if method == "HEAD" {
140 data = nil
141 }
142 return wire.HTTPResponse{Status: 200, Header: headers, Body: data}, true
143 }
144
145 func contentType(name string) string {
146 switch strings.ToLower(filepath.Ext(name)) {
147 case ".html", ".htm":
148 return "text/html; charset=utf-8"
149 case ".css":
150 return "text/css; charset=utf-8"
151 case ".js", ".mjs":
152 return "text/javascript; charset=utf-8"
153 case ".json":
154 return "application/json"
155 case ".wasm":
156 return "application/wasm"
157 case ".svg":
158 return "image/svg+xml"
159 case ".png":
160 return "image/png"
161 case ".jpg", ".jpeg":
162 return "image/jpeg"
163 case ".webp":
164 return "image/webp"
165 case ".ico":
166 return "image/x-icon"
167 case ".woff2":
168 return "font/woff2"
169 case ".txt":
170 return "text/plain; charset=utf-8"
171 default:
172 return "application/octet-stream"
173 }
174 }
175
176 // SerializeHTTP renders a response with Connection: close.
177 func SerializeHTTP(resp wire.HTTPResponse) []byte {
178 status := resp.Status
179 if status == 0 {
180 status = 200
181 }
182 hasLength := false
183 for _, h := range resp.Header {
184 if strings.EqualFold(h[0], "content-length") {
185 hasLength = true
186 }
187 }
188 buf := make([]byte, 0, 256+len(resp.Body))
189 buf = append(buf, "HTTP/1.1 "...)
190 buf = strconv.AppendInt(buf, int64(status), 10)
191 buf = append(buf, ' ')
192 buf = append(buf, httpsrv.StatusText(status)...)
193 buf = append(buf, "\r\nDate: "...)
194 buf = append(buf, time.Now().UTC().Format(time.RFC1123)...)
195 buf = append(buf, "\r\nConnection: close\r\n"...)
196 for _, h := range resp.Header {
197 buf = append(buf, h[0]...)
198 buf = append(buf, ": "...)
199 buf = append(buf, h[1]...)
200 buf = append(buf, "\r\n"...)
201 }
202 if !hasLength {
203 buf = append(buf, "Content-Length: "...)
204 buf = strconv.AppendInt(buf, int64(len(resp.Body)), 10)
205 buf = append(buf, "\r\n"...)
206 }
207 buf = append(buf, "\r\n"...)
208 return append(buf, resp.Body...)
209 }
210
211 var _ = fmt.Sprint
212