package io import ( "encoding/json" "fmt" "os" "path/filepath" "strconv" "strings" "time" "git.smesh.lol/torly/pkg/config" "git.smesh.lol/torly/pkg/httpsrv" "git.smesh.lol/torly/pkg/wire" ) // localResponse answers the requests a reader can serve without involving the // network domain: NIP-11, the static tree, and 404s. Health and Blossom // requests return false and are forwarded. func localResponse(cfg config.Config, req *httpsrv.Request) (wire.HTTPResponse, bool) { if req.Path == "/health" || isBlossomPath(req.Path) { return wire.HTTPResponse{}, false } accept := strings.ToLower(req.Header["accept"]) if (req.Path == "/" || req.Path == "") && strings.Contains(accept, "application/nostr+json") { return nip11Response(cfg, req.Method), true } if req.Path == "/" || req.Path == "" { if cfg.StaticDir != "" { if resp, ok := fileResponse(cfg.StaticDir, "/index.html", req.Method); ok { return resp, true } } body := cfg.AppName + " relay\n" return wire.HTTPResponse{ Status: 200, Header: [][2]string{{"Content-Type", "text/plain; charset=utf-8"}, {"Content-Length", strconv.Itoa(len(body))}}, Body: []byte(body), }, true } if cfg.StaticDir != "" { if resp, ok := fileResponse(cfg.StaticDir, req.Path, req.Method); ok { return resp, true } } return wire.HTTPResponse{ Status: 404, Header: [][2]string{{"Content-Type", "text/plain"}, {"Content-Length", "10"}}, Body: []byte("not found\n"), }, true } func isBlossomPath(path string) bool { // Clients and reverse proxies mount Blossom at both / and /blossom. if path == "/blossom" { path = "/" } else if strings.HasPrefix(path, "/blossom/") { path = strings.TrimPrefix(path, "/blossom") } if path == "/upload" || strings.HasPrefix(path, "/list/") { return true } h := strings.TrimPrefix(path, "/") if len(h) < 64 { return false } // BUD-01 also permits an extension: /.. if len(h) > 64 && h[64] != '.' { return false } return isHex(h[:64]) } func isHex(s string) bool { for i := 0; i < len(s); i++ { c := s[i] if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') { return false } } return true } func nip11Response(cfg config.Config, method string) wire.HTTPResponse { doc := config.NIP11{ Name: cfg.AppName, Description: cfg.Description, Contact: cfg.Contact, Icon: cfg.Icon, PubKey: cfg.RelayPubKey, SupportedNIPs: config.SupportedNIPs, Software: "https://git.smesh.lol/torly", Version: config.Version, Limitation: config.LimitDoc{ MaxMessageLength: cfg.MaxEventBytes, MaxSubscriptions: cfg.MaxSubsPerConn, MaxFilters: cfg.MaxFilters, MaxLimit: cfg.MaxLimit, MaxSubIDLength: 64, MinPrefix: 64, MaxEventTags: 2000, MaxContentLength: cfg.MaxEventBytes, AuthRequired: cfg.AuthRequired, }, } body, _ := json.Marshal(doc) headers := [][2]string{{"Content-Type", "application/nostr+json"}, {"Content-Length", strconv.Itoa(len(body))}} if method == "HEAD" { body = nil } return wire.HTTPResponse{Status: 200, Header: headers, Body: body} } func fileResponse(root, urlPath, method string) (wire.HTTPResponse, bool) { if method != "GET" && method != "HEAD" { return wire.HTTPResponse{}, false } if strings.Contains(urlPath, "..") { return wire.HTTPResponse{}, false } full := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(urlPath, "/"))) info, err := os.Stat(full) if err == nil && info.IsDir() { full = filepath.Join(full, "index.html") info, err = os.Stat(full) } if err != nil || info.IsDir() { return wire.HTTPResponse{}, false } data, err := os.ReadFile(full) if err != nil { return wire.HTTPResponse{}, false } headers := [][2]string{ {"Content-Type", contentType(full)}, {"Content-Length", strconv.Itoa(len(data))}, {"Cache-Control", "no-cache"}, } if method == "HEAD" { data = nil } return wire.HTTPResponse{Status: 200, Header: headers, Body: data}, true } func contentType(name string) string { switch strings.ToLower(filepath.Ext(name)) { case ".html", ".htm": return "text/html; charset=utf-8" case ".css": return "text/css; charset=utf-8" case ".js", ".mjs": return "text/javascript; charset=utf-8" case ".json": return "application/json" case ".wasm": return "application/wasm" case ".svg": return "image/svg+xml" case ".png": return "image/png" case ".jpg", ".jpeg": return "image/jpeg" case ".webp": return "image/webp" case ".ico": return "image/x-icon" case ".woff2": return "font/woff2" case ".txt": return "text/plain; charset=utf-8" default: return "application/octet-stream" } } // SerializeHTTP renders a response with Connection: close. func SerializeHTTP(resp wire.HTTPResponse) []byte { status := resp.Status if status == 0 { status = 200 } hasLength := false for _, h := range resp.Header { if strings.EqualFold(h[0], "content-length") { hasLength = true } } buf := make([]byte, 0, 256+len(resp.Body)) buf = append(buf, "HTTP/1.1 "...) buf = strconv.AppendInt(buf, int64(status), 10) buf = append(buf, ' ') buf = append(buf, httpsrv.StatusText(status)...) buf = append(buf, "\r\nDate: "...) buf = append(buf, time.Now().UTC().Format(time.RFC1123)...) buf = append(buf, "\r\nConnection: close\r\n"...) for _, h := range resp.Header { buf = append(buf, h[0]...) buf = append(buf, ": "...) buf = append(buf, h[1]...) buf = append(buf, "\r\n"...) } if !hasLength { buf = append(buf, "Content-Length: "...) buf = strconv.AppendInt(buf, int64(len(resp.Body)), 10) buf = append(buf, "\r\n"...) } buf = append(buf, "\r\n"...) return append(buf, resp.Body...) } var _ = fmt.Sprint