From d72efde704ce41a7d3eec82f508d054e15be63c9 Mon Sep 17 00:00:00 2001 From: Dionysos <75300347+ice-dionysos@users.noreply.github.com> Date: Mon, 18 Nov 2024 17:06:46 +0100 Subject: [PATCH] event filters (#19) --- database/query/.testdata/application.yaml | 4 +- database/query/client.go | 30 +- database/query/client_functions.go | 12 + database/query/global.go | 6 +- database/query/query.go | 70 ++- database/query/query_iterator.go | 21 +- database/query/query_iterator_test.go | 12 + database/query/query_test.go | 3 +- database/query/query_where_builder.go | 280 ++++++++--- database/query/query_where_builder_db_test.go | 4 - database/query/query_where_builder_filter.go | 120 +++++ .../query/query_where_builder_stmt_test.go | 88 +++- .../query/query_where_dependencies_parser.go | 303 ++++++++++++ .../query_where_dependencies_parser_test.go | 445 ++++++++++++++++++ go.mod | 7 +- go.sum | 14 +- model/event.go | 2 +- model/model.go | 4 +- server/http/.testdata/application.yaml | 5 +- server/ws/.testdata/application.yaml | 62 +-- 20 files changed, 1337 insertions(+), 155 deletions(-) create mode 100644 database/query/query_where_builder_filter.go create mode 100644 database/query/query_where_dependencies_parser.go create mode 100644 database/query/query_where_dependencies_parser_test.go diff --git a/database/query/.testdata/application.yaml b/database/query/.testdata/application.yaml index 529f959..214d442 100644 --- a/database/query/.testdata/application.yaml +++ b/database/query/.testdata/application.yaml @@ -1,6 +1,6 @@ # SPDX-License-Identifier: ice License 1.0 - database: query: - url: ":memory:" \ No newline at end of file + url: ":memory:" + private-key: "eff8260efe5ffe4a73a757d794e673d7497a16621ab7efb387446c65ec8488a9" diff --git a/database/query/client.go b/database/query/client.go index c29dffc..6981e3f 100644 --- a/database/query/client.go +++ b/database/query/client.go @@ -19,9 +19,9 @@ import ( type ( dbClient struct { *sqlx.DB - - stmtCacheMx *sync.RWMutex - stmtCache map[string]*sqlx.NamedStmt + relayPrivateKey string + stmtCacheMx *sync.RWMutex + stmtCache map[string]*sqlx.NamedStmt } ) @@ -57,6 +57,21 @@ func init() { Ptr: sqlAttestationUpdateIsAllowed, Pure: true, }, + { + Name: "subzero_nostr_tag_a_get_kind", + Ptr: sqlTagAGetAt(0), + Pure: true, + }, + { + Name: "subzero_nostr_tag_a_get_pk", + Ptr: sqlTagAGetAt(1), + Pure: true, + }, + { + Name: "subzero_nostr_tag_a_get_dtag", + Ptr: sqlTagAGetAt(2), + Pure: true, + }, } for idx := range funcTable { @@ -109,6 +124,15 @@ func openDatabase(target string, runDDL bool) *dbClient { return client } +func (db *dbClient) WithPrivateKey(privateKey string) *dbClient { + if privateKey == "" { + panic("private key is empty") + } + db.relayPrivateKey = privateKey + + return db +} + func (db *dbClient) exec(ctx context.Context, sql string, arg any) (rowsAffected int64, err error) { var ( hash = hashSQL(sql) diff --git a/database/query/client_functions.go b/database/query/client_functions.go index 945fb21..13956c8 100644 --- a/database/query/client_functions.go +++ b/database/query/client_functions.go @@ -4,6 +4,7 @@ package query import ( "encoding/json" + "strings" "github.com/cockroachdb/errors" @@ -52,3 +53,14 @@ func sqlAttestationUpdateIsAllowed(oldTagsJSON, newTagsJSON string) (bool, error return model.AttestationUpdateIsAllowed(oldTags, newTags), nil } + +func sqlTagAGetAt(pos int) func(string) string { + return func(tag string) string { + fields := strings.Split(tag, ":") + if len(fields) <= pos { + return "" + } + + return fields[pos] + } +} diff --git a/database/query/global.go b/database/query/global.go index 8b1bff6..426904a 100644 --- a/database/query/global.go +++ b/database/query/global.go @@ -22,14 +22,16 @@ var ( type ( config struct { - URL string `yaml:"url"` + URL string `yaml:"url"` + PrivateKey string `yaml:"private-key"` } ) func MustInit(ctx context.Context) { globalDB.Once.Do(func() { globalConfig = cfg.MustGet[config]() - globalDB.Client = openDatabase(globalConfig.URL, true) + globalDB.Client = openDatabase(globalConfig.URL, true). + WithPrivateKey(globalConfig.PrivateKey) go globalDB.Client.StartExpiredEventsCleanup(ctx) go func() { diff --git a/database/query/query.go b/database/query/query.go index dd442f4..0c7e4bb 100644 --- a/database/query/query.go +++ b/database/query/query.go @@ -188,6 +188,23 @@ func (db *dbClient) executeBatch(ctx context.Context, req *databaseBatchRequest) return err } +func (db *dbClient) MustSignEvent(event *databaseEvent) { + if event.PubKey != "" { + event.Tags = append(event.Tags, model.Tag{"p", event.PubKey}) + } + if event.ID != "" { + event.Tags = append(event.Tags, model.Tag{"i", event.ID, "event"}) + } + if event.MasterPubKey != "" && event.MasterPubKey != event.PubKey { + event.Tags = append(event.Tags, model.Tag{model.CustomIONTagOnBehalfOf, event.MasterPubKey}) + } + + err := event.Sign(db.relayPrivateKey) + if err != nil { + panic(errors.Wrap(err, "failed to sign event")) + } +} + func (db *dbClient) SelectEvents(ctx context.Context, subscription *model.Subscription) EventIterator { limit := int64(selectDefaultBatchLimit) hasLimitFilter := subscription != nil && len(subscription.Filters) > 0 && subscription.Filters[0].Limit > 0 @@ -197,6 +214,7 @@ func (db *dbClient) SelectEvents(ctx context.Context, subscription *model.Subscr it := &eventIterator{ oneShot: hasLimitFilter && limit <= selectDefaultBatchLimit, + signer: db, fetch: func(pivot int64) (*sqlx.Rows, error) { if limit <= 0 { return nil, nil @@ -268,7 +286,7 @@ func generateEventsCountClause(subscription *model.Subscription) (sqlQuery strin } } - where, params, err := generateEventsWhereClause(subscription) + where, _, params, err := generateEventsWhereClause(subscription) if err != nil { return "", nil, errors.Wrap(err, "failed to generate events where clause") } @@ -296,7 +314,7 @@ func (db *dbClient) CountEvents(ctx context.Context, subscription *model.Subscri } func generateSelectEventsSQL(subscription *model.Subscription, systemCreatedAtPivot, limit int64) (sql string, params map[string]any, err error) { - where, params, err := generateEventsWhereClause(subscription) + whereMain, depClause, params, err := generateEventsWhereClause(subscription) if err != nil { return "", nil, errors.Wrap(err, "failed to generate events where clause") } @@ -313,32 +331,72 @@ func generateSelectEventsSQL(subscription *model.Subscription, systemCreatedAtPi limitQuery = " limit :mainlimit" } - return ` + if depClause == "" { + return ` select e.kind, e.created_at, e.system_created_at, e.id, e.pubkey, + e.master_pubkey, e.sig, e.content, tags as jtags from events e -where ` + systemCreatedAtFilter + `(` + where + `) +where ` + systemCreatedAtFilter + `(` + whereMain + `) order by system_created_at desc ` + limitQuery, params, nil + } + + return ` +with eventsmain as ( + select + e.kind, + e.created_at, + e.system_created_at, + e.id, + e.pubkey, + e.master_pubkey, + e.sig, + e.content, + e.d_tag, + tags as jtags + from + events e + where ` + systemCreatedAtFilter + `(` + whereMain + `) +order by + system_created_at desc +` + limitQuery + ` +) +select + * +from + eventsmain +` + depClause, params, nil } -func generateEventsWhereClause(subscription *model.Subscription) (clause string, params map[string]any, err error) { +func generateEventsWhereClause(subscription *model.Subscription) (clauseMain, clauseDeps string, params map[string]any, err error) { var filters []model.Filter if subscription != nil { filters = subscription.Filters } - return newWhereBuilder().Build(filters...) + builder := newWhereBuilder() + clauseMain, params, err = builder.Build(filters...) + if err != nil { + return "", "", nil, err + } + + clauseDeps, params, err = builder.BuildDependencies("eventsmain") + if err != nil { + return "", "", nil, err + } + + return clauseMain, clauseDeps, params, nil } func (db *dbClient) deleteExpiredEvents(ctx context.Context) error { diff --git a/database/query/query_iterator.go b/database/query/query_iterator.go index a10da55..1432e21 100644 --- a/database/query/query_iterator.go +++ b/database/query/query_iterator.go @@ -14,10 +14,16 @@ import ( type EventIterator iter.Seq2[*model.Event, error] -type eventIterator struct { - fetch func(pivot int64) (*sqlx.Rows, error) - oneShot bool -} +type ( + eventSigner interface { + MustSignEvent(*databaseEvent) + } + eventIterator struct { + fetch func(pivot int64) (*sqlx.Rows, error) + signer eventSigner + oneShot bool + } +) func (it *eventIterator) decodeTags(jtags string) (tags model.Tags, err error) { if len(jtags) == 0 { @@ -42,6 +48,13 @@ func (it *eventIterator) scanEvent(rows *sqlx.Rows) (_ *databaseEvent, err error return nil, errors.Wrap(err, "failed to decode tags") } + if ev.Sig == "" { + switch ev.Kind { + case model.KindDVMCount, model.CustomIONKindRelayListMetadata: + it.signer.MustSignEvent(&ev) + } + } + return &ev, nil } diff --git a/database/query/query_iterator_test.go b/database/query/query_iterator_test.go index 0c3d9bb..a1ac46f 100644 --- a/database/query/query_iterator_test.go +++ b/database/query/query_iterator_test.go @@ -13,6 +13,18 @@ import ( "github.com/ice-blockchain/subzero/model" ) +func helperSelectEvents(t *testing.T, db *dbClient, filters ...model.Filter) (events []*model.Event) { + t.Helper() + + for ev, err := range db.SelectEvents(context.Background(), &model.Subscription{Filters: filters}) { + require.NoError(t, err) + require.NotNil(t, ev) + events = append(events, ev) + } + + return events +} + func helperSelectEventsN(t *testing.T, db *dbClient, limit int) (events map[string]*model.Event) { t.Helper() diff --git a/database/query/query_test.go b/database/query/query_test.go index abc139b..64205a9 100644 --- a/database/query/query_test.go +++ b/database/query/query_test.go @@ -35,7 +35,8 @@ func helperGetStoredEventsAll(t *testing.T, client *dbClient, ctx context.Contex func helperNewDatabase(t interface{ Helper() }) *dbClient { t.Helper() - return openDatabase(":memory:", true) + return openDatabase(":memory:", true). + WithPrivateKey(nostr.GeneratePrivateKey()) } func TestMain(m *testing.M) { diff --git a/database/query/query_where_builder.go b/database/query/query_where_builder.go index 489843b..8d9b66a 100644 --- a/database/query/query_where_builder.go +++ b/database/query/query_where_builder.go @@ -42,16 +42,19 @@ var ( type ( whereBuilder struct { - Params map[string]any + Params map[string]any + Dependencies []*filterDependencies strings.Builder } databaseFilterSearch struct { nostr.Filter - Expiration *bool - Videos *bool - Images *bool - Quotes *bool - References *bool + Expiration *bool + Videos *bool + Images *bool + Quotes *bool + References *bool + TagMarkers []databaseFilterMarker + Dependencies []*filterDependencies } databaseFilterDelete struct { Author string @@ -62,6 +65,10 @@ type ( TagD string } } + databaseFilterMarker struct { + Tag string + Marker string + } filterBuilder struct { Name string EventIds []string @@ -219,6 +226,21 @@ func (w *whereBuilder) maybeOR() { w.WriteString(" OR ") } +func (w *whereBuilder) applyFilterTagMarkers(filter *filterBuilder, markers []databaseFilterMarker) { + if len(markers) == 0 { + return + } + + for id, marker := range markers { + w.maybeAND() + w.WriteString("EXISTS (select true from event_tags where event_id = e.id AND event_tag_key = :") + w.WriteString(w.addParam(filter.Name, "mtag"+strconv.Itoa(id), marker.Tag)) + w.WriteString(" AND event_tag_value3 = :") + w.WriteString(w.addParam(filter.Name, "mtagvalue"+strconv.Itoa(id), marker.Marker)) + w.WriteRune(')') + } +} + func (w *whereBuilder) applyFilterTags(filter *filterBuilder, tags model.TagMap) { const valuesMax = 21 @@ -235,16 +257,7 @@ func (w *whereBuilder) applyFilterTags(filter *filterBuilder, tags model.TagMap) } tagID++ - if filter.HasEvents() { - // We already have some IDs, so we need to check if they have the tag. - w.WriteString("EXISTS (select 42 from event_tags where ") - w.WriteString(filter.BuildEvents(w)) - w.maybeAND() - } else { - // No IDs, so select all events that belong to the given tag. - w.WriteString("+id IN (select event_id from event_tags where ") - } - w.WriteString("event_tag_key = :") + w.WriteString("EXISTS (select event_id from event_tags where event_id = e.id AND event_tag_key = :") w.WriteString(w.addParam(filter.Name, "tag"+strconv.Itoa(tagID), tag)) for i, value := range values { @@ -263,6 +276,7 @@ func isFilterEmpty(filter *databaseFilterSearch) bool { len(filter.Kinds) == 0 && len(filter.Authors) == 0 && len(filter.Tags) == 0 && + len(filter.TagMarkers) == 0 && filter.Since == nil && filter.Until == nil && filter.Expiration == nil && @@ -447,67 +461,221 @@ func (w *whereBuilder) applyFilter(idx int, filter *databaseFilterSearch) error return err } w.applyFilterTags(builder, filter.Tags) + w.applyFilterTagMarkers(builder, filter.TagMarkers) w.WriteRune(')') // End the filter section. return nil } -func parseNostrFilter(filter model.Filter) *databaseFilterSearch { - f := databaseFilterSearch{ - Filter: filter, - } - flags := []struct { - Name string - Flag **bool - }{ - {"expiration", &f.Expiration}, - {"videos", &f.Videos}, - {"images", &f.Images}, - {"quotes", &f.Quotes}, - {"references", &f.References}, - } +func (w *whereBuilder) createWhereForDepFilter(filterID, cteName, field string, filter *filterDependenciesStart) string { + var sb strings.Builder + + sb.WriteString("select ") + sb.WriteString(field) + sb.WriteString(" from ") + sb.WriteString(cteName) + sb.WriteString(" where ") + sb.WriteString(cteName) + sb.WriteString(".kind = :") + sb.WriteString(w.addParam(filterID, "kind", filter.Kind)) + if filter.ProfileBadges { + sb.WriteString(" AND d_tag='profile_badges'") + } + if filter.Tag != "" { + sb.WriteString(" AND EXISTS (select 42 from event_tags where event_id = ") + sb.WriteString(cteName) + sb.WriteString(".id AND event_tag_key = :") + sb.WriteString(w.addParam(filterID, "tag", filter.Tag)) + sb.WriteString(")") + } + + return sb.String() +} - for idx := range flags { - flagStart := strings.Index(strings.ToLower(f.Search), flags[idx].Name+":") - if flagStart == -1 { - continue +func (w *whereBuilder) applyDepFilter(filterID, cteName string, filter *filterDependencies) { + if filter.Reduce.Kinds[0] == model.KindDVMCount { + w.WriteString(` +union all +select + 6400, + 0, + 0, + f.reference_id, + coalesce(evr.pubkey, ''), + coalesce(evr.master_pubkey, ''), + '', + cast(f.value as text) as content, + '', + '[]' as jtags +from + event_counters f +left join events evr on f.reference_id = evr.id +where +`) + } else { + w.WriteString(` +union all +select + e.kind, + e.created_at, + e.system_created_at, + e.id, + e.pubkey, + e.master_pubkey, + e.sig, + e.content, + e.d_tag, + tags as jtags +from + events e +where +`) + w.WriteString(`e.id not in (select `) + w.WriteString(cteName) + w.WriteString(`.id from `) + w.WriteString(cteName) + w.WriteString(`) AND `) + } + + switch filter.Reduce.Kinds[0] { + case nostr.KindTextNote, nostr.KindRepost, nostr.KindReaction: + w.WriteString("e.kind = :") + w.WriteString(w.addParam(filterID, "rkind", filter.Reduce.Kinds[0])) + if filter.Reduce.Author != "" { + w.WriteString(" AND :") + w.WriteString(w.addParam(filterID, "author", filter.Reduce.Author)) + w.WriteString(" IN (e.pubkey, e.master_pubkey) AND ") } - - flagEnd := strings.Index(f.Search[flagStart:], " ") - if flagEnd == -1 { - flagEnd = len(f.Search) - } else { - flagEnd += flagStart + tag := filter.Reduce.Tag + if tag == "" { + // Repost, reaction. + tag = "e" } - - value := strings.ToLower(f.Search[flagStart+len(flags[idx].Name)+1 : flagEnd]) - if value == "true" || value == "1" || value == "on" || value == "yes" { - on := true - *flags[idx].Flag = &on - } else if value == "false" || value == "0" || value == "off" || value == "no" { - off := false - *flags[idx].Flag = &off - } else { - // Do not now how to parse the value. - continue + w.WriteString("e.id in (select event_id from event_tags where event_tag_key = :") + w.WriteString(w.addParam(filterID, "rtag", tag)) + w.WriteString(" and event_tag_value1 in (") + w.WriteString(w.createWhereForDepFilter(filterID, cteName, "id", &filter.Start)) + w.WriteRune(')') + if filter.Reduce.Context != "" { + w.WriteString(" and event_tag_value3 = :") + w.WriteString(w.addParam(filterID, "rcontext", filter.Reduce.Context)) } + w.WriteString(" group by event_tag_value1) AND e.hidden=0") + + case nostr.KindBadgeDefinition: + startFilter := w.createWhereForDepFilter(filterID, cteName, "id", &filter.Start) + w.WriteString("e.id in ((select event_tag_value1 from event_tags where event_id in (") + w.WriteString(startFilter) + w.WriteString(") and event_tag_key = 'e'),") + w.WriteString(`(select ee.id from (select subzero_nostr_tag_a_get_pk(event_tag_value1) as pk, subzero_nostr_tag_a_get_dtag(event_tag_value1) as name from event_tags where event_id in (`) + w.WriteString(startFilter) + w.WriteString(") and event_tag_key = 'a') badge, events ee where badge.pk in (ee.pubkey, ee.master_pubkey) and ee.d_tag = badge.name and ee.kind = 30009 and hidden = 0)) AND e.hidden=0") + + case nostr.KindRelayListMetadata: + w.WriteString("e.kind = :") + w.WriteString(w.addParam(filterID, "rkind", filter.Reduce.Kinds[0])) + w.WriteString(" AND ( master_pubkey IN (") + w.WriteString(w.createWhereForDepFilter(filterID, cteName, "master_pubkey", &filter.Start)) + w.WriteString(") OR pubkey IN (") + w.WriteString(w.createWhereForDepFilter(filterID, cteName, "pubkey", &filter.Start)) + w.WriteString(")) AND e.hidden=0") + w.WriteString(` +union all +select + 20002, + 0 as created_at, + 0 as system_created_at, + '' as id, + e.pubkey, + e.master_pubkey, + '' as sig, + '' as content, + '' as d_tag, + '[]' as jtags +from + events e +inner join `) + w.WriteString(cteName) + w.WriteString(` on e.id = `) + w.WriteString(cteName) + w.WriteString(`.id where e.kind =:`) + w.WriteString(w.addParam(filterID, "kind", filter.Start.Kind)) + if filter.Start.Tag != "" { + w.WriteString(" AND EXISTS (select true from event_tags where event_id = ") + w.WriteString(cteName) + w.WriteString(".id AND event_tag_key = :") + w.WriteString(w.addParam(filterID, "tag", filter.Start.Tag)) + w.WriteString(")") + } + w.WriteString(` AND +not exists (select true from events subev where subev.kind = 10002 and +( + (subev.pubkey = e.pubkey and subev.hidden = 0) or + (subev.master_pubkey = e.master_pubkey and subev.hidden = 0) or + (subev.master_pubkey = e.pubkey and subev.hidden = 0) or + (subev.pubkey = e.master_pubkey and subev.hidden = 0) +)) and e.hidden=0 +group by e.pubkey, e.master_pubkey`) + + case nostr.KindProfileMetadata: + w.WriteString("e.kind = :") + w.WriteString(w.addParam(filterID, "rkind", filter.Reduce.Kinds[0])) + w.WriteString(" AND ( master_pubkey IN (") + w.WriteString(w.createWhereForDepFilter(filterID, cteName, "master_pubkey", &filter.Start)) + w.WriteString(") OR pubkey IN (") + w.WriteString(w.createWhereForDepFilter(filterID, cteName, "pubkey", &filter.Start)) + w.WriteString(")) AND e.hidden=0") + + case model.KindDVMCount: + w.WriteString("f.kind = :") + w.WriteString(w.addParam(filterID, "rkind", filter.Reduce.Kinds[1])) + var refType string + switch { + case filter.Reduce.Tag == "q": + refType = "quote" + + case filter.Reduce.Context == "content" || filter.Reduce.Tag == "e": + // Empty. + + case filter.Reduce.Context == "root" || filter.Reduce.Context == "reply": + refType = "reply" + } + w.WriteString(" AND f.reference_type = :") + w.WriteString(w.addParam(filterID, "rref", refType)) + w.WriteString(" AND f.reference_id IN (") + w.WriteString(w.createWhereForDepFilter(filterID, cteName, "id", &filter.Start)) + w.WriteString(")") + } +} - // Remove flag:value from the search string. - f.Search = strings.TrimSpace(f.Search[:flagStart] + f.Search[flagEnd:]) +func (w *whereBuilder) BuildDependencies(cteName string) (sql string, params map[string]any, err error) { + if len(w.Dependencies) == 0 { + return "", w.Params, nil } - f.Search = strings.TrimSpace(f.Search) + w.Reset() + for idx, filter := range w.Dependencies { + filterID := "dep" + cteName + strconv.Itoa(idx) + "_" + w.applyDepFilter(filterID, cteName, filter) + } - return &f + return w.String(), w.Params, nil } func (w *whereBuilder) Build(filters ...model.Filter) (sql string, params map[string]any, err error) { for idx := range filters { w.maybeOR() - if err := w.applyFilter(idx, parseNostrFilter(filters[idx])); err != nil { + dbFilter, err := parseNostrFilter(filters[idx]) + if err != nil { + return "", nil, errors.Wrapf(err, "failed to parse filter %d", idx) + } + if err := w.applyFilter(idx, dbFilter); err != nil { return "", nil, errors.Wrapf(err, "failed to apply filter %d", idx) } + if dbFilter.Dependencies != nil { + w.Dependencies = append(w.Dependencies, dbFilter.Dependencies...) + } } if w.Len() > 0 { diff --git a/database/query/query_where_builder_db_test.go b/database/query/query_where_builder_db_test.go index 6831873..0f47dd1 100644 --- a/database/query/query_where_builder_db_test.go +++ b/database/query/query_where_builder_db_test.go @@ -123,13 +123,9 @@ func generateKind() int { nostr.KindChannelMuteUser, nostr.KindPatch, nostr.KindFileMetadata, - nostr.KindSimpleGroupAddUser, nostr.KindSimpleGroupRemoveUser, nostr.KindSimpleGroupEditMetadata, - nostr.KindSimpleGroupAddPermission, - nostr.KindSimpleGroupRemovePermission, nostr.KindSimpleGroupDeleteEvent, - nostr.KindSimpleGroupEditGroupStatus, nostr.KindSimpleGroupCreateGroup, nostr.KindSimpleGroupJoinRequest, nostr.KindZapRequest, diff --git a/database/query/query_where_builder_filter.go b/database/query/query_where_builder_filter.go new file mode 100644 index 0000000..f92f974 --- /dev/null +++ b/database/query/query_where_builder_filter.go @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: ice License 1.0 + +package query + +import ( + "strings" + + "github.com/ice-blockchain/subzero/model" +) + +func parseNostrFilterFlags(f *databaseFilterSearch) *databaseFilterSearch { + flags := []struct { + Name string + Flag **bool + }{ + {"expiration", &f.Expiration}, + {"videos", &f.Videos}, + {"images", &f.Images}, + {"quotes", &f.Quotes}, + {"references", &f.References}, + } + + for idx := range flags { + flagStart := strings.Index(strings.ToLower(f.Search), flags[idx].Name+":") + if flagStart == -1 { + continue + } + + flagEnd := strings.Index(f.Search[flagStart:], " ") + if flagEnd == -1 { + flagEnd = len(f.Search) + } else { + flagEnd += flagStart + } + + value := strings.ToLower(f.Search[flagStart+len(flags[idx].Name)+1 : flagEnd]) + if value == "true" || value == "1" || value == "on" || value == "yes" { + on := true + *flags[idx].Flag = &on + } else if value == "false" || value == "0" || value == "off" || value == "no" { + off := false + *flags[idx].Flag = &off + } else { + // Do not now how to parse the value. + continue + } + + // Remove flag:value from the search string. + f.Search = strings.TrimSpace(f.Search[:flagStart] + f.Search[flagEnd:]) + } + + return f +} + +func parseNostrFilterTagMarkers(f *databaseFilterSearch) *databaseFilterSearch { + const tagMarker = `marker:` + var tagMarkerOffset int + for strings.Contains(f.Search[tagMarkerOffset:], tagMarker) { + tagMarkerStart := strings.Index(f.Search[tagMarkerOffset:], tagMarker) + if tagMarkerStart < 1 { + // Should be on the 1st position, not zero. + break + } + tagMarkerEnd := strings.Index(f.Search[tagMarkerOffset+tagMarkerStart+len(tagMarker):], " ") + if tagMarkerEnd == -1 { + tagMarkerEnd = len(f.Search[tagMarkerOffset:]) + } else if tagMarkerEnd > 0 { + tagMarkerEnd += tagMarkerStart + len(tagMarker) + } + if x := strings.TrimSpace(f.Search[tagMarkerOffset+tagMarkerStart-1 : tagMarkerOffset+tagMarkerStart]); x != "" { + f.TagMarkers = append(f.TagMarkers, databaseFilterMarker{ + Tag: x, + Marker: f.Search[tagMarkerOffset+tagMarkerStart+len(tagMarker) : tagMarkerOffset+tagMarkerEnd], + }) + if tagMarkerEnd < len(f.Search) { + tagMarkerEnd++ + } + f.Search = strings.TrimSpace(f.Search[:tagMarkerStart-1] + f.Search[tagMarkerEnd:]) + } else { + tagMarkerOffset += tagMarkerStart + len(tagMarker) + } + } + return f +} + +func parseNostrFilterDependencies(f *databaseFilterSearch) (*databaseFilterSearch, error) { + const dependenciesPrefix = "include:dependencies:" + for strings.Contains(f.Search, dependenciesPrefix) { + depStrStart := strings.Index(f.Search, dependenciesPrefix) + if depStrStart == -1 { + break + } + depStrEnd := strings.Index(f.Search[depStrStart:], " ") + if depStrEnd == -1 { + depStrEnd = len(f.Search) + } + dep, err := parseDepRequest(f.Search[depStrStart+len(dependenciesPrefix) : depStrEnd]) + if err != nil { + return nil, err + } + f.Dependencies = append(f.Dependencies, dep) + f.Search = strings.TrimSpace(f.Search[:depStrStart] + f.Search[depStrEnd:]) + } + return f, nil +} + +func parseNostrFilter(filter model.Filter) (*databaseFilterSearch, error) { + f := parseNostrFilterFlags(&databaseFilterSearch{ + Filter: filter, + }) + f = parseNostrFilterTagMarkers(f) + f, err := parseNostrFilterDependencies(f) + if err != nil { + return nil, err + } + + f.Search = strings.TrimSpace(f.Search) + + return f, nil +} diff --git a/database/query/query_where_builder_stmt_test.go b/database/query/query_where_builder_stmt_test.go index eb9ad40..3885656 100644 --- a/database/query/query_where_builder_stmt_test.go +++ b/database/query/query_where_builder_stmt_test.go @@ -18,7 +18,10 @@ func TestIsFilterEmpty(t *testing.T) { f := helperNewFilter(func(apply *model.Filter) { apply.IDs = []string{"123"} }) - require.False(t, isFilterEmpty(parseNostrFilter(f))) + + dbFilter, err := parseNostrFilter(f) + require.NoError(t, err) + require.False(t, isFilterEmpty(dbFilter)) } func TestWhereBuilderEmpty(t *testing.T) { @@ -137,7 +140,7 @@ func TestWhereBuilderSingleWithTags(t *testing.T) { })) t.Logf("stmt: %s (%+v)", q, params) require.NoError(t, err) - require.Len(t, params, 7) + require.Len(t, params, 6) helperEnsureParams(t, q, params) }) t.Run("TwoTagsShink", func(t *testing.T) { @@ -157,7 +160,7 @@ func TestWhereBuilderSingleWithTags(t *testing.T) { require.NoError(t, err) t.Logf("stmt: %s (%+v)", q, params) - require.Len(t, params, 29) + require.Len(t, params, 28) helperEnsureParams(t, q, params) }) } @@ -187,7 +190,7 @@ func TestWhereBuilderMulti(t *testing.T) { q, params, err := builder.Build(filters...) require.NoError(t, err) t.Logf("stmt: %s (%+v)", q, params) - require.Len(t, params, 18) + require.Len(t, params, 16) helperEnsureParams(t, q, params) } @@ -247,23 +250,26 @@ func TestParseNostrFilter(t *testing.T) { t.Parallel() t.Run("Empty", func(t *testing.T) { - f := parseNostrFilter(model.Filter{}) + f, err := parseNostrFilter(model.Filter{}) + require.NoError(t, err) require.Empty(t, f.Filter) require.Nil(t, f.Quotes) require.Nil(t, f.Images) }) t.Run("Images", func(t *testing.T) { - f := parseNostrFilter(model.Filter{ + f, err := parseNostrFilter(model.Filter{ Search: "images:true", }) + require.NoError(t, err) require.Empty(t, f.Filter) require.NotNil(t, f.Images) require.True(t, *f.Images) }) t.Run("ImagesWithQuotes", func(t *testing.T) { - f := parseNostrFilter(model.Filter{ + f, err := parseNostrFilter(model.Filter{ Search: "images:true quoteS:off", }) + require.NoError(t, err) require.NotNil(t, f.Images) require.True(t, *f.Images) require.NotNil(t, f.Quotes) @@ -271,9 +277,10 @@ func TestParseNostrFilter(t *testing.T) { require.Empty(t, f.Filter) }) t.Run("ImagesWithQuotesWithRef", func(t *testing.T) { - f := parseNostrFilter(model.Filter{ + f, err := parseNostrFilter(model.Filter{ Search: "images:true quoteS:off references:yes", }) + require.NoError(t, err) require.NotNil(t, f.Images) require.True(t, *f.Images) require.NotNil(t, f.Quotes) @@ -283,18 +290,20 @@ func TestParseNostrFilter(t *testing.T) { require.Empty(t, f.Filter) }) t.Run("ImagesWithUnknownValue", func(t *testing.T) { - f := parseNostrFilter(model.Filter{ + f, err := parseNostrFilter(model.Filter{ Search: "images:true quoteS:foo", }) + require.NoError(t, err) require.NotNil(t, f.Images) require.True(t, *f.Images) require.Nil(t, f.Quotes) require.Equal(t, "quoteS:foo", f.Filter.Search) }) t.Run("ImagesWithQuotesWithRefWithContent", func(t *testing.T) { - f := parseNostrFilter(model.Filter{ + f, err := parseNostrFilter(model.Filter{ Search: "images:true quoteS:off some content here references:yes", }) + require.NoError(t, err) require.NotNil(t, f.Images) require.True(t, *f.Images) require.NotNil(t, f.Quotes) @@ -303,6 +312,65 @@ func TestParseNostrFilter(t *testing.T) { require.True(t, *f.References) require.Equal(t, "some content here", f.Filter.Search) }) + t.Run("Image with dependencies", func(t *testing.T) { + f, err := parseNostrFilter(model.Filter{ + Search: "images:true some content here include:dependencies:kind1>kind2", + }) + require.NoError(t, err) + require.NotNil(t, f.Images) + require.True(t, *f.Images) + require.Len(t, f.Dependencies, 1) + require.Equal(t, &filterDependencies{ + Start: filterDependenciesStart{ + Kind: 1, + }, + Reduce: filterDependenciesReduce{ + Kinds: []int{2}, + }, + }, f.Dependencies[0]) + require.Equal(t, "some content here", f.Filter.Search) + }) + t.Run("Image with dependencies in the beginning", func(t *testing.T) { + f, err := parseNostrFilter(model.Filter{ + Search: "include:dependencies:kind1>kind3 some content here2 images:false", + }) + require.NoError(t, err) + require.NotNil(t, f.Images) + require.False(t, *f.Images) + require.Len(t, f.Dependencies, 1) + require.Equal(t, &filterDependencies{ + Start: filterDependenciesStart{ + Kind: 1, + }, + Reduce: filterDependenciesReduce{ + Kinds: []int{3}, + }, + }, f.Dependencies[0]) + require.Equal(t, "some content here2", f.Filter.Search) + }) + t.Run("E marker with reply and images", func(t *testing.T) { + f, err := parseNostrFilter(model.Filter{ + Search: "images:false some content here emarker:reply", + }) + require.NoError(t, err) + require.NotNil(t, f.Images) + require.False(t, *f.Images) + require.Len(t, f.TagMarkers, 1) + require.Equal(t, "e", f.TagMarkers[0].Tag) + require.Equal(t, "reply", f.TagMarkers[0].Marker) + require.Equal(t, "some content here", f.Filter.Search) + }) + t.Run("Three markers with videos", func(t *testing.T) { + f, err := parseNostrFilter(model.Filter{ + Search: "amarker:aval videos:true some bmarker:bval content here cmarker:cval marker:invalid x", + }) + require.NoError(t, err) + require.NotNil(t, f.Videos) + require.True(t, *f.Videos) + require.Equal(t, "some content here marker:invalid x", f.Filter.Search) + require.Len(t, f.TagMarkers, 3) + require.Equal(t, []databaseFilterMarker{{Tag: "a", Marker: "aval"}, {Tag: "b", Marker: "bval"}, {Tag: "c", Marker: "cval"}}, f.TagMarkers) + }) } func TestBuildLiteFilter(t *testing.T) { diff --git a/database/query/query_where_dependencies_parser.go b/database/query/query_where_dependencies_parser.go new file mode 100644 index 0000000..2cfaf91 --- /dev/null +++ b/database/query/query_where_dependencies_parser.go @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: ice License 1.0 + +package query + +import ( + "strconv" + + "github.com/bzick/tokenizer" + "github.com/cockroachdb/errors" +) + +type ( + token = tokenizer.TokenKey + + filterDependenciesStart struct { + Kind int + ProfileBadges bool + Tag string + } + + filterDependenciesReduce struct { + Kinds []int + Author string + Group bool + Tag string + Context string + } + + filterDependencies struct { + Start filterDependenciesStart + Reduce filterDependenciesReduce + } + + filterSequence struct { + Tokens []token + } +) + +const ( + tokenSearchExpr token = iota + 1 + tokenCondDetail + tokenCondInclude + + tokenLiteralKind + tokenLiteralProfileBadges + tokenLiteralGroup + tokenLiteralContent + tokenLiteralReply + + tokenLiteralTagE + tokenLiteralTagQ +) + +var ( + errDepParserUnexpectedToken = errors.New("unexpected token") + + parserTokens = map[token][]string{ + tokenSearchExpr: {">"}, + tokenCondDetail: {"+"}, + tokenCondInclude: {"@"}, + + tokenLiteralKind: {"kind"}, + tokenLiteralGroup: {"group"}, + tokenLiteralContent: {"content"}, + tokenLiteralProfileBadges: {"profile_badges"}, + tokenLiteralReply: {"reply", "root"}, + + tokenLiteralTagE: {"e"}, + tokenLiteralTagQ: {"q"}, + } + + parserKnownSequences = []filterSequence{ + // kind30008+profile_badges>kind30009>kind8. + { + Tokens: []token{ + tokenLiteralKind, tokenizer.TokenInteger, + tokenCondDetail, + tokenLiteralProfileBadges, + tokenSearchExpr, + tokenLiteralKind, tokenizer.TokenInteger, + tokenSearchExpr, + tokenLiteralKind, tokenizer.TokenInteger, + tokenizer.TokenUndef, + }, + }, + // kind1+q>kind10002. + { + Tokens: []token{ + tokenLiteralKind, tokenizer.TokenInteger, + tokenCondDetail, + tokenLiteralTagQ, + tokenSearchExpr, + tokenLiteralKind, tokenizer.TokenInteger, + tokenizer.TokenUndef, + }, + }, + // kind1>$logged_in_user_pubkey@kind1+e+root/reply. + { + Tokens: []token{ + tokenLiteralKind, tokenizer.TokenInteger, + tokenSearchExpr, + tokenizer.TokenKeyword, + tokenCondInclude, + tokenLiteralKind, tokenizer.TokenInteger, + tokenCondDetail, + tokenLiteralTagE, + tokenCondDetail, + tokenLiteralReply, + tokenizer.TokenUndef, + }, + }, + // kind1>$logged_in_user_pubkey@kind1+q. + { + Tokens: []token{ + tokenLiteralKind, tokenizer.TokenInteger, + tokenSearchExpr, + tokenizer.TokenKeyword, + tokenCondInclude, + tokenLiteralKind, tokenizer.TokenInteger, + tokenCondDetail, + tokenLiteralTagQ, + tokenizer.TokenUndef, + }, + }, + // kind1>$logged_in_user_pubkey@kind6 / kind1>$logged_in_user_pubkey@kind7. + { + Tokens: []token{ + tokenLiteralKind, tokenizer.TokenInteger, + tokenSearchExpr, + tokenizer.TokenKeyword, + tokenCondInclude, + tokenLiteralKind, tokenizer.TokenInteger, + tokenizer.TokenUndef, + }, + }, + // kind1>kind6400+kind1+group+root/reply. + { + Tokens: []token{ + tokenLiteralKind, tokenizer.TokenInteger, + tokenSearchExpr, + tokenLiteralKind, tokenizer.TokenInteger, + tokenCondDetail, + tokenLiteralKind, tokenizer.TokenInteger, + tokenCondDetail, + tokenLiteralGroup, + tokenCondDetail, + tokenLiteralReply, + tokenizer.TokenUndef, + }, + }, + // kind1>kind6400+kind6+group+e. + { + Tokens: []token{ + tokenLiteralKind, tokenizer.TokenInteger, + tokenSearchExpr, + tokenLiteralKind, tokenizer.TokenInteger, + tokenCondDetail, + tokenLiteralKind, tokenizer.TokenInteger, + tokenCondDetail, + tokenLiteralGroup, + tokenCondDetail, + tokenLiteralTagE, + tokenizer.TokenUndef, + }, + }, + // kind1>kind6400+kind1+group+q. + { + Tokens: []token{ + tokenLiteralKind, tokenizer.TokenInteger, + tokenSearchExpr, + tokenLiteralKind, tokenizer.TokenInteger, + tokenCondDetail, + tokenLiteralKind, tokenizer.TokenInteger, + tokenCondDetail, + tokenLiteralGroup, + tokenCondDetail, + tokenLiteralTagQ, + tokenizer.TokenUndef, + }, + }, + // kind1>kind6400+kind7+group+content. + { + Tokens: []token{ + tokenLiteralKind, tokenizer.TokenInteger, + tokenSearchExpr, + tokenLiteralKind, tokenizer.TokenInteger, + tokenCondDetail, + tokenLiteralKind, tokenizer.TokenInteger, + tokenCondDetail, + tokenLiteralGroup, + tokenCondDetail, + tokenLiteralContent, + tokenizer.TokenUndef, + }, + }, + // kind1>kind0 / kind6>kind10002. + { + Tokens: []token{ + tokenLiteralKind, tokenizer.TokenInteger, + tokenSearchExpr, + tokenLiteralKind, tokenizer.TokenInteger, + tokenizer.TokenUndef, + }, + }, + } + + dependenciesParser *tokenizer.Tokenizer +) + +func init() { + dependenciesParser = tokenizer.New() + dependenciesParser.SetWhiteSpaces([]byte{' ', '\t'}) + dependenciesParser. + AllowNumberUnderscore(). + AllowKeywordUnderscore(). + AllowNumbersInKeyword() + for k, v := range parserTokens { + dependenciesParser.DefineTokens(k, v) + } +} + +func (s *filterSequence) Parse(stream *tokenizer.Stream) (*filterDependencies, error) { + var filter filterDependencies + + if s == nil { + return nil, errors.Wrap(errDepParserUnexpectedToken, "sequence not found") + } + + tokens := s.Tokens + if tokens[len(tokens)-1] == tokenizer.TokenUndef { + tokens = tokens[:len(tokens)-1] + } + + start := true + for _, token := range tokens { + if !stream.CurrentToken().Is(token) || !stream.IsValid() { + panic("unexpected token in the parsed stream: " + strconv.Itoa(int(token))) + } + + switch token { + case tokenLiteralContent, tokenLiteralReply: + filter.Reduce.Context = stream.CurrentToken().ValueString() + + case tokenLiteralTagE, tokenLiteralTagQ: + if start { + filter.Start.Tag = stream.CurrentToken().ValueString() + } else { + filter.Reduce.Tag = stream.CurrentToken().ValueString() + } + + case tokenLiteralGroup: + filter.Reduce.Group = true + + case tokenSearchExpr: + start = false + + case tokenizer.TokenKeyword: + filter.Reduce.Author = stream.CurrentToken().ValueString() + + case tokenLiteralProfileBadges: + filter.Start.ProfileBadges = true + + case tokenLiteralKind: + val := int(stream.NextToken().ValueInt64()) + if start { + filter.Start.Kind = val + } else { + filter.Reduce.Kinds = append(filter.Reduce.Kinds, val) + } + } + + stream.GoNext() + } + + return &filter, nil +} + +func parseDepRequest(in string) (*filterDependencies, error) { + stream := dependenciesParser.ParseString(in) + defer stream.Close() + + if !stream.IsValid() { + return nil, errors.Wrap(errDepParserUnexpectedToken, "stream is not valid") + } + + var currentSequence *filterSequence + for i := range parserKnownSequences { + seq := &parserKnownSequences[i] + if !(stream.CurrentToken().Is(seq.Tokens[0]) && stream.IsNextSequence(seq.Tokens[1:]...)) { + continue + } + + currentSequence = seq + break + } + + f, err := currentSequence.Parse(stream) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse filter expression %q", in) + } + + return f, nil +} diff --git a/database/query/query_where_dependencies_parser_test.go b/database/query/query_where_dependencies_parser_test.go new file mode 100644 index 0000000..c9850f5 --- /dev/null +++ b/database/query/query_where_dependencies_parser_test.go @@ -0,0 +1,445 @@ +// SPDX-License-Identifier: ice License 1.0 + +package query + +import ( + "context" + "testing" + + "github.com/nbd-wtf/go-nostr" + "github.com/stretchr/testify/require" + + "github.com/ice-blockchain/subzero/model" +) + +func TestParseDepRequest(t *testing.T) { + t.Parallel() + + cases := []struct { + Input string + Expected filterDependencies + Err error + }{ + { + Input: "kind30008+profile_badges>kind30009>kind8", + Expected: filterDependencies{ + Start: filterDependenciesStart{ + Kind: 30008, + ProfileBadges: true, + }, + Reduce: filterDependenciesReduce{ + Kinds: []int{30009, 8}, + }, + }, + }, + { + Input: "kind6>kind10002", + Expected: filterDependencies{ + Start: filterDependenciesStart{ + Kind: 6, + }, + Reduce: filterDependenciesReduce{ + Kinds: []int{10002}, + }, + }, + }, + { + Input: "kind1+q>kind10002", + Expected: filterDependencies{ + Start: filterDependenciesStart{ + Kind: 1, + Tag: "q", + }, + Reduce: filterDependenciesReduce{ + Kinds: []int{10002}, + }, + }, + }, + { + Input: "kind1>publickey@kind1+e+root", + Expected: filterDependencies{ + Start: filterDependenciesStart{ + Kind: 1, + }, + Reduce: filterDependenciesReduce{ + Kinds: []int{1}, + Author: "publickey", + Tag: "e", + Context: "root", + }, + }, + }, + { + Input: "kind1>publickey@kind1+e+reply", + Expected: filterDependencies{ + Start: filterDependenciesStart{ + Kind: 1, + }, + Reduce: filterDependenciesReduce{ + Kinds: []int{1}, + Author: "publickey", + Tag: "e", + Context: "reply", + }, + }, + }, + { + Input: "kind1>publickey@kind1+q", + Expected: filterDependencies{ + Start: filterDependenciesStart{ + Kind: 1, + }, + Reduce: filterDependenciesReduce{ + Kinds: []int{1}, + Author: "publickey", + Tag: "q", + }, + }, + }, + { + Input: "kind1>publickey@kind6", + Expected: filterDependencies{ + Start: filterDependenciesStart{ + Kind: 1, + }, + Reduce: filterDependenciesReduce{ + Kinds: []int{6}, + Author: "publickey", + }, + }, + }, + { + Input: "kind1>kind6400+kind1+group+root", + Expected: filterDependencies{ + Start: filterDependenciesStart{ + Kind: 1, + }, + Reduce: filterDependenciesReduce{ + Kinds: []int{6400, 1}, + Group: true, + Context: "root", + }, + }, + }, + { + Input: "kind1>kind6400+kind1+group+q", + Expected: filterDependencies{ + Start: filterDependenciesStart{ + Kind: 1, + }, + Reduce: filterDependenciesReduce{ + Kinds: []int{6400, 1}, + Group: true, + Tag: "q", + }, + }, + }, + { + Input: "kind1>kind6400+kind7+group+content", + Expected: filterDependencies{ + Start: filterDependenciesStart{ + Kind: 1, + }, + Reduce: filterDependenciesReduce{ + Kinds: []int{6400, 7}, + Group: true, + Context: "content", + }, + }, + }, + { + Input: "kind1>kind6400+kind7+group+foo", + Err: errDepParserUnexpectedToken, + }, + { + Input: "", + Err: errDepParserUnexpectedToken, + }, + } + + for _, c := range cases { + t.Run(c.Input, func(t *testing.T) { + filter, err := parseDepRequest(c.Input) + if c.Err != nil { + require.ErrorIs(t, err, c.Err) + } else { + require.Equal(t, &c.Expected, filter) + } + }) + } +} + +func TestSelectWithDependencies(t *testing.T) { + t.Parallel() + db := helperNewDatabase(t) + defer db.Close() + + t.Run("kind1>kind0", func(t *testing.T) { + var ev model.Event + + ev.ID = "id1" + ev.Kind = nostr.KindProfileMetadata + ev.PubKey = "pk1" + ev.CreatedAt = 1 + err := db.AcceptEvents(context.Background(), &ev) + require.NoError(t, err) + + ev.ID = "id2" + ev.Kind = nostr.KindTextNote + ev.PubKey = "pk1" + ev.CreatedAt = 2 + ev.Content = "content of the note" + err = db.AcceptEvents(context.Background(), &ev) + require.NoError(t, err) + + events := helperSelectEvents(t, db, model.Filter{ + IDs: []string{"id2"}, + Search: "include:dependencies:kind1>kind0", + }) + require.Len(t, events, 2) + require.Equal(t, "id2", events[0].ID) + require.Equal(t, "id1", events[1].ID) + }) + t.Run("kind1>$logged_in_user_pubkey@kind1+e+root", func(t *testing.T) { + var ev model.Event + + ev.ID = "t2id1" + ev.Kind = nostr.KindTextNote + ev.PubKey = "t2pk1" + ev.CreatedAt = 1 + ev.Content = "text note 1" + err := db.AcceptEvents(context.Background(), &ev) + require.NoError(t, err) + + ev.ID = "t2id4" + ev.Kind = nostr.KindTextNote + ev.PubKey = "t2pk1" + ev.CreatedAt = 1 + err = db.AcceptEvents(context.Background(), &ev) + require.NoError(t, err) + + ev.ID = "t2id2" + ev.Kind = nostr.KindTextNote + ev.PubKey = "t2pk2" + ev.CreatedAt = 2 + ev.Tags = model.Tags{ + {"e", "t2id1", "", "root"}, + } + err = db.AcceptEvents(context.Background(), &ev) + require.NoError(t, err) + + ev.ID = "t2id3" + ev.Kind = nostr.KindTextNote + ev.PubKey = "t2pk2" + ev.CreatedAt = 3 + ev.Tags = model.Tags{ + {"e", "t2id1", "", "root"}, + } + err = db.AcceptEvents(context.Background(), &ev) + require.NoError(t, err) + + ev.ID = "t2id5" + ev.Kind = nostr.KindTextNote + ev.PubKey = "t2pk2" + ev.CreatedAt = 4 + ev.Tags = model.Tags{ + {"e", "id2", "", "root"}, + } + err = db.AcceptEvents(context.Background(), &ev) + require.NoError(t, err) + + events := helperSelectEvents(t, db, model.Filter{ + IDs: []string{"t2id1", "id2"}, + Search: "include:dependencies:kind1>t2pk2@kind1+e+root", + }) + require.Len(t, events, 4) + require.Equal(t, "t2id1", events[0].ID) + require.Equal(t, "id2", events[1].ID) + require.Equal(t, "t2id2", events[2].ID) + require.Equal(t, "t2id5", events[3].ID) + }) + t.Run("kind1>kind6400+kind7+group+content", func(t *testing.T) { + var ev model.Event + + ev.ID = "t3id1" + ev.Kind = nostr.KindReaction + ev.PubKey = "t3pk1" + ev.CreatedAt = 13 + ev.Content = "+" + ev.Tags = model.Tags{ + {"e", "t2id2"}, + } + err := db.AcceptEvents(context.Background(), &ev) + require.NoError(t, err) + + ev.ID = "t3id2" + ev.Kind = nostr.KindReaction + ev.PubKey = "t3pk2" + ev.CreatedAt = 13 + ev.Content = "+" + ev.Tags = model.Tags{ + {"e", "t2id3"}, + } + err = db.AcceptEvents(context.Background(), &ev) + require.NoError(t, err) + + events := helperSelectEvents(t, db, model.Filter{ + IDs: []string{"t2id2", "t2id3"}, + Search: "include:dependencies:kind1>kind6400+kind7+group+content", + }) + require.Len(t, events, 4) + require.Equal(t, "t2id3", events[0].ID) + require.Equal(t, "t2id2", events[1].ID) + for _, ev := range events[2:] { + t.Logf("dvm event: %+v", ev) + require.Equal(t, model.KindDVMCount, ev.Kind) + require.Equal(t, "1", ev.Content) + require.Len(t, ev.Tags, 2) + valid, err := ev.CheckSignature() + require.NoError(t, err) + require.Truef(t, valid, "signature is invalid: %+v", ev) + } + require.Equal(t, "t2pk2", events[2].Tags[0].Value()) + require.Equal(t, "t2id2", events[2].Tags[1].Value()) + require.Equal(t, "t2pk2", events[3].Tags[0].Value()) + require.Equal(t, "t2id3", events[3].Tags[1].Value()) + }) + t.Run("kind30008+profile_badges>kind30009>kind8", func(t *testing.T) { + var ev model.Event + + // badge definition of `testbadge`. + ev.ID = "t4id1" + ev.Kind = nostr.KindBadgeDefinition + ev.PubKey = "t4pk1" + ev.CreatedAt = 1 + ev.Tags = model.Tags{ + {"d", "testbadge"}, + {"name", "Test Badge"}, + } + err := db.AcceptEvents(context.Background(), &ev) + require.NoError(t, err) + + // t4pk2 wants to award t4pk3 the badge `testbadge`. + ev.ID = "t4id2" + ev.Kind = nostr.KindBadgeAward + ev.PubKey = "t4pk2" + ev.CreatedAt = 2 + ev.Tags = model.Tags{ + {"a", "30009:t4pk1:testbadge"}, + } + err = db.AcceptEvents(context.Background(), &ev) + require.NoError(t, err) + + // t4pk3 accepts the badge and updates their profile. + ev.ID = "t4id3" + ev.Kind = nostr.KindProfileBadges + ev.PubKey = "t4pk3" + ev.CreatedAt = 3 + ev.Tags = model.Tags{ + {"d", "profile_badges"}, + {"a", "30009:t4pk1:testbadge"}, + {"e", "t4id2"}, + } + err = db.AcceptEvents(context.Background(), &ev) + + events := helperSelectEvents(t, db, model.Filter{ + Authors: []string{"t4pk3"}, + Search: "include:dependencies:kind30008+profile_badges>kind30009>kind8", + }) + require.Len(t, events, 3) + require.Equal(t, nostr.KindProfileBadges, events[0].Kind) + require.Equal(t, nostr.KindBadgeDefinition, events[1].Kind) + require.Equal(t, nostr.KindBadgeAward, events[2].Kind) + }) + t.Run("Combined dependencies", func(t *testing.T) { + events := helperSelectEvents(t, db, model.Filter{ + Authors: []string{"t4pk3", "pk1"}, + Search: "include:dependencies:kind1>kind0 include:dependencies:kind30008+profile_badges>kind30009>kind8", + }) + require.Len(t, events, 5) // 2 from the first search, 3 from the second. + }) + t.Run("kind10002", func(t *testing.T) { + t.Run("kind6>kind10002", func(t *testing.T) { + var ev1, ev2 model.Event + + ev1.ID = "t6id1" + ev1.Kind = nostr.KindRepost + ev1.PubKey = "t6pk1" + ev1.CreatedAt = 13 + ev1.Tags = model.Tags{ + {"e", "t2id3"}, + } + ev2.ID = "t6id2" + ev2.Kind = nostr.KindRepost + ev2.PubKey = "t6pk2" + ev2.CreatedAt = 14 + ev2.Tags = model.Tags{ + {"e", "t2id5"}, + } + err := db.AcceptEvents(context.Background(), &ev1, &ev2) + require.NoError(t, err) + + evRelayMetadata := model.Event{} + evRelayMetadata.ID = "t6id3" + evRelayMetadata.Kind = nostr.KindRelayListMetadata + evRelayMetadata.PubKey = "t6pk2" + evRelayMetadata.CreatedAt = 15 + evRelayMetadata.Tags = model.Tags{ + {"r", "wss://foo.bar"}, + } + err = db.AcceptEvents(context.Background(), &evRelayMetadata) + require.NoError(t, err) + events := helperSelectEvents(t, db, model.Filter{ + IDs: []string{"t6id1", "t6id2", "t2id5"}, + Search: "include:dependencies:kind6>kind10002", + }) + require.Len(t, events, 5) // 2 reposts, 1 note, 2 relay metadata. + for i, k := range []int{nostr.KindRepost, nostr.KindRepost, nostr.KindTextNote, nostr.KindRelayListMetadata, model.CustomIONKindRelayListMetadata} { + require.Equalf(t, k, events[i].Kind, "event %d: %v", i, events[i]) + if events[i].Kind == model.CustomIONKindRelayListMetadata { + ok, err := events[i].CheckSignature() + require.NoError(t, err) + require.True(t, ok) + } + } + }) + t.Run("kind1+q>kind10002", func(t *testing.T) { + var ev1, ev2 model.Event + + ev1.ID = "t7id1" + ev1.Kind = nostr.KindTextNote + ev1.PubKey = "t7pk2" + ev1.CreatedAt = 15 + ev1.Tags = model.Tags{ + {"q", "t2id3"}, + } + ev2.ID = "t7id2" + ev2.Kind = nostr.KindRelayListMetadata + ev2.PubKey = "t7pk2" + ev2.CreatedAt = 15 + ev2.Tags = model.Tags{ + {"r", "wss://foo.bar2"}, + } + + err := db.AcceptEvents(context.Background(), &ev1, &ev2) + require.NoError(t, err) + + ev1.ID = "t7id3" + ev1.Kind = nostr.KindTextNote + ev1.PubKey = "t7pk3" + ev1.CreatedAt = 16 + ev1.Tags = model.Tags{} + err = db.AcceptEvents(context.Background(), &ev1) + require.NoError(t, err) + + events := helperSelectEvents(t, db, model.Filter{ + IDs: []string{"t7id1", "t7id3", "t2id5"}, + Search: "include:dependencies:kind1+q>kind10002", + }) + require.Len(t, events, 4) // 3 notes, 1 relay metadata. + for i, k := range []int{nostr.KindTextNote, nostr.KindTextNote, nostr.KindTextNote, nostr.KindRelayListMetadata} { + require.Equalf(t, k, events[i].Kind, "event %d: %v", i, events[i]) + } + }) + }) +} diff --git a/go.mod b/go.mod index 27d438b..11a88cf 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ replace ( ) require ( + github.com/bzick/tokenizer v1.4.6 github.com/cockroachdb/errors v1.11.3 github.com/cubewise-code/go-mime v0.0.0-20200519001935-8c5762b177d8 github.com/davidbyttow/govips/v2 v2.15.0 @@ -24,7 +25,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.24 github.com/mitchellh/mapstructure v1.5.0 github.com/mxschmitt/golang-combinations v1.2.0 - github.com/nbd-wtf/go-nostr v0.42.0 + github.com/nbd-wtf/go-nostr v0.42.2 github.com/puzpuzpuz/xsync/v3 v3.4.0 github.com/quic-go/quic-go v0.48.1 github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 @@ -66,7 +67,7 @@ require ( github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.22.1 // indirect + github.com/go-playground/validator/v10 v10.23.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/goccy/go-json v0.10.3 // indirect @@ -126,7 +127,7 @@ require ( golang.org/x/term v0.26.0 // indirect golang.org/x/text v0.20.0 // indirect golang.org/x/tools v0.27.0 // indirect - google.golang.org/protobuf v1.35.1 // indirect + google.golang.org/protobuf v1.35.2 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 94a9b33..01c17ed 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/bytedance/sonic v1.12.4/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKz github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bzick/tokenizer v1.4.6 h1:ztFweUnABG/Ov1ZxDK7pEJsIzdjo8V4ZT8myqGTVKmw= +github.com/bzick/tokenizer v1.4.6/go.mod h1:HYrKg9GGNb0/MCf7eGmz6ulvsxFfgyN+Ve3MqV2h5Zs= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= @@ -86,8 +88,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= -github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -197,8 +199,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mxschmitt/golang-combinations v1.2.0 h1:V5E7MncIK8Yr1SL/SpdqMuSquFsfoIs5auI7Y3n8z14= github.com/mxschmitt/golang-combinations v1.2.0/go.mod h1:RCm5eR03B+JrBOMRDLsKZWShluXdrHu+qwhPEJ0miBM= -github.com/nbd-wtf/go-nostr v0.42.0 h1:EofWfXEhKic9AYVf4RHuXZr+kKUZE2jVyJtJByNe1rE= -github.com/nbd-wtf/go-nostr v0.42.0/go.mod h1:FBa4FBJO7NuANvkeKSlrf0BIyxGufmrUbuelr6Q4Ick= +github.com/nbd-wtf/go-nostr v0.42.2 h1:X8vpfLutvmyxqjsroKPHdIyPliNa6sYD8+CA0kDVySw= +github.com/nbd-wtf/go-nostr v0.42.2/go.mod h1:FBa4FBJO7NuANvkeKSlrf0BIyxGufmrUbuelr6Q4Ick= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oasisprotocol/curve25519-voi v0.0.0-20230904125328-1f23a7beb09a h1:dlRvE5fWabOchtH7znfiFCcOvmIYgOeAS5ifBXBlh9Q= github.com/oasisprotocol/curve25519-voi v0.0.0-20230904125328-1f23a7beb09a/go.mod h1:hVoHR2EVESiICEMbg137etN/Lx+lSrHPTD39Z/uE+2s= @@ -421,8 +423,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/model/event.go b/model/event.go index 6fb8201..468ed70 100644 --- a/model/event.go +++ b/model/event.go @@ -234,7 +234,7 @@ func (v *EventEnvelope) UnmarshalJSON(data []byte) error { } func (v EventEnvelope) MarshalJSON() ([]byte, error) { - w := jwriter.Writer{} + w := jwriter.Writer{NoEscapeHTML: true} w.RawString(`["EVENT",`) if v.SubscriptionID != nil { w.RawString(`"` + *v.SubscriptionID + `"`) diff --git a/model/model.go b/model/model.go index 7456e65..30672ac 100644 --- a/model/model.go +++ b/model/model.go @@ -38,7 +38,9 @@ var ( ) const ( - CustomIONKindAttestation = 10_100 + CustomIONKindAttestation = 10_100 + CustomIONKindRelayListMetadata = 20_002 + KindDVMCount = 6400 ) const ( diff --git a/server/http/.testdata/application.yaml b/server/http/.testdata/application.yaml index eb07c52..085b81c 100644 --- a/server/http/.testdata/application.yaml +++ b/server/http/.testdata/application.yaml @@ -59,13 +59,16 @@ server: fixture: tls-cert: *ws-cert tls-key: *ws-key + database: query: url: ":memory:" + private-key: "eff8260efe5ffe4a73a757d794e673d7497a16621ab7efb387446c65ec8488a9" + storage: private-key: f2e7f1829027cf347d3ddb90e40890fcffb1ca99a3fc2564a120286570b690e548ac550ea62ab27c8c85d1f862b779b8c7dd09d733403dd9a3104be42f8ddb7b ion-storage-config-url: https://ton.org/testnet-global.config.json absolute-root-storage-path: ../../.test-uploads external-adnl-address: 127.0.0.1 external-adnl-port: 12345 - debug: true \ No newline at end of file + debug: true diff --git a/server/ws/.testdata/application.yaml b/server/ws/.testdata/application.yaml index cb8dc30..bc56c97 100644 --- a/server/ws/.testdata/application.yaml +++ b/server/ws/.testdata/application.yaml @@ -60,58 +60,10 @@ server: tls-key: *ws-key dvm: - tls-cert: | - -----BEGIN CERTIFICATE----- - MIID0TCCArmgAwIBAgIUJ66CoEOD8Ud1CJpgmxdw+zgx+uEwDQYJKoZIhvcNAQEL - BQAwbTEYMBYGA1UEAwwPbG9jYWxob3N0LmxvY2FsMQswCQYDVQQGEwJVUzERMA8G - A1UEBwwIWW91ckNpdHkxEjAQBgNVBAgMCVlvdXJTdGF0ZTEdMBsGA1UECgwURXhh - bXBsZS1DZXJ0aWZpY2F0ZXMwHhcNMjQxMDI1MDg1NDIxWhcNMjYxMDI1MDg1NDIx - WjBtMRgwFgYDVQQDDA9sb2NhbGhvc3QubG9jYWwxCzAJBgNVBAYTAlVTMREwDwYD - VQQHDAhZb3VyQ2l0eTESMBAGA1UECAwJWW91clN0YXRlMR0wGwYDVQQKDBRFeGFt - cGxlLUNlcnRpZmljYXRlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB - AOJqJlbncScX8311xIj3EJQ4HJs9bGIfbfZIVJq2s8Cc+Mx6Ze7GU9c/eeH3q8/F - nmfl3pjH+3UEOxmu3nR0oJLephLaNDSsVNjjo3P0ExEBr7SaKVpASPvNN3NzKgD2 - qOPOZODFGbws9raUk0GxoEWdTRkMDlgs9tc02fUvzHvxTuWqFV5dGdbASjHVqPtz - MvtY37byrBxQuWLgfir9kxKvgqGhtiXd92+Y5DJ0HJ2E6gcU8yL3GraLDi5PqdiL - xtfxBGHYaOwTuQK8uJdZJq3r3goWExImwwpUGDIy5TGqKvV6bW/FWf2b4P3nhNtt - 6nLxwxHNLS1YqltUnn2U/RECAwEAAaNpMGcwHQYDVR0OBBYEFNJwFyBrOgJxT3Tw - oFz0OC5Q850/MB8GA1UdIwQYMBaAFNJwFyBrOgJxT3TwoFz0OC5Q850/MA8GA1Ud - EwEB/wQFMAMBAf8wFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3DQEBCwUA - A4IBAQAz7xU4mu2IY6pFq9eT+9UXTVkl4LDI+oJrc8bIdmtm2nCaseNT4ys8rfLz - Fkp6PgDazO5ysRTtiu8XR8JfrxkkUUZvfs30nVwi3JHsUQ4kAyUwcSCMuR6IpK59 - wjclTVV6t3S1phbbVT7kpdPTVxAHHL7wvEdb26KU45o8abXmaW/zXrdVkdvY/gVx - nSsmZGcs0hGXr2tKY317rDioFXIiVJQi+aD74cJaQmGh01H2fbSSKPYne8nHdUjD - DJ3nUZjYQEBhd7+KllKkIOvCwnsHDDejl+M3nBUV1cax3rNinX6XA6ifWdi/m9BT - A6iTj8nve86vgG0h2Sa4689Qd8kF - -----END CERTIFICATE----- - tls-key: | - -----BEGIN PRIVATE KEY----- - MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDiaiZW53EnF/N9 - dcSI9xCUOBybPWxiH232SFSatrPAnPjMemXuxlPXP3nh96vPxZ5n5d6Yx/t1BDsZ - rt50dKCS3qYS2jQ0rFTY46Nz9BMRAa+0milaQEj7zTdzcyoA9qjjzmTgxRm8LPa2 - lJNBsaBFnU0ZDA5YLPbXNNn1L8x78U7lqhVeXRnWwEox1aj7czL7WN+28qwcULli - 4H4q/ZMSr4KhobYl3fdvmOQydBydhOoHFPMi9xq2iw4uT6nYi8bX8QRh2GjsE7kC - vLiXWSat694KFhMSJsMKVBgyMuUxqir1em1vxVn9m+D954Tbbepy8cMRzS0tWKpb - VJ59lP0RAgMBAAECggEAB3CFotCMhoRnibK4sD0ZWZwauTF0Z1NROD40r3e7jM5A - 8OvAVS9FmnqZYpF83wEcknKPv5/p6on8Iid9jkCLH9WlNH810hmWg8uTt5qTFM7k - IIWklbSr4UA4kfh8WRdBweh/0Ts7Zv7oOrluOZf1+aDwo1ci0oa5s/pmg1ixz0xf - oP3NapHhEArfcFGvbxxQ6Ro2c3uZ100ARlf3BPPtXIasCTPCPCqGn9LLDkbsfTXz - NEKbqOxyAnJusnE2fCj4asKCJCmKeyCzuszhWiH/tT93tZpsj0WBQmKXF3BNinrn - bwg6Gp7enbhydYDuZ2Mj0XcKz42kOWHBYCPhyX+kcQKBgQDyAZLG3yW73uHmXI45 - sx6mKNpf63p8JZQsMKW4Ve/wR9xatvS+kxvOIGtOxsQbzx1kgkeF8B9LPWROfRTa - TGwEKFFOdNSNOU6AcOodWb+PCgRZy/0Zfer4zFqlEPt9I07O+6aaUqGN8qMGOdKq - 8AcpCT/aHR632hesoCfteKKBmQKBgQDvgcZcGCGbJEnNEcPGFv6lcLW7mS077tR1 - GgdVXu6C3lE7ShePpo+LBH22vRLj6YGs8UEHIrjepzwH+hMklQQFui70OpU+CtHM - tvW6PLxp5C2fKSECfd3KjWebAsaSNxmUMb3lECpHEuZd93sR7xnvHrexRMDfJW2A - UHpmDzByOQKBgEezUaJcUNi1s2ZF+9l6iVUfe3u5z8NieuEQ1LiltX4SPGYgGKkx - +qX6kvB3EXlPxtyVgM1dFFh9au+aPYhj5gjhTR2anbLtzKVfTu29PnUJubpFJsrb - tLdcDGslee1cfafzhSvp2XWt8sEQdqswjF6LIADmach89FCv7sR73mHZAoGBAIWW - ctcnNodcBDX3dKTman5IkXTjiRFmsuXl8LUzUAF6kwBo4R9knafCh4QHjaPFuGiH - 3+dk/Ixj5t3kJA1BeI7hPBNJIbkLO93dJs4L1mORu8iMnjbPkYfKu0EAmVUwmnnI - DMzY6VvfNNKwmNp17JctIPQlDxbnv13UhZCrZDAxAoGBAI3/G2nPUW/IayvgpdeD - 4AAIP4Q+QPNqsN6fJKFFmyabrJCLVEdOE00Upl/9QF7uaGZ/5s1gXSZ9kBVQ6g4z - LEmxIHOYAtmTwLwU+eKFs/3gjVUxSBg4AA0h1Ox/gjA6Hb+aNZHapPW8XH+jF6Ij - RK3+I8t8OXWV3eiFcVaUGUlc - -----END PRIVATE KEY----- - private-key: | - eff8260efe5ffe4a73a757d794e673d7497a16621ab7efb387446c65ec8488a9 \ No newline at end of file + tls-cert: *ws-cert + tls-key: *ws-key + private-key: &pk eff8260efe5ffe4a73a757d794e673d7497a16621ab7efb387446c65ec8488a9 + +database/query: + url: ":memory:" + private-key: *pk