Manage access just by storing ephemeral events

This commit is contained in:
Jon Staab
2025-09-26 13:42:52 -07:00
parent 86a6eec127
commit f3f56491ad
4 changed files with 78 additions and 245 deletions
-174
View File
@@ -1,174 +0,0 @@
package zooid
import (
"fmt"
"time"
"fiatjaf.com/nostr"
"github.com/Masterminds/squirrel"
)
type Invite struct {
ID string
CreatedAt int
Pubkey nostr.PubKey
Claim string
}
type Redemption struct {
ID string
InviteID string
CreatedAt int
Pubkey nostr.PubKey
}
type AccessStore struct {
Config *Config
Schema *Schema
}
func (access *AccessStore) Init() error {
schema := access.Schema.Render(`
CREATE TABLE IF NOT EXISTS {{.Prefix}}__invites (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
pubkey TEXT NOT NULL,
claim TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_invites_created_at ON {{.Prefix}}__invites(created_at);
CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_invites_pubkey ON {{.Prefix}}__invites(pubkey);
CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_invites_claim ON {{.Prefix}}__invites(claim);
CREATE TABLE IF NOT EXISTS {{.Prefix}}__redemptions (
id TEXT PRIMARY KEY,
invite_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
pubkey TEXT NOT NULL,
FOREIGN KEY (invite_id) REFERENCES {{.Prefix}}__invites(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_redemptions_invite_id ON {{.Prefix}}__redemptions(invite_id);
CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_redemptions_created_at ON {{.Prefix}}__redemptions(created_at);
CREATE INDEX IF NOT EXISTS {{.Prefix}}__idx_redemptions_pubkey ON {{.Prefix}}__redemptions(pubkey);
`)
if _, err := GetDb().Exec(schema); err != nil {
return fmt.Errorf("failed to create schema: %w", err)
}
return nil
}
// Invite utils
func (access *AccessStore) SelectInvites() squirrel.SelectBuilder {
return squirrel.Select("id", "created_at", "pubkey", "claim").From(access.Schema.Prefix("invites"))
}
func (access *AccessStore) QueryInvites(builder squirrel.SelectBuilder) []Invite {
rows, err := builder.RunWith(GetDb()).Query()
if err != nil {
return []Invite{}
}
defer rows.Close()
var invites []Invite
for rows.Next() {
var invite Invite
var pubkeyStr string
err := rows.Scan(&invite.ID, &invite.CreatedAt, &pubkeyStr, &invite.Claim)
if err != nil {
continue
}
if pubkey, err := nostr.PubKeyFromHex(pubkeyStr); err == nil {
invite.Pubkey = pubkey
} else {
continue
}
invites = append(invites, invite)
}
return invites
}
func (access *AccessStore) AddInvite(pubkey nostr.PubKey, claim string) error {
id := RandomString(32)
createdAt := int(time.Now().Unix())
insertQb := squirrel.Insert(access.Schema.Prefix("invites")).
Columns("id", "created_at", "pubkey", "claim").
Values(id, createdAt, pubkey.Hex(), claim)
_, err := insertQb.RunWith(GetDb()).Exec()
if err != nil {
return fmt.Errorf("failed to add invite: %w", err)
}
return nil
}
func (access *AccessStore) GetInvitesByClaim(claim string) []Invite {
return access.QueryInvites(access.SelectInvites().Where(squirrel.Eq{"claim": claim}))
}
func (access *AccessStore) GetInvitesByPubkey(pubkey nostr.PubKey) []Invite {
return access.QueryInvites(access.SelectInvites().Where(squirrel.Eq{"pubkey": pubkey.Hex()}))
}
// Redemption utils
func (access *AccessStore) SelectRedemptions() squirrel.SelectBuilder {
return squirrel.Select("id", "invite_id", "created_at", "pubkey").From(access.Schema.Prefix("redemptions"))
}
func (access *AccessStore) QueryRedemptions(builder squirrel.SelectBuilder) []Redemption {
rows, err := builder.RunWith(GetDb()).Query()
if err != nil {
return []Redemption{}
}
defer rows.Close()
var redemptions []Redemption
for rows.Next() {
var redemption Redemption
var pubkeyStr string
err := rows.Scan(&redemption.ID, &redemption.InviteID, &redemption.CreatedAt, &pubkeyStr)
if err != nil {
continue
}
if pubkey, err := nostr.PubKeyFromHex(pubkeyStr); err == nil {
redemption.Pubkey = pubkey
} else {
continue
}
redemptions = append(redemptions, redemption)
}
return redemptions
}
func (access *AccessStore) AddRedemption(pubkey nostr.PubKey, invite Invite) error {
id := RandomString(32)
createdAt := int(time.Now().Unix())
insertQb := squirrel.Insert(access.Schema.Prefix("redemptions")).
Columns("id", "invite_id", "created_at", "pubkey").
Values(id, invite.ID, createdAt, pubkey.Hex())
_, err := insertQb.RunWith(GetDb()).Exec()
if err != nil {
return fmt.Errorf("failed to add invite: %w", err)
}
return nil
}
func (access *AccessStore) GetRedemptionsByPubkey(pubkey nostr.PubKey) []Invite {
return access.QueryInvites(access.SelectRedemptions().Where(squirrel.Eq{"pubkey": pubkey.Hex()}))
}
+10 -6
View File
@@ -100,9 +100,9 @@ func (events *EventStore) QueryEvents(filter nostr.Filter, maxLimit int) iter.Se
return return
} }
if maxLimit > 0 && maxLimit < filter.Limit { if maxLimit > 0 && maxLimit < filter.Limit {
filter.Limit = maxLimit filter.Limit = maxLimit
} }
rows, err := events.buildSelectQuery(filter).RunWith(GetDb()).Query() rows, err := events.buildSelectQuery(filter).RunWith(GetDb()).Query()
if err != nil { if err != nil {
@@ -205,9 +205,13 @@ func (events *EventStore) buildSelectQuery(filter nostr.Filter) squirrel.SelectB
} }
for tagKey, tagValues := range filter.Tags { for tagKey, tagValues := range filter.Tags {
if len(tagValues) == 0 { if len(tagValues) == 0 {
continue continue
} }
if len(tagKey) != 1 {
continue
}
tagValueInterfaces := make([]interface{}, len(tagValues)) tagValueInterfaces := make([]interface{}, len(tagValues))
for i, tagValue := range tagValues { for i, tagValue := range tagValues {
+65 -62
View File
@@ -2,16 +2,16 @@ package zooid
import ( import (
"context" "context"
"slices"
"iter" "iter"
"log" "log"
"net/http" "net/http"
"slices"
"sync" "sync"
"fiatjaf.com/nostr" "fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip29"
"fiatjaf.com/nostr/eventstore" "fiatjaf.com/nostr/eventstore"
"fiatjaf.com/nostr/khatru" "fiatjaf.com/nostr/khatru"
"fiatjaf.com/nostr/nip29"
"github.com/gosimple/slug" "github.com/gosimple/slug"
) )
@@ -20,7 +20,6 @@ type Instance struct {
Config *Config Config *Config
Secret nostr.SecretKey Secret nostr.SecretKey
Events eventstore.Store Events eventstore.Store
Access *AccessStore
Blossom *BlossomStore Blossom *BlossomStore
Management *ManagementStore Management *ManagementStore
Relay *khatru.Relay Relay *khatru.Relay
@@ -52,12 +51,6 @@ func MakeInstance(hostname string) (*Instance, error) {
Name: slug.Make(config.Self.Schema) + "_events", Name: slug.Make(config.Self.Schema) + "_events",
}, },
}, },
Access: &AccessStore{
Config: config,
Schema: &Schema{
Name: slug.Make(config.Self.Schema) + "_access",
},
},
Blossom: &BlossomStore{ Blossom: &BlossomStore{
Config: config, Config: config,
Schema: &Schema{ Schema: &Schema{
@@ -101,10 +94,6 @@ func MakeInstance(hostname string) (*Instance, error) {
log.Fatal("Failed to initialize event store:", err) log.Fatal("Failed to initialize event store:", err)
} }
if err := instance.Access.Init(); err != nil {
log.Fatal("Failed to initialize access store:", err)
}
if err := instance.Blossom.Init(); err != nil { if err := instance.Blossom.Init(); err != nil {
log.Fatal("Failed to initialize blossom store:", err) log.Fatal("Failed to initialize blossom store:", err)
} }
@@ -171,7 +160,12 @@ func (instance *Instance) HasAccess(pubkey nostr.PubKey) bool {
return true return true
} }
if len(instance.Access.GetRedemptionsByPubkey(pubkey)) > 0 { filter := nostr.Filter{
Kinds: []nostr.Kind{AUTH_JOIN},
Authors: []nostr.PubKey{pubkey},
}
for range instance.Events.QueryEvents(filter, 1) {
return true return true
} }
@@ -210,7 +204,7 @@ func (instance *Instance) AllowRecipientEvent(event nostr.Event) bool {
recipientTag := event.Tags.Find("p") recipientTag := event.Tags.Find("p")
if recipientTag != nil { if recipientTag != nil {
pubkey, err := nostr.PubKeyFromHex(recipientTag[1]) pubkey, err := nostr.PubKeyFromHex(recipientTag[1])
if err == nil && instance.HasAccess(pubkey) { if err == nil && instance.HasAccess(pubkey) {
return true return true
@@ -221,6 +215,35 @@ func (instance *Instance) AllowRecipientEvent(event nostr.Event) bool {
return false return false
} }
func (instance *Instance) GenerateInviteEvent(pubkey nostr.PubKey) nostr.Event {
filter := nostr.Filter{
Kinds: []nostr.Kind{AUTH_INVITE},
Authors: []nostr.PubKey{pubkey},
}
for event := range instance.Events.QueryEvents(filter, 1) {
return event
}
event := nostr.Event{
Kind: AUTH_INVITE,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
[]string{"claim", RandomString(8)},
[]string{"p", pubkey.Hex()},
},
}
event.Sign(instance.Secret)
err := instance.Events.SaveEvent(event)
if err != nil {
log.Printf("Failed to generate invite event: %w", err)
}
return event
}
func (instance *Instance) OnJoinEvent(event nostr.Event) (reject bool, msg string) { func (instance *Instance) OnJoinEvent(event nostr.Event) (reject bool, msg string) {
claimTag := event.Tags.Find("claim") claimTag := event.Tags.Find("claim")
@@ -230,22 +253,21 @@ func (instance *Instance) OnJoinEvent(event nostr.Event) (reject bool, msg strin
filter := nostr.Filter{ filter := nostr.Filter{
Kinds: []nostr.Kind{AUTH_INVITE}, Kinds: []nostr.Kind{AUTH_INVITE},
Tags: nostr.TagMap{
"claim": []string{claimTag[1]},
},
} }
for range instance.Events.QueryEvents(filter, 1) { for event := range instance.Events.QueryEvents(filter, 0) {
return false, "" if event.Tags.FindWithValue("claim", claimTag[1]) != nil {
return false, ""
}
} }
return true, "invalid: failed to validate invite code" return true, "invalid: failed to validate invite code"
} }
func (instance *Instance) GetGroupMetadataEvent(h string) nostr.Event { func (instance *Instance) GetGroupMetadataEvent(h string) nostr.Event {
for event := range instance.Events.QueryEvents(MakeGroupMetadataFilter(h), 1) { for event := range instance.Events.QueryEvents(MakeGroupMetadataFilter(h), 1) {
return event return event
} }
return nostr.Event{} return nostr.Event{}
} }
@@ -358,12 +380,12 @@ func (instance *Instance) OnEventSaved(ctx context.Context, event nostr.Event) {
} }
if event.Kind == nostr.KindSimpleGroupJoinRequest && instance.Config.Groups.AutoJoin { if event.Kind == nostr.KindSimpleGroupJoinRequest && instance.Config.Groups.AutoJoin {
h := GetGroupIDFromEvent(event) h := GetGroupIDFromEvent(event)
meta := instance.GetGroupMetadataEvent(h) meta := instance.GetGroupMetadataEvent(h)
if !HasTag(meta.Tags, "closed") { if !HasTag(meta.Tags, "closed") {
addEvent(MakePutUserEvent(h, event.PubKey)) addEvent(MakePutUserEvent(h, event.PubKey))
} }
} }
if event.Kind == nostr.KindSimpleGroupLeaveRequest && instance.Config.Groups.AutoLeave { if event.Kind == nostr.KindSimpleGroupLeaveRequest && instance.Config.Groups.AutoLeave {
@@ -388,6 +410,9 @@ func (instance *Instance) OnEventSaved(ctx context.Context, event nostr.Event) {
} }
func (instance *Instance) OnEphemeralEvent(ctx context.Context, event nostr.Event) { func (instance *Instance) OnEphemeralEvent(ctx context.Context, event nostr.Event) {
if slices.Contains([]nostr.Kind{AUTH_INVITE, AUTH_JOIN}, event.Kind) {
instance.Events.SaveEvent(event)
}
} }
func (instance *Instance) OnRequest(ctx context.Context, filter nostr.Filter) (reject bool, msg string) { func (instance *Instance) OnRequest(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
@@ -414,7 +439,7 @@ func (instance *Instance) QueryStored(ctx context.Context, filter nostr.Filter)
stripSignature := func(event nostr.Event) nostr.Event { stripSignature := func(event nostr.Event) nostr.Event {
if instance.Config.Policy.StripSignatures && !instance.IsAdmin(pubkey) { if instance.Config.Policy.StripSignatures && !instance.IsAdmin(pubkey) {
var zeroSig [64]byte var zeroSig [64]byte
event.Sig = zeroSig event.Sig = zeroSig
} }
@@ -422,52 +447,30 @@ func (instance *Instance) QueryStored(ctx context.Context, filter nostr.Filter)
} }
if slices.Contains(filter.Kinds, AUTH_INVITE) && instance.Config.CanInvite(pubkey) { if slices.Contains(filter.Kinds, AUTH_INVITE) && instance.Config.CanInvite(pubkey) {
var claim string if !yield(stripSignature(instance.GenerateInviteEvent(pubkey))) {
invites := instance.Access.GetInvitesByPubkey(pubkey)
if len(invites) > 0 {
claim = First(invites).Claim
} else {
claim = RandomString(8)
instance.Access.AddInvite(pubkey, claim)
}
event := nostr.Event{
Kind: AUTH_INVITE,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
nostr.Tag{"claim", claim},
},
}
event.Sign(instance.Secret)
if !yield(stripSignature(event)) {
return return
} }
} }
for event := range instance.Events.QueryEvents(filter, 1000) { for event := range instance.Events.QueryEvents(filter, 1000) {
hTag := event.Tags.Find("h") // We save some ephemeral events for bookkeeping, don't return them
if event.Kind.IsEphemeral() {
continue
}
// Prune group related events if groups are disabled h := GetGroupIDFromEvent(event)
if !instance.Config.Groups.Enabled {
if slices.Contains(nip29.ModerationEventKinds, event.Kind) { if h != "" {
if !instance.Config.Groups.Enabled {
continue continue
} }
if slices.Contains(nip29.MetadataEventKinds, event.Kind) { if !instance.HasGroupAccess(h, pubkey) {
continue
}
if hTag != nil {
continue continue
} }
} }
// Prune events that the user doesn't have access to if !instance.Config.Groups.Enabled && slices.Contains(nip29.MetadataEventKinds, event.Kind) {
if hTag != nil && !instance.HasGroupAccess(hTag[1], pubkey) {
continue continue
} }
+3 -3
View File
@@ -1,9 +1,9 @@
package zooid package zooid
import ( import (
"fiatjaf.com/nostr"
"math/rand" "math/rand"
"strings" "strings"
"fiatjaf.com/nostr"
) )
const ( const (
@@ -71,7 +71,7 @@ func HasTag(tags nostr.Tags, key string) bool {
} }
func IsEmptyEvent(event nostr.Event) bool { func IsEmptyEvent(event nostr.Event) bool {
var zeroID nostr.ID var zeroID nostr.ID
return event.ID == zeroID return event.ID == zeroID
} }