clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / internal / sites / sites.go
sites.go 6.7K
1// Package sites implements NIP-5A manifests and the self-hosted site door.2package sites34import (5 "crypto/sha256"6 "encoding/hex"7 "errors"8 "fmt"9 "math/big"10 "net/url"11 "regexp"12 "sort"13 "strings"1415 "fiatjaf.com/nostr"16 "github.com/FelineStateMachine/tinyrelay/internal/event"17)1819const (20 KindSite = 1512821 KindNamedSite = 3512822 KindSiteSnapshot = 512823)2425var (26 hexPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)27 namePattern = regexp.MustCompile(`^[a-z0-9-]{1,13}$`)28 pathPattern = regexp.MustCompile(`^/(?:[^/]+/)*[^/.]+\.[^/.]+$`)29)3031// Site identifies a manifest family and its addressable key.32type Site struct {33 Kind int34 PubKey string35 D string36 ID string37}3839// 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, nil50}5152// 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 "", false56 }57 n, ok := new(big.Int).SetString(value, 36)58 if !ok || n.BitLen() > 256 {59 return "", false60 }61 return fmt.Sprintf("%064x", n), true62}6364// 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()}, true70 }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}, true76 }77 }78 if len(label) < 51 || len(label) > 63 || strings.HasSuffix(label, "-") {79 return Site{}, false80 }81 key, ok := Unbase36(label[:50])82 if !ok || !namePattern.MatchString(label[50:]) {83 return Site{}, false84 }85 return Site{Kind: KindNamedSite, PubKey: key, D: label[50:]}, true86}8788// 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" + key109 default:110 return ""111 }112}113114// 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 paths123}124125// 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}137138// 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 nil142 }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]] = true160 }161 if err := validateAggregate(e); err != nil {162 return err163 }164 return validateLineage(e)165}166167func 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 nil176}177178func 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 nil192}193194func 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 false198 }199 return parts[0] == fmt.Sprint(KindSite) && parts[2] == "" || parts[0] == fmt.Sprint(KindNamedSite) && namePattern.MatchString(parts[2])200}201202func 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 out210}211func hasDotSegment(path string) bool {212 for _, part := range strings.Split(path, "/") {213 if part == "." || part == ".." {214 return true215 }216 }217 return false218}219220func 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 typ228 }229 return "application/octet-stream"230}231232func RequestedPath(raw string) (string, bool) {233 decoded, err := url.PathUnescape(raw)234 return decoded, err == nil235}