forked from coracle/zooid
Manage access just by storing ephemeral events
This commit is contained in:
-174
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user