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