diff --git a/README.md b/README.md index 2d2efc5..49a1fb4 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Defines roles that can be assigned to different users and attendant privileges. - `pubkeys` - a list of nostr pubkeys this role is assigned to. - `can_invite` - a boolean indicating whether this role can invite new members to the relay by requesting a `kind 28935` claim. Defaults to `false`. See [access requests](https://github.com/nostr-protocol/nips/pull/1079) for more details. +- `can_manage` - a boolean indicating whether this role can use NIP 86 relay management and administer NIP 29 groups. Defaults to `false`. A special `[roles.member]` heading may be used to configure policies for all relay users (that is, pubkeys assigned to other roles, or who have redeemed an invite code). @@ -89,6 +90,7 @@ can_invite = true [roles.admin] pubkeys = ["d9254d9898fd4728f7e2b32b87520221a50f6b8b97d935d7da2de8923988aa6d"] +can_manage = true ``` ## Development diff --git a/zooid/access.go b/zooid/access.go new file mode 100644 index 0000000..af80925 --- /dev/null +++ b/zooid/access.go @@ -0,0 +1,174 @@ +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()})) +} diff --git a/zooid/blossom.go b/zooid/blossom.go index 3a780cd..2546210 100644 --- a/zooid/blossom.go +++ b/zooid/blossom.go @@ -15,14 +15,15 @@ import ( func EnableBlossom(instance *Instance) { fs := afero.NewOsFs() + dir := Env("DATA") + "/media" - if err := fs.MkdirAll(Env("DATA"), 0755); err != nil { + if err := fs.MkdirAll(dir, 0755); err != nil { log.Fatal("🚫 error creating blossom path:", err) } store := &EventStore{ Schema: &Schema{ - Name: slug.Make(config.Self.Schema) + "__blossom", + Name: slug.Make(instance.Config.Self.Schema) + "__blossom", }, } @@ -34,7 +35,7 @@ func EnableBlossom(instance *Instance) { } backend.StoreBlob = func(ctx context.Context, sha256 string, ext string, body []byte) error { - file, err := fs.Create(instance.Config.Blossom.Directory + "/" + sha256) + file, err := fs.Create(dir + "/" + sha256) if err != nil { return err } @@ -47,7 +48,7 @@ func EnableBlossom(instance *Instance) { } backend.LoadBlob = func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, *url.URL, error) { - file, err := fs.Open(instance.Config.Blossom.Directory + "/" + sha256) + file, err := fs.Open(dir + "/" + sha256) if err != nil { return nil, nil, err } @@ -55,7 +56,7 @@ func EnableBlossom(instance *Instance) { } backend.DeleteBlob = func(ctx context.Context, sha256 string, ext string) error { - return fs.Remove(instance.Config.Blossom.Directory + "/" + sha256) + return fs.Remove(dir + "/" + sha256) } backend.RejectUpload = func(ctx context.Context, auth *nostr.Event, size int, ext string) (bool, string, int) { @@ -63,7 +64,7 @@ func EnableBlossom(instance *Instance) { return true, "file too large", 413 } - if auth == nil || !instance.IsMember(auth.PubKey) { + if auth == nil || !instance.HasAccess(auth.PubKey) { return true, "unauthorized", 403 } @@ -71,7 +72,7 @@ func EnableBlossom(instance *Instance) { } backend.RejectGet = func(ctx context.Context, auth *nostr.Event, sha256 string, ext string) (bool, string, int) { - if auth == nil || !instance.IsMember(auth.PubKey) { + if auth == nil || !instance.HasAccess(auth.PubKey) { return true, "unauthorized", 403 } @@ -79,7 +80,7 @@ func EnableBlossom(instance *Instance) { } backend.RejectList = func(ctx context.Context, auth *nostr.Event, pubkey nostr.PubKey) (bool, string, int) { - if auth == nil || !instance.IsMember(auth.PubKey) { + if auth == nil || !instance.HasAccess(auth.PubKey) { return true, "unauthorized", 403 } @@ -87,7 +88,7 @@ func EnableBlossom(instance *Instance) { } backend.RejectDelete = func(ctx context.Context, auth *nostr.Event, sha256 string, ext string) (bool, string, int) { - if auth == nil || !instance.IsMember(auth.PubKey) { + if auth == nil || !instance.HasAccess(auth.PubKey) { return true, "unauthorized", 403 } diff --git a/zooid/config.go b/zooid/config.go index 616d532..c05db10 100644 --- a/zooid/config.go +++ b/zooid/config.go @@ -1,15 +1,24 @@ package zooid import ( + "fiatjaf.com/nostr" "fmt" "github.com/BurntSushi/toml" "path/filepath" + "slices" ) +type Role struct { + Pubkeys []string `toml:"pubkeys"` + CanInvite bool `toml:"can_invite"` + CanManage bool `toml:"can_manage"` +} + type Config struct { Self struct { Name string `toml:"name"` Icon string `toml:"icon"` + Schema string `toml:"schema"` Secret string `toml:"secret"` Pubkey string `toml:"pubkey"` Description string `toml:"description"` @@ -27,19 +36,10 @@ type Config struct { } `toml:"management"` Blossom struct { - Enabled bool `toml:"enabled"` - Directory string `toml:"directory"` + Enabled bool `toml:"enabled"` } `toml:"blossom"` - Roles map[string]struct { - Pubkeys []string `toml:"pubkeys"` - CanInvite bool `toml:"can_invite"` - } `toml:"roles"` - - Data struct { - Events string `toml:"events"` - Blossom string `toml:"blossom"` - } `toml:"data"` + Roles map[string]Role `toml:"roles"` } func LoadConfig(hostname string) (*Config, error) { @@ -52,3 +52,36 @@ func LoadConfig(hostname string) (*Config, error) { return &config, nil } + +func (config *Config) IsSelf(pubkey nostr.PubKey) bool { + return pubkey == nostr.MustSecretKeyFromHex(config.Self.Secret).Public() +} + +func (config *Config) IsOwner(pubkey nostr.PubKey) bool { + return pubkey == nostr.MustPubKeyFromHex(config.Self.Pubkey) +} + +func (config *Config) GetRolesForPubkey(pubkey nostr.PubKey) []Role { + roles := make([]Role, 0) + for name, role := range config.Roles { + if name == "member" { + roles = append(roles, role) + } + + if slices.Contains(role.Pubkeys, pubkey.Hex()) { + roles = append(roles, role) + } + } + + return roles +} + +func (config *Config) CanManage(roles []Role) bool { + for _, role := range roles { + if role.CanManage { + return true + } + } + + return false +} diff --git a/zooid/env.go b/zooid/env.go new file mode 100644 index 0000000..5b06181 --- /dev/null +++ b/zooid/env.go @@ -0,0 +1,34 @@ +package zooid + +import ( + "os" + "strings" + "sync" +) + +var ( + env map[string]string + envOnce sync.Once +) + +func Env(k string, fallback ...string) (v string) { + envOnce.Do(func() { + env = make(map[string]string) + + env["PORT"] = "3334" + env["DATA"] = "./data" + + for _, item := range os.Environ() { + parts := strings.SplitN(item, "=", 2) + env[parts[0]] = parts[1] + } + }) + + v = env[k] + + if v == "" && len(fallback) > 0 { + v = fallback[0] + } + + return v +} diff --git a/zooid/events.go b/zooid/events.go index 529423d..a57e444 100644 --- a/zooid/events.go +++ b/zooid/events.go @@ -13,6 +13,7 @@ import ( ) type EventStore struct { + Config *Config Schema *Schema FTSAvailable bool } diff --git a/zooid/instance.go b/zooid/instance.go index 4587625..7952a1e 100644 --- a/zooid/instance.go +++ b/zooid/instance.go @@ -5,7 +5,6 @@ import ( "iter" "log" "net/http" - "os" "sync" "fiatjaf.com/nostr" @@ -17,7 +16,9 @@ import ( type Instance struct { Host string Config *Config + Secret nostr.SecretKey Events eventstore.Store + Access *AccessStore Relay *khatru.Relay } @@ -32,15 +33,23 @@ func MakeInstance(hostname string) (*Instance, error) { return nil, err } - // secret, err := nostr.SecretKeyFromHex(config.Self.Secret) - // if err != nil { - // return nil, err - // } + secret, err := nostr.SecretKeyFromHex(config.Self.Secret) + if err != nil { + return nil, err + } instance := &Instance{ Host: hostname, Config: config, + Secret: secret, Events: &EventStore{ + Config: config, + Schema: &Schema{ + Name: slug.Make(config.Self.Schema) + "__events", + }, + }, + Access: &AccessStore{ + Config: config, Schema: &Schema{ Name: slug.Make(config.Self.Schema) + "__events", }, @@ -84,14 +93,14 @@ func MakeInstance(hostname string) (*Instance, error) { // Initialize stuff - if err := os.MkdirAll(instance.Config.Data.Events, 0755); err != nil { - log.Fatal("Failed to create event store path:", err) - } - if err := instance.Events.Init(); err != nil { log.Fatal("Failed to initialize event store:", err) } + if err := instance.Access.Init(); err != nil { + log.Fatal("Failed to initialize access store:", err) + } + return instance, nil } @@ -121,18 +130,59 @@ func GetInstance(hostname string) (*Instance, error) { // Utility methods -func (instance *Instance) IsMember(pubkey nostr.PubKey) bool { - pubkeyStr := pubkey.String() - for _, role := range instance.Config.Roles { - for _, pk := range role.Pubkeys { - if pk == pubkeyStr { - return true - } - } +func (instance *Instance) HasAccess(pubkey nostr.PubKey) bool { + if instance.Config.IsOwner(pubkey) { + return true } + + if instance.Config.IsSelf(pubkey) { + return true + } + + roles := instance.Config.GetRolesForPubkey(pubkey) + + if instance.Config.CanManage(roles) { + return true + } + + if len(instance.Access.GetRedemptionsByPubkey(pubkey)) > 0 { + return true + } + return false } +func (instance *Instance) GenerateInviteEvents(ctx context.Context, filter nostr.Filter) []*nostr.Event { + pubkey, ok := khatru.GetAuthed(ctx) + + if !ok { + return []*nostr.Event{} + } + + var claim string + + 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) + + return []*nostr.Event{&event} +} + // Handlers func (instance *Instance) OnConnect(ctx context.Context) { diff --git a/zooid/util.go b/zooid/util.go index 378f373..b127ad9 100644 --- a/zooid/util.go +++ b/zooid/util.go @@ -1,9 +1,8 @@ package zooid import ( - "os" + "math/rand" "strings" - "sync" ) const ( @@ -11,29 +10,52 @@ const ( AUTH_INVITE = 28935 ) -var ( - env map[string]string - envOnce sync.Once -) - -func Env(k string, fallback ...string) (v string) { - envOnce.Do(func() { - env = make(map[string]string) - - env["PORT"] = "3334" - env["DATA"] = "./data" - - for _, item := range os.Environ() { - parts := strings.SplitN(item, "=", 2) - env[parts[0]] = parts[1] - } - }) - - v = env[k] - - if v == "" && len(fallback) > 0 { - v = fallback[0] +func First[T any](s []T) T { + if len(s) == 0 { + var zero T + return zero } - return v + return s[0] +} + +func Keys[K comparable, V any](m map[K]V) []K { + ks := make([]K, len(m)) + + i := 0 + for k := range m { + ks[i] = k + i++ + } + + return ks +} + +func Filter[T any](ss []T, test func(T) bool) (ret []T) { + for _, s := range ss { + if test(s) { + ret = append(ret, s) + } + } + + return +} + +const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func RandomString(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + + return string(b) +} + +func Split(s string, delim string) []string { + if s == "" { + return []string{} + } else { + return strings.Split(s, delim) + } }