diff --git a/README.md b/README.md index b1abcf2..f00d1d4 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,13 @@ This is a multi-tenant relay based on [Khatru](https://gitworkshop.dev/fiatjaf.c A single zooid instance can run any number of "virtual" relays. The `config` directory can contain any number of configuration files, each of which represents a single virtual relay. +## Environment + +Zooid supports a few environment variables, which configure shared resources, like the web server or sqlite database file. + +- `PORT` - the port the server will listen on for all requests. Defaults to `3334`. +- `DATABASE_PATH` - the location of the database path. Defaults to `./data.db` + ## Configuration Configuration files are written using [toml](https://toml.io). The name of the configuration file should be the hostname the relay serves, for example `relay.example.com`. Config files contain the following sections: @@ -56,13 +63,6 @@ Defines roles that can be assigned to different users and attendant privileges. 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). -### `[data]` - -Contains information related to data persistence. - -- `events` - the location of the sqlite database file used to store events. Defaults to `./data/{my-relay}/events`. -- `media` - the location of the sqlite database file used to store file metadata. Defaults to `./data/{my-relay}/media`. - ### Example The below config file might be saved as `./config/my-relay.example.com` in order to route requests from `wss://my-relay.example.com` to this virtual relay. diff --git a/go.mod b/go.mod index 03d00b6..0fb9910 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.24.1 require ( fiatjaf.com/nostr v0.0.0-20250924142401-59bd3c29fffd github.com/BurntSushi/toml v1.5.0 + github.com/Masterminds/squirrel v1.5.4 + github.com/gosimple/slug v1.15.0 github.com/mattn/go-sqlite3 v1.14.32 github.com/spf13/afero v1.15.0 github.com/stretchr/testify v1.10.0 @@ -12,8 +14,6 @@ require ( require ( github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect - github.com/Masterminds/squirrel v1.5.4 // indirect - github.com/PowerDNS/lmdb-go v1.9.3 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect @@ -23,6 +23,7 @@ require ( github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/fasthttp/websocket v1.5.12 // indirect + github.com/gosimple/unidecode v1.0.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect diff --git a/go.sum b/go.sum index 2089d90..95faea0 100644 --- a/go.sum +++ b/go.sum @@ -31,16 +31,16 @@ github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= +github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= +github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= +github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= @@ -62,8 +62,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc= diff --git a/sqlite/delete.go b/sqlite/delete.go index b0a34bd..5b497e7 100644 --- a/sqlite/delete.go +++ b/sqlite/delete.go @@ -6,7 +6,7 @@ import ( ) func (s *SqliteBackend) DeleteEvent(id nostr.ID) error { - _, err := squirrel.Delete("events").Where(squirrel.Eq{"id": id.Hex()}).RunWith(s.db).Exec() + _, err := squirrel.Delete(s.tmpl("{{.Prefix}}events")).Where(squirrel.Eq{"id": id.Hex()}).RunWith(s.db).Exec() return err } diff --git a/sqlite/lib.go b/sqlite/lib.go index 6d8c3d0..ab30231 100644 --- a/sqlite/lib.go +++ b/sqlite/lib.go @@ -1,8 +1,10 @@ package sqlite import ( + "bytes" "database/sql" "fmt" + "text/template" "fiatjaf.com/nostr/eventstore" _ "github.com/mattn/go-sqlite3" @@ -13,6 +15,7 @@ var _ eventstore.Store = (*SqliteBackend)(nil) type SqliteBackend struct { db *sql.DB Path string + Prefix string FTSAvailable bool } @@ -41,10 +44,20 @@ func (s *SqliteBackend) Init() error { return nil } +func (s *SqliteBackend) tmpl(t string) string { + var buf bytes.Buffer + err := template.Must(template.New("schema").Parse(t)).Execute(&buf, s) + if err != nil { + panic(err) + } + + return buf.String() +} + func (s *SqliteBackend) createSchema() error { // Create basic schema first - basicSchema := ` - CREATE TABLE IF NOT EXISTS events ( + basicSchema := s.tmpl(` + CREATE TABLE IF NOT EXISTS {{.Prefix}}events ( id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, kind INTEGER NOT NULL, @@ -54,23 +67,23 @@ func (s *SqliteBackend) createSchema() error { sig TEXT NOT NULL ); - CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); - CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind); - CREATE INDEX IF NOT EXISTS idx_events_pubkey ON events(pubkey); - CREATE INDEX IF NOT EXISTS idx_events_kind_pubkey ON events(kind, pubkey); - CREATE INDEX IF NOT EXISTS idx_events_kind_pubkey_created_at ON events(kind, pubkey, created_at DESC); + CREATE INDEX IF NOT EXISTS {{.Prefix}}idx_events_created_at ON {{.Prefix}}events(created_at); + CREATE INDEX IF NOT EXISTS {{.Prefix}}idx_events_kind ON {{.Prefix}}events(kind); + CREATE INDEX IF NOT EXISTS {{.Prefix}}idx_events_pubkey ON {{.Prefix}}events(pubkey); + CREATE INDEX IF NOT EXISTS {{.Prefix}}idx_events_kind_pubkey ON {{.Prefix}}events(kind, pubkey); + CREATE INDEX IF NOT EXISTS {{.Prefix}}idx_events_kind_pubkey_created_at ON {{.Prefix}}events(kind, pubkey, created_at DESC); - CREATE TABLE IF NOT EXISTS event_tags ( + CREATE TABLE IF NOT EXISTS {{.Prefix}}event_tags ( event_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, - FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE + FOREIGN KEY (event_id) REFERENCES {{.Prefix}}events(id) ON DELETE CASCADE ); - CREATE INDEX IF NOT EXISTS idx_event_tags_event_id ON event_tags(event_id); - CREATE INDEX IF NOT EXISTS idx_event_tags_key ON event_tags(key); - CREATE INDEX IF NOT EXISTS idx_event_tags_key_value ON event_tags(key, value); - ` + CREATE INDEX IF NOT EXISTS {{.Prefix}}idx_event_tags_event_id ON {{.Prefix}}event_tags(event_id); + CREATE INDEX IF NOT EXISTS {{.Prefix}}idx_event_tags_key ON {{.Prefix}}event_tags(key); + CREATE INDEX IF NOT EXISTS {{.Prefix}}idx_event_tags_key_value ON {{.Prefix}}event_tags(key, value); + `) if _, err := s.db.Exec(basicSchema); err != nil { return fmt.Errorf("failed to create schema: %w", err) @@ -78,23 +91,26 @@ func (s *SqliteBackend) createSchema() error { // Try to create FTS5 schema - if it fails, continue without it ftsSchema := ` - CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5( + CREATE VIRTUAL TABLE IF NOT EXISTS {{.Prefix}}events_fts USING fts5( content, - content='events', + content='{{.Prefix}}events', content_rowid='rowid' ); - CREATE TRIGGER IF NOT EXISTS events_ai AFTER INSERT ON events BEGIN - INSERT INTO events_fts(rowid, content) VALUES (new.rowid, new.content); + CREATE TRIGGER IF NOT EXISTS {{.Prefix}}events_ai AFTER INSERT ON {{.Prefix}}events BEGIN + INSERT INTO {{.Prefix}}events_fts(rowid, content) VALUES (new.rowid, new.content); END; - CREATE TRIGGER IF NOT EXISTS events_ad AFTER DELETE ON events BEGIN - INSERT INTO events_fts(events_fts, rowid, content) VALUES('delete', old.rowid, old.content); + CREATE TRIGGER IF NOT EXISTS {{.Prefix}}events_ad AFTER DELETE ON {{.Prefix}}events BEGIN + INSERT INTO {{.Prefix}}events_fts({{.Prefix}}events_fts, rowid, content) + VALUES('delete', old.rowid, old.content); END; - CREATE TRIGGER IF NOT EXISTS events_au AFTER UPDATE ON events BEGIN - INSERT INTO events_fts(events_fts, rowid, content) VALUES('delete', old.rowid, old.content); - INSERT INTO events_fts(rowid, content) VALUES (new.rowid, new.content); + CREATE TRIGGER IF NOT EXISTS {{.Prefix}}events_au AFTER UPDATE ON {{.Prefix}}events BEGIN + INSERT INTO {{.Prefix}}events_fts({{.Prefix}}events_fts, rowid, content) + VALUES('delete', old.rowid, old.content); + INSERT INTO {{.Prefix}}events_fts(rowid, content) + VALUES (new.rowid, new.content); END; ` diff --git a/sqlite/query.go b/sqlite/query.go index 3bfc56e..956e09a 100644 --- a/sqlite/query.go +++ b/sqlite/query.go @@ -76,12 +76,12 @@ func (s *SqliteBackend) QueryEvents(filter nostr.Filter, maxLimit int) iter.Seq[ func (s *SqliteBackend) buildSelectQuery(filter nostr.Filter, limit int) squirrel.SelectBuilder { qb := squirrel.Select("id", "created_at", "kind", "pubkey", "content", "tags", "sig"). - From("events"). + From(s.tmpl("{{.Prefix}}events")). OrderBy("created_at DESC") // Handle search with FTS (if available) if filter.Search != "" && s.FTSAvailable { - qb = qb.Join("events_fts ON events.rowid = events_fts.rowid"). + qb = qb.Join(s.tmpl("{{.Prefix}}events_fts ON {{.Prefix}}events.rowid = {{.Prefix}}events_fts.rowid")). Where(squirrel.Eq{"events_fts": filter.Search}) } else if filter.Search != "" { // Fallback to LIKE search if FTS not available @@ -128,7 +128,7 @@ func (s *SqliteBackend) buildSelectQuery(filter nostr.Filter, limit int) squirre } subQuery := squirrel.Select("event_id"). - From("event_tags"). + From(s.tmpl("{{.Prefix}}event_tags")). Where(squirrel.Eq{"key": tagKey}). Where(squirrel.Eq{"value": tagValueInterfaces}) diff --git a/sqlite/save.go b/sqlite/save.go index af75d81..6892949 100644 --- a/sqlite/save.go +++ b/sqlite/save.go @@ -13,7 +13,7 @@ import ( func (s *SqliteBackend) SaveEvent(evt nostr.Event) error { // Check if event already exists var existingID string - qb := squirrel.Select("id").From("events").Where(squirrel.Eq{"id": evt.ID.Hex()}) + qb := squirrel.Select("id").From(s.tmpl("{{.Prefix}}events")).Where(squirrel.Eq{"id": evt.ID.Hex()}) err := qb.RunWith(s.db).QueryRow().Scan(&existingID) if err == nil { // Event already exists @@ -27,7 +27,7 @@ func (s *SqliteBackend) SaveEvent(evt nostr.Event) error { } // Insert the event - insertQb := squirrel.Insert("events"). + insertQb := squirrel.Insert(s.tmpl("{{.Prefix}}events")). Columns("id", "created_at", "kind", "pubkey", "content", "tags", "sig"). Values( evt.ID.Hex(), @@ -48,7 +48,7 @@ func (s *SqliteBackend) SaveEvent(evt nostr.Event) error { // Insert single-letter tags into event_tags table for _, tag := range evt.Tags { if len(tag) >= 2 && len(tag[0]) == 1 { - tagQb := squirrel.Insert("event_tags"). + tagQb := squirrel.Insert(s.tmpl("{{.Prefix}}event_tags")). Columns("event_id", "key", "value"). Values(evt.ID.Hex(), tag[0], tag[1]) diff --git a/sqlite/sqlite_test.go b/sqlite/sqlite_test.go index f6d6792..7115e6d 100644 --- a/sqlite/sqlite_test.go +++ b/sqlite/sqlite_test.go @@ -12,7 +12,8 @@ func TestSqliteFlow(t *testing.T) { os.RemoveAll("/tmp/sqlitetest.db") sb := &SqliteBackend{ - Path: "/tmp/sqlitetest.db", + Path: "/tmp/sqlitetest.db", + Prefix: "prefix", } err := sb.Init() assert.NoError(t, err) diff --git a/zooid/instance.go b/zooid/instance.go index 78b0b9d..d8f735b 100644 --- a/zooid/instance.go +++ b/zooid/instance.go @@ -11,6 +11,7 @@ import ( "fiatjaf.com/nostr" "fiatjaf.com/nostr/eventstore" "fiatjaf.com/nostr/khatru" + "github.com/gosimple/slug" "zooid/sqlite" ) @@ -40,8 +41,11 @@ func MakeInstance(hostname string) (*Instance, error) { instance := &Instance{ Host: hostname, Config: config, - Events: &sqlite.SqliteBackend{Path: config.Data.Events}, - Relay: khatru.NewRelay(), + Events: &sqlite.SqliteBackend{ + Path: Env("DATABASE_PATH", "./data.db"), + Prefix: slug.Make(hostname) + "__", + }, + Relay: khatru.NewRelay(), } instance.Relay.Info.Name = config.Self.Name