From e13557703c8750603abfd09a7978c3d1b1d2cc41 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 13 Feb 2026 16:39:59 -0800 Subject: [PATCH] Refactor api --- zooid/api.go | 593 ++++++++++++++++------------------------- zooid/config.go | 50 ++-- zooid/config_test.go | 39 +-- zooid/instance_test.go | 11 +- 4 files changed, 256 insertions(+), 437 deletions(-) diff --git a/zooid/api.go b/zooid/api.go index bca6609..97cc2fa 100644 --- a/zooid/api.go +++ b/zooid/api.go @@ -1,7 +1,6 @@ package zooid import ( - "bytes" "encoding/base64" "encoding/json" "fmt" @@ -21,38 +20,6 @@ type APIHandler struct { configDir string } -// RelayConfigJSON is the JSON schema for relay configuration input -type RelayConfigJSON struct { - Host string `json:"host"` - Schema string `json:"schema"` - Secret string `json:"secret"` - Info struct { - Name string `json:"name"` - Icon string `json:"icon"` - Pubkey string `json:"pubkey"` - Description string `json:"description"` - } `json:"info"` - Policy struct { - PublicJoin bool `json:"public_join"` - StripSignatures bool `json:"strip_signatures"` - } `json:"policy"` - Groups struct { - Enabled bool `json:"enabled"` - AutoJoin bool `json:"auto_join"` - } `json:"groups"` - Push struct { - Enabled bool `json:"enabled"` - } `json:"push"` - Management struct { - Enabled bool `json:"enabled"` - Methods []string `json:"methods"` - } `json:"management"` - Blossom struct { - Enabled bool `json:"enabled"` - } `json:"blossom"` - Roles map[string]Role `json:"roles"` -} - // NewAPIHandler creates a new API handler with the given whitelist func NewAPIHandler(whitelist string, configDir string) *APIHandler { w := make(map[string]bool) @@ -70,21 +37,18 @@ func NewAPIHandler(whitelist string, configDir string) *APIHandler { // ServeHTTP implements the http.Handler interface func (api *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Set JSON content type w.Header().Set("Content-Type", "application/json") // Authenticate the request using NIP-98 pubkey, err := api.authenticateNIP98(r) if err != nil { - w.WriteHeader(http.StatusUnauthorized) - json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + writeError(w, http.StatusUnauthorized, err.Error()) return } // Check if pubkey is in whitelist if !api.whitelist[pubkey.Hex()] { - w.WriteHeader(http.StatusForbidden) - json.NewEncoder(w).Encode(map[string]string{"error": "pubkey not in whitelist"}) + writeError(w, http.StatusForbidden, "pubkey not in whitelist") return } @@ -93,15 +57,13 @@ func (api *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { parts := strings.Split(path, "/") if len(parts) < 2 || parts[0] != "relay" { - w.WriteHeader(http.StatusNotFound) - json.NewEncoder(w).Encode(map[string]string{"error": "not found"}) + writeError(w, http.StatusNotFound, "not found") return } id := parts[1] if id == "" { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": "relay id is required"}) + writeError(w, http.StatusBadRequest, "relay id is required") return } @@ -115,11 +77,22 @@ func (api *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case http.MethodDelete: api.deleteRelay(w, r, id) default: - w.WriteHeader(http.StatusMethodNotAllowed) - json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"}) + writeError(w, http.StatusMethodNotAllowed, "method not allowed") } } +// writeError writes a JSON error response +func writeError(w http.ResponseWriter, status int, message string) { + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": message}) +} + +// writeJSON writes a JSON success response +func writeJSON(w http.ResponseWriter, status int, data map[string]string) { + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + // authenticateNIP98 validates NIP-98 HTTP AUTH func (api *APIHandler) authenticateNIP98(r *http.Request) (nostr.PubKey, error) { authHeader := r.Header.Get("Authorization") @@ -127,37 +100,31 @@ func (api *APIHandler) authenticateNIP98(r *http.Request) (nostr.PubKey, error) return nostr.PubKey{}, fmt.Errorf("missing authorization header") } - // Parse the Authorization header parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || strings.ToLower(parts[0]) != "nostr" { return nostr.PubKey{}, fmt.Errorf("invalid authorization header format") } - // Decode the base64 event eventJSON, err := base64.StdEncoding.DecodeString(parts[1]) if err != nil { return nostr.PubKey{}, fmt.Errorf("invalid base64 encoding: %w", err) } - // Parse the event var event nostr.Event if err := json.Unmarshal(eventJSON, &event); err != nil { return nostr.PubKey{}, fmt.Errorf("invalid event json: %w", err) } - // Verify the event kind is HTTP Auth (27235) if event.Kind != nostr.KindHTTPAuth { return nostr.PubKey{}, fmt.Errorf("invalid event kind: expected %d, got %d", nostr.KindHTTPAuth, event.Kind) } - // Verify the event signature if !event.VerifySignature() { return nostr.PubKey{}, fmt.Errorf("invalid event signature") } - // Verify the event tags contain the correct URL and method - var hasURL, hasMethod bool expectedURL := fmt.Sprintf("%s://%s%s", scheme(r), r.Host, r.URL.Path) + var hasURL, hasMethod bool for _, tag := range event.Tags { if len(tag) < 2 { @@ -195,254 +162,119 @@ 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) { - configPath := filepath.Join(api.configDir, id+".toml") + configPath := api.configPath(id) - // Check if file already exists if _, err := os.Stat(configPath); err == nil { - w.WriteHeader(http.StatusConflict) - json.NewEncoder(w).Encode(map[string]string{"error": "relay with this id already exists"}) + writeError(w, http.StatusConflict, "relay with this id already exists") return } - // Parse and validate the JSON config config, err := api.parseAndValidateConfig(r) if err != nil { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + writeError(w, http.StatusBadRequest, err.Error()) return } - // Check for duplicate schema or host if err := api.checkDuplicateSchemaOrHost(config, ""); err != nil { - w.WriteHeader(http.StatusConflict) - json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + writeError(w, http.StatusConflict, err.Error()) return } - // Convert to TOML and write - if err := api.writeConfigAsTOML(configPath, config); err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to write config: %v", err)}) + if err := api.saveConfig(configPath, config); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to write config: %v", err)) return } - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]string{"message": "relay created successfully"}) + writeJSON(w, http.StatusCreated, map[string]string{"message": "relay created successfully"}) } // updateRelay updates an existing relay config file func (api *APIHandler) updateRelay(w http.ResponseWriter, r *http.Request, id string) { - configPath := filepath.Join(api.configDir, id+".toml") + configPath := api.configPath(id) - // Check if file exists - if _, err := os.Stat(configPath); err != nil { + if err := api.checkConfigExists(configPath); err != nil { if os.IsNotExist(err) { - w.WriteHeader(http.StatusNotFound) - json.NewEncoder(w).Encode(map[string]string{"error": "relay not found"}) - return + writeError(w, http.StatusNotFound, "relay not found") + } else { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to check config: %v", err)) } - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to check config: %v", err)}) return } - // Parse and validate the JSON config config, err := api.parseAndValidateConfig(r) if err != nil { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + writeError(w, http.StatusBadRequest, err.Error()) return } - // Check for duplicate schema or host (excluding this config file) if err := api.checkDuplicateSchemaOrHost(config, id+".toml"); err != nil { - w.WriteHeader(http.StatusConflict) - json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + writeError(w, http.StatusConflict, err.Error()) return } - // Convert to TOML and write - if err := api.writeConfigAsTOML(configPath, config); err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to write config: %v", err)}) + if err := api.saveConfig(configPath, config); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to write config: %v", err)) return } - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"message": "relay updated successfully"}) + writeJSON(w, http.StatusOK, map[string]string{"message": "relay updated successfully"}) } -// patchRelay partially updates an existing relay config by recursively merging changes +// patchRelay partially updates an existing relay config func (api *APIHandler) patchRelay(w http.ResponseWriter, r *http.Request, id string) { - configPath := filepath.Join(api.configDir, id+".toml") + configPath := api.configPath(id) - // Check if file exists - if _, err := os.Stat(configPath); err != nil { + if err := api.checkConfigExists(configPath); err != nil { if os.IsNotExist(err) { - w.WriteHeader(http.StatusNotFound) - json.NewEncoder(w).Encode(map[string]string{"error": "relay not found"}) - return - } - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to check config: %v", err)}) - return - } - - // Read existing config - var existingConfig RelayConfigJSON - if _, err := toml.DecodeFile(configPath, &existingConfig); err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to read existing config: %v", err)}) - return - } - - // Read and parse the patch - r.Body = http.MaxBytesReader(nil, r.Body, 1024*1024) // 1MB limit - defer r.Body.Close() - - body, err := io.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to read body: %v", err)}) - return - } - - var patch map[string]interface{} - if err := json.Unmarshal(body, &patch); err != nil { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("invalid json: %v", err)}) - return - } - - // Convert existing config to map for merging - existingJSON, _ := json.Marshal(existingConfig) - var existingMap map[string]interface{} - json.Unmarshal(existingJSON, &existingMap) - - // Recursively merge patch into existing - mergedMap := deepMerge(existingMap, patch) - - // Convert back to RelayConfigJSON - mergedJSON, _ := json.Marshal(mergedMap) - var mergedConfig RelayConfigJSON - if err := json.Unmarshal(mergedJSON, &mergedConfig); err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to process merged config: %v", err)}) - return - } - - // Validate required fields are still present - if mergedConfig.Host == "" { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": "host is required"}) - return - } - if mergedConfig.Schema == "" { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": "schema is required"}) - return - } - if mergedConfig.Secret == "" { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": "secret is required"}) - return - } - - // Validate the secret key - if _, err := nostr.SecretKeyFromHex(mergedConfig.Secret); err != nil { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("invalid secret key: %v", err)}) - return - } - - // Validate info.pubkey if provided - if mergedConfig.Info.Pubkey != "" { - if _, err := nostr.PubKeyFromHex(mergedConfig.Info.Pubkey); err != nil { - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("invalid info.pubkey: %v", err)}) - return - } - } - - // Check for duplicate schema or host (excluding this config file) - if err := api.checkDuplicateSchemaOrHost(&mergedConfig, id+".toml"); err != nil { - w.WriteHeader(http.StatusConflict) - json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) - return - } - - // Convert to TOML and write - if err := api.writeConfigAsTOML(configPath, &mergedConfig); err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to write config: %v", err)}) - return - } - - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"message": "relay patched successfully"}) -} - -// deepMerge recursively merges patch into base -func deepMerge(base, patch map[string]interface{}) map[string]interface{} { - result := make(map[string]interface{}) - - // Copy all values from base - for k, v := range base { - result[k] = v - } - - // Apply patches - for k, v := range patch { - if v == nil { - // Remove key if explicitly set to null - delete(result, k) - } else if patchMap, ok := v.(map[string]interface{}); ok { - // Recursively merge nested maps - if baseMap, ok := base[k].(map[string]interface{}); ok { - result[k] = deepMerge(baseMap, patchMap) - } else { - result[k] = v - } + writeError(w, http.StatusNotFound, "relay not found") } else { - // Replace value - result[k] = v + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to check config: %v", err)) } - } - - return result -} - -// deleteRelay deletes a relay config file -func (api *APIHandler) deleteRelay(w http.ResponseWriter, r *http.Request, id string) { - configPath := filepath.Join(api.configDir, id+".toml") - - // Check if file exists - if _, err := os.Stat(configPath); err != nil { - if os.IsNotExist(err) { - w.WriteHeader(http.StatusNotFound) - json.NewEncoder(w).Encode(map[string]string{"error": "relay not found"}) - return - } - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to check config: %v", err)}) return } - // Delete the config file - if err := os.Remove(configPath); err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("failed to delete config: %v", err)}) + // Load existing config + existing, err := api.loadConfigFromPath(configPath) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to read existing config: %v", err)) return } - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"message": "relay deleted successfully"}) + // Parse patch + patch, err := api.readPatch(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + // Apply patch to existing config + if err := api.applyPatch(existing, patch); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + // Validate the patched config + if err := api.validatePatchedConfig(existing); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := api.checkDuplicateSchemaOrHost(existing, id+".toml"); err != nil { + writeError(w, http.StatusConflict, err.Error()) + return + } + + if err := api.saveConfig(configPath, existing); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to write config: %v", err)) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "relay patched successfully"}) } -// parseAndValidateConfig parses and validates the JSON config from the request body -func (api *APIHandler) parseAndValidateConfig(r *http.Request) (*RelayConfigJSON, error) { - // Limit body size to prevent abuse - r.Body = http.MaxBytesReader(nil, r.Body, 1024*1024) // 1MB limit +// readPatch reads and parses the patch JSON from the request +func (api *APIHandler) readPatch(r *http.Request) (map[string]interface{}, error) { + r.Body = http.MaxBytesReader(nil, r.Body, 1024*1024) defer r.Body.Close() body, err := io.ReadAll(r.Body) @@ -450,162 +282,185 @@ func (api *APIHandler) parseAndValidateConfig(r *http.Request) (*RelayConfigJSON return nil, fmt.Errorf("failed to read body: %w", err) } - // Parse the JSON config - var config RelayConfigJSON + var patch map[string]interface{} + if err := json.Unmarshal(body, &patch); err != nil { + return nil, fmt.Errorf("invalid json: %w", err) + } + + return patch, nil +} + +// applyPatch applies a JSON patch to a config using reflection via JSON marshaling +func (api *APIHandler) applyPatch(config *Config, patch map[string]interface{}) error { + // Convert config to map for merging + configJSON, _ := json.Marshal(config) + var configMap map[string]interface{} + json.Unmarshal(configJSON, &configMap) + + // Merge patch + merged := deepMerge(configMap, patch) + + // Convert back to a new config (don't modify original until validation passes) + mergedJSON, _ := json.Marshal(merged) + var patched Config + if err := json.Unmarshal(mergedJSON, &patched); err != nil { + return err + } + + // Copy patched values to original config + *config = patched + return nil +} + +// deepMerge recursively merges patch into base +func deepMerge(base, patch map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + + for k, v := range base { + result[k] = v + } + + for k, v := range patch { + if v == nil { + delete(result, k) + } else if patchMap, ok := v.(map[string]interface{}); ok { + if baseMap, ok := base[k].(map[string]interface{}); ok { + result[k] = deepMerge(baseMap, patchMap) + } else { + result[k] = v + } + } else { + result[k] = v + } + } + + return result +} + +// validatePatchedConfig validates a config after patching +func (api *APIHandler) validatePatchedConfig(config *Config) error { + if config.Host == "" { + return fmt.Errorf("host is required") + } + if config.Schema == "" { + return fmt.Errorf("schema is required") + } + if config.Secret == "" { + return fmt.Errorf("secret is required") + } + if _, err := nostr.SecretKeyFromHex(config.Secret); err != nil { + return fmt.Errorf("invalid secret key: %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) { + configPath := api.configPath(id) + + if err := api.checkConfigExists(configPath); err != nil { + if os.IsNotExist(err) { + writeError(w, http.StatusNotFound, "relay not found") + } else { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to check config: %v", err)) + } + return + } + + if err := os.Remove(configPath); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to delete config: %v", err)) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "relay deleted successfully"}) +} + +// configPath returns the full path for a config file +func (api *APIHandler) configPath(id string) string { + return filepath.Join(api.configDir, id+".toml") +} + +// checkConfigExists checks if a config file exists +func (api *APIHandler) checkConfigExists(path string) error { + _, err := os.Stat(path) + return err +} + +// loadConfigFromPath loads a config from a file path +func (api *APIHandler) loadConfigFromPath(path string) (*Config, error) { + var config Config + _, err := toml.DecodeFile(path, &config) + if err != nil { + return nil, err + } + return &config, nil +} + +// parseAndValidateConfig parses and validates the JSON config from the request body +func (api *APIHandler) parseAndValidateConfig(r *http.Request) (*Config, error) { + r.Body = http.MaxBytesReader(nil, r.Body, 1024*1024) + defer r.Body.Close() + + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, fmt.Errorf("failed to read body: %w", err) + } + + var config Config if err := json.Unmarshal(body, &config); err != nil { return nil, fmt.Errorf("invalid json config: %w", err) } - // Validate required fields - if config.Host == "" { - return nil, fmt.Errorf("host is required") - } - if config.Schema == "" { - return nil, fmt.Errorf("schema is required") - } - if config.Secret == "" { - return nil, fmt.Errorf("secret is required") - } - - // Validate the secret key - if _, err := nostr.SecretKeyFromHex(config.Secret); err != nil { - return nil, fmt.Errorf("invalid secret key: %w", err) - } - - // Validate info.pubkey if provided - if config.Info.Pubkey != "" { - if _, err := nostr.PubKeyFromHex(config.Info.Pubkey); err != nil { - return nil, fmt.Errorf("invalid info.pubkey: %w", err) - } + if err := api.validatePatchedConfig(&config); err != nil { + return nil, err } return &config, nil } -// writeConfigAsTOML writes the config as TOML to the given path -func (api *APIHandler) writeConfigAsTOML(path string, config *RelayConfigJSON) error { - // Convert JSON config to TOML-compatible struct - tomlConfig := struct { - Host string `toml:"host"` - Schema string `toml:"schema"` - Secret string `toml:"secret"` - Info struct { - Name string `toml:"name"` - Icon string `toml:"icon"` - Pubkey string `toml:"pubkey"` - Description string `toml:"description"` - } `toml:"info"` - Policy struct { - PublicJoin bool `toml:"public_join"` - StripSignatures bool `toml:"strip_signatures"` - } `toml:"policy"` - Groups struct { - Enabled bool `toml:"enabled"` - AutoJoin bool `toml:"auto_join"` - } `toml:"groups"` - Push struct { - Enabled bool `toml:"enabled"` - } `toml:"push"` - Management struct { - Enabled bool `toml:"enabled"` - Methods []string `toml:"methods"` - } `toml:"management"` - Blossom struct { - Enabled bool `toml:"enabled"` - } `toml:"blossom"` - Roles map[string]Role `toml:"roles"` - }{ - Host: config.Host, - Schema: config.Schema, - Secret: config.Secret, - Info: struct { - Name string `toml:"name"` - Icon string `toml:"icon"` - Pubkey string `toml:"pubkey"` - Description string `toml:"description"` - }{ - Name: config.Info.Name, - Icon: config.Info.Icon, - Pubkey: config.Info.Pubkey, - Description: config.Info.Description, - }, - Policy: struct { - PublicJoin bool `toml:"public_join"` - StripSignatures bool `toml:"strip_signatures"` - }{ - PublicJoin: config.Policy.PublicJoin, - StripSignatures: config.Policy.StripSignatures, - }, - Groups: struct { - Enabled bool `toml:"enabled"` - AutoJoin bool `toml:"auto_join"` - }{ - Enabled: config.Groups.Enabled, - AutoJoin: config.Groups.AutoJoin, - }, - Push: struct { - Enabled bool `toml:"enabled"` - }{ - Enabled: config.Push.Enabled, - }, - Management: struct { - Enabled bool `toml:"enabled"` - Methods []string `toml:"methods"` - }{ - Enabled: config.Management.Enabled, - Methods: config.Management.Methods, - }, - Blossom: struct { - Enabled bool `toml:"enabled"` - }{ - Enabled: config.Blossom.Enabled, - }, - Roles: config.Roles, +// saveConfig saves a config to a file as TOML +func (api *APIHandler) saveConfig(path string, config *Config) error { + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) } + defer file.Close() - // Encode to TOML - var buf bytes.Buffer - encoder := toml.NewEncoder(&buf) - if err := encoder.Encode(tomlConfig); err != nil { + encoder := toml.NewEncoder(file) + if err := encoder.Encode(config); err != nil { return fmt.Errorf("failed to encode toml: %w", err) } - // Write to file - if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil { - return fmt.Errorf("failed to write file: %w", err) - } - return nil } // checkDuplicateSchemaOrHost checks if the schema or host is already in use by another config -func (api *APIHandler) checkDuplicateSchemaOrHost(config *RelayConfigJSON, excludeFilename string) error { +func (api *APIHandler) checkDuplicateSchemaOrHost(config *Config, excludeFilename string) error { entries, err := os.ReadDir(api.configDir) if err != nil { return fmt.Errorf("failed to read config directory: %w", err) } for _, entry := range entries { - if entry.IsDir() { - continue - } - if entry.Name() == excludeFilename { - continue - } - if !strings.HasSuffix(entry.Name(), ".toml") { + if entry.IsDir() || entry.Name() == excludeFilename || !strings.HasSuffix(entry.Name(), ".toml") { continue } path := filepath.Join(api.configDir, entry.Name()) - var existingConfig Config - if _, err := toml.DecodeFile(path, &existingConfig); err != nil { - continue // Skip invalid configs + var existing Config + if _, err := toml.DecodeFile(path, &existing); err != nil { + continue } - if existingConfig.Schema == config.Schema { + if existing.Schema == config.Schema { return fmt.Errorf("schema %q is already in use", config.Schema) } - if existingConfig.Host == config.Host { + if existing.Host == config.Host { return fmt.Errorf("host %q is already in use", config.Host) } } diff --git a/zooid/config.go b/zooid/config.go index 98d5638..3f96068 100644 --- a/zooid/config.go +++ b/zooid/config.go @@ -10,46 +10,46 @@ import ( ) type Role struct { - Pubkeys []string `toml:"pubkeys"` - CanInvite bool `toml:"can_invite"` - CanManage bool `toml:"can_manage"` + Pubkeys []string `toml:"pubkeys" json:"pubkeys"` + CanInvite bool `toml:"can_invite" json:"can_invite"` + CanManage bool `toml:"can_manage" json:"can_manage"` } type Config struct { - Host string `toml:"host"` - Schema string `toml:"schema"` - Secret string `toml:"secret"` + Host string `toml:"host" json:"host"` + Schema string `toml:"schema" json:"schema"` + Secret string `toml:"secret" json:"secret"` Info struct { - Name string `toml:"name"` - Icon string `toml:"icon"` - Pubkey string `toml:"pubkey"` - Description string `toml:"description"` - } `toml:"info"` + Name string `toml:"name" json:"name"` + Icon string `toml:"icon" json:"icon"` + Pubkey string `toml:"pubkey" json:"pubkey"` + Description string `toml:"description" json:"description"` + } `toml:"info" json:"info"` Policy struct { - PublicJoin bool `toml:"public_join"` - StripSignatures bool `toml:"strip_signatures"` - } `toml:"policy"` + PublicJoin bool `toml:"public_join" json:"public_join"` + StripSignatures bool `toml:"strip_signatures" json:"strip_signatures"` + } `toml:"policy" json:"policy"` Groups struct { - Enabled bool `toml:"enabled"` - AutoJoin bool `toml:"auto_join"` - } `toml:"groups"` + Enabled bool `toml:"enabled" json:"enabled"` + AutoJoin bool `toml:"auto_join" json:"auto_join"` + } `toml:"groups" json:"groups"` Push struct { - Enabled bool `toml:"enabled"` - } `toml:"push"` + Enabled bool `toml:"enabled" json:"enabled"` + } `toml:"push" json:"push"` Management struct { - Enabled bool `toml:"enabled"` - Methods []string `toml:"methods"` - } `toml:"management"` + Enabled bool `toml:"enabled" json:"enabled"` + Methods []string `toml:"methods" json:"methods"` + } `toml:"management" json:"management"` Blossom struct { - Enabled bool `toml:"enabled"` - } `toml:"blossom"` + Enabled bool `toml:"enabled" json:"enabled"` + } `toml:"blossom" json:"blossom"` - Roles map[string]Role `toml:"roles"` + Roles map[string]Role `toml:"roles" json:"roles"` // Private/parsed values path string diff --git a/zooid/config_test.go b/zooid/config_test.go index d6c182b..45a8dd2 100644 --- a/zooid/config_test.go +++ b/zooid/config_test.go @@ -10,16 +10,8 @@ func TestConfig_IsOwner(t *testing.T) { ownerPubkey := nostr.MustPubKeyFromHex("1234567890123456789012345678901234567890123456789012345678901234") otherPubkey := nostr.MustPubKeyFromHex("abcdef1234567890123456789012345678901234567890123456789012345678") - config := &Config{ - Info: struct { - Name string `toml:"name"` - Icon string `toml:"icon"` - Pubkey string `toml:"pubkey"` - Description string `toml:"description"` - }{ - Pubkey: ownerPubkey.Hex(), - }, - } + config := &Config{} + config.Info.Pubkey = ownerPubkey.Hex() if !config.IsOwner(ownerPubkey) { t.Error("IsOwner() should return true for owner pubkey") @@ -87,14 +79,6 @@ func TestConfig_CanManage(t *testing.T) { config := &Config{ secret: nostr.Generate(), - Info: struct { - Name string `toml:"name"` - Icon string `toml:"icon"` - Pubkey string `toml:"pubkey"` - Description string `toml:"description"` - }{ - Pubkey: ownerPubkey.Hex(), - }, Roles: map[string]Role{ "admin": { Pubkeys: []string{adminPubkey.Hex()}, @@ -106,6 +90,7 @@ func TestConfig_CanManage(t *testing.T) { }, }, } + config.Info.Pubkey = ownerPubkey.Hex() if !config.CanManage(adminPubkey) { t.Error("CanManage() should return true for admin") @@ -123,14 +108,6 @@ func TestConfig_CanInvite(t *testing.T) { config := &Config{ secret: nostr.Generate(), - Info: struct { - Name string `toml:"name"` - Icon string `toml:"icon"` - Pubkey string `toml:"pubkey"` - Description string `toml:"description"` - }{ - Pubkey: ownerPubkey.Hex(), - }, Roles: map[string]Role{ "inviter": { Pubkeys: []string{inviterPubkey.Hex()}, @@ -142,6 +119,7 @@ func TestConfig_CanInvite(t *testing.T) { }, }, } + config.Info.Pubkey = ownerPubkey.Hex() if !config.CanInvite(inviterPubkey) { t.Error("CanInvite() should return true for inviter") @@ -158,14 +136,6 @@ func TestConfig_MemberRole(t *testing.T) { config := &Config{ secret: nostr.Generate(), - Info: struct { - Name string `toml:"name"` - Icon string `toml:"icon"` - Pubkey string `toml:"pubkey"` - Description string `toml:"description"` - }{ - Pubkey: ownerPubkey.Hex(), - }, Roles: map[string]Role{ "member": { Pubkeys: []string{}, @@ -173,6 +143,7 @@ func TestConfig_MemberRole(t *testing.T) { }, }, } + config.Info.Pubkey = ownerPubkey.Hex() roles := config.GetAllRoles(anyPubkey) if len(roles) != 1 { diff --git a/zooid/instance_test.go b/zooid/instance_test.go index 2c2ab61..d3f0f1a 100644 --- a/zooid/instance_test.go +++ b/zooid/instance_test.go @@ -14,15 +14,6 @@ func createTestInstance() *Instance { config := &Config{ Host: "test.com", secret: ownerSecret, - Info: struct { - Name string `toml:"name"` - Icon string `toml:"icon"` - Pubkey string `toml:"pubkey"` - Description string `toml:"description"` - }{ - Name: "Test Relay", - Pubkey: ownerPubkey.Hex(), - }, Roles: map[string]Role{ "admin": { Pubkeys: []string{ownerPubkey.Hex()}, @@ -31,6 +22,8 @@ func createTestInstance() *Instance { }, }, } + config.Info.Name = "Test Relay" + config.Info.Pubkey = ownerPubkey.Hex() schema := &Schema{Name: "test_" + RandomString(8)}