1package gates23import (4 "context"5 "errors"6 "fmt"7 "strings"8 "sync"910 "github.com/FelineStateMachine/tinyrelay/internal/community"11 "github.com/FelineStateMachine/tinyrelay/internal/event"12 "github.com/FelineStateMachine/tinyrelay/internal/policy"13 "github.com/FelineStateMachine/tinyrelay/internal/relay"14 "github.com/FelineStateMachine/tinyrelay/internal/storage"15 "github.com/dlclark/regexp2"16)1718// Config supplies the policy and optional durable services used by Gate.19// Store and Community enable checks that depend on persisted relay state.20type Config struct {21 Store *storage.Store22 Community *community.Service23 Policy func() policy.Policy24 Slug string25 ArtifactVisible func(context.Context, event.Event, relay.Session) bool26}2728// Gate evaluates event admission and visibility for one tenant configuration.29type Gate struct {30 cfg Config31 agents agentLimiter32}33type artifactCheckContextKey struct{}34type artifactCheckState struct {35 seen map[string]struct{}36 depth int37}3839// agentLimiter is the per-agent sliding one-minute counter. It lives in40// memory: a restart forgives the last minute, which is the cheap and honest41// trade for a limit that never touches the database on the hot path.42type agentLimiter struct {43 mu sync.Mutex44 recent map[string][]int6445}4647func (l *agentLimiter) allow(agent string, limit int, now int64) bool {48 l.mu.Lock()49 defer l.mu.Unlock()50 if l.recent == nil {51 l.recent = map[string][]int64{}52 }53 kept := l.recent[agent][:0]54 for _, at := range l.recent[agent] {55 if at > now-60 {56 kept = append(kept, at)57 }58 }59 if len(kept) >= limit {60 l.recent[agent] = kept61 return false62 }63 l.recent[agent] = append(kept, now)64 return true65}6667// agentAdmission applies an agent grant to an event from a key whose only68// standing here is that grant. Keys with a human role keep that role's69// permissions. Events from agents are never stamped or rewritten; they are70// kept as signed or refused with a restricted reason.71func (g *Gate) agentAdmission(ctx context.Context, e event.Event, now int64) error {72 if g.cfg.Community == nil {73 return nil74 }75 grant, ok, err := g.cfg.Community.AgentGrant(ctx, e.PubKey)76 if err != nil {77 return fmt.Errorf("check agent grant: %w", err)78 }79 if !ok {80 if community.IsAgentGrantRequest(e) {81 return errors.New("restricted: grant requests require an active agent grant")82 }83 return nil84 }85 role, err := g.cfg.Community.Role(ctx, e.PubKey)86 if err != nil {87 return fmt.Errorf("check agent role: %w", err)88 }89 if role != "" && role != "agent" {90 if community.IsAgentGrantRequest(e) {91 return errors.New("restricted: only the granted agent may request a grant change")92 }93 return nil94 }95 if community.IsAgentGrantRequest(e) {96 if _, reviewErr := g.cfg.Community.ReviewAgentGrantRequest(ctx, e, now); reviewErr != nil {97 return reviewErr98 }99 }100 if !community.IsAgentGrantRequest(e) {101 if err := grant.Check(e, now); err != nil {102 return err103 }104 }105 if err := g.jobReply(ctx, e, now, true); err != nil {106 return err107 }108 if !g.agents.allow(e.PubKey, grant.Scope.Rate, now) {109 return fmt.Errorf("restricted: agent grant does not allow more than %d events per minute", grant.Scope.Rate)110 }111 return nil112}113114// agentGrantShape checks a grant event's signer and tags before it is stored.115func (g *Gate) agentGrantShape(ctx context.Context, e event.Event, now int64) error {116 if e.Kind != event.KIND_AGENT_GRANT {117 return nil118 }119 if g.cfg.Community == nil {120 return errors.New("restricted: agent grants need the membership service")121 }122 role, err := g.cfg.Community.Role(ctx, e.PubKey)123 if err != nil {124 return fmt.Errorf("check grant signer: %w", err)125 }126 if !community.CanGrantAgents(role) {127 return errors.New("restricted: only the owner or a moderator can grant an agent")128 }129 if event.Tag(e, "grant-request") != "" || event.Tag(e, "grant-base") != "" {130 if err := g.cfg.Community.ValidateAgentGrantReplacement(ctx, e, now); err != nil {131 return err132 }133 }134 _, err = community.ParseAgentGrant(e, now)135 return err136}137138func New(cfg Config) (*Gate, error) {139 if cfg.Policy == nil {140 return nil, errors.New("gates: policy function is required")141 }142 return &Gate{cfg: cfg}, nil143}144145// Write validates a client-published event against protocol shape, tenant146// policy, membership, bans, room access, retention and proof-of-work rules.147func (g *Gate) Write(ctx context.Context, e event.Event, s relay.Session, now int64) error {148 p := g.cfg.Policy()149 if e.Kind == event.KIND_AUTH {150 return errors.New("blocked: kind 22242 is only accepted inside AUTH")151 }152 if e.Kind == event.KIND_MARMOT_GROUP || e.Kind == event.KIND_MARMOT_KEY_PACKAGE {153 if !p.Features.Marmot {154 return errors.New("restricted: Marmot transport is switched off on this relay")155 }156 if err := MarmotShape(e); err != nil {157 return err158 }159 if e.Kind == event.KIND_MARMOT_GROUP {160 if contains(s.PubKeys, e.PubKey) {161 return errors.New("invalid: kind 445 must use a fresh ephemeral author")162 }163 if g.cfg.Store != nil {164 result, err := g.cfg.Store.Query(ctx, event.Filter{Kinds: []int{event.KIND_MARMOT_GROUP}, Authors: []string{e.PubKey}}, storage.QueryOptions{Now: now, Access: storage.Access{All: true}, Limit: 1})165 if err != nil {166 return fmt.Errorf("check Marmot ephemeral author: %w", err)167 }168 if len(result.Events) > 0 {169 return errors.New("invalid: kind 445 ephemeral author was already used")170 }171 }172 }173 }174 if err := event.Validate(e); err != nil {175 return err176 }177 if err := nip43Shape(e, now); err != nil {178 return err179 }180 if err := jobFeature(p, e); err != nil {181 return err182 }183 if err := JobShape(e); err != nil {184 return err185 }186 if p.MaxFuture > 0 && e.CreatedAt > now+p.MaxFuture {187 return errors.New("invalid: event creation date is too far off from the current time")188 }189 if exp := event.Expiration(e); exp > 0 && exp <= now && e.Kind != event.KIND_AGENT_GRANT {190 return errors.New("invalid: event has already expired")191 }192 if g.cfg.Community != nil {193 if s.RemoteIP != "" {194 blocked, err := g.cfg.Community.IsIPBlocked(ctx, s.RemoteIP)195 if err != nil {196 return fmt.Errorf("check IP block: %w", err)197 }198 if blocked {199 return errors.New("blocked: this IP address is blocked")200 }201 }202 banned, err := g.cfg.Community.IsBanned(ctx, e.PubKey)203 if err != nil {204 return fmt.Errorf("check pubkey ban: %w", err)205 }206 if banned {207 return errors.New("blocked: this pubkey is banned")208 }209 banned, err = g.cfg.Community.IsEventBanned(ctx, e.ID)210 if err != nil {211 return fmt.Errorf("check event ban: %w", err)212 }213 if banned {214 return errors.New("blocked: this event is banned")215 }216 }217 if err := g.agentGrantShape(ctx, e, now); err != nil {218 return err219 }220 if err := g.agentAdmission(ctx, e, now); err != nil {221 return err222 }223 if where := blockedWord(p, e); where != "" && !g.isModerator(ctx, e.PubKey) {224 return errors.New("blocked: " + where)225 }226 if e.Kind == event.KIND_NOSTR_CONNECT {227 if !p.Features.Signer {228 return errors.New("restricted: signer traffic is switched off")229 }230 return nil231 }232 if err := g.roomWrite(ctx, e); err != nil {233 return err234 }235 if p.Inbox.Targeted && !inboxAdmission(p, e) {236 return errors.New("blocked: inbox event must target the relay owner")237 }238 if hasTag(e, "-") && !contains(s.PubKeys, e.PubKey) {239 return errors.New("auth-required: this event may only be published by its author")240 }241 writeAccess := accessFor(ctx, g.cfg.Community, p, relay.Session{PubKeys: []string{e.PubKey}})242 if p.Writes == "wot" && !writeAccess.Member && !writeAccess.Owner && !g.wotAllows(ctx, e.PubKey, p.Owner) {243 return errors.New("restricted: this relay only accepts events from its members and the people they follow")244 }245 if e.Kind == event.KIND_MARMOT_GROUP {246 principal, err := g.Principal(ctx, e, s)247 if err != nil {248 return err249 }250 if g.cfg.Community != nil {251 banned, banErr := g.cfg.Community.IsBanned(ctx, principal)252 if banErr != nil {253 return fmt.Errorf("check Marmot principal ban: %w", banErr)254 }255 if banned {256 return errors.New("blocked: Marmot account is banned")257 }258 }259 writeAccess = accessFor(ctx, g.cfg.Community, p, relay.Session{PubKeys: []string{principal}})260 }261 if err := jobWriter(e, writeAccess); err != nil {262 return err263 }264 if err := g.jobReply(ctx, e, now, false); err != nil {265 return err266 }267 ownerReplaceable := writeAccess.Owner && (event.IsReplaceable(e.Kind) || event.IsAddressable(e.Kind))268 allowed, blocked, rulesErr := g.kindRules(ctx, p, e.Kind)269 if rulesErr != nil {270 return fmt.Errorf("read kind rules: %w", rulesErr)271 }272 if blocked || !allowed {273 if !ownerReplaceable {274 return errors.New("blocked: this relay does not accept this event kind")275 }276 }277 membershipEvent := e.Kind == event.KIND_JOIN || e.Kind == event.KIND_LEAVE || e.Kind == event.KIND_NIP43_JOIN || e.Kind == event.KIND_NIP43_LEAVE278 if !ownerReplaceable && !membershipEvent && !policy.CanWrite(p, e, writeAccess) && !g.guestPass(ctx, p, e, writeAccess, now) {279 return errors.New("blocked: this event is not allowed by the relay write policy")280 }281 if days := g.RetentionDays(ctx, e.Kind); days > 0 && !protectedKind(e.Kind) && !(ownerReplaceable) && e.CreatedAt < now-int64(days)*86400 {282 return errors.New("blocked: event is older than this relay's retention window")283 }284 if e.Kind == event.KIND_WRAP && g.isDMInbox(ctx, p) && g.cfg.Community != nil && len(event.TagValues(e, "p")) > 0 {285 valid := false286 for _, recipient := range event.TagValues(e, "p") {287 member, err := g.cfg.Community.IsMember(ctx, recipient)288 if err == nil && member {289 valid = true290 }291 }292 if !valid {293 return errors.New("blocked: gift wrap recipient is not local")294 }295 }296 if p.MinPow > 0 && event.Difficulty(e.ID) < p.MinPow {297 return fmt.Errorf("pow: difficulty %d is less than %d", event.Difficulty(e.ID), p.MinPow)298 }299 if p.MinPow > 0 && event.CommittedDifficulty(e) > 0 && event.CommittedDifficulty(e) < p.MinPow {300 return fmt.Errorf("pow: committed target %d is less than %d", event.CommittedDifficulty(e), p.MinPow)301 }302 return nil303}304305func inboxAdmission(p policy.Policy, e event.Event) bool {306 if e.PubKey == p.Owner && (e.Kind == 0 || e.Kind == 3 || e.Kind == 10002) {307 return true308 }309 for _, tag := range e.Tags {310 if len(tag) >= 2 && tag[0] == "p" && tag[1] == p.Owner {311 return true312 }313 }314 return false315}316317// Import validates an event received from another host using protocol shape,318// safety, ban, agent and room existence checks. The caller records its origin319// in storage after this common gate succeeds.320func (g *Gate) Import(ctx context.Context, e event.Event, now int64) error {321 p := g.cfg.Policy()322 if err := event.Validate(e); err != nil {323 return err324 }325 if err := nip43Shape(e, now); err != nil {326 return err327 }328 if err := jobFeature(p, e); err != nil {329 return err330 }331 if err := JobShape(e); err != nil {332 return err333 }334 if p.MaxFuture > 0 && e.CreatedAt > now+p.MaxFuture {335 return errors.New("invalid: event creation date is too far off from the current time")336 }337 if exp := event.Expiration(e); exp > 0 && exp <= now && e.Kind != event.KIND_AGENT_GRANT {338 return errors.New("invalid: event has already expired")339 }340 if g.cfg.Community != nil {341 banned, err := g.cfg.Community.IsBanned(ctx, e.PubKey)342 if err != nil {343 return fmt.Errorf("check pubkey ban: %w", err)344 }345 if banned {346 return errors.New("blocked: this pubkey is banned")347 }348 banned, err = g.cfg.Community.IsEventBanned(ctx, e.ID)349 if err != nil {350 return fmt.Errorf("check event ban: %w", err)351 }352 if banned {353 return errors.New("blocked: this event is banned")354 }355 }356 if err := g.agentGrantShape(ctx, e, now); err != nil {357 return err358 }359 if err := g.agentAdmission(ctx, e, now); err != nil {360 return err361 }362 if where := blockedWord(p, e); where != "" && !g.isModerator(ctx, e.PubKey) {363 return errors.New("blocked: " + where)364 }365 if e.Kind == event.KIND_AUTH {366 return errors.New("blocked: kind 22242 is only accepted inside AUTH")367 }368 if e.Kind == event.KIND_NOSTR_CONNECT && !p.Features.Signer {369 return errors.New("restricted: signer traffic is switched off")370 }371 if e.Kind == event.KIND_MARMOT_GROUP || e.Kind == event.KIND_MARMOT_KEY_PACKAGE {372 if !p.Features.Marmot {373 return errors.New("restricted: Marmot transport is switched off on this relay")374 }375 if err := MarmotShape(e); err != nil {376 return err377 }378 }379 if err := g.roomExists(ctx, e); err != nil {380 return err381 }382 return nil383}384385// roomID names the room an event belongs to, or nothing for events outside386// the room dialect. Marmot group messages reuse the h tag for other ends.387func (g *Gate) roomID(e event.Event) string {388 if e.Kind == event.KIND_MARMOT_GROUP {389 return ""390 }391 return event.Tag(e, "h")392}393394// roomExists is the common room check: an h tag must name the slug room or395// a live room. Room creation is the one event allowed to name a new room.396func (g *Gate) roomExists(ctx context.Context, e event.Event) error {397 id := g.roomID(e)398 if id == "" || id == g.cfg.Slug || e.Kind == event.KIND_CREATE_GROUP {399 return nil400 }401 if g.cfg.Community == nil {402 return fmt.Errorf("blocked: this relay hosts one group: %s", g.cfg.Slug)403 }404 room, err := g.cfg.Community.Room(ctx, id)405 if err != nil {406 return fmt.Errorf("check room: %w", err)407 }408 if !room.Live() {409 return fmt.Errorf("invalid: unknown room %s", id)410 }411 return nil412}413414// roomWrite applies room membership to client writes. The slug room keeps415// the tenant's own rules. In other rooms, management events are checked by416// the room handler; every other event needs room membership, which an open417// room grants to any tenant member.418func (g *Gate) roomWrite(ctx context.Context, e event.Event) error {419 if err := g.roomExists(ctx, e); err != nil {420 return err421 }422 id := g.roomID(e)423 if id != "" && community.RoomNoticeKind(e.Kind) {424 return errors.New("blocked: member notices are signed by the relay")425 }426 if id == "" || id == g.cfg.Slug || e.Kind == event.KIND_CREATE_GROUP || community.RoomAdminKind(e.Kind) {427 return nil428 }429 allowed, _, err := g.cfg.Community.RoomWriteAllowed(ctx, id, e.PubKey)430 if err != nil {431 return fmt.Errorf("check room membership: %w", err)432 }433 if !allowed {434 return fmt.Errorf("restricted: not a member of room %s", id)435 }436 return nil437}438439// roomVisible applies room access to reads. Members-only rooms, including440// the relay's own 39000 to 39002 records for them, are served only to room441// members, the tenant owner and moderators.442func (g *Gate) roomVisible(ctx context.Context, e event.Event, s relay.Session) bool {443 id := g.roomID(e)444 if id == "" && (e.Kind == event.KIND_GROUP_METADATA || e.Kind == event.KIND_GROUP_ADMINS || e.Kind == event.KIND_GROUP_MEMBERS) {445 id = event.Tag(e, "d")446 }447 if id == "" || id == g.cfg.Slug || g.cfg.Community == nil {448 return true449 }450 visible, err := g.cfg.Community.RoomVisible(ctx, id, s.PubKeys)451 return err == nil && visible452}453454// membersOnlyRoomFilter reports whether a filter asks for a members-only455// room, so an unauthenticated subscriber is told to AUTH instead of waiting456// on an empty stream.457func (g *Gate) membersOnlyRoomFilter(ctx context.Context, f event.Filter) bool {458 if g.cfg.Community == nil {459 return false460 }461 for _, id := range f.Tags["h"] {462 if id == g.cfg.Slug {463 continue464 }465 room, err := g.cfg.Community.Room(ctx, id)466 if err == nil && room.Live() && room.Access == community.RoomMembers {467 return true468 }469 }470 return false471}472473func (g *Gate) kindRules(ctx context.Context, p policy.Policy, kind int) (bool, bool, error) {474 if g.cfg.Community == nil {475 return len(p.AllowedKinds) == 0 || containsInt(p.AllowedKinds, kind), containsInt(p.BlockedKinds, kind), nil476 }477 allowedAny, blocked := false, false478 value, err := g.cfg.Community.Execute(ctx, p.Owner, "listallowedkinds", nil)479 if err != nil {480 return false, false, err481 }482 if values, ok := value.([]int); ok {483 allowedAny = len(values) == 0 || containsInt(values, kind)484 } else {485 allowedAny = true486 }487 value, err = g.cfg.Community.Execute(ctx, p.Owner, "listblockedkinds", nil)488 if err != nil {489 return false, false, err490 }491 if values, ok := value.([]int); ok {492 blocked = containsInt(values, kind)493 }494 return allowedAny, blocked, nil495}496497// RetentionDays returns the configured retention period for kind, using the498// wildcard rule when no exact rule exists.499func (g *Gate) RetentionDays(ctx context.Context, kind int) int {500 if g.cfg.Community == nil {501 wildcard := 0502 for _, rule := range g.cfg.Policy().Retention {503 if rule.Kind == kind {504 return rule.Days505 }506 if rule.Kind < 0 {507 wildcard = rule.Days508 }509 }510 return wildcard511 }512 value, err := g.cfg.Community.Execute(ctx, g.cfg.Policy().Owner, "listretention", nil)513 if err != nil {514 return 0515 }516 if rules, ok := value.([]map[string]int); ok {517 wildcard := 0518 for _, rule := range rules {519 if rule["kind"] == kind {520 return rule["days"]521 }522 if rule["kind"] < 0 {523 wildcard = rule["days"]524 }525 }526 return wildcard527 }528 return 0529}530531func protectedKind(kind int) bool {532 switch kind {533 case 0, 3, 10002, 9735, 13534, 8000, 8001, 9000, 9001, 33534, 39000, 39001, 39002, 39003, 39005:534 return true535 }536 return false537}538539func nip43Shape(e event.Event, now int64) error {540 if e.Kind != event.KIND_NIP43_JOIN && e.Kind != event.KIND_NIP43_LEAVE {541 return nil542 }543 if delta := e.CreatedAt - now; delta > 300 || delta < -300 {544 return errors.New("invalid: NIP-43 request is outside the allowed time window")545 }546 if e.Kind == event.KIND_NIP43_LEAVE {547 protected := 0548 for _, tag := range e.Tags {549 if len(tag) > 0 && tag[0] == "-" {550 protected++551 if len(tag) != 1 {552 return errors.New("invalid: NIP-43 leave protected tag must be empty")553 }554 }555 }556 if protected != 1 {557 return errors.New("invalid: NIP-43 leave needs one protected tag")558 }559 }560 // A join without a claim tag is an access request held for the owner's561 // review, so the claim stays optional.562 return nil563}564565func (g *Gate) wotAllows(ctx context.Context, target, owner string) bool {566 if g.cfg.Store == nil {567 return false568 }569 principals := []string{owner}570 if g.cfg.Community != nil {571 members, err := g.cfg.Community.Execute(ctx, owner, "listmembers", nil)572 if err == nil {573 if list, ok := members.([]community.Member); ok {574 for _, member := range list {575 principals = append(principals, member.PubKey)576 }577 }578 }579 }580 seen := map[string]bool{}581 for _, principal := range principals {582 if seen[principal] {583 continue584 }585 seen[principal] = true586 result, err := g.cfg.Store.Query(ctx, event.Filter{Authors: []string{principal}, Kinds: []int{3}}, storage.QueryOptions{Now: 0, Access: storage.Access{All: true}})587 if err != nil || len(result.Events) == 0 {588 continue589 }590 latest := result.Events[0]591 for _, tag := range latest.Tags {592 if len(tag) > 1 && tag[0] == "p" && tag[1] == target {593 return true594 }595 }596 }597 return false598}599600func (g *Gate) isDMInbox(ctx context.Context, p policy.Policy) bool {601 if p.Reads != "members" || g.cfg.Community == nil {602 return p.Reads == "members" && containsInt(p.AllowedKinds, event.KIND_WRAP)603 }604 value, err := g.cfg.Community.Execute(ctx, p.Owner, "listallowedkinds", nil)605 if err != nil {606 return false607 }608 values, ok := value.([]int)609 return ok && containsInt(values, event.KIND_WRAP)610}611612func (g *Gate) guestPass(ctx context.Context, p policy.Policy, e event.Event, a policy.Access, now int64) bool {613 if !a.Member && !a.Owner && p.Writes != "open" {614 if containsInt(p.OpenKinds, e.Kind) {615 return true616 }617 if !p.GuestReplies || (e.Kind != 1 && e.Kind != 1111) || g.cfg.Store == nil {618 return false619 }620 for _, id := range append(event.TagValues(e, "e"), event.TagValues(e, "E")...) {621 result, err := g.cfg.Store.Query(ctx, event.Filter{IDs: []string{id}}, storage.QueryOptions{Now: now, Access: storage.Access{All: true}, Limit: 1})622 if err == nil && len(result.Events) > 0 {623 author := result.Events[0].PubKey624 if author == p.Owner {625 return true626 }627 if g.cfg.Community != nil {628 member, memberErr := g.cfg.Community.IsMember(ctx, author)629 if memberErr == nil && member {630 return true631 }632 }633 }634 }635 }636 return false637}638639// Read validates a subscription request and returns an AUTH hint when a640// subscriber may receive private or members-only results after authenticating.641func (g *Gate) Read(ctx context.Context, filters []event.Filter, s relay.Session) (bool, error) {642 p := g.cfg.Policy()643 if p.Features.Signer && len(filters) > 0 {644 signerOnly := true645 for _, f := range filters {646 if len(f.Kinds) != 1 || f.Kinds[0] != event.KIND_NOSTR_CONNECT {647 signerOnly = false648 }649 }650 if signerOnly {651 return false, nil652 }653 }654 if p.Features.Search == "off" {655 for _, f := range filters {656 if f.Search != "" {657 return false, errors.New("unsupported: search is switched off on this relay")658 }659 }660 }661 a := accessFor(ctx, g.cfg.Community, p, s)662 if p.Reads == "auth" && len(a.PubKeys) == 0 {663 return false, errors.New("auth-required: this relay requires AUTH")664 }665 if p.Reads == "members" && !a.Member && !a.Owner {666 if len(a.PubKeys) == 0 {667 return false, errors.New("auth-required: this relay is members-only")668 }669 return false, errors.New("restricted: this relay is members-only")670 }671 privateOnly := true672 authHint := false673 for _, f := range filters {674 if len(a.PubKeys) == 0 && g.membersOnlyRoomFilter(ctx, f) {675 authHint = true676 }677 if len(f.Kinds) == 0 {678 privateOnly = false679 authHint = true680 continue681 }682 seenPrivate := false683 for _, k := range f.Kinds {684 if event.IsPrivate(k) {685 seenPrivate = true686 } else {687 privateOnly = false688 }689 }690 if seenPrivate && len(a.PubKeys) == 0 {691 authHint = true692 }693 }694 if privateOnly && len(a.PubKeys) == 0 {695 return false, errors.New("auth-required: private kinds are only served to their recipients")696 }697 return authHint, nil698}699700// CanSee reports whether session s may receive e. It applies hidden-event,701// ban, repository privacy, policy, room and recipient checks.702func (g *Gate) CanSee(ctx context.Context, e event.Event, s relay.Session, f *event.Filter) bool {703 p := g.cfg.Policy()704 if e.Kind == event.KIND_VIEW && g.cfg.ArtifactVisible != nil {705 state, _ := ctx.Value(artifactCheckContextKey{}).(artifactCheckState)706 if state.seen == nil {707 state.seen = map[string]struct{}{}708 }709 if _, cycle := state.seen[e.ID]; cycle || state.depth >= 32 {710 return false711 }712 seen := make(map[string]struct{}, len(state.seen)+1)713 for id := range state.seen {714 seen[id] = struct{}{}715 }716 seen[e.ID] = struct{}{}717 ctx = context.WithValue(ctx, artifactCheckContextKey{}, artifactCheckState{seen: seen, depth: state.depth + 1})718 if !g.cfg.ArtifactVisible(ctx, e, s) {719 return false720 }721 }722 if community.IsAgentGrantRequest(e) {723 operator := event.Tag(e, "p")724 if !contains(s.PubKeys, e.PubKey) && !contains(s.PubKeys, operator) {725 return false726 }727 }728 if g.cfg.Store != nil {729 var held int730 if err := g.cfg.Store.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM (SELECT id FROM hidden_events WHERE id=? UNION ALL SELECT id FROM pending_events WHERE id=?)`, e.ID, e.ID).Scan(&held); err != nil || held > 0 {731 return false732 }733 }734 private, err := privateRepositoryEvent(ctx, g.cfg.Store, e)735 if err != nil || private && (!p.Features.Grasp08 || p.Reads != "members") {736 return false737 }738 if g.cfg.Community != nil {739 banned, err := g.cfg.Community.IsEventBanned(ctx, e.ID)740 if err != nil || banned {741 return false742 }743 banned, err = g.cfg.Community.IsBanned(ctx, e.PubKey)744 if err != nil || banned {745 return false746 }747 for _, key := range s.PubKeys {748 banned, err = g.cfg.Community.IsBanned(ctx, key)749 if err != nil || banned {750 return false751 }752 }753 }754 if e.Kind == event.KIND_PUSH_REGISTRATION {755 return contains(s.PubKeys, e.PubKey)756 }757 if e.Kind == event.KIND_NOSTR_CONNECT {758 return p.Features.Signer && canSeeSignerMessage(e, s, f)759 }760 a := accessFor(ctx, g.cfg.Community, p, s)761 if !policy.CanRead(p, e, a) {762 return false763 }764 if !g.roomVisible(ctx, e, s) {765 return false766 }767 if event.IsPrivate(e.Kind) {768 for _, key := range s.PubKeys {769 if key == e.PubKey {770 return true771 }772 }773 for _, tag := range e.Tags {774 if len(tag) >= 2 && tag[0] == "p" && contains(s.PubKeys, tag[1]) {775 return true776 }777 }778 return false779 }780 return true781}782783// privateRepositoryEvent also protects legacy state events. Their privacy is784// inherited from the corresponding replaceable announcement, so checking only785// the state event's own tags would allow metadata to escape after a restart.786func privateRepositoryEvent(ctx context.Context, store *storage.Store, e event.Event) (bool, error) {787 return policy.PrivateRepository(ctx, store, e)788}789790func canSeeSignerMessage(e event.Event, s relay.Session, f *event.Filter) bool {791 if contains(s.PubKeys, e.PubKey) {792 return true793 }794 for _, recipient := range event.TagValues(e, "p") {795 if contains(s.PubKeys, recipient) {796 return true797 }798 // The client must receive encrypted replies before it can use its799 // remote signer to authenticate. Limit this to its addressed inbox.800 if f != nil && len(f.Kinds) == 1 && f.Kinds[0] == event.KIND_NOSTR_CONNECT && contains(f.Tags["p"], recipient) {801 return true802 }803 }804 return false805}806807func accessFor(ctx context.Context, c *community.Service, p policy.Policy, s relay.Session) policy.Access {808 a := policy.Access{PubKeys: append([]string(nil), s.PubKeys...)}809 a.Owner = contains(s.PubKeys, p.Owner)810 if c != nil {811 for _, key := range s.PubKeys {812 member, err := c.IsMember(ctx, key)813 if err == nil && member {814 a.Member = true815 }816 role, err := c.Role(ctx, key)817 if err == nil && role != "" {818 a.Role = role819 }820 }821 }822 return a823}824825func (g *Gate) isModerator(ctx context.Context, key string) bool {826 if g.cfg.Community == nil {827 return false828 }829 role, err := g.cfg.Community.Role(ctx, key)830 return err == nil && (role == "owner" || role == "moderator")831}832833func blockedWord(p policy.Policy, e event.Event) string {834 for _, word := range p.BlockedWords {835 pattern := word836 regex := false837 if strings.HasPrefix(pattern, "/") && strings.HasSuffix(pattern, "/") {838 pattern, regex = pattern[1:len(pattern)-1], true839 }840 matches := func(value string) bool {841 if !regex {842 return strings.Contains(strings.ToLower(value), strings.ToLower(pattern))843 }844 re, err := regexp2.Compile(pattern, regexp2.IgnoreCase)845 if err != nil {846 return false847 }848 ok, err := re.MatchString(value)849 return err == nil && ok850 }851 if matches(e.Content) {852 return "content contains a blocked word"853 }854 if p.BlockedWordsInTags {855 for _, tag := range e.Tags {856 for _, value := range tag {857 if matches(value) {858 return "a tag contains a blocked word"859 }860 }861 }862 }863 }864 return ""865}866867func hasTag(e event.Event, name string) bool {868 for _, tag := range e.Tags {869 if len(tag) > 0 && tag[0] == name {870 return true871 }872 }873 return false874}875func contains(values []string, wanted string) bool {876 for _, value := range values {877 if value == wanted {878 return true879 }880 }881 return false882}883884func containsInt(values []int, wanted int) bool {885 for _, value := range values {886 if value == wanted {887 return true888 }889 }890 return false891}892func proofOfWork(id string) int {893 bits := 0894 for _, ch := range id {895 value := byte(0)896 switch {897 case ch >= '0' && ch <= '9':898 value = byte(ch - '0')899 case ch >= 'a' && ch <= 'f':900 value = byte(ch - 'a' + 10)901 default:902 return bits903 }904 for mask := byte(8); mask > 0 && value&mask == 0; mask >>= 1 {905 bits++906 }907 if value != 0 {908 break909 }910 }911 return bits912}