tinyrelayfile
tinyrelay/internal/sites/sites.go/

internal/sites/sites.go raw
1// Package sites implements NIP-5A manifests and the self-hosted site door.
2package sites
3
4import (
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "math/big"
10 "net/url"
11 "regexp"
12 "sort"
13 "strings"
14
15 "fiatjaf.com/nostr"
16 "github.com/FelineStateMachine/tinyrelay/internal/event"
17)
18
19const (
20 KindSite = 15128
21 KindNamedSite = 35128
22 KindSiteSnapshot = 5128
23)
24
25var (
26 hexPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
27 namePattern = regexp.MustCompile(`^[a-z0-9-]{1,13}$`)
28 pathPattern = regexp.MustCompile(`^/(?:[^/]+/)*[^/.]+\.[^/.]+$`)
29)
30
31// Site identifies a manifest family and its addressable key.
32type Site struct {
33 Kind int
34 PubKey string
35 D string
36 ID string
37}
38
39// Base36 encodes a lowercase 32-byte hexadecimal key as a fixed-width label.
40func Base36(value string) (string, error) {
41 if !hexPattern.MatchString(value) {
42 return "", errors.New("expected lowercase 32-byte hex")
43 }
44 n := new(big.Int)
45 if _, ok := n.SetString(value, 16); !ok {
46 return "", errors.New("invalid hexadecimal value")
47 }
48 value36 := n.Text(36)
49 return strings.Repeat("0", 50-len(value36)) + value36, nil
50}
51
52// Unbase36 decodes a fixed-width site label into lowercase hexadecimal.
53func Unbase36(value string) (string, bool) {
54 if len(value) != 50 || !regexp.MustCompile(`^[0-9a-z]+$`).MatchString(value) {
55 return "", false
56 }
57 n, ok := new(big.Int).SetString(value, 36)
58 if !ok || n.BitLen() > 256 {
59 return "", false
60 }
61 return fmt.Sprintf("%064x", n), true
62}
63
64// ParseSite parses an npub, named-site or snapshot label.
65func ParseSite(label string) (Site, bool) {
66 if strings.HasPrefix(label, "npub1") {
67 key, ok := decodeNpub(label)
68 if ok {
69 return Site{Kind: KindSite, PubKey: key.Hex()}, true
70 }
71 }
72 if strings.HasPrefix(label, "v") && len(label) == 51 {
73 id, ok := Unbase36(label[1:])
74 if ok {
75 return Site{Kind: KindSiteSnapshot, ID: id}, true
76 }
77 }
78 if len(label) < 51 || len(label) > 63 || strings.HasSuffix(label, "-") {
79 return Site{}, false
80 }
81 key, ok := Unbase36(label[:50])
82 if !ok || !namePattern.MatchString(label[50:]) {
83 return Site{}, false
84 }
85 return Site{Kind: KindNamedSite, PubKey: key, D: label[50:]}, true
86}
87
88// SiteLabel returns the host label represented by a manifest event.
89func SiteLabel(e event.Event) string {
90 switch e.Kind {
91 case KindSite:
92 key, err := nostr.PubKeyFromHex(e.PubKey)
93 if err != nil {
94 return ""
95 }
96 return encodeNpub(key)
97 case KindNamedSite:
98 key, err := Base36(e.PubKey)
99 if err != nil {
100 return ""
101 }
102 return key + event.Tag(e, "d")
103 case KindSiteSnapshot:
104 key, err := Base36(e.ID)
105 if err != nil {
106 return ""
107 }
108 return "v" + key
109 default:
110 return ""
111 }
112}
113
114// SitePaths returns the path tags in manifest order.
115func SitePaths(e event.Event) [][]string {
116 paths := make([][]string, 0)
117 for _, tag := range e.Tags {
118 if len(tag) > 0 && tag[0] == "path" {
119 paths = append(paths, tag)
120 }
121 }
122 return paths
123}
124
125// Aggregate hashes the canonical path and blob mappings in a manifest.
126func Aggregate(paths [][]string) string {
127 lines := make([]string, 0, len(paths))
128 for _, tag := range paths {
129 if len(tag) == 3 && tag[0] == "path" {
130 lines = append(lines, tag[2]+" "+tag[1]+"\n")
131 }
132 }
133 sort.Strings(lines)
134 sum := sha256.Sum256([]byte(strings.Join(lines, "")))
135 return hex.EncodeToString(sum[:])
136}
137
138// ValidateManifest checks a site manifest's paths, aggregate and lineage.
139func ValidateManifest(e event.Event) error {
140 if e.Kind != KindSite && e.Kind != KindNamedSite && e.Kind != KindSiteSnapshot {
141 return nil
142 }
143 dTags := tagsNamed(e, "d")
144 if (e.Kind == KindNamedSite && (len(dTags) != 1 || !namePattern.MatchString(dTags[0][1]))) || (e.Kind != KindNamedSite && len(dTags) != 0) {
145 return errors.New("invalid: site d tag")
146 }
147 paths := SitePaths(e)
148 if len(paths) == 0 {
149 return errors.New("invalid: site needs a path")
150 }
151 seen := map[string]bool{}
152 for _, tag := range paths {
153 if len(tag) != 3 || !pathPattern.MatchString(tag[1]) || strings.ContainsAny(tag[1], "\\?#\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f\x7f") || hasDotSegment(tag[1]) || !hexPattern.MatchString(tag[2]) {
154 return errors.New("invalid: site path")
155 }
156 if seen[tag[1]] {
157 return errors.New("invalid: duplicate site path")
158 }
159 seen[tag[1]] = true
160 }
161 if err := validateAggregate(e); err != nil {
162 return err
163 }
164 return validateLineage(e)
165}
166
167func validateAggregate(e event.Event) error {
168 x := tagsNamed(e, "x")
169 if len(x) > 1 || (e.Kind == KindSiteSnapshot && len(x) != 1) {
170 return errors.New("invalid: site x tag")
171 }
172 if len(x) == 1 && (len(x[0]) != 3 || x[0][2] != "aggregate" || x[0][1] != Aggregate(SitePaths(e))) {
173 return errors.New("invalid: site aggregate hash")
174 }
175 return nil
176}
177
178func validateLineage(e event.Event) error {
179 a, A := tagsNamed(e, "a"), tagsNamed(e, "A")
180 for _, tag := range append(append([][]string{}, a...), A...) {
181 if len(tag) != 2 || !validReference(tag[1]) {
182 return errors.New("invalid: site lineage")
183 }
184 }
185 if e.Kind == KindSiteSnapshot && len(a) != 1 {
186 return errors.New("invalid: snapshot source")
187 }
188 if e.Kind != KindSiteSnapshot && len(a) != len(A) {
189 return errors.New("invalid: copied site lineage")
190 }
191 return nil
192}
193
194func validReference(value string) bool {
195 parts := strings.Split(value, ":")
196 if len(parts) != 3 || !hexPattern.MatchString(parts[1]) || parts[0] != fmt.Sprint(KindSite) && parts[0] != fmt.Sprint(KindNamedSite) {
197 return false
198 }
199 return parts[0] == fmt.Sprint(KindSite) && parts[2] == "" || parts[0] == fmt.Sprint(KindNamedSite) && namePattern.MatchString(parts[2])
200}
201
202func tagsNamed(e event.Event, name string) [][]string {
203 out := make([][]string, 0)
204 for _, tag := range e.Tags {
205 if len(tag) > 0 && tag[0] == name {
206 out = append(out, tag)
207 }
208 }
209 return out
210}
211func hasDotSegment(path string) bool {
212 for _, part := range strings.Split(path, "/") {
213 if part == "." || part == ".." {
214 return true
215 }
216 }
217 return false
218}
219
220func SiteType(path string) string {
221 ext := strings.ToLower(strings.TrimPrefix(path, "."))
222 i := strings.LastIndexByte(ext, '.')
223 if i >= 0 {
224 ext = ext[i+1:]
225 }
226 if typ := map[string]string{"html": "text/html; charset=utf-8", "htm": "text/html; charset=utf-8", "css": "text/css", "js": "text/javascript", "mjs": "text/javascript", "json": "application/json", "svg": "image/svg+xml", "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp", "ico": "image/x-icon", "txt": "text/plain; charset=utf-8", "xml": "application/xml", "wasm": "application/wasm", "woff": "font/woff", "woff2": "font/woff2", "pdf": "application/pdf"}[ext]; typ != "" {
227 return typ
228 }
229 return "application/octet-stream"
230}
231
232func RequestedPath(raw string) (string, bool) {
233 decoded, err := url.PathUnescape(raw)
234 return decoded, err == nil
235}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs