clone

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

compose tenant services and route local doors

Dami committed ee88d2a
commit
ee88d2a06e952638cbb3f479ed06d9bb94d58d2e
author
Dami <dami.etoile@gmail.com>
3 files changed, +796 -0
Ainternal/daemon/http.go379++++++++++++++++++++++++++++++
Ainternal/daemon/services.go159+++++++++++++
Ainternal/daemon/tenant.go258++++++++++++++++++++
A internal/daemon/http.go +379 -0
@@ -0,0 +1,379 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "html/template"8+ "io"9+ "net"10+ "net/http"11+ "strings"12+ "time"13+14+ "github.com/FelineStateMachine/tinyrelay/internal/event"15+ "github.com/FelineStateMachine/tinyrelay/internal/relay"16+ "github.com/FelineStateMachine/tinyrelay/internal/storage"17+)18+19+func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) {20+ w.Header().Set("Access-Control-Allow-Origin", "*")21+ w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-SHA-256, X-Content-Length, X-Content-Type")22+ w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS")23+ w.Header().Set("X-Content-Type-Options", "nosniff")24+ if r.Method == http.MethodOptions {25+ w.WriteHeader(http.StatusNoContent)26+ return27+ }28+ if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {29+ t.router.HandleHTTP(w, r)30+ return31+ }32+ if t.sites != nil && t.sites.MatchesHost(r.Host) {33+ t.sites.Handler().ServeHTTP(w, r)34+ return35+ }36+ if t.git != nil && (strings.HasPrefix(r.URL.Path, "/npub1") || strings.HasPrefix(r.URL.Path, "/prs/")) {37+ t.git.ServeHTTP(w, r)38+ return39+ }40+ if t.blobs != nil && (r.URL.Path == "/upload" || r.URL.Path == "/mirror" || r.URL.Path == "/report" || r.URL.Path == "/nip96" || strings.HasPrefix(r.URL.Path, "/.well-known/nostr/nip96") || strings.HasPrefix(r.URL.Path, "/list/") || len(r.URL.Path) == 65 || len(r.URL.Path) == 69) {41+ t.blobs.Handler().ServeHTTP(w, r)42+ return43+ }44+ if strings.Contains(r.Header.Get("Accept"), "application/nostr+json") {45+ t.information(w, r)46+ return47+ }48+ if r.Method == http.MethodPost && strings.Contains(r.Header.Get("Content-Type"), "application/nostr+json+rpc") {49+ t.manageHTTP(w, r)50+ return51+ }52+ if r.Method == http.MethodPost && (r.URL.Path == "/events" || r.URL.Path == "/query" || r.URL.Path == "/count") {53+ t.bridge(w, r)54+ return55+ }56+ if r.URL.Path == "/.well-known/nostr.json" {57+ t.nip05(w, r)58+ return59+ }60+ if r.URL.Path == "/healthz" {61+ writeJSON(w, 200, map[string]any{"status": "ready"})62+ return63+ }64+ if t.ui != nil {65+ t.ui.ServeHTTP(w, r)66+ return67+ }68+ if r.URL.Path == "/" {69+ t.home(w, r)70+ return71+ }72+ http.NotFound(w, r)73+}74+75+func writeJSON(w http.ResponseWriter, status int, value any) {76+ raw, err := json.Marshal(value)77+ if err != nil {78+ http.Error(w, "could not encode response", 500)79+ return80+ }81+ w.Header().Set("Content-Type", "application/json; charset=utf-8")82+ w.WriteHeader(status)83+ if _, err := w.Write(raw); err != nil {84+ return85+ }86+}87+88+func (t *Tenant) requestBody(w http.ResponseWriter, r *http.Request) ([]byte, error) {89+ var reader io.Reader = r.Body90+ if t.app.cfg.MaxMessageBytes > 0 {91+ reader = http.MaxBytesReader(w, r.Body, t.app.cfg.MaxMessageBytes)92+ }93+ body, err := io.ReadAll(reader)94+ if err != nil {95+ return nil, err96+ }97+ return body, nil98+}99+100+func (t *Tenant) session(r *http.Request, body []byte) (relay.Session, error) {101+ ip, _, err := net.SplitHostPort(r.RemoteAddr)102+ if err != nil {103+ ip = r.RemoteAddr104+ }105+ s := relay.Session{RelayURL: t.RelayURL(), RemoteIP: ip}106+ url := t.publicURL + r.URL.RequestURI()107+ e, err := t.auth.VerifyNIP98(r.Header.Get("Authorization"), url, r.Method, string(body))108+ if err != nil {109+ return s, err110+ }111+ s.PubKeys = []string{e.PubKey}112+ return s, nil113+}114+115+func (t *Tenant) bridge(w http.ResponseWriter, r *http.Request) {116+ body, err := t.requestBody(w, r)117+ if err != nil {118+ writeJSON(w, 400, map[string]string{"error": err.Error()})119+ return120+ }121+ s, err := t.session(r, body)122+ if err != nil {123+ writeJSON(w, 401, map[string]string{"error": err.Error()})124+ return125+ }126+ if r.URL.Path == "/events" {127+ t.httpPublish(w, r, s, body)128+ return129+ }130+ var raw []json.RawMessage131+ if err := json.Unmarshal(body, &raw); err != nil || len(raw) == 0 {132+ writeJSON(w, 400, map[string]string{"error": "invalid: body must be a non-empty array of filters"})133+ return134+ }135+ filters := make([]event.Filter, len(raw))136+ for i, b := range raw {137+ f, err := event.ParseFilter(b)138+ if err != nil {139+ writeJSON(w, 400, map[string]string{"error": err.Error()})140+ return141+ }142+ filters[i] = f143+ }144+ var result any145+ if r.URL.Path == "/count" {146+ result, err = t.Count(r.Context(), filters, s)147+ } else {148+ result, err = t.Query(r.Context(), filters, s)149+ }150+ if err != nil {151+ writeJSON(w, statusFor(err), map[string]string{"error": err.Error()})152+ return153+ }154+ writeJSON(w, 200, result)155+}156+157+func (t *Tenant) httpPublish(w http.ResponseWriter, r *http.Request, s relay.Session, body []byte) {158+ e, err := event.Parse(body)159+ if err != nil {160+ writeJSON(w, 400, map[string]string{"error": err.Error()})161+ return162+ }163+ message, err := t.router.Publish(r.Context(), e, s)164+ status := 200165+ if err != nil {166+ message = err.Error()167+ status = 400168+ }169+ writeJSON(w, status, map[string]any{"event_id": e.ID, "accepted": err == nil, "message": message})170+}171+172+func statusFor(err error) int {173+ if strings.HasPrefix(err.Error(), "auth-required:") {174+ return 401175+ }176+ if strings.HasPrefix(err.Error(), "restricted:") || strings.HasPrefix(err.Error(), "blocked:") {177+ return 403178+ }179+ return 400180+}181+182+func (t *Tenant) information(w http.ResponseWriter, r *http.Request) {183+ p := t.Policy()184+ nips := []int{1, 9, 11, 13, 17, 40, 42, 62, 67, 70, 86, 98}185+ if p.Features.Count {186+ nips = append(nips, 45)187+ }188+ if p.Features.Search != "off" {189+ nips = append(nips, 50)190+ }191+ if p.Features.Sync {192+ nips = append(nips, 77)193+ }194+ if p.Features.Names {195+ nips = append(nips, 5)196+ }197+ if p.Features.Signer {198+ nips = append(nips, 46)199+ }200+ name := p.Name201+ if name == "" {202+ name = t.meta.Name203+ }204+ limits := map[string]any{"max_subid_length": 64, "auth_required": p.Reads != "open", "payment_required": false, "restricted_writes": p.Writes != "open"}205+ if t.app.cfg.MaxMessageBytes > 0 {206+ limits["max_message_length"] = t.app.cfg.MaxMessageBytes207+ }208+ if p.MaxFuture > 0 {209+ limits["created_at_upper_limit"] = p.MaxFuture210+ }211+ if p.MinPow > 0 {212+ limits["min_pow_difficulty"] = p.MinPow213+ }214+ info := map[string]any{"name": name, "description": p.Description, "pubkey": p.Owner, "supported_nips": nips, "software": "https://github.com/FelineStateMachine/tinyrelay", "version": t.app.cfg.Version, "self_url": t.RelayURL(), "limitation": limits}215+ for key, value := range map[string]string{"icon": p.Icon, "banner": p.Banner, "contact": p.Contact, "posting_policy": p.PostingPolicy, "privacy_policy": p.PrivacyPolicy} {216+ if value != "" {217+ info[key] = value218+ }219+ }220+ if p.JoinTerms != "" {221+ info["terms_of_service"] = t.publicURL + "/terms"222+ }223+ if len(p.Tags) > 0 {224+ info["tags"] = p.Tags225+ }226+ if len(p.LanguageTags) > 0 {227+ info["language_tags"] = p.LanguageTags228+ }229+ if len(p.RelayCountries) > 0 {230+ info["relay_countries"] = p.RelayCountries231+ }232+ raw, err := json.Marshal(info)233+ if err != nil {234+ http.Error(w, err.Error(), 500)235+ return236+ }237+ w.Header().Set("Content-Type", "application/nostr+json")238+ if _, err := w.Write(raw); err != nil {239+ return240+ }241+}242+243+func (t *Tenant) nip05(w http.ResponseWriter, r *http.Request) {244+ if !t.Policy().Features.Names {245+ http.NotFound(w, r)246+ return247+ }248+ result, err := t.community.NIP05(r.Context(), r.URL.Query().Get("name"), t.RelayURL())249+ if err != nil {250+ writeJSON(w, 404, map[string]string{"error": "name not found"})251+ return252+ }253+ writeJSON(w, 200, result)254+}255+256+func (t *Tenant) home(w http.ResponseWriter, r *http.Request) {257+ tmpl, err := template.New("home").Parse(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>{{.Name}}</title><h1>{{.Name}}</h1><p>{{.Description}}</p><p>Relay: <code>{{.URL}}</code></p><p><a href="./people">People</a> ยท <a href="./manage">Manage relay</a></p></html>`)258+ if err != nil {259+ http.Error(w, "page unavailable", 500)260+ return261+ }262+ p := t.Policy()263+ name := p.Name264+ if name == "" {265+ name = t.meta.Name266+ }267+ w.Header().Set("Content-Type", "text/html; charset=utf-8")268+ if err := tmpl.Execute(w, map[string]string{"Name": name, "Description": p.Description, "URL": t.RelayURL()}); err != nil {269+ t.app.telemetry.Logger().Error("render page", "error", err)270+ }271+}272+273+func (t *Tenant) manageHTTP(w http.ResponseWriter, r *http.Request) {274+ body, err := t.requestBody(w, r)275+ if err != nil {276+ writeJSON(w, 400, map[string]string{"error": err.Error()})277+ return278+ }279+ var call struct {280+ Method string `json:"method"`281+ Params []json.RawMessage `json:"params"`282+ }283+ if err := json.Unmarshal(body, &call); err != nil {284+ writeJSON(w, 400, map[string]string{"error": "invalid: malformed management request"})285+ return286+ }287+ actor := ""288+ if call.Method != "supportedmethods" {289+ s, err := t.session(r, body)290+ if err != nil {291+ writeJSON(w, 401, map[string]string{"error": err.Error()})292+ return293+ }294+ actor = s.PubKeys[0]295+ }296+ result, err := t.Execute(r.Context(), actor, call.Method, call.Params)297+ if err != nil {298+ writeJSON(w, statusFor(err), map[string]string{"error": err.Error()})299+ return300+ }301+ writeJSON(w, 200, map[string]any{"result": result})302+}303+304+func (t *Tenant) Execute(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) {305+ if method == "supportedmethods" {306+ methods := append([]string{"supportedmethods", "getpolicy", "setpolicy", "stats", "listlisthistory", "restorelist"}, t.community.Methods()...)307+ if t.config != nil {308+ methods = append(methods, "exportconfig", "importconfig", "planconfig", "applypreset", "listpresets")309+ }310+ if t.replication != nil {311+ methods = append(methods, t.replication.Methods()...)312+ }313+ return methods, nil314+ }315+ role, err := t.community.Role(ctx, actor)316+ if err != nil {317+ return nil, err318+ }319+ if method == "listlisthistory" {320+ return t.store.ListHistory(ctx, actor, time.Now().Unix())321+ }322+ if method == "restorelist" {323+ if len(params) == 0 {324+ return nil, errors.New("invalid: event id required")325+ }326+ var id string327+ if err := json.Unmarshal(params[0], &id); err != nil {328+ return nil, err329+ }330+ return t.store.RestoreHistory(ctx, storage.RestoreOptions{Owner: actor, EventID: id, Now: time.Now().Unix()})331+ }332+ if role != "owner" && role != "moderator" {333+ return nil, errors.New("restricted: owner or moderator required")334+ }335+ switch method {336+ case "getpolicy":337+ return t.Policy(), nil338+ case "setpolicy":339+ if role != "owner" {340+ return nil, errors.New("restricted: only the owner sets policy")341+ }342+ if len(params) == 0 {343+ return nil, errors.New("invalid: policy patch required")344+ }345+ var patch map[string]json.RawMessage346+ if err := json.Unmarshal(params[0], &patch); err != nil {347+ return nil, err348+ }349+ delete(patch, "owner")350+ return t.setPolicy(ctx, patch)351+ case "stats":352+ return t.store.Stats(ctx)353+ default:354+ if t.config != nil && containsString([]string{"exportconfig", "importconfig", "planconfig", "applypreset", "listpresets"}, method) {355+ return t.config.Execute(ctx, method, params)356+ }357+ if t.replication != nil && containsString(t.replication.Methods(), method) {358+ values := make([]any, len(params))359+ for i, raw := range params {360+ var value any361+ if err := json.Unmarshal(raw, &value); err != nil {362+ return nil, err363+ }364+ values[i] = value365+ }366+ return t.replication.Execute(ctx, method, values...)367+ }368+ return t.community.Execute(ctx, actor, method, params)369+ }370+}371+372+func containsString(values []string, wanted string) bool {373+ for _, value := range values {374+ if value == wanted {375+ return true376+ }377+ }378+ return false379+}
A internal/daemon/services.go +159 -0
@@ -0,0 +1,159 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "io"7+ "net/http"8+ "net/url"9+ "strings"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/blob"12+ "github.com/FelineStateMachine/tinyrelay/internal/configport"13+ "github.com/FelineStateMachine/tinyrelay/internal/event"14+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"15+ "github.com/FelineStateMachine/tinyrelay/internal/policy"16+ "github.com/FelineStateMachine/tinyrelay/internal/records"17+ "github.com/FelineStateMachine/tinyrelay/internal/replication"18+ "github.com/FelineStateMachine/tinyrelay/internal/sites"19+ "github.com/FelineStateMachine/tinyrelay/internal/storage"20+ "github.com/FelineStateMachine/tinyrelay/internal/webui"21+)22+23+// initServices constructs the doors around a tenant's single durable store.24+// Each service receives the same policy snapshot function so policy changes25+// take effect without reopening the tenant.26+func (t *Tenant) initServices(ctx context.Context) error {27+ var err error28+ t.blobs, err = blob.New(ctx, blob.Config{29+ Root: t.meta.Paths.Root, Store: t.store, Authorize: t.authorizeBlob,30+ CanRead: func(ctx context.Context, pubkey string, hashes []string) bool {31+ p := t.Policy()32+ if p.Reads == "open" {33+ return true34+ }35+ role, err := t.community.Role(ctx, pubkey)36+ return err == nil && role != ""37+ }, IsOwner: func(pubkey string) bool { return pubkey == t.Policy().Owner },38+ })39+ if err != nil {40+ return err41+ }42+ t.sites, err = sites.New(sites.Config{43+ Store: t.store,44+ GetBlob: func(ctx context.Context, hash string) (sites.Blob, error) {45+ entry, body, err := t.blobs.Get(ctx, hash)46+ if err != nil {47+ return sites.Blob{}, err48+ }49+ return sites.Blob{Body: body, Type: entry.Type, Size: entry.Size}, nil50+ },51+ PutBlob: func(ctx context.Context, hash, typ string, body io.Reader) error {52+ _, err := t.blobs.Put(ctx, blob.PutOptions{Reader: body, Type: typ, Uploader: t.Policy().Owner, Hash: hash})53+ return err54+ }, Policy: t.Policy, BaseDomain: hostName(t.publicURL), ReadAccess: func(r *http.Request) bool { return t.siteReadAccess(r) },55+ })56+ if err != nil {57+ return err58+ }59+ t.records, err = records.New(ctx, records.Config{Store: t.store, Policy: t.Policy, RelayURL: t.RelayURL(), OnGenerated: t.generatedRecord})60+ if err != nil {61+ return err62+ }63+ t.config = configport.New(configport.ConfigStore{Store: t.store, Community: t.community, Policy: t.Policy, OnApplied: func(p policy.Policy) { t.replacePolicy(p) }})64+ transport := &replication.NostrTransport{Dialer: replication.WebsocketDialer{}}65+ t.replication, err = replication.NewService(replication.Config{Store: t.store, Policy: replication.Policy{Enabled: t.Policy().Delivery.Enabled, SelfPubKey: t.Policy().Owner}, CurrentPolicy: func() replication.Policy {66+ p := t.Policy()67+ return replication.Policy{Enabled: p.Delivery.Enabled, SelfPubKey: p.Owner}68+ }, Delivery: transport, Pull: transport, Push: transport, DataDir: t.meta.Paths.Root, Ingest: t.ingest})69+ if err != nil {70+ return err71+ }72+ t.git, err = gitrelay.New(gitrelay.Config{Store: t.store, Root: t.meta.Paths.Git, Policy: t.Policy, PublicURL: t.publicURL, AuthorizeHTTP: t.authorizeGit})73+ if err != nil {74+ return err75+ }76+ t.ui, err = webui.New(backend{tenant: t}, webui.Options{Actor: t.resolveUIActor})77+ if err != nil {78+ return err79+ }80+ t.workCtx, t.workCancel = context.WithCancel(context.Background())81+ t.workWG.Add(1)82+ go func() { defer t.workWG.Done(); _ = t.replication.Run(t.workCtx) }()83+ return nil84+}85+86+// backend adapts the management method signature to webui's form-oriented API.87+type backend struct{ tenant *Tenant }88+89+func (b backend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {90+ return b.tenant.Execute(ctx, actor, method, params)91+}92+func (b backend) Policy() policy.Policy { return b.tenant.Policy() }93+func (b backend) URL() string { return b.tenant.publicURL }94+func (b backend) Slug() string { return b.tenant.meta.Name }95+func (b backend) Identity() string { return b.tenant.records.PublicKey() }96+97+func (t *Tenant) closeServices(ctx context.Context) error {98+ if t.workCancel != nil {99+ t.workCancel()100+ }101+ t.workWG.Wait()102+ return nil103+}104+105+func hostName(raw string) string {106+ u, err := url.Parse(raw)107+ if err != nil {108+ return "localhost"109+ }110+ if u.Hostname() == "" {111+ return "localhost"112+ }113+ return u.Hostname()114+}115+func (t *Tenant) siteReadAccess(r *http.Request) bool {116+ if t.Policy().Reads == "open" {117+ return true118+ }119+ actor, err := t.resolveUIActor(r)120+ return err == nil && actor != ""121+}122+func (t *Tenant) authorizeBlob(r *http.Request, action blob.Action) (string, error) {123+ e, err := t.auth.WhoAsks(r.Header.Get("Authorization"), t.publicURL+r.URL.RequestURI(), r.Method, "", string(action), strings.TrimPrefix(r.URL.Path, "/"))124+ return first(e), err125+}126+func (t *Tenant) authorizeGit(ctx context.Context, r *http.Request, repo gitrelay.Repository) error {127+ _, err := t.auth.VerifyNIP98(r.Header.Get("Authorization"), t.publicURL+r.URL.RequestURI(), r.Method, "")128+ return err129+}130+func first(v []string) string {131+ if len(v) == 0 {132+ return ""133+ }134+ return v[0]135+}136+func (t *Tenant) resolveUIActor(r *http.Request) (string, error) {137+ e, err := t.auth.VerifyNIP98(r.Header.Get("Authorization"), t.publicURL+r.URL.RequestURI(), r.Method, "")138+ if err != nil {139+ return "", err140+ }141+ return e.PubKey, nil142+}143+func (t *Tenant) replacePolicy(p policy.Policy) {144+ t.mu.Lock()145+ t.policy = p146+ t.mu.Unlock()147+ t.router.CloseSubscriptions("blocked: relay policy changed; subscribe again")148+}149+func (t *Tenant) generatedRecord(ctx context.Context, e event.Event) error {150+ _, err := t.store.Save(ctx, e, storage.SaveOptions{Now: e.CreatedAt, SearchMode: t.Policy().Features.Search})151+ if err == nil {152+ t.router.BroadcastGenerated(e)153+ }154+ return err155+}156+func (t *Tenant) ingest(ctx context.Context, e event.Event, _ replication.Origin) error {157+ _, err := t.store.Save(ctx, e, storage.SaveOptions{Now: e.CreatedAt, SearchMode: t.Policy().Features.Search})158+ return err159+}
A internal/daemon/tenant.go +258 -0
@@ -0,0 +1,258 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "sort"8+ "strings"9+ "sync"10+ "time"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/auth"13+ "github.com/FelineStateMachine/tinyrelay/internal/blob"14+ "github.com/FelineStateMachine/tinyrelay/internal/catalog"15+ "github.com/FelineStateMachine/tinyrelay/internal/community"16+ "github.com/FelineStateMachine/tinyrelay/internal/configport"17+ "github.com/FelineStateMachine/tinyrelay/internal/event"18+ "github.com/FelineStateMachine/tinyrelay/internal/gates"19+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"20+ "github.com/FelineStateMachine/tinyrelay/internal/policy"21+ "github.com/FelineStateMachine/tinyrelay/internal/records"22+ "github.com/FelineStateMachine/tinyrelay/internal/relay"23+ "github.com/FelineStateMachine/tinyrelay/internal/replication"24+ "github.com/FelineStateMachine/tinyrelay/internal/sites"25+ "github.com/FelineStateMachine/tinyrelay/internal/storage"26+ "github.com/FelineStateMachine/tinyrelay/internal/syncprotocol"27+ "github.com/FelineStateMachine/tinyrelay/internal/webui"28+)29+30+type tenantConfig struct {31+ app *App32+ meta catalog.Tenant33+ store *storage.Store34+ policy policy.Policy35+ publicURL string36+}37+38+type Tenant struct {39+ app *App40+ meta catalog.Tenant41+ store *storage.Store42+ community *community.Service43+ gate *gates.Gate44+ router *relay.Router45+ auth *auth.Validator46+ blobs *blob.Service47+ sites *sites.Service48+ records *records.Service49+ config *configport.ConfigStore50+ replication *replication.Service51+ git *gitrelay.GitRelay52+ ui *webui.App53+ workCtx context.Context54+ workCancel context.CancelFunc55+ workWG sync.WaitGroup56+ publicURL string57+ mu sync.RWMutex58+ policy policy.Policy59+}60+61+func newTenant(ctx context.Context, cfg tenantConfig) (*Tenant, error) {62+ t := &Tenant{app: cfg.app, meta: cfg.meta, store: cfg.store, policy: cfg.policy, publicURL: cfg.publicURL, auth: auth.NewValidator(time.Now)}63+ var err error64+ t.community, err = community.New(ctx, t.store, t.policy.Owner)65+ if err != nil {66+ return nil, err67+ }68+ t.gate, err = gates.New(gates.Config{Store: t.store, Community: t.community, Policy: t.Policy, Slug: t.meta.Name})69+ if err != nil {70+ return nil, err71+ }72+ t.router = relay.New(t, relay.Config{RelayURL: t.RelayURL(), MaxMessageBytes: t.app.cfg.MaxMessageBytes, MaxPendingBytes: t.app.cfg.MaxPendingBytes, OriginPatterns: []string{"*"}})73+ if err := t.initServices(ctx); err != nil {74+ return nil, err75+ }76+ return t, nil77+}78+79+func (t *Tenant) Policy() policy.Policy {80+ t.mu.RLock()81+ defer t.mu.RUnlock()82+ // Policy patches produce a fresh value; callers only inspect this snapshot.83+ return t.policy84+}85+func (t *Tenant) RelayURL() string { return strings.Replace(t.publicURL, "http", "ws", 1) }86+func (t *Tenant) Close(ctx context.Context) error {87+ return errors.Join(t.closeServices(ctx), t.router.Close(ctx), t.store.Close())88+}89+90+func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (string, error) {91+ ctx, finish := t.app.telemetry.Start(ctx, "publish")92+ outcome := "error"93+ defer func() { finish(outcome) }()94+ now := time.Now().Unix()95+ if err := t.gate.Write(ctx, e, s, now); err != nil {96+ return "", err97+ }98+ if e.Kind == event.KIND_VANISH {99+ if err := t.vanish(ctx, e); err != nil {100+ return "", err101+ }102+ outcome = "ok"103+ return "", nil104+ }105+ opts := storage.SaveOptions{Now: now, SearchMode: t.Policy().Features.Search}106+ if t.sites != nil {107+ if err := sites.ValidateManifest(e); err != nil {108+ return "", err109+ }110+ opts = t.sites.SaveOptions(e, now)111+ opts.SearchMode = t.Policy().Features.Search112+ opts.Intents = append(opts.Intents, t.replication.Prepare(e, replication.OriginClient)...)113+ }114+ _, err := t.store.Save(ctx, e, opts)115+ if errors.Is(err, storage.ErrDuplicate) {116+ outcome = "duplicate"117+ return storage.ErrDuplicate.Error(), nil118+ }119+ if err != nil {120+ return "", err121+ }122+ outcome = "ok"123+ return "", nil124+}125+126+func (t *Tenant) vanish(ctx context.Context, e event.Event) error {127+ for _, tag := range e.Tags {128+ if len(tag) > 1 && tag[0] == "relay" && (tag[1] == "ALL_RELAYS" || strings.TrimSuffix(tag[1], "/") == strings.TrimSuffix(t.RelayURL(), "/")) {129+ return t.store.Vanish(ctx, e.PubKey, e.CreatedAt)130+ }131+ }132+ return errors.New("invalid: vanish request does not name this relay")133+}134+135+func (t *Tenant) Query(ctx context.Context, filters []event.Filter, s relay.Session) ([]event.Event, error) {136+ if _, err := t.gate.Read(ctx, filters, s); err != nil {137+ return nil, err138+ }139+ result := []event.Event{}140+ seen := make(map[string]bool)141+ for _, f := range filters {142+ rows, err := t.store.Query(ctx, f, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: s.PubKeys}})143+ if err != nil {144+ return nil, err145+ }146+ for _, e := range rows.Events {147+ if !seen[e.ID] && t.gate.CanSee(ctx, e, s, &f) {148+ seen[e.ID] = true149+ result = append(result, e)150+ }151+ }152+ }153+ sort.Slice(result, func(i, j int) bool {154+ if result[i].CreatedAt == result[j].CreatedAt {155+ return result[i].ID < result[j].ID156+ }157+ return result[i].CreatedAt > result[j].CreatedAt158+ })159+ return result, nil160+}161+162+func (t *Tenant) Count(ctx context.Context, filters []event.Filter, s relay.Session) (any, error) {163+ if !t.Policy().Features.Count {164+ return nil, errors.New("unsupported: COUNT is switched off on this relay")165+ }166+ // Count the same authorized union as REQ, with NIP-45 ignoring limit.167+ unlimited := make([]event.Filter, len(filters))168+ copy(unlimited, filters)169+ for i := range unlimited {170+ unlimited[i].Limit = nil171+ }172+ events, err := t.Query(ctx, unlimited, s)173+ if err != nil {174+ return nil, err175+ }176+ result := map[string]any{"count": len(events)}177+ if len(filters) == 1 {178+ if offset, ok := syncprotocol.HLLFilterOffset(filters[0]); ok {179+ hll := syncprotocol.NewHLL(offset)180+ for _, e := range events {181+ if err := hll.Add(e.PubKey); err != nil {182+ return nil, err183+ }184+ }185+ result["hll"] = hll.Hex()186+ }187+ }188+ return result, nil189+}190+191+func (t *Tenant) Sync(ctx context.Context, f event.Filter, s relay.Session) ([]syncprotocol.Item, error) {192+ if !t.Policy().Features.Sync {193+ return nil, errors.New("unsupported: sync is switched off on this relay")194+ }195+ f.Limit = nil196+ events, err := t.Query(ctx, []event.Filter{f}, s)197+ if err != nil {198+ return nil, err199+ }200+ items := make([]syncprotocol.Item, len(events))201+ for i, e := range events {202+ items[i] = syncprotocol.Item{ID: e.ID, Timestamp: e.CreatedAt}203+ }204+ return items, nil205+}206+207+func (t *Tenant) CanRead(e event.Event, s relay.Session) bool {208+ return t.gate.CanSee(context.Background(), e, s, nil)209+}210+211+func (t *Tenant) CanReadFilter(e event.Event, s relay.Session, f *event.Filter) bool {212+ return t.gate.CanSee(context.Background(), e, s, f)213+}214+215+func (t *Tenant) QueryHints(ctx context.Context, filters []event.Filter, s relay.Session) ([]event.Event, []string, error) {216+ authHint, err := t.gate.Read(ctx, filters, s)217+ if err != nil {218+ return nil, nil, err219+ }220+ events, err := t.Query(ctx, filters, s)221+ if err != nil {222+ return nil, nil, err223+ }224+ hints := []string{"finish"}225+ if authHint {226+ hints = append(hints, "auth")227+ }228+ for _, f := range filters {229+ if f.Limit == nil {230+ continue231+ }232+ rows, err := t.store.Query(ctx, f, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: s.PubKeys}})233+ if err != nil {234+ return nil, nil, err235+ }236+ if rows.More {237+ hints[0] = "more"238+ break239+ }240+ }241+ return events, hints, nil242+}243+244+func (t *Tenant) setPolicy(ctx context.Context, patch map[string]json.RawMessage) (policy.Policy, error) {245+ t.mu.Lock()246+ next, err := policy.Patch(t.policy, patch)247+ if err == nil {248+ err = t.store.PutSetting(ctx, "policy", next)249+ }250+ if err == nil {251+ t.policy = next252+ }253+ t.mu.Unlock()254+ if err == nil {255+ t.router.CloseSubscriptions("blocked: relay policy changed; subscribe again")256+ }257+ return next, err258+}