forked from coracle/zooid
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ef94a76c8 | |||
| f48d4a0d12 | |||
| 34c02b45b2 | |||
| 9960a0fae8 | |||
| 959d019b54 | |||
| 53bf913fe6 | |||
| b3c2ee7f87 | |||
| 081c4765ed | |||
| aa0eba1fbe |
+11
-2
@@ -1,7 +1,13 @@
|
||||
FROM golang AS build
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25 AS build
|
||||
|
||||
ARG TARGETOS TARGETARCH
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc-aarch64-linux-gnu libc6-dev-arm64-cross \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
RUN go mod download
|
||||
@@ -9,7 +15,10 @@ RUN go mod download
|
||||
COPY zooid zooid
|
||||
COPY cmd cmd
|
||||
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o bin/zooid cmd/relay/main.go
|
||||
RUN set -eux; \
|
||||
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc; fi; \
|
||||
CGO_ENABLED=1 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
||||
go build -o bin/zooid cmd/relay/main.go
|
||||
|
||||
FROM gcr.io/distroless/base-debian12 AS run
|
||||
|
||||
|
||||
@@ -1,6 +1,41 @@
|
||||
# Zooid
|
||||
<p align="center">
|
||||
<img src="./zooid-wordmark.jpeg" alt="Zooid" width="280" />
|
||||
</p>
|
||||
|
||||
This is a multi-tenant relay based on [Khatru](https://gitworkshop.dev/fiatjaf.com/nostrlib/tree/master/khatru) which implements a range of access controls. It's designed to be used with [Flotilla](https://flotilla.social) as a community relay (complete with NIP 29 support), but it can also be used outside of a community context.
|
||||
<p align="center">
|
||||
<b>A multi-tenant Nostr relay for communities.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#quick-start">Quick start</a> ·
|
||||
<a href="#configuration">Configuration</a> ·
|
||||
<a href="#api">API</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
Zooid is a multi-tenant relay built on [Khatru](https://gitworkshop.dev/fiatjaf.com/nostrlib/tree/master/khatru) with a flexible set of access controls. It's designed to pair with [Flotilla](https://flotilla.social) as a community relay (with full NIP 29 support), but it works just fine outside of a community context too.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-tenant** — run any number of virtual relays from a single instance, each with its own host, schema, and policy.
|
||||
- **Community-ready** — first-class support for [NIP 29](https://github.com/nostr-protocol/nips/blob/master/29.md) groups, invite codes, and role-based access.
|
||||
- **Batteries included** — optional [Blossom](https://github.com/hzrd149/blossom) media, [NIP 86](https://github.com/nostr-protocol/nips/blob/master/86.md) management, [NIP 9a](https://github.com/nostr-protocol/nips/pull/1079) push, and [LiveKit](https://livekit.io/) audio/video calls.
|
||||
- **Remotely manageable** — JSON REST API authenticated via [NIP 98](https://github.com/nostr-protocol/nips/blob/master/98.md).
|
||||
- **Operationally simple** — single binary, SQLite storage, OCI container, optional pprof.
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
docker run -it \
|
||||
-p 3334:3334 \
|
||||
-v ./config:/app/config \
|
||||
-v ./media:/app/media \
|
||||
-v ./data:/app/data \
|
||||
ghcr.io/coracle-social/zooid
|
||||
```
|
||||
|
||||
Drop a TOML config file into `./config/` (see [Configuration](#configuration)) and the relay will be available at `ws://<host>:3334`.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -13,7 +48,7 @@ Zooid supports a few environment variables, which configure shared resources lik
|
||||
- `PORT` - the port the server will listen on for all requests. Defaults to `3334`.
|
||||
- `CONFIG` - where to store relay configuration files. Defaults to `./config`.
|
||||
- `MEDIA` - where to store blossom media files. Defaults to `./media`.
|
||||
- `DATA` - where to store databse files. Defaults to `./data`.
|
||||
- `DATA` - where to store database files. Defaults to `./data`.
|
||||
- `API_HOST` - the hostname on which to expose the management API. If not set, the API is disabled.
|
||||
- `API_WHITELIST` - a comma-separated list of nostr hex pubkeys authorized to use the management API. Required when `API_HOST` is set.
|
||||
- `PPROF_ADDR` - an http host to serve pprof stats on.
|
||||
@@ -89,10 +124,10 @@ On your LiveKit server you should also set up a webhook that points to `https://
|
||||
|
||||
### 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.
|
||||
The below config file might be saved as `./config/my-relay.example.com` in order to route requests from `wss://my-relay.example.com:3334` to this virtual relay.
|
||||
|
||||
```toml
|
||||
host = "my-relay.example.com"
|
||||
host = "my-relay.example.com:3334"
|
||||
schema = "my_relay"
|
||||
secret = "<hex private key>"
|
||||
|
||||
@@ -151,15 +186,6 @@ After running `just build`, a number of scripts will be available:
|
||||
|
||||
See `justfile` for defined commands.
|
||||
|
||||
## Deploying
|
||||
## License
|
||||
|
||||
Zooid can be run using an OCI container:
|
||||
|
||||
```sh
|
||||
podman run -it \
|
||||
-p 3334:3334 \
|
||||
-v ./config:/app/config \
|
||||
-v ./media:/app/media \
|
||||
-v ./data:/app/data \
|
||||
ghcr.io/coracle-social/zooid
|
||||
```
|
||||
[MIT](./LICENSE)
|
||||
|
||||
@@ -18,7 +18,7 @@ require (
|
||||
buf.build/go/protovalidate v0.13.1 // indirect
|
||||
buf.build/go/protoyaml v0.6.0 // indirect
|
||||
cel.dev/expr v0.24.0 // indirect
|
||||
fiatjaf.com/lib v0.3.6 // indirect
|
||||
fiatjaf.com/lib v0.3.7 // indirect
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
@@ -114,4 +114,4 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace fiatjaf.com/nostr => gitea.coracle.social/Coracle/nostrlib v0.0.0-20260414151249-4daeb8737c1c
|
||||
replace fiatjaf.com/nostr => gitea.coracle.social/Coracle/nostrlib v0.0.0-20260505183642-fefc85d50080
|
||||
|
||||
@@ -8,12 +8,10 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
||||
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
fiatjaf.com/lib v0.3.6 h1:GRZNSxHI2EWdjSKVuzaT+c0aifLDtS16SzkeJaHyJfY=
|
||||
fiatjaf.com/lib v0.3.6/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
||||
gitea.coracle.social/Coracle/nostrlib v0.0.0-20260313164927-662e7d271c47 h1:Pg/8ZXG2diV3uWbgt3mcAWF2ifL4FZXwotieokY8TBA=
|
||||
gitea.coracle.social/Coracle/nostrlib v0.0.0-20260313164927-662e7d271c47/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
|
||||
gitea.coracle.social/Coracle/nostrlib v0.0.0-20260414151249-4daeb8737c1c h1:RqKwqUz1R3LQC2IcsdsyYHEUAZACIAKYxGuntyBCGw8=
|
||||
gitea.coracle.social/Coracle/nostrlib v0.0.0-20260414151249-4daeb8737c1c/go.mod h1:1cmygNC87Pw06/WjkZqDV+Xo6rV10kpTjzuayosIX4Y=
|
||||
fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI=
|
||||
fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
||||
gitea.coracle.social/Coracle/nostrlib v0.0.0-20260505183642-fefc85d50080 h1:nNL6kqhG0U4dVHYoRULb/klaocv2NGEQm/qxFiZzbzY=
|
||||
gitea.coracle.social/Coracle/nostrlib v0.0.0-20260505183642-fefc85d50080/go.mod h1:b1EIUDnd133Ie8Pg8O/biaKdFyCMz28aD4n64g1GqvM=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 302 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
+78
-97
@@ -13,12 +13,14 @@ import (
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/gosimple/slug"
|
||||
)
|
||||
|
||||
// APIHandler handles REST API requests for managing virtual relays
|
||||
type APIHandler struct {
|
||||
whitelist map[string]bool
|
||||
configDir string
|
||||
mux http.Handler
|
||||
}
|
||||
|
||||
// NewAPIHandler creates a new API handler with the given whitelist
|
||||
@@ -30,78 +32,48 @@ func NewAPIHandler(whitelist string, configDir string) *APIHandler {
|
||||
w[pubkey] = true
|
||||
}
|
||||
}
|
||||
return &APIHandler{
|
||||
api := &APIHandler{
|
||||
whitelist: w,
|
||||
configDir: configDir,
|
||||
}
|
||||
api.mux = api.buildMux()
|
||||
return api
|
||||
}
|
||||
|
||||
func (api *APIHandler) buildMux() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /relay/{id}", api.auth(api.createRelay))
|
||||
mux.HandleFunc("PUT /relay/{id}", api.auth(api.updateRelay))
|
||||
mux.HandleFunc("PATCH /relay/{id}", api.auth(api.patchRelay))
|
||||
mux.HandleFunc("DELETE /relay/{id}", api.auth(api.deleteRelay))
|
||||
mux.HandleFunc("GET /relay/{id}/members", api.auth(api.listRelayMembers))
|
||||
return mux
|
||||
}
|
||||
|
||||
func (api *APIHandler) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
pubkey, err := validateNIP98Auth(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
if !api.whitelist[pubkey.Hex()] {
|
||||
writeError(w, http.StatusForbidden, "pubkey not in whitelist")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP implements the http.Handler interface
|
||||
func (api *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Authenticate the request using NIP-98
|
||||
pubkey, err := validateNIP98Auth(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Check if pubkey is in whitelist
|
||||
if !api.whitelist[pubkey.Hex()] {
|
||||
writeError(w, http.StatusForbidden, "pubkey not in whitelist")
|
||||
return
|
||||
}
|
||||
|
||||
// Route the request
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
if len(parts) < 2 || parts[0] != "relay" {
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
id := parts[1]
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "relay id is required")
|
||||
return
|
||||
}
|
||||
|
||||
if len(parts) > 2 {
|
||||
if len(parts) == 3 && parts[2] == "members" {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
api.listRelayMembers(w, id)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep trailing-slash compatibility for existing /relay/{id}/ calls.
|
||||
if len(parts) != 3 || parts[2] != "" {
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
api.createRelay(w, r, id)
|
||||
case http.MethodPut:
|
||||
api.updateRelay(w, r, id)
|
||||
case http.MethodPatch:
|
||||
api.patchRelay(w, r, id)
|
||||
case http.MethodDelete:
|
||||
api.deleteRelay(w, r, id)
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
}
|
||||
api.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// listRelayMembers returns members for a relay as an array of pubkeys.
|
||||
func (api *APIHandler) listRelayMembers(w http.ResponseWriter, id string) {
|
||||
func (api *APIHandler) listRelayMembers(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
members, err := api.resolveRelayMembers(id)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -112,8 +84,7 @@ func (api *APIHandler) listRelayMembers(w http.ResponseWriter, id string) {
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string][]string{"members": members})
|
||||
writeJSON(w, http.StatusOK, map[string][]string{"members": members})
|
||||
}
|
||||
|
||||
func (api *APIHandler) resolveRelayMembers(id string) ([]string, error) {
|
||||
@@ -121,23 +92,26 @@ func (api *APIHandler) resolveRelayMembers(id string) ([]string, error) {
|
||||
return members, nil
|
||||
}
|
||||
|
||||
configPath := api.configPath(id)
|
||||
if err := api.checkConfigExists(configPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
instance, err := MakeInstanceFromPath(configPath)
|
||||
config, err := api.loadConfigFromPath(api.configPath(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer instance.Cleanup()
|
||||
|
||||
memberSet := make(map[string]struct{})
|
||||
for _, pubkey := range instance.Management.GetMembers() {
|
||||
memberSet[pubkey.Hex()] = struct{}{}
|
||||
events := &EventStore{
|
||||
Config: config,
|
||||
Schema: &Schema{Name: slug.Make(config.Schema)},
|
||||
}
|
||||
|
||||
return sortedMembers(memberSet), nil
|
||||
if err := events.Init(); err != nil {
|
||||
return nil, fmt.Errorf("failed to init event store: %w", err)
|
||||
}
|
||||
|
||||
management := &ManagementStore{
|
||||
Config: config,
|
||||
Events: events,
|
||||
}
|
||||
|
||||
return collectMembers(management), nil
|
||||
}
|
||||
|
||||
func (api *APIHandler) getMembersFromLoadedInstance(id string) ([]string, bool) {
|
||||
@@ -149,15 +123,14 @@ func (api *APIHandler) getMembersFromLoadedInstance(id string) ([]string, bool)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
memberSet := make(map[string]struct{})
|
||||
for _, pubkey := range instance.Management.GetMembers() {
|
||||
memberSet[pubkey.Hex()] = struct{}{}
|
||||
}
|
||||
|
||||
return sortedMembers(memberSet), true
|
||||
return collectMembers(instance.Management), true
|
||||
}
|
||||
|
||||
func sortedMembers(memberSet map[string]struct{}) []string {
|
||||
func collectMembers(management *ManagementStore) []string {
|
||||
memberSet := make(map[string]struct{})
|
||||
for _, pubkey := range management.GetMembers() {
|
||||
memberSet[pubkey.Hex()] = struct{}{}
|
||||
}
|
||||
members := Keys(memberSet)
|
||||
sort.Strings(members)
|
||||
return members
|
||||
@@ -170,9 +143,9 @@ func writeError(w http.ResponseWriter, status int, message string) {
|
||||
}
|
||||
|
||||
// writeJSON writes a JSON success response
|
||||
func writeJSON(w http.ResponseWriter, status int, data map[string]string) {
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// scheme returns the URL scheme based on the request
|
||||
@@ -184,7 +157,8 @@ func scheme(r *http.Request) string {
|
||||
}
|
||||
|
||||
// createRelay creates a new relay config file
|
||||
func (api *APIHandler) createRelay(w http.ResponseWriter, r *http.Request, id string) {
|
||||
func (api *APIHandler) createRelay(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
configPath := api.configPath(id)
|
||||
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
@@ -212,7 +186,8 @@ func (api *APIHandler) createRelay(w http.ResponseWriter, r *http.Request, id st
|
||||
}
|
||||
|
||||
// updateRelay updates an existing relay config file
|
||||
func (api *APIHandler) updateRelay(w http.ResponseWriter, r *http.Request, id string) {
|
||||
func (api *APIHandler) updateRelay(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
configPath := api.configPath(id)
|
||||
|
||||
if err := api.checkConfigExists(configPath); err != nil {
|
||||
@@ -244,7 +219,8 @@ func (api *APIHandler) updateRelay(w http.ResponseWriter, r *http.Request, id st
|
||||
}
|
||||
|
||||
// patchRelay partially updates an existing relay config
|
||||
func (api *APIHandler) patchRelay(w http.ResponseWriter, r *http.Request, id string) {
|
||||
func (api *APIHandler) patchRelay(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
configPath := api.configPath(id)
|
||||
|
||||
if err := api.checkConfigExists(configPath); err != nil {
|
||||
@@ -277,7 +253,7 @@ func (api *APIHandler) patchRelay(w http.ResponseWriter, r *http.Request, id str
|
||||
}
|
||||
|
||||
// Validate the patched config
|
||||
if err := api.validatePatchedConfig(existing); err != nil {
|
||||
if err := api.validateConfig(existing); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -360,8 +336,8 @@ func deepMerge(base, patch map[string]interface{}) map[string]interface{} {
|
||||
return result
|
||||
}
|
||||
|
||||
// validatePatchedConfig validates a config after patching
|
||||
func (api *APIHandler) validatePatchedConfig(config *Config) error {
|
||||
// validateConfig validates a config
|
||||
func (api *APIHandler) validateConfig(config *Config) error {
|
||||
if config.Host == "" {
|
||||
return fmt.Errorf("host is required")
|
||||
}
|
||||
@@ -377,17 +353,17 @@ func (api *APIHandler) validatePatchedConfig(config *Config) error {
|
||||
if _, err := nostr.SecretKeyFromHex(config.Secret); err != nil {
|
||||
return fmt.Errorf("invalid secret key: %w", err)
|
||||
}
|
||||
if config.Info.Pubkey == "" {
|
||||
return fmt.Errorf("info.pubkey is required")
|
||||
}
|
||||
if _, err := nostr.PubKeyFromHex(config.Info.Pubkey); err != nil {
|
||||
return fmt.Errorf("invalid info.pubkey: %w", err)
|
||||
if config.Info.Pubkey != "" {
|
||||
if _, err := nostr.PubKeyFromHex(config.Info.Pubkey); err != nil {
|
||||
return fmt.Errorf("invalid info.pubkey: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteRelay deletes a relay config file
|
||||
func (api *APIHandler) deleteRelay(w http.ResponseWriter, r *http.Request, id string) {
|
||||
func (api *APIHandler) deleteRelay(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
configPath := api.configPath(id)
|
||||
|
||||
if err := api.checkConfigExists(configPath); err != nil {
|
||||
@@ -407,9 +383,14 @@ func (api *APIHandler) deleteRelay(w http.ResponseWriter, r *http.Request, id st
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "relay deleted successfully"})
|
||||
}
|
||||
|
||||
// configName returns the config file name
|
||||
func (api *APIHandler) configName(id string) string {
|
||||
return id+".toml"
|
||||
}
|
||||
|
||||
// configPath returns the full path for a config file
|
||||
func (api *APIHandler) configPath(id string) string {
|
||||
return filepath.Join(api.configDir, id+".toml")
|
||||
return filepath.Join(api.configDir, api.configName(id))
|
||||
}
|
||||
|
||||
// checkConfigExists checks if a config file exists
|
||||
@@ -443,7 +424,7 @@ func (api *APIHandler) parseAndValidateConfig(r *http.Request) (*Config, error)
|
||||
return nil, fmt.Errorf("invalid json config: %w", err)
|
||||
}
|
||||
|
||||
if err := api.validatePatchedConfig(&config); err != nil {
|
||||
if err := api.validateConfig(&config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
+28
-13
@@ -13,6 +13,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/gosimple/slug"
|
||||
)
|
||||
|
||||
func TestAPIHandler_Authentication(t *testing.T) {
|
||||
@@ -676,25 +677,40 @@ func TestAPIHandler_ListRelayMembers(t *testing.T) {
|
||||
|
||||
t.Run("list members from config fallback", func(t *testing.T) {
|
||||
relaySecret := nostr.Generate()
|
||||
owner := nostr.Generate().Public()
|
||||
rolePubkey := nostr.Generate().Public()
|
||||
member1 := nostr.Generate().Public()
|
||||
member2 := nostr.Generate().Public()
|
||||
|
||||
config := &Config{
|
||||
Host: "members.example.com",
|
||||
Schema: "members_" + RandomString(8),
|
||||
Secret: relaySecret.Hex(),
|
||||
Roles: map[string]Role{
|
||||
"admin": {
|
||||
Pubkeys: []string{rolePubkey.Hex()},
|
||||
},
|
||||
},
|
||||
}
|
||||
config.Info.Pubkey = owner.Hex()
|
||||
|
||||
if err := api.saveConfig(api.configPath("fallback"), config); err != nil {
|
||||
t.Fatalf("failed to save config: %v", err)
|
||||
}
|
||||
|
||||
// Seed DB with RELAY_MEMBERS to simulate a prior relay load.
|
||||
seedEvents := &EventStore{
|
||||
Config: &Config{secret: relaySecret},
|
||||
Schema: &Schema{Name: slug.Make(config.Schema)},
|
||||
}
|
||||
if err := seedEvents.Init(); err != nil {
|
||||
t.Fatalf("failed to init seed events: %v", err)
|
||||
}
|
||||
membersEvent := nostr.Event{
|
||||
Kind: RELAY_MEMBERS,
|
||||
CreatedAt: nostr.Now(),
|
||||
Tags: nostr.Tags{
|
||||
{"-"},
|
||||
{"member", member1.Hex()},
|
||||
{"member", member2.Hex()},
|
||||
},
|
||||
}
|
||||
if err := seedEvents.SignAndStoreEvent(&membersEvent, false); err != nil {
|
||||
t.Fatalf("failed to seed members event: %v", err)
|
||||
}
|
||||
|
||||
instancesMux.Lock()
|
||||
oldByName := instancesByName
|
||||
oldByHost := instancesByHost
|
||||
@@ -725,9 +741,8 @@ func TestAPIHandler_ListRelayMembers(t *testing.T) {
|
||||
}
|
||||
|
||||
expected := map[string]struct{}{
|
||||
owner.Hex(): {},
|
||||
relaySecret.Public().Hex(): {},
|
||||
rolePubkey.Hex(): {},
|
||||
member1.Hex(): {},
|
||||
member2.Hex(): {},
|
||||
}
|
||||
|
||||
if len(payload.Members) != len(expected) {
|
||||
@@ -809,8 +824,8 @@ func TestAPIHandler_InvalidPath(t *testing.T) {
|
||||
|
||||
api.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ func LoadConfig(filename string) (*Config, error) {
|
||||
}
|
||||
|
||||
func LoadConfigFromPath(path string) (*Config, error) {
|
||||
|
||||
var config Config
|
||||
if _, err := toml.DecodeFile(path, &config); err != nil {
|
||||
return nil, fmt.Errorf("Failed to parse config file %s: %w", path, err)
|
||||
|
||||
+1
-1
@@ -364,7 +364,7 @@ func (events *EventStore) SignAndStoreEvent(event *nostr.Event, broadcast bool)
|
||||
return err
|
||||
}
|
||||
|
||||
if broadcast {
|
||||
if broadcast && events.Relay != nil {
|
||||
events.Relay.BroadcastEvent(*event)
|
||||
}
|
||||
|
||||
|
||||
+3
-11
@@ -31,15 +31,6 @@ func MakeInstance(filename string) (*Instance, error) {
|
||||
return makeInstance(config, filename)
|
||||
}
|
||||
|
||||
func MakeInstanceFromPath(path string) (*Instance, error) {
|
||||
config, err := LoadConfigFromPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return makeInstance(config, path)
|
||||
}
|
||||
|
||||
func makeInstance(config *Config, source string) (*Instance, error) {
|
||||
relay := khatru.NewRelay()
|
||||
|
||||
@@ -277,8 +268,9 @@ func (instance *Instance) StoreEvent(ctx context.Context, event nostr.Event) err
|
||||
return instance.Events.StoreEvent(event)
|
||||
}
|
||||
|
||||
func (instance *Instance) ReplaceEvent(ctx context.Context, event nostr.Event) ([]nostr.Event, error) {
|
||||
return instance.Events.ReplaceEvent(event)
|
||||
func (instance *Instance) ReplaceEvent(ctx context.Context, event nostr.Event) error {
|
||||
_, err := instance.Events.ReplaceEvent(event)
|
||||
return err
|
||||
}
|
||||
|
||||
func (instance *Instance) DeleteEvent(ctx context.Context, id nostr.ID) error {
|
||||
|
||||
@@ -27,7 +27,7 @@ func createTestInstance() *Instance {
|
||||
|
||||
schema := &Schema{Name: "test_" + RandomString(8)}
|
||||
|
||||
relay := &khatru.Relay{}
|
||||
relay := khatru.NewRelay()
|
||||
|
||||
events := &EventStore{
|
||||
Relay: relay,
|
||||
|
||||
+4
-11
@@ -28,15 +28,6 @@ func Dispatch(hostname string) (*Instance, bool) {
|
||||
return instance, exists
|
||||
}
|
||||
|
||||
func cleanupIfInactive(instance *Instance) bool {
|
||||
if instance != nil && instance.Config != nil && instance.Config.Inactive {
|
||||
instance.Cleanup()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func Start() {
|
||||
dataDir := Env("DATA")
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
@@ -72,7 +63,8 @@ func Start() {
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to make instance for %s: %v", entry.Name(), err)
|
||||
} else if cleanupIfInactive(instance) {
|
||||
} else if instance.Config.Inactive {
|
||||
instance.Cleanup()
|
||||
log.Printf("Skipped inactive %s", entry.Name())
|
||||
} else {
|
||||
instancesByHost[instance.Config.Host] = instance
|
||||
@@ -119,7 +111,8 @@ func Start() {
|
||||
instance, err := MakeInstance(filename)
|
||||
if err != nil {
|
||||
log.Printf("Failed to reload %s: %v", filename, err)
|
||||
} else if cleanupIfInactive(instance) {
|
||||
} else if instance.Config.Inactive {
|
||||
instance.Cleanup()
|
||||
log.Printf("Skipped inactive %s", filename)
|
||||
} else {
|
||||
instancesByHost[instance.Config.Host] = instance
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package zooid
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "zooid-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
os.Setenv("DATA", dir)
|
||||
|
||||
code := m.Run()
|
||||
|
||||
os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ func createTestManagementStore() *ManagementStore {
|
||||
secret: nostr.Generate(),
|
||||
}
|
||||
schema := &Schema{Name: "test_" + RandomString(8)}
|
||||
relay := &khatru.Relay{}
|
||||
relay := khatru.NewRelay()
|
||||
events := &EventStore{
|
||||
Relay: relay,
|
||||
Config: config,
|
||||
|
||||
Reference in New Issue
Block a user