diff --git a/cmd/export/main.go b/cmd/export/main.go index 53c6a00..984678e 100644 --- a/cmd/export/main.go +++ b/cmd/export/main.go @@ -26,10 +26,9 @@ func main() { } // Load config for the specified relay - filename := fmt.Sprintf("%s.toml", *relay) - config, err := zooid.LoadConfig(filename) + config, err := zooid.LoadConfigFromId(*relay) if err != nil { - fmt.Fprintln(os.Stderr, "No such config file", filename) + fmt.Fprintln(os.Stderr, err) os.Exit(1) } diff --git a/cmd/import/main.go b/cmd/import/main.go index efb3245..cb37f19 100644 --- a/cmd/import/main.go +++ b/cmd/import/main.go @@ -40,10 +40,9 @@ func main() { } // Load config for the specified relay - filename := fmt.Sprintf("%s.toml", *relay) - config, err := zooid.LoadConfig(filename) + config, err := zooid.LoadConfigFromId(*relay) if err != nil { - fmt.Fprintln(os.Stderr, "No such config file", filename) + fmt.Fprintln(os.Stderr, err) os.Exit(1) } diff --git a/cmd/relay/main.go b/cmd/relay/main.go index 1f4fe1d..9dc2d2c 100644 --- a/cmd/relay/main.go +++ b/cmd/relay/main.go @@ -21,8 +21,6 @@ func main() { port := zooid.Env("PORT") apiHost := zooid.Env("API_HOST") - apiWhitelist := zooid.Env("API_WHITELIST") - configDir := zooid.Env("CONFIG") pprofAddr := zooid.Env("PPROF_ADDR") // pprof server — only starts when PPROF_ADDR is set. Bind to @@ -50,8 +48,8 @@ func main() { // Wrap with API handler if API_HOST is configured var handler http.Handler = mainHandler - if apiHost != "" && apiWhitelist != "" { - apiHandler := zooid.NewAPIHandler(apiWhitelist, configDir) + if apiHost != "" { + apiHandler := zooid.NewAPIHandler() handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Check if this request is for the API host if r.Host == apiHost { diff --git a/zooid/api.go b/zooid/api.go index 7db35e8..c191169 100644 --- a/zooid/api.go +++ b/zooid/api.go @@ -6,48 +6,38 @@ import ( "io" "net/http" "os" - "path/filepath" - "regexp" "sort" "strings" - - "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 -func NewAPIHandler(whitelist string, configDir string) *APIHandler { - w := make(map[string]bool) - for _, pubkey := range Split(whitelist, ",") { +func NewAPIHandler() *APIHandler { + whitelist := make(map[string]bool) + for _, pubkey := range Split(Env("API_WHITELIST"), ",") { pubkey = strings.TrimSpace(pubkey) if pubkey != "" { - w[pubkey] = true + whitelist[pubkey] = true } } - api := &APIHandler{ - whitelist: w, - configDir: configDir, - } - api.mux = api.buildMux() - return api -} -func (api *APIHandler) buildMux() http.Handler { - mux := http.NewServeMux() + api := &APIHandler{ + whitelist: whitelist, + } + + mux := http.NewServeMux() mux.HandleFunc("POST /relay/{id}", api.auth(api.createRelay)) - mux.HandleFunc("PUT /relay/{id}", api.auth(api.updateRelay)) + mux.HandleFunc("PUT /relay/{id}", api.auth(api.putRelay)) 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 + + api.mux = mux + + return api } func (api *APIHandler) auth(next http.HandlerFunc) http.HandlerFunc { @@ -65,214 +55,45 @@ func (api *APIHandler) auth(next http.HandlerFunc) http.HandlerFunc { } } -// ServeHTTP implements the http.Handler interface func (api *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") api.mux.ServeHTTP(w, r) } -// listRelayMembers returns members for a relay as an array of pubkeys. -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) { - writeError(w, http.StatusNotFound, "relay not found") - } else { - writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to load relay members: %v", err)) - } - return - } - - writeJSON(w, http.StatusOK, map[string][]string{"members": members}) -} - -func (api *APIHandler) resolveRelayMembers(id string) ([]string, error) { - if members, ok := api.getMembersFromLoadedInstance(id); ok { - return members, nil - } - - config, err := api.loadConfigFromPath(api.configPath(id)) - if err != nil { - return nil, err - } - - events := &EventStore{ - Config: config, - Schema: &Schema{Name: slug.Make(config.Schema)}, - } - - 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) { - instancesMux.RLock() - instance, exists := instancesByName[id+".toml"] - instancesMux.RUnlock() - - if !exists || instance == nil || instance.Config == nil || instance.Management == nil { - return nil, false - } - - return collectMembers(instance.Management), true -} - -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 -} - -// 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, v any) { w.WriteHeader(status) json.NewEncoder(w).Encode(v) } -// scheme returns the URL scheme based on the request -func scheme(r *http.Request) string { - if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { - return "https" +// Relay CRUD + +func (api *APIHandler) configFromRequest(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) } - return "http" + + var config Config + if err := json.Unmarshal(body, &config); err != nil { + return nil, fmt.Errorf("invalid json config: %w", err) + } + + if err := config.Validate(); err != nil { + return nil, err + } + + return &config, nil } -// createRelay creates a new relay config file -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 { - writeError(w, http.StatusConflict, "relay with this id already exists") - return - } - - config, err := api.parseAndValidateConfig(r) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - - if err := api.checkDuplicateSchemaOrHost(config, ""); err != nil { - writeError(w, http.StatusConflict, err.Error()) - return - } - - if err := api.saveConfig(configPath, config); err != nil { - writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to write config: %v", err)) - return - } - - 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 := r.PathValue("id") - 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 - } - - config, err := api.parseAndValidateConfig(r) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - - if err := api.checkDuplicateSchemaOrHost(config, id+".toml"); err != nil { - writeError(w, http.StatusConflict, err.Error()) - return - } - - if err := api.saveConfig(configPath, config); err != nil { - writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to write config: %v", err)) - return - } - - writeJSON(w, http.StatusOK, map[string]string{"message": "relay updated successfully"}) -} - -// patchRelay partially updates an existing relay config -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 { - 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 - } - - // 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 - } - - // 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.validateConfig(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"}) -} - -// readPatch reads and parses the patch JSON from the request -func (api *APIHandler) readPatch(r *http.Request) (map[string]interface{}, error) { +func (api *APIHandler) patchFromRequest(r *http.Request) (map[string]interface{}, error) { r.Body = http.MaxBytesReader(nil, r.Body, 1024*1024) defer r.Body.Close() @@ -283,13 +104,138 @@ func (api *APIHandler) readPatch(r *http.Request) (map[string]interface{}, error var patch map[string]interface{} if err := json.Unmarshal(body, &patch); err != nil { - return nil, fmt.Errorf("invalid json: %w", err) + return nil, fmt.Errorf("invalid json config: %w", err) } return patch, nil } -// applyPatch applies a JSON patch to a config using reflection via JSON marshaling +func (api *APIHandler) checkDuplicateSchemaOrHost(config *Config, excludeFilename string) error { + entries, err := os.ReadDir(Env("CONFIG")) + if err != nil { + return fmt.Errorf("failed to read config directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() || entry.Name() == excludeFilename || !strings.HasSuffix(entry.Name(), ".toml") { + continue + } + + if existing, err := LoadConfigFromName(entry.Name()); err == nil { + if existing.Schema == config.Schema { + return fmt.Errorf("schema %q is already in use", config.Schema) + } + if existing.Host == config.Host { + return fmt.Errorf("host %q is already in use", config.Host) + } + } + } + + return nil +} + +// Create relay + +func (api *APIHandler) createRelay(w http.ResponseWriter, r *http.Request) { + path := ConfigPathFromId(r.PathValue("id")) + if _, err := os.Stat(path); err == nil { + writeError(w, http.StatusConflict, "relay with this id already exists") + return + } + + config, err := api.configFromRequest(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := api.checkDuplicateSchemaOrHost(config, ""); err != nil { + writeError(w, http.StatusConflict, err.Error()) + return + } + + if err := config.Save(); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to write config: %v", err)) + return + } + + writeJSON(w, http.StatusCreated, map[string]string{"message": "relay created successfully"}) +} + +// Put relay + +func (api *APIHandler) putRelay(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + path := ConfigPathFromId(id) + if _, err := os.Stat(path); err != nil { + writeError(w, http.StatusConflict, "relay not found") + return + } + + config, err := api.configFromRequest(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := api.checkDuplicateSchemaOrHost(config, id+".toml"); err != nil { + writeError(w, http.StatusConflict, err.Error()) + return + } + + if err := config.Save(); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to write config: %v", err)) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "relay updated successfully"}) +} + +// Patch relay + +func (api *APIHandler) patchRelay(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + path := ConfigPathFromId(id) + if _, err := os.Stat(path); err != nil { + writeError(w, http.StatusConflict, "relay not found") + return + } + + config, err := LoadConfigFromPath(path) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to read existing config: %v", err)) + return + } + + patch, err := api.patchFromRequest(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := api.applyPatch(config, patch); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := config.Validate(); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := api.checkDuplicateSchemaOrHost(config, id+".toml"); err != nil { + writeError(w, http.StatusConflict, err.Error()) + return + } + + if err := config.Save(); 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"}) +} + func (api *APIHandler) applyPatch(config *Config, patch map[string]interface{}) error { // Convert config to map for merging configJSON, _ := json.Marshal(config) @@ -311,7 +257,6 @@ func (api *APIHandler) applyPatch(config *Config, patch map[string]interface{}) return nil } -// deepMerge recursively merges patch into base func deepMerge(base, patch map[string]interface{}) map[string]interface{} { result := make(map[string]interface{}) @@ -336,50 +281,17 @@ func deepMerge(base, patch map[string]interface{}) map[string]interface{} { return result } -// validateConfig validates a config -func (api *APIHandler) validateConfig(config *Config) error { - if config.Host == "" { - return fmt.Errorf("host is required") - } - if config.Schema == "" { - return fmt.Errorf("schema is required") - } - if !regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`).MatchString(config.Schema) { - return fmt.Errorf("schema must contain only letters, numbers, and underscores") - } - 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) - } - } - normalizeBlossomConfig(config) - if err := validateBlossomFileStorage(config); err != nil { - return err - } - return nil -} +// Delete relay -// deleteRelay deletes a relay config file 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 { - if os.IsNotExist(err) { - writeError(w, http.StatusNotFound, "relay not found") - } else { - writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to check config: %v", err)) - } + path := ConfigPathFromId(id) + if _, err := os.Stat(path); err != nil { + writeError(w, http.StatusConflict, "relay not found") return } - if err := os.Remove(configPath); err != nil { + if err := os.Remove(path); err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to delete config: %v", err)) return } @@ -387,96 +299,60 @@ func (api *APIHandler) deleteRelay(w http.ResponseWriter, r *http.Request) { 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" +// Relay members endpoint + +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) { + writeError(w, http.StatusNotFound, "relay not found") + } else { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to load relay members: %v", err)) + } + return + } + + writeJSON(w, http.StatusOK, map[string][]string{"members": members}) } -// configPath returns the full path for a config file -func (api *APIHandler) configPath(id string) string { - return filepath.Join(api.configDir, api.configName(id)) -} +func (api *APIHandler) resolveRelayMembers(id string) ([]string, error) { + instancesMux.RLock() + instance, exists := instancesByName[id+".toml"] + instancesMux.RUnlock() -// checkConfigExists checks if a config file exists -func (api *APIHandler) checkConfigExists(path string) error { - _, err := os.Stat(path) - return err -} + if exists { + return collectMembers(instance.Management), nil + } -// loadConfigFromPath loads a config from a file path -func (api *APIHandler) loadConfigFromPath(path string) (*Config, error) { - var config Config - _, err := toml.DecodeFile(path, &config) + config, err := LoadConfigFromId(id) if err != nil { return nil, err } - normalizeBlossomConfig(&config) - return &config, nil + + events := &EventStore{ + Config: config, + Schema: &Schema{Name: config.Schema}, + } + + 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 } -// 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) +func collectMembers(management *ManagementStore) []string { + memberSet := make(map[string]struct{}) + for _, pubkey := range management.GetMembers() { + memberSet[pubkey.Hex()] = struct{}{} } - - var config Config - if err := json.Unmarshal(body, &config); err != nil { - return nil, fmt.Errorf("invalid json config: %w", err) - } - - if err := api.validateConfig(&config); err != nil { - return nil, err - } - - return &config, nil -} - -// 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() - - encoder := toml.NewEncoder(file) - if err := encoder.Encode(config); err != nil { - return fmt.Errorf("failed to encode toml: %w", err) - } - - return nil -} - -// checkDuplicateSchemaOrHost checks if the schema or host is already in use by another config -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() || entry.Name() == excludeFilename || !strings.HasSuffix(entry.Name(), ".toml") { - continue - } - - path := filepath.Join(api.configDir, entry.Name()) - var existing Config - if _, err := toml.DecodeFile(path, &existing); err != nil { - continue - } - - if existing.Schema == config.Schema { - return fmt.Errorf("schema %q is already in use", config.Schema) - } - if existing.Host == config.Host { - return fmt.Errorf("host %q is already in use", config.Host) - } - } - - return nil + members := Keys(memberSet) + sort.Strings(members) + return members } diff --git a/zooid/api_test.go b/zooid/api_test.go index 6bc66ce..0b8aab6 100644 --- a/zooid/api_test.go +++ b/zooid/api_test.go @@ -13,7 +13,6 @@ import ( "testing" "fiatjaf.com/nostr" - "github.com/gosimple/slug" ) func TestAPIHandler_Authentication(t *testing.T) { @@ -693,7 +692,7 @@ func TestAPIHandler_ListRelayMembers(t *testing.T) { // Seed DB with RELAY_MEMBERS to simulate a prior relay load. seedEvents := &EventStore{ Config: &Config{secret: relaySecret}, - Schema: &Schema{Name: slug.Make(config.Schema)}, + Schema: &Schema{Name: config.Schema}, } if err := seedEvents.Init(); err != nil { t.Fatalf("failed to init seed events: %v", err) diff --git a/zooid/blossom.go b/zooid/blossom.go index 0ff6791..959f578 100644 --- a/zooid/blossom.go +++ b/zooid/blossom.go @@ -3,8 +3,8 @@ package zooid import ( "bytes" "context" - "fmt" "io" + "fmt" "log" "net/url" "path/filepath" @@ -16,7 +16,6 @@ import ( awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/gosimple/slug" "github.com/spf13/afero" ) @@ -25,131 +24,24 @@ type BlossomStore struct { Events eventstore.Store } -func loadAWSConfigForBlossomS3(ctx context.Context, s *BlossomS3Settings) (aws.Config, error) { - return awsconfig.LoadDefaultConfig(ctx, - awsconfig.WithRegion(s.Region), - awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(s.AccessKey, s.SecretKey, "")), - ) -} - -func s3APIClientForBlossomSettings(awsCfg aws.Config, s *BlossomS3Settings) *s3.Client { - customEndpoint := s.Endpoint != "" - return s3.NewFromConfig(awsCfg, func(o *s3.Options) { - if customEndpoint { - o.BaseEndpoint = aws.String(s.Endpoint) - // Custom endpoints (e.g. MinIO) expect path-style addressing. - o.UsePathStyle = true - } - }) -} - -func blossomS3ObjectKey(slugName, sha256, keyPrefix string) string { - rel := slugName + "/" + sha256 - if keyPrefix != "" { - return keyPrefix + "/" + rel - } - return rel -} - -func attachBlossomLocalBlobs(bs *blossom.BlossomServer, slugName string) { - dir := filepath.Join(Env("MEDIA"), slugName) - osfs := afero.NewOsFs() - _ = osfs.MkdirAll(dir, 0755) - - bs.StoreBlob = func(ctx context.Context, sha256 string, ext string, body []byte) error { - file, err := osfs.Create(filepath.Join(dir, sha256)) - if err != nil { - return err - } - - if _, err := io.Copy(file, bytes.NewReader(body)); err != nil { - return err - } - - return nil - } - - bs.LoadBlob = func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, *url.URL, error) { - file, err := osfs.Open(filepath.Join(dir, sha256)) - if err != nil { - return nil, nil, err - } - return file, nil, nil - } - - bs.DeleteBlob = func(ctx context.Context, sha256 string, ext string) error { - return osfs.Remove(filepath.Join(dir, sha256)) - } -} - -func attachBlossomS3Blobs(bs *blossom.BlossomServer, cfg *Config, slugName string) error { - s := &cfg.Blossom.S3 - ctx := context.Background() - - awsCfg, err := loadAWSConfigForBlossomS3(ctx, s) - if err != nil { - return fmt.Errorf("aws config: %w", err) - } - - client := s3APIClientForBlossomSettings(awsCfg, s) - bucket := s.Bucket - - bs.StoreBlob = func(ctx context.Context, sha256 string, ext string, body []byte) error { - _, err := client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(bucket), - Key: aws.String(blossomS3ObjectKey(slugName, sha256, s.KeyPrefix)), - Body: bytes.NewReader(body), - }) - return err - } - - bs.LoadBlob = func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, *url.URL, error) { - out, err := client.GetObject(ctx, &s3.GetObjectInput{ - Bucket: aws.String(bucket), - Key: aws.String(blossomS3ObjectKey(slugName, sha256, s.KeyPrefix)), - }) - if err != nil { - return nil, nil, err - } - defer out.Body.Close() - - data, err := io.ReadAll(out.Body) - if err != nil { - return nil, nil, err - } - - return bytes.NewReader(data), nil, nil - } - - bs.DeleteBlob = func(ctx context.Context, sha256 string, ext string) error { - _, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{ - Bucket: aws.String(bucket), - Key: aws.String(blossomS3ObjectKey(slugName, sha256, s.KeyPrefix)), - }) - return err - } - - return nil -} - func (bl *BlossomStore) Enable(instance *Instance) { - slugName := slug.Make(bl.Config.Schema) backend := blossom.New(instance.Relay, "https://"+bl.Config.Host) - backend.Store = blossom.EventStoreBlobIndexWrapper{ Store: bl.Events, ServiceURL: "https://" + bl.Config.Host, } - switch bl.Config.Blossom.Backend { + switch bl.Config.Blossom.Adapter { case "local": - attachBlossomLocalBlobs(backend, slugName) + if err := bl.UseLocalAdapter(backend); err != nil { + log.Fatalf("blossom: failed to use local adapter %q", err) + } case "s3": - if err := attachBlossomS3Blobs(backend, bl.Config, slugName); err != nil { - log.Fatalf("blossom: s3: %v", err) + if err := bl.UseS3Adapter(backend); err != nil { + log.Fatalf("blossom: failed to use s3 adapter %q", err) } default: - log.Fatalf("blossom: unknown backend %q (use local or s3)", bl.Config.Blossom.Backend) + log.Fatalf("blossom: unknown backend %q", bl.Config.Blossom.Adapter) } backend.RejectUpload = func(ctx context.Context, auth *nostr.Event, size int, ext string) (bool, string, int) { @@ -197,3 +89,114 @@ func (bl *BlossomStore) Enable(instance *Instance) { instance.Relay.Info.SupportedNIPs = append(instance.Relay.Info.SupportedNIPs, "BUD-02") instance.Relay.Info.SupportedNIPs = append(instance.Relay.Info.SupportedNIPs, "BUD-11") } + +// Local adapter + +func (bl *BlossomStore) UseLocalAdapter(backend *blossom.BlossomServer) error { + dir := filepath.Join(Env("MEDIA"), bl.Config.Schema) + osfs := afero.NewOsFs() + _ = osfs.MkdirAll(dir, 0755) + + backend.StoreBlob = func(ctx context.Context, sha256 string, ext string, body []byte) error { + file, err := osfs.Create(filepath.Join(dir, sha256)) + if err != nil { + return err + } + + if _, err := io.Copy(file, bytes.NewReader(body)); err != nil { + return err + } + + return nil + } + + backend.LoadBlob = func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, *url.URL, error) { + file, err := osfs.Open(filepath.Join(dir, sha256)) + if err != nil { + return nil, nil, err + } + return file, nil, nil + } + + backend.DeleteBlob = func(ctx context.Context, sha256 string, ext string) error { + return osfs.Remove(filepath.Join(dir, sha256)) + } + + return nil +} + +// S3 adapter + +func (bl *BlossomStore) S3Key(sha256 string) string { + key := bl.Config.Schema + "/" + sha256 + + if bl.Config.Blossom.S3.KeyPrefix != "" { + key = bl.Config.Blossom.S3.KeyPrefix + "/" + key + } + + return key +} + +func (bl *BlossomStore) UseS3Adapter(backend *blossom.BlossomServer) error { + ctx := context.Background() + awsConfig, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithRegion(bl.Config.Blossom.S3.Region), + awsconfig.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider( + bl.Config.Blossom.S3.AccessKey, + bl.Config.Blossom.S3.SecretKey, + "", + ), + ), + ) + + if err != nil { + return fmt.Errorf("aws config: %w", err) + } + + client := s3.NewFromConfig(awsConfig, func(o *s3.Options) { + if bl.Config.Blossom.S3.Endpoint != "" { + o.BaseEndpoint = aws.String(bl.Config.Blossom.S3.Endpoint) + o.UsePathStyle = true + } + }) + + backend.StoreBlob = func(ctx context.Context, sha256 string, ext string, body []byte) error { + _, err := client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bl.Config.Blossom.S3.Bucket), + Key: aws.String(bl.S3Key(sha256)), + Body: bytes.NewReader(body), + }) + + return err + } + + backend.LoadBlob = func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, *url.URL, error) { + out, err := client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(bl.Config.Blossom.S3.Bucket), + Key: aws.String(bl.S3Key(sha256)), + }) + if err != nil { + return nil, nil, err + } + defer out.Body.Close() + + data, err := io.ReadAll(out.Body) + if err != nil { + return nil, nil, err + } + + return bytes.NewReader(data), nil, nil + } + + backend.DeleteBlob = func(ctx context.Context, sha256 string, ext string) error { + _, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(bl.Config.Blossom.S3.Bucket), + Key: aws.String(bl.S3Key(sha256)), + }) + + return err + } + + return nil +} diff --git a/zooid/config.go b/zooid/config.go index b2d12ba..25f855f 100644 --- a/zooid/config.go +++ b/zooid/config.go @@ -3,11 +3,11 @@ package zooid import ( "fiatjaf.com/nostr" "fmt" + "regexp" "github.com/BurntSushi/toml" "os" "path/filepath" "slices" - "strings" ) type Role struct { @@ -46,10 +46,10 @@ type Config struct { } `toml:"management" json:"management"` Blossom struct { - Enabled bool `toml:"enabled" json:"enabled"` - AuthenticatedRead bool `toml:"authenticated_read" json:"authenticated_read"` - Backend string `toml:"backend" json:"backend"` - S3 BlossomS3Settings `toml:"s3" json:"s3"` + Enabled bool `toml:"enabled" json:"enabled"` + AuthenticatedRead bool `toml:"authenticated_read" json:"authenticated_read"` + Adapter string `toml:"adapter" json:"adapter"` + S3 BlossomS3Settings `toml:"s3" json:"s3"` } `toml:"blossom" json:"blossom"` Livekit struct { @@ -66,7 +66,7 @@ type Config struct { } // BlossomS3Settings configures S3-compatible object storage for Blossom blobs -// when [blossom] backend is "s3". +// when [blossom] adapter is "s3". type BlossomS3Settings struct { Endpoint string `toml:"endpoint" json:"endpoint"` Region string `toml:"region" json:"region"` @@ -76,10 +76,20 @@ type BlossomS3Settings struct { KeyPrefix string `toml:"key_prefix" json:"key_prefix"` } -func LoadConfig(filename string) (*Config, error) { - path := filepath.Join(Env("CONFIG"), filename) +func ConfigPathFromId(id string) string { + return filepath.Join(Env("CONFIG"), id+".toml") +} - return LoadConfigFromPath(path) +func ConfigPathFromName(name string) string { + return filepath.Join(Env("CONFIG"), name) +} + +func LoadConfigFromId(id string) (*Config, error) { + return LoadConfigFromPath(ConfigPathFromId(id)) +} + +func LoadConfigFromName(name string) (*Config, error) { + return LoadConfigFromPath(ConfigPathFromName(name)) } func LoadConfigFromPath(path string) (*Config, error) { @@ -88,79 +98,63 @@ func LoadConfigFromPath(path string) (*Config, error) { return nil, fmt.Errorf("Failed to parse config file %s: %w", path, err) } - normalizeBlossomConfig(&config) - - if config.Host == "" { - return nil, fmt.Errorf("host is required") - } - - if config.Schema == "" { - return nil, fmt.Errorf("schema is required") - } - - if config.Info.Pubkey == "" { - return nil, fmt.Errorf("info.pubkey is required") - } - - secret, err := nostr.SecretKeyFromHex(config.Secret) - if err != nil { - return nil, err - } - - // Save the path for later config.path = path - // Make the secret... secret - config.Secret = "" - config.secret = secret - - if err := validateBlossomFileStorage(&config); err != nil { + if err := config.Validate(); err != nil { return nil, err } return &config, nil } -func normalizeBlossomConfig(c *Config) { - s := &c.Blossom.S3 - s.Region = strings.TrimSpace(s.Region) - s.Bucket = strings.TrimSpace(s.Bucket) - s.AccessKey = strings.TrimSpace(s.AccessKey) - s.SecretKey = strings.TrimSpace(s.SecretKey) - s.Endpoint = strings.TrimRight(strings.TrimSpace(s.Endpoint), "/") - s.KeyPrefix = strings.Trim(strings.TrimSpace(s.KeyPrefix), "/") +func (config *Config) Validate() error { + if config.Blossom.Adapter == "" { + config.Blossom.Adapter = "local" + } - c.Blossom.Backend = strings.ToLower(strings.TrimSpace(c.Blossom.Backend)) - if c.Blossom.Backend == "" { - c.Blossom.Backend = "local" + if config.Host == "" { + return fmt.Errorf("host is required") } -} -func validateBlossomFileStorage(c *Config) error { - if !c.Blossom.Enabled { - return nil + if config.Schema == "" { + return fmt.Errorf("schema is required") } - switch c.Blossom.Backend { - case "local": - return nil - case "s3": - // fall through - default: - return fmt.Errorf(`blossom.backend must be "local", "s3", or empty (defaults to local)`) + + if !regexp.MustCompile(`^[a-z_][a-z0-9_]*$`).MatchString(config.Schema) { + return fmt.Errorf("schema must contain only lowercase letters, numbers, and underscores") } - s := c.Blossom.S3 - if s.Bucket == "" { - return fmt.Errorf("blossom.s3.bucket is required when blossom.backend is s3") + + secret, err := nostr.SecretKeyFromHex(config.Secret) + + if err != nil { + return fmt.Errorf("invalid secret key: %w", err) } - if s.Region == "" { - return fmt.Errorf("blossom.s3.region is required when blossom.backend is s3") + + // Make the secret... secret + config.Secret = "" + config.secret = secret + + if _, err := nostr.PubKeyFromHex(config.Info.Pubkey); err != nil { + return fmt.Errorf("invalid info.pubkey: %w", err) } - if s.AccessKey == "" { - return fmt.Errorf("blossom.s3.access_key is required when blossom.backend is s3") - } - if s.SecretKey == "" { - return fmt.Errorf("blossom.s3.secret_key is required when blossom.backend is s3") + + if config.Blossom.Adapter == "s3" { + if config.Blossom.S3.Bucket == "" { + return fmt.Errorf("blossom.s3.bucket is required when blossom.adapter is s3") + } + if config.Blossom.S3.Region == "" { + return fmt.Errorf("blossom.s3.region is required when blossom.adapter is s3") + } + if config.Blossom.S3.AccessKey == "" { + return fmt.Errorf("blossom.s3.access_key is required when blossom.adapter is s3") + } + if config.Blossom.S3.SecretKey == "" { + return fmt.Errorf("blossom.s3.secret_key is required when blossom.adapter is s3") + } + } else if config.Blossom.Adapter != "local" { + return fmt.Errorf("invalid blossom adapter") } + return nil } diff --git a/zooid/instance.go b/zooid/instance.go index 6176bd6..16ca29f 100644 --- a/zooid/instance.go +++ b/zooid/instance.go @@ -9,7 +9,6 @@ import ( "fiatjaf.com/nostr" "fiatjaf.com/nostr/khatru" - "github.com/gosimple/slug" ) type Instance struct { @@ -23,7 +22,7 @@ type Instance struct { } func MakeInstance(filename string) (*Instance, error) { - config, err := LoadConfig(filename) + config, err := LoadConfigFromName(filename) if err != nil { return nil, err } @@ -38,7 +37,7 @@ func makeInstance(config *Config, source string) (*Instance, error) { Relay: relay, Config: config, Schema: &Schema{ - Name: slug.Make(config.Schema), + Name: config.Schema, }, } diff --git a/zooid/lib.go b/zooid/lib.go index 3857566..ac3e052 100644 --- a/zooid/lib.go +++ b/zooid/lib.go @@ -64,7 +64,7 @@ func Start() { if err != nil { log.Printf("Failed to make instance for %s: %v", entry.Name(), err) } else if instance.Config.Inactive { - instance.Cleanup() + instance.Cleanup() log.Printf("Skipped inactive %s", entry.Name()) } else { instancesByHost[instance.Config.Host] = instance @@ -112,7 +112,7 @@ func Start() { if err != nil { log.Printf("Failed to reload %s: %v", filename, err) } else if instance.Config.Inactive { - instance.Cleanup() + instance.Cleanup() log.Printf("Skipped inactive %s", filename) } else { instancesByHost[instance.Config.Host] = instance diff --git a/zooid/util.go b/zooid/util.go index e54ec55..57454b2 100644 --- a/zooid/util.go +++ b/zooid/util.go @@ -183,7 +183,12 @@ func validateNIP98Auth(r *http.Request) (nostr.PubKey, error) { return nostr.PubKey{}, fmt.Errorf("invalid event signature") } - expectedURL := fmt.Sprintf("%s://%s%s", scheme(r), r.Host, r.URL.Path) + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = scheme + "s" + } + + expectedURL := fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.Path) var hasURL, hasMethod bool for _, tag := range event.Tags {