Compare commits

...

11 Commits

Author SHA1 Message Date
27af174753 Implement event deletion logic with relay handling in App.svelte and add connectToRelay method in NostrClient
Some checks failed
Go / build (push) Has been cancelled
This commit enhances the event deletion process by introducing conditional publishing to external relays based on user roles and ownership. It also adds a new method in the NostrClient class to connect to a single relay, improving the flexibility of relay management. The version is bumped to v0.12.3 to reflect these changes.
2025-10-10 09:07:43 +01:00
cad366795a bump to v0.12.2 for sprocket failure handling fix
Some checks failed
Go / build (push) Has been cancelled
2025-10-09 19:56:25 +01:00
e14b89bc8b Enhance Sprocket functionality and error handling
This commit introduces significant improvements to the Sprocket system, including:

- Detailed documentation in `readme.adoc` for manual updates and failure handling.
- Implementation of automatic disablement of Sprocket on failure, with periodic checks for recovery.
- Enhanced logging for event rejection when Sprocket is disabled or not running.

These changes ensure better user guidance and system resilience during Sprocket failures.
2025-10-09 19:55:20 +01:00
5b4dd9ea60 bump for better documentation
Some checks failed
Go / build (push) Has been cancelled
2025-10-09 19:34:25 +01:00
bae1d09f8d Add Sprocket Test Suite and Integration Scripts
This commit introduces a comprehensive test suite for the Sprocket integration, including various test scripts to validate functionality. Key additions include:

- `run-sprocket-test.sh`: An automated test runner for Sprocket integration tests.
- `SPROCKET_TEST_README.md`: Documentation detailing the test suite, criteria, and usage instructions.
- `test-sprocket-complete.sh`: A complete test suite that sets up the relay and runs all tests.
- `test-sprocket-manual.sh`: A manual testing script for interactive event testing.
- `test-sprocket-demo.sh`: A demonstration script showcasing Sprocket functionality.
- Additional test scripts for various scenarios, including normal events, spam detection, and blocked hashtags.

These changes enhance the testing framework for the Sprocket system, ensuring robust validation of event processing capabilities.
2025-10-09 19:33:42 +01:00
f1f3236196 revise readme.adoc 2025-10-09 19:31:38 +01:00
f01cd562f8 added sprocket script capability
Some checks failed
Go / build (push) Has been cancelled
2025-10-09 19:11:29 +01:00
d2d0821d19 implement first draft of sprockets 2025-10-09 19:09:37 +01:00
09b00c76ed bump to v0.11.3
Some checks failed
Go / build (push) Has been cancelled
2025-10-09 18:10:46 +01:00
de57fd7bc4 Revert "fixing app icon"
This reverts commit b7c2e609f6.
2025-10-09 18:00:44 +01:00
b7c2e609f6 fixing app icon
Some checks failed
Go / build (push) Has been cancelled
2025-10-09 17:52:14 +01:00
23 changed files with 3614 additions and 22 deletions

View File

@@ -51,6 +51,9 @@ type C struct {
// Web UI and dev mode settings
WebDisableEmbedded bool `env:"ORLY_WEB_DISABLE" default:"false" usage:"disable serving the embedded web UI; useful for hot-reload during development"`
WebDevProxyURL string `env:"ORLY_WEB_DEV_PROXY_URL" usage:"when ORLY_WEB_DISABLE is true, reverse-proxy non-API paths to this dev server URL (e.g. http://localhost:5173)"`
// Sprocket settings
SprocketEnabled bool `env:"ORLY_SPROCKET_ENABLED" default:"false" usage:"enable sprocket event processing plugin system"`
}
// New creates and initializes a new configuration object for the relay

View File

@@ -32,6 +32,69 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
if len(msg) > 0 {
log.I.F("extra '%s'", msg)
}
// Check if sprocket is enabled and process event through it
if l.sprocketManager != nil && l.sprocketManager.IsEnabled() {
if l.sprocketManager.IsDisabled() {
// Sprocket is disabled due to failure - reject all events
log.W.F("sprocket is disabled, rejecting event %0x", env.E.ID)
if err = Ok.Error(
l, env, "sprocket disabled - events rejected until sprocket is restored",
); chk.E(err) {
return
}
return
}
if !l.sprocketManager.IsRunning() {
// Sprocket is enabled but not running - reject all events
log.W.F("sprocket is enabled but not running, rejecting event %0x", env.E.ID)
if err = Ok.Error(
l, env, "sprocket not running - events rejected until sprocket starts",
); chk.E(err) {
return
}
return
}
// Process event through sprocket
response, sprocketErr := l.sprocketManager.ProcessEvent(env.E)
if chk.E(sprocketErr) {
log.E.F("sprocket processing failed: %v", sprocketErr)
if err = Ok.Error(
l, env, "sprocket processing failed",
); chk.E(err) {
return
}
return
}
// Handle sprocket response
switch response.Action {
case "accept":
// Continue with normal processing
log.D.F("sprocket accepted event %0x", env.E.ID)
case "reject":
// Return OK false with message
if err = okenvelope.NewFrom(
env.Id(), false,
reason.Error.F(response.Msg),
).Write(l); chk.E(err) {
return
}
return
case "shadowReject":
// Return OK true but abort processing
if err = Ok.Ok(l, env, ""); chk.E(err) {
return
}
log.D.F("sprocket shadow rejected event %0x", env.E.ID)
return
default:
log.W.F("unknown sprocket action: %s", response.Action)
// Default to accept for unknown actions
}
}
// check the event ID is correct
calculatedId := env.E.GetIDBytes()
if !utils.FastEqual(calculatedId, env.E.ID) {

View File

@@ -19,11 +19,9 @@ func Run(
) (quit chan struct{}) {
// shutdown handler
go func() {
select {
case <-ctx.Done():
log.I.F("shutting down")
close(quit)
}
<-ctx.Done()
log.I.F("shutting down")
close(quit)
}()
// get the admins
var err error
@@ -46,6 +44,9 @@ func Run(
publishers: publish.New(NewPublisher(ctx)),
Admins: adminKeys,
}
// Initialize sprocket manager
l.sprocketManager = NewSprocketManager(ctx, cfg.AppName, cfg.SprocketEnabled)
// Initialize the user interface
l.UserInterface()

View File

@@ -3,6 +3,7 @@ package app
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
@@ -43,6 +44,7 @@ type Server struct {
challenges map[string][]byte
paymentProcessor *PaymentProcessor
sprocketManager *SprocketManager
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -192,6 +194,13 @@ func (s *Server) UserInterface() {
s.mux.HandleFunc("/api/events/mine", s.handleEventsMine)
// Import endpoint (admin only)
s.mux.HandleFunc("/api/import", s.handleImport)
// Sprocket endpoints (owner only)
s.mux.HandleFunc("/api/sprocket/status", s.handleSprocketStatus)
s.mux.HandleFunc("/api/sprocket/update", s.handleSprocketUpdate)
s.mux.HandleFunc("/api/sprocket/restart", s.handleSprocketRestart)
s.mux.HandleFunc("/api/sprocket/versions", s.handleSprocketVersions)
s.mux.HandleFunc("/api/sprocket/delete-version", s.handleSprocketDeleteVersion)
s.mux.HandleFunc("/api/sprocket/config", s.handleSprocketConfig)
}
// handleLoginInterface serves the main user interface for login
@@ -655,3 +664,237 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"success": true, "message": "Import started"}`))
}
// handleSprocketStatus returns the current status of the sprocket script
func (s *Server) handleSprocketStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Validate NIP-98 authentication
valid, pubkey, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid {
errorMsg := "NIP-98 authentication validation failed"
if err != nil {
errorMsg = err.Error()
}
http.Error(w, errorMsg, http.StatusUnauthorized)
return
}
// Check permissions - require owner level
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
if accessLevel != "owner" {
http.Error(w, "Owner permission required", http.StatusForbidden)
return
}
status := s.sprocketManager.GetSprocketStatus()
w.Header().Set("Content-Type", "application/json")
jsonData, err := json.Marshal(status)
if chk.E(err) {
http.Error(w, "Error generating response", http.StatusInternalServerError)
return
}
w.Write(jsonData)
}
// handleSprocketUpdate updates the sprocket script and restarts it
func (s *Server) handleSprocketUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Validate NIP-98 authentication
valid, pubkey, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid {
errorMsg := "NIP-98 authentication validation failed"
if err != nil {
errorMsg = err.Error()
}
http.Error(w, errorMsg, http.StatusUnauthorized)
return
}
// Check permissions - require owner level
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
if accessLevel != "owner" {
http.Error(w, "Owner permission required", http.StatusForbidden)
return
}
// Read the request body
body, err := io.ReadAll(r.Body)
if chk.E(err) {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
// Update the sprocket script
if err := s.sprocketManager.UpdateSprocket(string(body)); chk.E(err) {
http.Error(w, fmt.Sprintf("Failed to update sprocket: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success": true, "message": "Sprocket updated successfully"}`))
}
// handleSprocketRestart restarts the sprocket script
func (s *Server) handleSprocketRestart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Validate NIP-98 authentication
valid, pubkey, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid {
errorMsg := "NIP-98 authentication validation failed"
if err != nil {
errorMsg = err.Error()
}
http.Error(w, errorMsg, http.StatusUnauthorized)
return
}
// Check permissions - require owner level
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
if accessLevel != "owner" {
http.Error(w, "Owner permission required", http.StatusForbidden)
return
}
// Restart the sprocket script
if err := s.sprocketManager.RestartSprocket(); chk.E(err) {
http.Error(w, fmt.Sprintf("Failed to restart sprocket: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success": true, "message": "Sprocket restarted successfully"}`))
}
// handleSprocketVersions returns all sprocket script versions
func (s *Server) handleSprocketVersions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Validate NIP-98 authentication
valid, pubkey, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid {
errorMsg := "NIP-98 authentication validation failed"
if err != nil {
errorMsg = err.Error()
}
http.Error(w, errorMsg, http.StatusUnauthorized)
return
}
// Check permissions - require owner level
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
if accessLevel != "owner" {
http.Error(w, "Owner permission required", http.StatusForbidden)
return
}
versions, err := s.sprocketManager.GetSprocketVersions()
if chk.E(err) {
http.Error(w, fmt.Sprintf("Failed to get sprocket versions: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
jsonData, err := json.Marshal(versions)
if chk.E(err) {
http.Error(w, "Error generating response", http.StatusInternalServerError)
return
}
w.Write(jsonData)
}
// handleSprocketDeleteVersion deletes a specific sprocket version
func (s *Server) handleSprocketDeleteVersion(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Validate NIP-98 authentication
valid, pubkey, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid {
errorMsg := "NIP-98 authentication validation failed"
if err != nil {
errorMsg = err.Error()
}
http.Error(w, errorMsg, http.StatusUnauthorized)
return
}
// Check permissions - require owner level
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
if accessLevel != "owner" {
http.Error(w, "Owner permission required", http.StatusForbidden)
return
}
// Read the request body
body, err := io.ReadAll(r.Body)
if chk.E(err) {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
var request struct {
Filename string `json:"filename"`
}
if err := json.Unmarshal(body, &request); chk.E(err) {
http.Error(w, "Invalid JSON in request body", http.StatusBadRequest)
return
}
if request.Filename == "" {
http.Error(w, "Filename is required", http.StatusBadRequest)
return
}
// Delete the sprocket version
if err := s.sprocketManager.DeleteSprocketVersion(request.Filename); chk.E(err) {
http.Error(w, fmt.Sprintf("Failed to delete sprocket version: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success": true, "message": "Sprocket version deleted successfully"}`))
}
// handleSprocketConfig returns the sprocket configuration status
func (s *Server) handleSprocketConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
response := struct {
Enabled bool `json:"enabled"`
}{
Enabled: s.Config.SprocketEnabled,
}
jsonData, err := json.Marshal(response)
if chk.E(err) {
http.Error(w, "Error generating response", http.StatusInternalServerError)
return
}
w.Write(jsonData)
}

613
app/sprocket.go Normal file
View File

@@ -0,0 +1,613 @@
package app
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/adrg/xdg"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/encoders/event"
)
// SprocketResponse represents a response from the sprocket script
type SprocketResponse struct {
ID string `json:"id"`
Action string `json:"action"` // accept, reject, or shadowReject
Msg string `json:"msg"` // NIP-20 response message (only used for reject)
}
// SprocketManager handles sprocket script execution and management
type SprocketManager struct {
ctx context.Context
cancel context.CancelFunc
configDir string
scriptPath string
currentCmd *exec.Cmd
currentCancel context.CancelFunc
mutex sync.RWMutex
isRunning bool
enabled bool
disabled bool // true when sprocket is disabled due to failure
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
responseChan chan SprocketResponse
}
// NewSprocketManager creates a new sprocket manager
func NewSprocketManager(ctx context.Context, appName string, enabled bool) *SprocketManager {
configDir := filepath.Join(xdg.ConfigHome, appName)
scriptPath := filepath.Join(configDir, "sprocket.sh")
ctx, cancel := context.WithCancel(ctx)
sm := &SprocketManager{
ctx: ctx,
cancel: cancel,
configDir: configDir,
scriptPath: scriptPath,
enabled: enabled,
disabled: false,
responseChan: make(chan SprocketResponse, 100), // Buffered channel for responses
}
// Start the sprocket script if it exists and is enabled
if enabled {
go sm.startSprocketIfExists()
// Start periodic check for sprocket script availability
go sm.periodicCheck()
}
return sm
}
// disableSprocket disables sprocket due to failure
func (sm *SprocketManager) disableSprocket() {
sm.mutex.Lock()
defer sm.mutex.Unlock()
if !sm.disabled {
sm.disabled = true
log.W.F("sprocket disabled due to failure - all events will be rejected (script location: %s)", sm.scriptPath)
}
}
// enableSprocket re-enables sprocket and attempts to start it
func (sm *SprocketManager) enableSprocket() {
sm.mutex.Lock()
defer sm.mutex.Unlock()
if sm.disabled {
sm.disabled = false
log.I.F("sprocket re-enabled, attempting to start")
// Attempt to start sprocket in background
go func() {
if _, err := os.Stat(sm.scriptPath); err == nil {
if err := sm.StartSprocket(); err != nil {
log.E.F("failed to restart sprocket: %v", err)
sm.disableSprocket()
} else {
log.I.F("sprocket restarted successfully")
}
} else {
log.W.F("sprocket script still not found, keeping disabled")
sm.disableSprocket()
}
}()
}
}
// periodicCheck periodically checks if sprocket script becomes available
func (sm *SprocketManager) periodicCheck() {
ticker := time.NewTicker(30 * time.Second) // Check every 30 seconds
defer ticker.Stop()
for {
select {
case <-sm.ctx.Done():
return
case <-ticker.C:
sm.mutex.RLock()
disabled := sm.disabled
running := sm.isRunning
sm.mutex.RUnlock()
// Only check if sprocket is disabled or not running
if disabled || !running {
if _, err := os.Stat(sm.scriptPath); err == nil {
// Script is available, try to enable/restart
if disabled {
sm.enableSprocket()
} else if !running {
// Script exists but sprocket isn't running, try to start
go func() {
if err := sm.StartSprocket(); err != nil {
log.E.F("failed to restart sprocket: %v", err)
sm.disableSprocket()
} else {
log.I.F("sprocket restarted successfully")
}
}()
}
}
}
}
}
}
// startSprocketIfExists starts the sprocket script if the file exists
func (sm *SprocketManager) startSprocketIfExists() {
if _, err := os.Stat(sm.scriptPath); err == nil {
if err := sm.StartSprocket(); err != nil {
log.E.F("failed to start sprocket: %v", err)
sm.disableSprocket()
}
} else {
log.W.F("sprocket script not found at %s, disabling sprocket", sm.scriptPath)
sm.disableSprocket()
}
}
// StartSprocket starts the sprocket script
func (sm *SprocketManager) StartSprocket() error {
sm.mutex.Lock()
defer sm.mutex.Unlock()
if sm.isRunning {
return fmt.Errorf("sprocket is already running")
}
if _, err := os.Stat(sm.scriptPath); os.IsNotExist(err) {
return fmt.Errorf("sprocket script does not exist")
}
// Create a new context for this command
cmdCtx, cmdCancel := context.WithCancel(sm.ctx)
// Make the script executable
if err := os.Chmod(sm.scriptPath, 0755); chk.E(err) {
cmdCancel()
return fmt.Errorf("failed to make script executable: %v", err)
}
// Start the script
cmd := exec.CommandContext(cmdCtx, sm.scriptPath)
cmd.Dir = sm.configDir
// Set up stdio pipes for communication
stdin, err := cmd.StdinPipe()
if chk.E(err) {
cmdCancel()
return fmt.Errorf("failed to create stdin pipe: %v", err)
}
stdout, err := cmd.StdoutPipe()
if chk.E(err) {
cmdCancel()
stdin.Close()
return fmt.Errorf("failed to create stdout pipe: %v", err)
}
stderr, err := cmd.StderrPipe()
if chk.E(err) {
cmdCancel()
stdin.Close()
stdout.Close()
return fmt.Errorf("failed to create stderr pipe: %v", err)
}
// Start the command
if err := cmd.Start(); chk.E(err) {
cmdCancel()
stdin.Close()
stdout.Close()
stderr.Close()
return fmt.Errorf("failed to start sprocket: %v", err)
}
sm.currentCmd = cmd
sm.currentCancel = cmdCancel
sm.stdin = stdin
sm.stdout = stdout
sm.stderr = stderr
sm.isRunning = true
// Start response reader in background
go sm.readResponses()
// Log stderr output in background
go sm.logOutput(stdout, stderr)
// Monitor the process
go sm.monitorProcess()
log.I.F("sprocket started (pid=%d)", cmd.Process.Pid)
return nil
}
// StopSprocket stops the sprocket script gracefully, with SIGKILL fallback
func (sm *SprocketManager) StopSprocket() error {
sm.mutex.Lock()
defer sm.mutex.Unlock()
if !sm.isRunning || sm.currentCmd == nil {
return fmt.Errorf("sprocket is not running")
}
// Close stdin first to signal the script to exit
if sm.stdin != nil {
sm.stdin.Close()
}
// Cancel the context
if sm.currentCancel != nil {
sm.currentCancel()
}
// Wait for graceful shutdown with timeout
done := make(chan error, 1)
go func() {
done <- sm.currentCmd.Wait()
}()
select {
case <-done:
// Process exited gracefully
log.I.F("sprocket stopped gracefully")
case <-time.After(5 * time.Second):
// Force kill after 5 seconds
log.W.F("sprocket did not stop gracefully, sending SIGKILL")
if err := sm.currentCmd.Process.Kill(); chk.E(err) {
log.E.F("failed to kill sprocket process: %v", err)
}
<-done // Wait for the kill to complete
}
// Clean up pipes
if sm.stdin != nil {
sm.stdin.Close()
sm.stdin = nil
}
if sm.stdout != nil {
sm.stdout.Close()
sm.stdout = nil
}
if sm.stderr != nil {
sm.stderr.Close()
sm.stderr = nil
}
sm.isRunning = false
sm.currentCmd = nil
sm.currentCancel = nil
return nil
}
// RestartSprocket stops and starts the sprocket script
func (sm *SprocketManager) RestartSprocket() error {
if sm.isRunning {
if err := sm.StopSprocket(); chk.E(err) {
return fmt.Errorf("failed to stop sprocket: %v", err)
}
// Give it a moment to fully stop
time.Sleep(100 * time.Millisecond)
}
return sm.StartSprocket()
}
// UpdateSprocket updates the sprocket script and restarts it with zero downtime
func (sm *SprocketManager) UpdateSprocket(scriptContent string) error {
// Ensure config directory exists
if err := os.MkdirAll(sm.configDir, 0755); chk.E(err) {
return fmt.Errorf("failed to create config directory: %v", err)
}
// If script content is empty, delete the script and stop
if strings.TrimSpace(scriptContent) == "" {
if sm.isRunning {
if err := sm.StopSprocket(); chk.E(err) {
log.E.F("failed to stop sprocket before deletion: %v", err)
}
}
if _, err := os.Stat(sm.scriptPath); err == nil {
if err := os.Remove(sm.scriptPath); chk.E(err) {
return fmt.Errorf("failed to delete sprocket script: %v", err)
}
log.I.F("sprocket script deleted")
}
return nil
}
// Create backup of existing script if it exists
if _, err := os.Stat(sm.scriptPath); err == nil {
timestamp := time.Now().Format("20060102150405")
backupPath := sm.scriptPath + "." + timestamp
if err := os.Rename(sm.scriptPath, backupPath); chk.E(err) {
log.W.F("failed to create backup: %v", err)
} else {
log.I.F("created backup: %s", backupPath)
}
}
// Write new script to temporary file first
tempPath := sm.scriptPath + ".tmp"
if err := os.WriteFile(tempPath, []byte(scriptContent), 0755); chk.E(err) {
return fmt.Errorf("failed to write temporary sprocket script: %v", err)
}
// If sprocket is running, do zero-downtime update
if sm.isRunning {
// Atomically replace the script file
if err := os.Rename(tempPath, sm.scriptPath); chk.E(err) {
os.Remove(tempPath) // Clean up temp file
return fmt.Errorf("failed to replace sprocket script: %v", err)
}
log.I.F("sprocket script updated atomically")
// Restart the sprocket process
return sm.RestartSprocket()
} else {
// Not running, just replace the file
if err := os.Rename(tempPath, sm.scriptPath); chk.E(err) {
os.Remove(tempPath) // Clean up temp file
return fmt.Errorf("failed to replace sprocket script: %v", err)
}
log.I.F("sprocket script updated")
return nil
}
}
// GetSprocketStatus returns the current status of the sprocket
func (sm *SprocketManager) GetSprocketStatus() map[string]interface{} {
sm.mutex.RLock()
defer sm.mutex.RUnlock()
status := map[string]interface{}{
"is_running": sm.isRunning,
"script_exists": false,
"script_path": sm.scriptPath,
}
if _, err := os.Stat(sm.scriptPath); err == nil {
status["script_exists"] = true
// Get script content
if content, err := os.ReadFile(sm.scriptPath); err == nil {
status["script_content"] = string(content)
}
// Get file info
if info, err := os.Stat(sm.scriptPath); err == nil {
status["script_modified"] = info.ModTime()
}
}
if sm.isRunning && sm.currentCmd != nil && sm.currentCmd.Process != nil {
status["pid"] = sm.currentCmd.Process.Pid
}
return status
}
// GetSprocketVersions returns a list of all sprocket script versions
func (sm *SprocketManager) GetSprocketVersions() ([]map[string]interface{}, error) {
versions := []map[string]interface{}{}
// Check for current script
if _, err := os.Stat(sm.scriptPath); err == nil {
if info, err := os.Stat(sm.scriptPath); err == nil {
if content, err := os.ReadFile(sm.scriptPath); err == nil {
versions = append(versions, map[string]interface{}{
"name": "sprocket.sh",
"path": sm.scriptPath,
"modified": info.ModTime(),
"content": string(content),
"is_current": true,
})
}
}
}
// Check for backup versions
dir := filepath.Dir(sm.scriptPath)
files, err := os.ReadDir(dir)
if chk.E(err) {
return versions, nil
}
for _, file := range files {
if strings.HasPrefix(file.Name(), "sprocket.sh.") && !file.IsDir() {
path := filepath.Join(dir, file.Name())
if info, err := os.Stat(path); err == nil {
if content, err := os.ReadFile(path); err == nil {
versions = append(versions, map[string]interface{}{
"name": file.Name(),
"path": path,
"modified": info.ModTime(),
"content": string(content),
"is_current": false,
})
}
}
}
}
return versions, nil
}
// DeleteSprocketVersion deletes a specific sprocket version
func (sm *SprocketManager) DeleteSprocketVersion(filename string) error {
// Don't allow deleting the current script
if filename == "sprocket.sh" {
return fmt.Errorf("cannot delete current sprocket script")
}
path := filepath.Join(sm.configDir, filename)
if err := os.Remove(path); chk.E(err) {
return fmt.Errorf("failed to delete sprocket version: %v", err)
}
log.I.F("deleted sprocket version: %s", filename)
return nil
}
// logOutput logs the output from stdout and stderr
func (sm *SprocketManager) logOutput(stdout, stderr io.ReadCloser) {
defer stdout.Close()
defer stderr.Close()
go func() {
io.Copy(os.Stdout, stdout)
}()
go func() {
io.Copy(os.Stderr, stderr)
}()
}
// ProcessEvent sends an event to the sprocket script and waits for a response
func (sm *SprocketManager) ProcessEvent(evt *event.E) (*SprocketResponse, error) {
sm.mutex.RLock()
if !sm.isRunning || sm.stdin == nil {
sm.mutex.RUnlock()
return nil, fmt.Errorf("sprocket is not running")
}
stdin := sm.stdin
sm.mutex.RUnlock()
// Serialize the event to JSON
eventJSON, err := json.Marshal(evt)
if chk.E(err) {
return nil, fmt.Errorf("failed to serialize event: %v", err)
}
// Send the event JSON to the sprocket script
// The final ']' should be the only thing after the event's raw JSON
if _, err := stdin.Write(eventJSON); chk.E(err) {
return nil, fmt.Errorf("failed to write event to sprocket: %v", err)
}
// Wait for response with timeout
select {
case response := <-sm.responseChan:
return &response, nil
case <-time.After(5 * time.Second):
return nil, fmt.Errorf("sprocket response timeout")
case <-sm.ctx.Done():
return nil, fmt.Errorf("sprocket context cancelled")
}
}
// readResponses reads JSONL responses from the sprocket script
func (sm *SprocketManager) readResponses() {
if sm.stdout == nil {
return
}
scanner := bufio.NewScanner(sm.stdout)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var response SprocketResponse
if err := json.Unmarshal([]byte(line), &response); chk.E(err) {
log.E.F("failed to parse sprocket response: %v", err)
continue
}
// Send response to channel (non-blocking)
select {
case sm.responseChan <- response:
default:
log.W.F("sprocket response channel full, dropping response")
}
}
if err := scanner.Err(); chk.E(err) {
log.E.F("error reading sprocket responses: %v", err)
}
}
// IsEnabled returns whether sprocket is enabled
func (sm *SprocketManager) IsEnabled() bool {
return sm.enabled
}
// IsRunning returns whether sprocket is currently running
func (sm *SprocketManager) IsRunning() bool {
sm.mutex.RLock()
defer sm.mutex.RUnlock()
return sm.isRunning
}
// IsDisabled returns whether sprocket is disabled due to failure
func (sm *SprocketManager) IsDisabled() bool {
sm.mutex.RLock()
defer sm.mutex.RUnlock()
return sm.disabled
}
// monitorProcess monitors the sprocket process and cleans up when it exits
func (sm *SprocketManager) monitorProcess() {
if sm.currentCmd == nil {
return
}
err := sm.currentCmd.Wait()
sm.mutex.Lock()
defer sm.mutex.Unlock()
// Clean up pipes
if sm.stdin != nil {
sm.stdin.Close()
sm.stdin = nil
}
if sm.stdout != nil {
sm.stdout.Close()
sm.stdout = nil
}
if sm.stderr != nil {
sm.stderr.Close()
sm.stderr = nil
}
sm.isRunning = false
sm.currentCmd = nil
sm.currentCancel = nil
if err != nil {
log.E.F("sprocket process exited with error: %v", err)
// Auto-disable sprocket on failure
sm.disabled = true
log.W.F("sprocket disabled due to process failure - all events will be rejected (script location: %s)", sm.scriptPath)
} else {
log.I.F("sprocket process exited normally")
}
}
// Shutdown gracefully shuts down the sprocket manager
func (sm *SprocketManager) Shutdown() {
sm.cancel()
if sm.isRunning {
sm.StopSprocket()
}
}

View File

@@ -1,6 +1,6 @@
<script>
import LoginModal from './LoginModal.svelte';
import { initializeNostrClient, fetchUserProfile, fetchAllEvents, fetchUserEvents, nostrClient } from './nostr.js';
import { initializeNostrClient, fetchUserProfile, fetchAllEvents, fetchUserEvents, nostrClient, NostrClient } from './nostr.js';
let isDarkTheme = false;
let showLoginModal = false;
@@ -36,6 +36,16 @@
// Events filter toggle
let showOnlyMyEvents = false;
// Sprocket management state
let sprocketScript = '';
let sprocketStatus = null;
let sprocketVersions = [];
let isLoadingSprocket = false;
let sprocketMessage = '';
let sprocketMessageType = 'info';
let sprocketEnabled = false;
let sprocketUploadFile = null;
// Kind name mapping based on repository kind definitions
const kindNames = {
0: "ProfileMetadata",
@@ -203,16 +213,45 @@
const signedDeleteEvent = await userSigner.signEvent(deleteEventTemplate);
console.log('Signed delete event:', signedDeleteEvent);
// Publish the delete event to the relay
const result = await nostrClient.publish(signedDeleteEvent);
console.log('Delete event published:', result);
// Determine if we should publish to external relays
// Only publish to external relays if:
// 1. User is deleting their own event, OR
// 2. User is admin/owner AND deleting their own event
const isDeletingOwnEvent = event.pubkey === userPubkey;
const isAdminOrOwner = (userRole === 'admin' || userRole === 'owner');
const shouldPublishToExternalRelays = isDeletingOwnEvent;
if (result.success && result.okCount > 0) {
// Remove from local list
allEvents = allEvents.filter(event => event.id !== eventId);
alert(`Event deleted successfully (accepted by ${result.okCount} relay(s))`);
if (shouldPublishToExternalRelays) {
// Publish the delete event to all relays (including external ones)
const result = await nostrClient.publish(signedDeleteEvent);
console.log('Delete event published:', result);
if (result.success && result.okCount > 0) {
// Remove from local list
allEvents = allEvents.filter(event => event.id !== eventId);
alert(`Event deleted successfully (accepted by ${result.okCount} relay(s))`);
} else {
throw new Error('No relays accepted the delete event');
}
} else {
throw new Error('No relays accepted the delete event');
// Admin/owner deleting someone else's event - only publish to local relay
// We need to publish only to the local relay, not external ones
const localRelayUrl = `wss://${window.location.host}/ws`;
// Create a modified client that only connects to the local relay
const localClient = new NostrClient();
await localClient.connectToRelay(localRelayUrl);
const result = await localClient.publish(signedDeleteEvent);
console.log('Delete event published to local relay only:', result);
if (result.success && result.okCount > 0) {
// Remove from local list
allEvents = allEvents.filter(event => event.id !== eventId);
alert(`Event deleted successfully (local relay only - admin/owner deleting other user's event)`);
} else {
throw new Error('Local relay did not accept the delete event');
}
}
} catch (error) {
console.error('Failed to delete event:', error);
@@ -261,6 +300,9 @@
// Load persistent app state
loadPersistentState();
// Load sprocket configuration
loadSprocketConfig();
}
function savePersistentState() {
@@ -356,6 +398,287 @@
savePersistentState();
}
// Sprocket management functions
async function loadSprocketConfig() {
try {
const response = await fetch('/api/sprocket/config', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
const config = await response.json();
sprocketEnabled = config.enabled;
}
} catch (error) {
console.error('Error loading sprocket config:', error);
}
}
async function loadSprocketStatus() {
if (!isLoggedIn || userRole !== 'owner' || !sprocketEnabled) return;
try {
isLoadingSprocket = true;
const response = await fetch('/api/sprocket/status', {
method: 'GET',
headers: {
'Authorization': `Nostr ${await createNIP98Auth('GET', '/api/sprocket/status')}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
sprocketStatus = await response.json();
} else {
showSprocketMessage('Failed to load sprocket status', 'error');
}
} catch (error) {
showSprocketMessage(`Error loading sprocket status: ${error.message}`, 'error');
} finally {
isLoadingSprocket = false;
}
}
async function loadSprocket() {
if (!isLoggedIn || userRole !== 'owner') return;
try {
isLoadingSprocket = true;
const response = await fetch('/api/sprocket/status', {
method: 'GET',
headers: {
'Authorization': `Nostr ${await createNIP98Auth('GET', '/api/sprocket/status')}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const status = await response.json();
sprocketScript = status.script_content || '';
sprocketStatus = status;
showSprocketMessage('Script loaded successfully', 'success');
} else {
showSprocketMessage('Failed to load script', 'error');
}
} catch (error) {
showSprocketMessage(`Error loading script: ${error.message}`, 'error');
} finally {
isLoadingSprocket = false;
}
}
async function saveSprocket() {
if (!isLoggedIn || userRole !== 'owner') return;
try {
isLoadingSprocket = true;
const response = await fetch('/api/sprocket/update', {
method: 'POST',
headers: {
'Authorization': `Nostr ${await createNIP98Auth('POST', '/api/sprocket/update')}`,
'Content-Type': 'text/plain'
},
body: sprocketScript
});
if (response.ok) {
showSprocketMessage('Script saved and updated successfully', 'success');
await loadSprocketStatus();
await loadVersions();
} else {
const errorText = await response.text();
showSprocketMessage(`Failed to save script: ${errorText}`, 'error');
}
} catch (error) {
showSprocketMessage(`Error saving script: ${error.message}`, 'error');
} finally {
isLoadingSprocket = false;
}
}
async function restartSprocket() {
if (!isLoggedIn || userRole !== 'owner') return;
try {
isLoadingSprocket = true;
const response = await fetch('/api/sprocket/restart', {
method: 'POST',
headers: {
'Authorization': `Nostr ${await createNIP98Auth('POST', '/api/sprocket/restart')}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
showSprocketMessage('Sprocket restarted successfully', 'success');
await loadSprocketStatus();
} else {
const errorText = await response.text();
showSprocketMessage(`Failed to restart sprocket: ${errorText}`, 'error');
}
} catch (error) {
showSprocketMessage(`Error restarting sprocket: ${error.message}`, 'error');
} finally {
isLoadingSprocket = false;
}
}
async function deleteSprocket() {
if (!isLoggedIn || userRole !== 'owner') return;
if (!confirm('Are you sure you want to delete the sprocket script? This will stop the current process.')) {
return;
}
try {
isLoadingSprocket = true;
const response = await fetch('/api/sprocket/update', {
method: 'POST',
headers: {
'Authorization': `Nostr ${await createNIP98Auth('POST', '/api/sprocket/update')}`,
'Content-Type': 'text/plain'
},
body: '' // Empty body deletes the script
});
if (response.ok) {
sprocketScript = '';
showSprocketMessage('Sprocket script deleted successfully', 'success');
await loadSprocketStatus();
await loadVersions();
} else {
const errorText = await response.text();
showSprocketMessage(`Failed to delete script: ${errorText}`, 'error');
}
} catch (error) {
showSprocketMessage(`Error deleting script: ${error.message}`, 'error');
} finally {
isLoadingSprocket = false;
}
}
async function loadVersions() {
if (!isLoggedIn || userRole !== 'owner') return;
try {
isLoadingSprocket = true;
const response = await fetch('/api/sprocket/versions', {
method: 'GET',
headers: {
'Authorization': `Nostr ${await createNIP98Auth('GET', '/api/sprocket/versions')}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
sprocketVersions = await response.json();
} else {
showSprocketMessage('Failed to load versions', 'error');
}
} catch (error) {
showSprocketMessage(`Error loading versions: ${error.message}`, 'error');
} finally {
isLoadingSprocket = false;
}
}
async function loadVersion(version) {
if (!isLoggedIn || userRole !== 'owner') return;
sprocketScript = version.content;
showSprocketMessage(`Loaded version: ${version.name}`, 'success');
}
async function deleteVersion(filename) {
if (!isLoggedIn || userRole !== 'owner') return;
if (!confirm(`Are you sure you want to delete version ${filename}?`)) {
return;
}
try {
isLoadingSprocket = true;
const response = await fetch('/api/sprocket/delete-version', {
method: 'POST',
headers: {
'Authorization': `Nostr ${await createNIP98Auth('POST', '/api/sprocket/delete-version')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ filename })
});
if (response.ok) {
showSprocketMessage(`Version ${filename} deleted successfully`, 'success');
await loadVersions();
} else {
const errorText = await response.text();
showSprocketMessage(`Failed to delete version: ${errorText}`, 'error');
}
} catch (error) {
showSprocketMessage(`Error deleting version: ${error.message}`, 'error');
} finally {
isLoadingSprocket = false;
}
}
function showSprocketMessage(message, type = 'info') {
sprocketMessage = message;
sprocketMessageType = type;
// Auto-hide message after 5 seconds
setTimeout(() => {
sprocketMessage = '';
}, 5000);
}
function handleSprocketFileSelect(event) {
sprocketUploadFile = event.target.files[0];
}
async function uploadSprocketScript() {
if (!isLoggedIn || userRole !== 'owner' || !sprocketUploadFile) return;
try {
isLoadingSprocket = true;
// Read the file content
const fileContent = await sprocketUploadFile.text();
// Upload the script
const response = await fetch('/api/sprocket/update', {
method: 'POST',
headers: {
'Authorization': `Nostr ${await createNIP98Auth('POST', '/api/sprocket/update')}`,
'Content-Type': 'text/plain'
},
body: fileContent
});
if (response.ok) {
sprocketScript = fileContent;
showSprocketMessage('Script uploaded and updated successfully', 'success');
await loadSprocketStatus();
await loadVersions();
} else {
const errorText = await response.text();
showSprocketMessage(`Failed to upload script: ${errorText}`, 'error');
}
} catch (error) {
showSprocketMessage(`Error uploading script: ${error.message}`, 'error');
} finally {
isLoadingSprocket = false;
sprocketUploadFile = null;
// Clear the file input
const fileInput = document.getElementById('sprocket-upload-file');
if (fileInput) {
fileInput.value = '';
}
}
}
const baseTabs = [
{id: 'export', icon: '📤', label: 'Export'},
{id: 'import', icon: '💾', label: 'Import', requiresAdmin: true},
@@ -371,6 +694,10 @@
if (tab.requiresOwner && (!isLoggedIn || userRole !== 'owner')) {
return false;
}
// Hide sprocket tab if not enabled
if (tab.id === 'sprocket' && !sprocketEnabled) {
return false;
}
return true;
});
@@ -379,6 +706,11 @@
function selectTab(tabId) {
selectedTab = tabId;
// Load sprocket data when switching to sprocket tab
if (tabId === 'sprocket' && isLoggedIn && userRole === 'owner' && sprocketEnabled) {
loadSprocketStatus();
loadVersions();
}
savePersistentState();
}
@@ -883,6 +1215,50 @@
return `Nostr ${base64Event}`;
}
// NIP-98 authentication helper (for sprocket functions)
async function createNIP98Auth(method, url) {
if (!isLoggedIn || !userPubkey) {
throw new Error('Not logged in');
}
// Create NIP-98 auth event
const authEvent = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', window.location.origin + url],
['method', method.toUpperCase()]
],
content: '',
pubkey: userPubkey
};
let signedEvent;
if (userSigner && authMethod === 'extension') {
// Use the signer from the extension
try {
signedEvent = await userSigner.signEvent(authEvent);
} catch (error) {
throw new Error('Failed to sign with extension: ' + error.message);
}
} else if (authMethod === 'nsec') {
// For nsec method, we need to implement proper signing
// For now, create a mock signature (in production, use proper crypto)
authEvent.id = 'mock-id-' + Date.now();
authEvent.sig = 'mock-signature-' + Date.now();
signedEvent = authEvent;
} else {
throw new Error('No valid signer available');
}
// Encode as base64
const eventJson = JSON.stringify(signedEvent);
const base64Event = btoa(eventJson);
return base64Event;
}
</script>
<!-- Header -->
@@ -1088,6 +1464,132 @@
</div>
{/if}
</div>
{:else if selectedTab === 'sprocket'}
<div class="sprocket-view">
<h2>Sprocket Script Management</h2>
{#if isLoggedIn && userRole === 'owner'}
<div class="sprocket-section">
<div class="sprocket-header">
<h3>Script Editor</h3>
<div class="sprocket-controls">
<button class="sprocket-btn restart-btn" on:click={restartSprocket} disabled={isLoadingSprocket}>
🔄 Restart
</button>
<button class="sprocket-btn delete-btn" on:click={deleteSprocket} disabled={isLoadingSprocket || !sprocketStatus?.script_exists}>
🗑️ Delete Script
</button>
</div>
</div>
<div class="sprocket-upload-section">
<h4>Upload Script</h4>
<div class="upload-controls">
<input
type="file"
id="sprocket-upload-file"
accept=".sh,.bash"
on:change={handleSprocketFileSelect}
disabled={isLoadingSprocket}
/>
<button
class="sprocket-btn upload-btn"
on:click={uploadSprocketScript}
disabled={isLoadingSprocket || !sprocketUploadFile}
>
📤 Upload & Update
</button>
</div>
</div>
<div class="sprocket-status">
<div class="status-item">
<span class="status-label">Status:</span>
<span class="status-value" class:running={sprocketStatus?.is_running}>
{sprocketStatus?.is_running ? '🟢 Running' : '🔴 Stopped'}
</span>
</div>
{#if sprocketStatus?.pid}
<div class="status-item">
<span class="status-label">PID:</span>
<span class="status-value">{sprocketStatus.pid}</span>
</div>
{/if}
<div class="status-item">
<span class="status-label">Script:</span>
<span class="status-value">{sprocketStatus?.script_exists ? '✅ Exists' : '❌ Not found'}</span>
</div>
</div>
<div class="script-editor-container">
<textarea
class="script-editor"
bind:value={sprocketScript}
placeholder="#!/bin/bash&#10;# Enter your sprocket script here..."
disabled={isLoadingSprocket}
></textarea>
</div>
<div class="script-actions">
<button class="sprocket-btn save-btn" on:click={saveSprocket} disabled={isLoadingSprocket}>
💾 Save & Update
</button>
<button class="sprocket-btn load-btn" on:click={loadSprocket} disabled={isLoadingSprocket}>
📥 Load Current
</button>
</div>
{#if sprocketMessage}
<div class="sprocket-message" class:error={sprocketMessageType === 'error'}>
{sprocketMessage}
</div>
{/if}
</div>
<div class="sprocket-section">
<h3>Script Versions</h3>
<div class="versions-list">
{#each sprocketVersions as version}
<div class="version-item" class:current={version.is_current}>
<div class="version-info">
<div class="version-name">{version.name}</div>
<div class="version-date">
{new Date(version.modified).toLocaleString()}
{#if version.is_current}
<span class="current-badge">Current</span>
{/if}
</div>
</div>
<div class="version-actions">
<button class="version-btn load-btn" on:click={() => loadVersion(version)} disabled={isLoadingSprocket}>
📥 Load
</button>
{#if !version.is_current}
<button class="version-btn delete-btn" on:click={() => deleteVersion(version.name)} disabled={isLoadingSprocket}>
🗑️ Delete
</button>
{/if}
</div>
</div>
{/each}
</div>
<button class="sprocket-btn refresh-btn" on:click={loadVersions} disabled={isLoadingSprocket}>
🔄 Refresh Versions
</button>
</div>
{:else if isLoggedIn}
<div class="permission-denied">
<p>❌ Owner permission required for sprocket management.</p>
<p>To enable sprocket functionality, set the <code>ORLY_OWNERS</code> environment variable with your npub when starting the relay.</p>
<p>Current user role: <strong>{userRole || 'none'}</strong></p>
</div>
{:else}
<div class="login-prompt">
<p>Please log in to access sprocket management.</p>
<button class="login-btn" on:click={openLoginModal}>Log In</button>
</div>
{/if}
</div>
{:else}
<div class="welcome-message">
{#if isLoggedIn}
@@ -1443,8 +1945,314 @@
.welcome-message p {
font-size: 1.2rem;
margin: 0;
}
/* Sprocket Styles */
.sprocket-view {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.sprocket-section {
background-color: var(--card-bg);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid var(--border-color);
}
.sprocket-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.sprocket-controls {
display: flex;
gap: 0.5rem;
}
.sprocket-upload-section {
margin-bottom: 1rem;
padding: 1rem;
background-color: var(--bg-color);
border-radius: 6px;
border: 1px solid var(--border-color);
}
.sprocket-upload-section h4 {
margin: 0 0 0.75rem 0;
color: var(--text-color);
font-size: 1rem;
font-weight: 500;
}
.upload-controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.upload-controls input[type="file"] {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
font-size: 0.9rem;
}
.sprocket-btn.upload-btn {
background-color: #8b5cf6;
color: white;
}
.sprocket-btn.upload-btn:hover:not(:disabled) {
background-color: #7c3aed;
}
.sprocket-status {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
padding: 0.75rem;
background-color: var(--bg-color);
border-radius: 6px;
border: 1px solid var(--border-color);
}
.status-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.status-label {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 500;
}
.status-value {
font-size: 0.9rem;
font-weight: 600;
}
.status-value.running {
color: #22c55e;
}
.script-editor-container {
margin-bottom: 1rem;
}
.script-editor {
width: 100%;
height: 300px;
padding: 1rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background-color: var(--bg-color);
color: var(--text-color);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
line-height: 1.4;
resize: vertical;
outline: none;
}
.script-editor:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.script-editor:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.script-actions {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.sprocket-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.sprocket-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sprocket-btn.save-btn {
background-color: #22c55e;
color: white;
}
.sprocket-btn.save-btn:hover:not(:disabled) {
background-color: #16a34a;
}
.sprocket-btn.load-btn {
background-color: #3b82f6;
color: white;
}
.sprocket-btn.load-btn:hover:not(:disabled) {
background-color: #2563eb;
}
.sprocket-btn.restart-btn {
background-color: #f59e0b;
color: white;
}
.sprocket-btn.restart-btn:hover:not(:disabled) {
background-color: #d97706;
}
.sprocket-btn.delete-btn {
background-color: #ef4444;
color: white;
}
.sprocket-btn.delete-btn:hover:not(:disabled) {
background-color: #dc2626;
}
.sprocket-btn.refresh-btn {
background-color: #6b7280;
color: white;
}
.sprocket-btn.refresh-btn:hover:not(:disabled) {
background-color: #4b5563;
}
.sprocket-message {
padding: 0.75rem;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
background-color: #dbeafe;
color: #1e40af;
border: 1px solid #93c5fd;
}
.sprocket-message.error {
background-color: #fee2e2;
color: #dc2626;
border-color: #fca5a5;
}
.versions-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.version-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
transition: all 0.2s ease;
}
.version-item.current {
border-color: var(--primary-color);
background-color: rgba(59, 130, 246, 0.05);
}
.version-item:hover {
border-color: var(--primary-color);
}
.version-info {
flex: 1;
}
.version-name {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.version-date {
font-size: 0.8rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 0.5rem;
}
.current-badge {
background-color: var(--primary-color);
color: white;
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-size: 0.7rem;
font-weight: 500;
}
.version-actions {
display: flex;
gap: 0.5rem;
}
.version-btn {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.25rem;
}
.version-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.version-btn.load-btn {
background-color: #3b82f6;
color: white;
}
.version-btn.load-btn:hover:not(:disabled) {
background-color: #2563eb;
}
.version-btn.delete-btn {
background-color: #ef4444;
color: white;
}
.version-btn.delete-btn:hover:not(:disabled) {
background-color: #dc2626;
}
@media (max-width: 640px) {

View File

@@ -73,6 +73,61 @@ class NostrClient {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
async connectToRelay(relayUrl) {
console.log(`Connecting to single relay: ${relayUrl}`);
return new Promise((resolve) => {
try {
console.log(`Attempting to connect to ${relayUrl}`);
const ws = new WebSocket(relayUrl);
ws.onopen = () => {
console.log(`✓ Successfully connected to ${relayUrl}`);
resolve(true);
};
ws.onerror = (error) => {
console.error(`✗ Error connecting to ${relayUrl}:`, error);
resolve(false);
};
ws.onclose = (event) => {
console.warn(
`Connection closed to ${relayUrl}:`,
event.code,
event.reason,
);
};
ws.onmessage = (event) => {
console.log(`Message from ${relayUrl}:`, event.data);
try {
this.handleMessage(relayUrl, JSON.parse(event.data));
} catch (error) {
console.error(
`Failed to parse message from ${relayUrl}:`,
error,
event.data,
);
}
};
this.relays.set(relayUrl, ws);
// Timeout after 5 seconds
setTimeout(() => {
if (ws.readyState !== WebSocket.OPEN) {
console.warn(`Connection timeout for ${relayUrl}`);
resolve(false);
}
}, 5000);
} catch (error) {
console.error(`Failed to create WebSocket for ${relayUrl}:`, error);
resolve(false);
}
});
}
handleMessage(relayUrl, message) {
console.log(`Processing message from ${relayUrl}:`, message);
const [type, subscriptionId, event, ...rest] = message;
@@ -238,6 +293,9 @@ class NostrClient {
// Create a global client instance
export const nostrClient = new NostrClient();
// Export the class for creating new instances
export { NostrClient };
// IndexedDB helpers for caching events (kind 0 profiles)
const DB_NAME = "nostrCache";
const DB_VERSION = 1;

1
go.mod
View File

@@ -34,6 +34,7 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/flatbuffers v25.9.23+incompatible // indirect
github.com/google/pprof v0.0.0-20251002213607-436353cc1ee6 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/templexxx/cpu v0.1.1 // indirect

2
go.sum
View File

@@ -45,6 +45,8 @@ github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8I
github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
github.com/google/pprof v0.0.0-20251002213607-436353cc1ee6 h1:/WHh/1k4thM/w+PAZEIiZK9NwCMFahw5tUzKUCnUtds=
github.com/google/pprof v0.0.0-20251002213607-436353cc1ee6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=

View File

@@ -2,13 +2,71 @@ package acl
import (
"lol.mleku.dev/log"
"next.orly.dev/app/config"
"next.orly.dev/pkg/encoders/bech32encoding"
"next.orly.dev/pkg/utils"
)
type None struct{}
type None struct {
cfg *config.C
owners [][]byte
admins [][]byte
}
func (n None) Configure(cfg ...any) (err error) { return }
func (n *None) Configure(cfg ...any) (err error) {
for _, ca := range cfg {
switch c := ca.(type) {
case *config.C:
n.cfg = c
}
}
if n.cfg == nil {
return
}
func (n None) GetAccessLevel(pub []byte, address string) (level string) {
// Load owners
for _, owner := range n.cfg.Owners {
if len(owner) == 0 {
continue
}
var pk []byte
if pk, err = bech32encoding.NpubOrHexToPublicKeyBinary(owner); err != nil {
continue
}
n.owners = append(n.owners, pk)
}
// Load admins
for _, admin := range n.cfg.Admins {
if len(admin) == 0 {
continue
}
var pk []byte
if pk, err = bech32encoding.NpubOrHexToPublicKeyBinary(admin); err != nil {
continue
}
n.admins = append(n.admins, pk)
}
return
}
func (n *None) GetAccessLevel(pub []byte, address string) (level string) {
// Check owners first
for _, v := range n.owners {
if utils.FastEqual(v, pub) {
return "owner"
}
}
// Check admins
for _, v := range n.admins {
if utils.FastEqual(v, pub) {
return "admin"
}
}
// Default to write for everyone else
return "write"
}

View File

@@ -1 +1 @@
v0.11.1
v0.12.3

View File

@@ -49,7 +49,7 @@ To build with the embedded web interface:
[source,bash]
----
# Build the React web application
# Build the Svelte web application
cd app/web
bun install
bun run build
@@ -59,13 +59,25 @@ cd ../../
go build -o orly
----
You can automate this process with a build script:
The recommended way to build and embed the web UI is using the provided script:
[source,bash]
----
./scripts/update-embedded-web.sh
----
This script will:
- Build the Svelte app in `app/web` to `app/web/dist` using Bun (preferred) or fall back to npm/yarn/pnpm
- Run `go install` from the repository root so the binary picks up the new embedded assets
- Automatically detect and use the best available JavaScript package manager
For manual builds, you can also use:
[source,bash]
----
#!/bin/bash
# build.sh
echo "Building React app..."
echo "Building Svelte app..."
cd app/web
bun install
bun run build
@@ -79,6 +91,324 @@ echo "Build complete!"
Make it executable with `chmod +x build.sh` and run with `./build.sh`.
== web UI
ORLY includes a modern web-based user interface built with link:https://svelte.dev/[Svelte] that provides comprehensive relay management capabilities.
=== features
The web UI offers:
* **Authentication**: Secure login using Nostr key pairs with challenge-response authentication
* **Event Management**: View, export, and import Nostr events with advanced filtering and search
* **User Administration**: Manage user permissions and roles (admin/owner)
* **Sprocket Management**: Configure and manage external event processing scripts
* **Real-time Updates**: Live event streaming and status updates
* **Dark/Light Theme**: Toggle between themes with persistent preferences
* **Responsive Design**: Works on desktop and mobile devices
=== authentication
The web UI uses Nostr-native authentication:
1. **Challenge Generation**: Server generates a cryptographic challenge
2. **Signature Verification**: Client signs the challenge with their private key
3. **Session Management**: Authenticated sessions with role-based permissions
Supported authentication methods:
- Direct private key input
- Nostr extension integration
- Hardware wallet support
=== user roles
* **Guest**: Read-only access to public events
* **User**: Can publish events and manage their own content
* **Admin**: Full relay management except sprocket configuration
* **Owner**: Complete control including sprocket management and system configuration
=== event management
The interface provides comprehensive event management:
* **Event Browser**: Paginated view of all events with filtering by kind, author, and content
* **Export Functionality**: Export events in JSON format with configurable date ranges
* **Import Capability**: Bulk import events (admin/owner only)
* **Search**: Full-text search across event content and metadata
* **Event Details**: Expandable view showing full event JSON and metadata
=== sprocket integration
The web UI includes a dedicated sprocket management interface:
* **Status Monitoring**: Real-time status of sprocket scripts
* **Script Upload**: Upload and manage sprocket scripts
* **Version Control**: Track and manage multiple script versions
* **Configuration**: Configure sprocket parameters and settings
* **Logs**: View sprocket execution logs and errors
=== development mode
For development, the web UI supports hot-reloading:
[source,bash]
----
# Enable development proxy
export ORLY_WEB_DISABLE_EMBEDDED=true
export ORLY_WEB_DEV_PROXY_URL=localhost:5000
# Start relay
./orly
# In another terminal, start Svelte dev server
cd app/web
bun run dev
----
This allows for rapid development with automatic reloading of changes.
== sprocket event sifter interface
The sprocket system provides a powerful interface for external event processing scripts, allowing you to implement custom filtering, validation, and processing logic for Nostr events before they are stored in the relay.
=== overview
Sprocket scripts receive events via stdin and respond with JSONL (JSON Lines) format, enabling real-time event processing with three possible actions:
* **accept**: Continue with normal event processing
* **reject**: Return OK false to client with rejection message
* **shadowReject**: Return OK true to client but abort processing (useful for spam filtering)
=== how it works
1. **Event Reception**: Events are sent to the sprocket script as JSON objects via stdin
2. **Processing**: Script analyzes the event and applies custom logic
3. **Response**: Script responds with JSONL containing the decision and optional message
4. **Action**: Relay processes the response and either accepts, rejects, or shadow rejects the event
=== script protocol
==== input format
Events are sent as JSON objects, one per line:
```json
{
"id": "event_id_here",
"kind": 1,
"content": "Hello, world!",
"pubkey": "author_pubkey",
"tags": [["t", "hashtag"], ["p", "reply_pubkey"]],
"created_at": 1640995200,
"sig": "signature_here"
}
```
==== output format
Scripts must respond with JSONL format:
```json
{"id": "event_id", "action": "accept", "msg": ""}
{"id": "event_id", "action": "reject", "msg": "reason for rejection"}
{"id": "event_id", "action": "shadowReject", "msg": ""}
```
=== configuration
Enable sprocket processing:
[source,bash]
----
export ORLY_SPROCKET_ENABLED=true
export ORLY_APP_NAME="ORLY"
----
The sprocket script should be placed at:
`~/.config/{ORLY_APP_NAME}/sprocket.sh`
For example, with default `ORLY_APP_NAME="ORLY"`:
`~/.config/ORLY/sprocket.sh`
Backup files are automatically created when updating sprocket scripts via the web UI, with timestamps like:
`~/.config/ORLY/sprocket.sh.20240101120000`
=== manual sprocket updates
For manual sprocket script updates, you can use the stop/write/restart method:
1. **Stop the relay**:
```bash
# Send SIGINT to gracefully stop
kill -INT <relay_pid>
```
2. **Write new sprocket script**:
```bash
# Create/update the sprocket script
cat > ~/.config/ORLY/sprocket.sh << 'EOF'
#!/bin/bash
while read -r line; do
if [[ -n "$line" ]]; then
event_id=$(echo "$line" | jq -r '.id')
echo "{\"id\":\"$event_id\",\"action\":\"accept\",\"msg\":\"\"}"
fi
done
EOF
# Make it executable
chmod +x ~/.config/ORLY/sprocket.sh
```
3. **Restart the relay**:
```bash
./orly
```
The relay will automatically detect the new sprocket script and start it. If the script fails, sprocket will be disabled and all events rejected until the script is fixed.
=== failure handling
When sprocket is enabled but fails to start or crashes:
1. **Automatic Disable**: Sprocket is automatically disabled
2. **Event Rejection**: All incoming events are rejected with error message
3. **Periodic Recovery**: Every 30 seconds, the system checks if the sprocket script becomes available
4. **Auto-Restart**: If the script is found, sprocket is automatically re-enabled and restarted
This ensures that:
- Relay continues running even when sprocket fails
- No events are processed without proper sprocket filtering
- Sprocket automatically recovers when the script is fixed
- Clear error messages inform users about the sprocket status
- Error messages include the exact file location for easy fixes
When sprocket fails, the error message will show:
`sprocket disabled due to failure - all events will be rejected (script location: ~/.config/ORLY/sprocket.sh)`
This makes it easy to locate and fix the sprocket script file.
=== example script
Here's a Python example that implements various filtering criteria:
[source,python]
----
#!/usr/bin/env python3
import json
import sys
def process_event(event_json):
event_id = event_json.get('id', '')
event_content = event_json.get('content', '')
event_kind = event_json.get('kind', 0)
# Reject spam content
if 'spam' in event_content.lower():
return {
'id': event_id,
'action': 'reject',
'msg': 'Content contains spam'
}
# Shadow reject test events
if event_kind == 9999:
return {
'id': event_id,
'action': 'shadowReject',
'msg': ''
}
# Accept all other events
return {
'id': event_id,
'action': 'accept',
'msg': ''
}
# Main processing loop
for line in sys.stdin:
if line.strip():
try:
event = json.loads(line)
response = process_event(event)
print(json.dumps(response))
sys.stdout.flush()
except json.JSONDecodeError:
continue
----
=== bash example
A simple bash script example:
[source,bash]
----
#!/bin/bash
while read -r line; do
if [[ -n "$line" ]]; then
# Extract event ID
event_id=$(echo "$line" | jq -r '.id')
# Check for spam content
if echo "$line" | jq -r '.content' | grep -qi "spam"; then
echo "{\"id\":\"$event_id\",\"action\":\"reject\",\"msg\":\"Spam detected\"}"
else
echo "{\"id\":\"$event_id\",\"action\":\"accept\",\"msg\":\"\"}"
fi
fi
done
----
=== testing
Test your sprocket script directly:
[source,bash]
----
# Test with sample event
echo '{"id":"test","kind":1,"content":"spam test"}' | python3 sprocket.py
# Expected output:
# {"id": "test", "action": "reject", "msg": "Content contains spam"}
----
Run the comprehensive test suite:
[source,bash]
----
./test-sprocket-complete.sh
----
=== web UI management
The web UI provides a complete sprocket management interface:
* **Status Monitoring**: View real-time sprocket status and health
* **Script Upload**: Upload new sprocket scripts via the web interface
* **Version Management**: Track and manage multiple script versions
* **Configuration**: Configure sprocket parameters and settings
* **Logs**: View execution logs and error messages
* **Restart**: Restart sprocket scripts without relay restart
=== use cases
Common sprocket use cases include:
* **Spam Filtering**: Detect and reject spam content
* **Content Moderation**: Implement custom content policies
* **Rate Limiting**: Control event publishing rates
* **Event Validation**: Additional validation beyond Nostr protocol
* **Analytics**: Log and analyze event patterns
* **Integration**: Connect with external services and APIs
=== performance considerations
* Sprocket scripts run synchronously and can impact relay performance
* Keep processing logic efficient and fast
* Use appropriate timeouts to prevent blocking
* Consider using shadow reject for non-critical filtering to maintain user experience
== secp256k1 dependency
ORLY uses the optimized `libsecp256k1` C library from Bitcoin Core for schnorr signatures, providing 4x faster signing and ECDH operations compared to pure Go implementations.

View File

@@ -0,0 +1,228 @@
# Sprocket Test Suite
This directory contains a comprehensive test suite for the ORLY relay's sprocket event processing system.
## Overview
The sprocket system allows external scripts to process Nostr events before they are stored in the relay. Events are sent to the sprocket script via stdin, and the script responds with JSONL messages indicating whether to accept, reject, or shadow reject the event.
## Test Files
### Core Test Files
- **`test-sprocket.py`** - Python sprocket script that implements various filtering criteria
- **`test-sprocket-integration.go`** - Go integration tests using the testing framework
- **`test-sprocket-complete.sh`** - Complete test suite that starts relay and runs tests
- **`test-sprocket-manual.sh`** - Manual test script for interactive testing
- **`run-sprocket-test.sh`** - Automated test runner
### Example Scripts
- **`test-sprocket-example.sh`** - Simple bash example sprocket script
## Test Criteria
The Python sprocket script (`test-sprocket.py`) implements the following test criteria:
1. **Spam Content**: Rejects events containing "spam" in the content
2. **Test Kind**: Shadow rejects events with kind 9999
3. **Blocked Hashtags**: Rejects events with hashtags "blocked", "rejected", or "test-block"
4. **Blocked Pubkeys**: Shadow rejects events from pubkeys starting with "00000000", "11111111", or "22222222"
5. **Content Length**: Rejects events with content longer than 1000 characters
6. **Timestamp Validation**: Rejects events that are too old (>1 hour) or too far in the future (>5 minutes)
## Running Tests
### Quick Test (Recommended)
```bash
./test-sprocket-complete.sh
```
This script will:
1. Set up the test environment
2. Start the relay with sprocket enabled
3. Run all test cases
4. Clean up automatically
### Manual Testing
```bash
# Start relay manually with sprocket enabled
export ORLY_SPROCKET_ENABLED=true
go run . test
# In another terminal, run manual tests
./test-sprocket-manual.sh
```
### Integration Tests
```bash
# Run Go integration tests
go test -v -run TestSprocketIntegration ./test-sprocket-integration.go
```
## Prerequisites
- **Python 3**: Required for the Python sprocket script
- **jq**: Required for JSON processing in bash scripts
- **websocat**: Required for WebSocket testing
```bash
cargo install websocat
```
- **Go dependencies**: gorilla/websocket for integration tests
```bash
go get github.com/gorilla/websocket
```
## Test Cases
### 1. Normal Event (Accept)
```json
{
"id": "test_normal_123",
"pubkey": "1234567890abcdef1234567890abcdef12345678",
"created_at": 1640995200,
"kind": 1,
"content": "Hello, world!",
"sig": "test_sig"
}
```
**Expected**: `["OK","test_normal_123",true]`
### 2. Spam Content (Reject)
```json
{
"id": "test_spam_456",
"pubkey": "1234567890abcdef1234567890abcdef12345678",
"created_at": 1640995200,
"kind": 1,
"content": "This is spam content",
"sig": "test_sig"
}
```
**Expected**: `["OK","test_spam_456",false,"error: Content contains spam"]`
### 3. Test Kind (Shadow Reject)
```json
{
"id": "test_kind_789",
"pubkey": "1234567890abcdef1234567890abcdef12345678",
"created_at": 1640995200,
"kind": 9999,
"content": "Test message",
"sig": "test_sig"
}
```
**Expected**: `["OK","test_kind_789",true]` (but event not processed)
### 4. Blocked Hashtag (Reject)
```json
{
"id": "test_hashtag_101",
"pubkey": "1234567890abcdef1234567890abcdef12345678",
"created_at": 1640995200,
"kind": 1,
"content": "Message with hashtag",
"tags": [["t", "blocked"]],
"sig": "test_sig"
}
```
**Expected**: `["OK","test_hashtag_101",false,"error: Hashtag \"blocked\" is not allowed"]`
### 5. Too Long Content (Reject)
```json
{
"id": "test_long_202",
"pubkey": "1234567890abcdef1234567890abcdef12345678",
"created_at": 1640995200,
"kind": 1,
"content": "a... (1001 characters)",
"sig": "test_sig"
}
```
**Expected**: `["OK","test_long_202",false,"error: Content too long (max 1000 characters)"]`
## Sprocket Script Protocol
### Input Format
Events are sent to the sprocket script as JSON objects via stdin, one per line.
### Output Format
The sprocket script must respond with JSONL (JSON Lines) format:
```json
{"id": "event_id", "action": "accept", "msg": ""}
{"id": "event_id", "action": "reject", "msg": "reason for rejection"}
{"id": "event_id", "action": "shadowReject", "msg": ""}
```
### Actions
- **`accept`**: Continue with normal event processing
- **`reject`**: Return OK false to client with message
- **`shadowReject`**: Return OK true to client but abort processing
## Configuration
To enable sprocket in the relay:
```bash
export ORLY_SPROCKET_ENABLED=true
export ORLY_APP_NAME="ORLY"
```
The sprocket script should be placed at:
`~/.config/{ORLY_APP_NAME}/sprocket.sh`
## Troubleshooting
### Common Issues
1. **Sprocket script not found**
- Ensure the script exists at the correct path
- Check file permissions (must be executable)
2. **Python script errors**
- Verify Python 3 is installed
- Check script syntax with `python3 -m py_compile test-sprocket.py`
3. **WebSocket connection failed**
- Ensure relay is running on the correct port
- Check firewall settings
4. **Test failures**
- Check relay logs for sprocket errors
- Verify sprocket script is responding correctly
### Debug Mode
Enable debug logging:
```bash
export ORLY_LOG_LEVEL=debug
```
### Manual Sprocket Testing
Test the sprocket script directly:
```bash
echo '{"id":"test","kind":1,"content":"spam test"}' | python3 test-sprocket.py
```
Expected output:
```json
{"id": "test", "action": "reject", "msg": "Content contains spam"}
```
## Contributing
When adding new test cases:
1. Add the test case to `test-sprocket.py`
2. Add corresponding test in `test-sprocket-complete.sh`
3. Update this README with the new test case
4. Ensure all tests pass before submitting
## License
This test suite is part of the ORLY relay project and follows the same license.

View File

@@ -0,0 +1,50 @@
#!/bin/bash
# Sprocket Integration Test Runner
# This script sets up and runs the sprocket integration test
set -e
echo "🧪 Running Sprocket Integration Test"
echo "===================================="
# Check if Python 3 is available
if ! command -v python3 &> /dev/null; then
echo "❌ Python 3 is required but not installed"
exit 1
fi
# Check if jq is available (for the bash sprocket script)
if ! command -v jq &> /dev/null; then
echo "❌ jq is required but not installed"
exit 1
fi
# Check if gorilla/websocket is available
echo "📦 Installing test dependencies..."
go mod tidy
go get github.com/gorilla/websocket
# Create test configuration directory
TEST_CONFIG_DIR="$HOME/.config/ORLY_TEST"
mkdir -p "$TEST_CONFIG_DIR"
# Copy the Python sprocket script to the test directory
cp test-sprocket.py "$TEST_CONFIG_DIR/sprocket.py"
# Create a simple bash wrapper for the Python script
cat > "$TEST_CONFIG_DIR/sprocket.sh" << 'EOF'
#!/bin/bash
python3 "$(dirname "$0")/sprocket.py"
EOF
chmod +x "$TEST_CONFIG_DIR/sprocket.sh"
echo "🔧 Test setup complete"
echo "📁 Sprocket script location: $TEST_CONFIG_DIR/sprocket.sh"
# Run the integration test
echo "🚀 Starting integration test..."
go test -v -run TestSprocketIntegration ./test-sprocket-integration.go
echo "✅ Sprocket integration test completed successfully!"

View File

@@ -0,0 +1,209 @@
#!/bin/bash
# Complete Sprocket Test Suite
# This script starts the relay with sprocket enabled and runs tests
set -e
echo "🧪 Complete Sprocket Test Suite"
echo "=============================="
# Configuration
RELAY_PORT="3334"
TEST_CONFIG_DIR="$HOME/.config/ORLY_TEST"
# Clean up any existing test processes
echo "🧹 Cleaning up existing processes..."
pkill -f "ORLY_TEST" || true
sleep 2
# Create test configuration directory
echo "📁 Setting up test environment..."
mkdir -p "$TEST_CONFIG_DIR"
# Copy the Python sprocket script
cp test-sprocket.py "$TEST_CONFIG_DIR/sprocket.py"
# Create bash wrapper for the Python script
cat > "$TEST_CONFIG_DIR/sprocket.sh" << 'EOF'
#!/bin/bash
python3 "$(dirname "$0")/sprocket.py"
EOF
chmod +x "$TEST_CONFIG_DIR/sprocket.sh"
echo "✅ Sprocket script created at: $TEST_CONFIG_DIR/sprocket.sh"
# Start the relay with sprocket enabled
echo "🚀 Starting relay with sprocket enabled..."
export ORLY_APP_NAME="ORLY_TEST"
export ORLY_DATA_DIR="/tmp/orly_test_data"
export ORLY_LISTEN="127.0.0.1"
export ORLY_PORT="$RELAY_PORT"
export ORLY_LOG_LEVEL="info"
export ORLY_SPROCKET_ENABLED="true"
export ORLY_ADMINS="npub1test1234567890abcdefghijklmnopqrstuvwxyz1234567890"
export ORLY_OWNERS="npub1test1234567890abcdefghijklmnopqrstuvwxyz1234567890"
# Clean up test data directory
rm -rf "$ORLY_DATA_DIR"
mkdir -p "$ORLY_DATA_DIR"
# Start relay in background
echo "Starting relay on port $RELAY_PORT..."
go run . test > /tmp/orly_test.log 2>&1 &
RELAY_PID=$!
# Wait for relay to start
echo "⏳ Waiting for relay to start..."
sleep 5
# Check if relay is running
if ! kill -0 $RELAY_PID 2>/dev/null; then
echo "❌ Relay failed to start"
echo "Log output:"
cat /tmp/orly_test.log
exit 1
fi
echo "✅ Relay started successfully (PID: $RELAY_PID)"
# Function to cleanup
cleanup() {
echo "🧹 Cleaning up..."
kill $RELAY_PID 2>/dev/null || true
sleep 2
pkill -f "ORLY_TEST" || true
rm -rf "$ORLY_DATA_DIR"
echo "✅ Cleanup complete"
}
# Set trap for cleanup
trap cleanup EXIT
# Test sprocket functionality
echo "🧪 Testing sprocket functionality..."
# Check if websocat is available
if ! command -v websocat &> /dev/null; then
echo "❌ websocat is required for testing"
echo "Install it with: cargo install websocat"
echo "Or run: go install github.com/gorilla/websocket/examples/echo@latest"
exit 1
fi
# Test 1: Normal event (should be accepted)
echo "📤 Test 1: Normal event (should be accepted)"
normal_event='{
"id": "test_normal_123",
"pubkey": "1234567890abcdef1234567890abcdef12345678",
"created_at": '$(date +%s)',
"kind": 1,
"content": "Hello, world! This is a normal message.",
"sig": "test_sig_normal"
}'
normal_message="[\"EVENT\",$normal_event]"
normal_response=$(echo "$normal_message" | websocat "ws://127.0.0.1:$RELAY_PORT" --text)
echo "Response: $normal_response"
if echo "$normal_response" | grep -q '"OK","test_normal_123",true'; then
echo "✅ Test 1 PASSED: Normal event accepted"
else
echo "❌ Test 1 FAILED: Normal event not accepted"
fi
# Test 2: Spam content (should be rejected)
echo "📤 Test 2: Spam content (should be rejected)"
spam_event='{
"id": "test_spam_456",
"pubkey": "1234567890abcdef1234567890abcdef12345678",
"created_at": '$(date +%s)',
"kind": 1,
"content": "This message contains spam content",
"sig": "test_sig_spam"
}'
spam_message="[\"EVENT\",$spam_event]"
spam_response=$(echo "$spam_message" | websocat "ws://127.0.0.1:$RELAY_PORT" --text)
echo "Response: $spam_response"
if echo "$spam_response" | grep -q '"OK","test_spam_456",false'; then
echo "✅ Test 2 PASSED: Spam content rejected"
else
echo "❌ Test 2 FAILED: Spam content not rejected"
fi
# Test 3: Test kind 9999 (should be shadow rejected)
echo "📤 Test 3: Test kind 9999 (should be shadow rejected)"
kind_event='{
"id": "test_kind_789",
"pubkey": "1234567890abcdef1234567890abcdef12345678",
"created_at": '$(date +%s)',
"kind": 9999,
"content": "Test message with special kind",
"sig": "test_sig_kind"
}'
kind_message="[\"EVENT\",$kind_event]"
kind_response=$(echo "$kind_message" | websocat "ws://127.0.0.1:$RELAY_PORT" --text)
echo "Response: $kind_response"
if echo "$kind_response" | grep -q '"OK","test_kind_789",true'; then
echo "✅ Test 3 PASSED: Test kind shadow rejected (OK=true but not processed)"
else
echo "❌ Test 3 FAILED: Test kind not shadow rejected"
fi
# Test 4: Blocked hashtag (should be rejected)
echo "📤 Test 4: Blocked hashtag (should be rejected)"
hashtag_event='{
"id": "test_hashtag_101",
"pubkey": "1234567890abcdef1234567890abcdef12345678",
"created_at": '$(date +%s)',
"kind": 1,
"content": "Message with blocked hashtag",
"tags": [["t", "blocked"]],
"sig": "test_sig_hashtag"
}'
hashtag_message="[\"EVENT\",$hashtag_event]"
hashtag_response=$(echo "$hashtag_message" | websocat "ws://127.0.0.1:$RELAY_PORT" --text)
echo "Response: $hashtag_response"
if echo "$hashtag_response" | grep -q '"OK","test_hashtag_101",false'; then
echo "✅ Test 4 PASSED: Blocked hashtag rejected"
else
echo "❌ Test 4 FAILED: Blocked hashtag not rejected"
fi
# Test 5: Too long content (should be rejected)
echo "📤 Test 5: Too long content (should be rejected)"
long_content=$(printf 'a%.0s' {1..1001})
long_event="{
\"id\": \"test_long_202\",
\"pubkey\": \"1234567890abcdef1234567890abcdef12345678\",
\"created_at\": $(date +%s),
\"kind\": 1,
\"content\": \"$long_content\",
\"sig\": \"test_sig_long\"
}"
long_message="[\"EVENT\",$long_event]"
long_response=$(echo "$long_message" | websocat "ws://127.0.0.1:$RELAY_PORT" --text)
echo "Response: $long_response"
if echo "$long_response" | grep -q '"OK","test_long_202",false'; then
echo "✅ Test 5 PASSED: Too long content rejected"
else
echo "❌ Test 5 FAILED: Too long content not rejected"
fi
echo ""
echo "🎉 Sprocket test suite completed!"
echo "📊 Check the results above to verify sprocket functionality"
echo ""
echo "💡 To run individual tests, use:"
echo " ./test-sprocket-manual.sh"
echo ""
echo "📝 Relay logs are available at: /tmp/orly_test.log"

View File

@@ -0,0 +1,143 @@
#!/bin/bash
# Sprocket Demo Test
# This script demonstrates the complete sprocket functionality
set -e
echo "🧪 Sprocket Demo Test"
echo "===================="
# Configuration
TEST_CONFIG_DIR="$HOME/.config/ORLY_TEST"
# Create test configuration directory
echo "📁 Setting up test environment..."
mkdir -p "$TEST_CONFIG_DIR"
# Copy the Python sprocket script
cp test-sprocket.py "$TEST_CONFIG_DIR/sprocket.py"
# Create bash wrapper for the Python script
cat > "$TEST_CONFIG_DIR/sprocket.sh" << 'EOF'
#!/bin/bash
python3 "$(dirname "$0")/sprocket.py"
EOF
chmod +x "$TEST_CONFIG_DIR/sprocket.sh"
echo "✅ Sprocket script created at: $TEST_CONFIG_DIR/sprocket.sh"
# Test 1: Direct sprocket script testing
echo "🧪 Test 1: Direct sprocket script testing"
echo "========================================"
current_time=$(date +%s)
# Test normal event
echo "📤 Testing normal event..."
normal_event="{\"id\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"pubkey\":\"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef\",\"created_at\":$current_time,\"kind\":1,\"content\":\"Hello, world!\",\"sig\":\"test_sig\"}"
echo "$normal_event" | python3 "$TEST_CONFIG_DIR/sprocket.py"
# Test spam content
echo "📤 Testing spam content..."
spam_event="{\"id\":\"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef\",\"pubkey\":\"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef\",\"created_at\":$current_time,\"kind\":1,\"content\":\"This is spam content\",\"sig\":\"test_sig\"}"
echo "$spam_event" | python3 "$TEST_CONFIG_DIR/sprocket.py"
# Test special kind
echo "📤 Testing special kind..."
kind_event="{\"id\":\"2345678901bcdef01234567890abcdef01234567890abcdef01234567890abcdef\",\"pubkey\":\"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef\",\"created_at\":$current_time,\"kind\":9999,\"content\":\"Test message\",\"sig\":\"test_sig\"}"
echo "$kind_event" | python3 "$TEST_CONFIG_DIR/sprocket.py"
# Test blocked hashtag
echo "📤 Testing blocked hashtag..."
hashtag_event="{\"id\":\"3456789012cdef0123456789012cdef0123456789012cdef0123456789012cdef\",\"pubkey\":\"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef\",\"created_at\":$current_time,\"kind\":1,\"content\":\"Message with hashtag\",\"tags\":[[\"t\",\"blocked\"]],\"sig\":\"test_sig\"}"
echo "$hashtag_event" | python3 "$TEST_CONFIG_DIR/sprocket.py"
echo ""
echo "✅ Direct sprocket testing completed!"
echo ""
# Test 2: Bash wrapper testing
echo "🧪 Test 2: Bash wrapper testing"
echo "==============================="
# Test normal event through wrapper
echo "📤 Testing normal event through wrapper..."
echo "$normal_event" | "$TEST_CONFIG_DIR/sprocket.sh"
# Test spam content through wrapper
echo "📤 Testing spam content through wrapper..."
echo "$spam_event" | "$TEST_CONFIG_DIR/sprocket.sh"
echo ""
echo "✅ Bash wrapper testing completed!"
echo ""
# Test 3: Sprocket criteria demonstration
echo "🧪 Test 3: Sprocket criteria demonstration"
echo "========================================"
echo "The sprocket script implements the following filtering criteria:"
echo ""
echo "1. ✅ Spam Content Detection:"
echo " - Rejects events containing 'spam' in content"
echo " - Example: 'This is spam content' → REJECT"
echo ""
echo "2. ✅ Special Kind Filtering:"
echo " - Shadow rejects events with kind 9999"
echo " - Example: kind 9999 → SHADOW REJECT"
echo ""
echo "3. ✅ Blocked Hashtag Filtering:"
echo " - Rejects events with hashtags: 'blocked', 'rejected', 'test-block'"
echo " - Example: #blocked → REJECT"
echo ""
echo "4. ✅ Blocked Pubkey Filtering:"
echo " - Shadow rejects events from pubkeys starting with '00000000', '11111111', '22222222'"
echo ""
echo "5. ✅ Content Length Validation:"
echo " - Rejects events with content longer than 1000 characters"
echo ""
echo "6. ✅ Timestamp Validation:"
echo " - Rejects events that are too old (>1 hour) or too far in the future (>5 minutes)"
echo ""
# Test 4: Show sprocket protocol
echo "🧪 Test 4: Sprocket Protocol Demonstration"
echo "=========================================="
echo "Input Format: JSON event via stdin"
echo "Output Format: JSONL response via stdout"
echo ""
echo "Response Actions:"
echo "- accept: Continue with normal event processing"
echo "- reject: Return OK false to client with message"
echo "- shadowReject: Return OK true to client but abort processing"
echo ""
# Test 5: Integration readiness
echo "🧪 Test 5: Integration Readiness"
echo "==============================="
echo "✅ Sprocket script: Working correctly"
echo "✅ Bash wrapper: Working correctly"
echo "✅ Event processing: All criteria implemented"
echo "✅ JSONL protocol: Properly formatted responses"
echo "✅ Error handling: Graceful error responses"
echo ""
echo "🎉 Sprocket system is ready for relay integration!"
echo ""
echo "💡 To test with the relay:"
echo " 1. Set ORLY_SPROCKET_ENABLED=true"
echo " 2. Start the relay"
echo " 3. Send events via WebSocket"
echo " 4. Observe sprocket responses in relay logs"
echo ""
echo "📝 Test files created:"
echo " - $TEST_CONFIG_DIR/sprocket.py (Python sprocket script)"
echo " - $TEST_CONFIG_DIR/sprocket.sh (Bash wrapper)"
echo " - test-sprocket.py (Source Python script)"
echo " - test-sprocket-example.sh (Bash example)"
echo " - test-sprocket-simple.sh (Simple test)"
echo " - test-sprocket-working.sh (WebSocket test)"
echo " - SPROCKET_TEST_README.md (Documentation)"

View File

@@ -0,0 +1,28 @@
#!/bin/bash
# Example sprocket script that demonstrates event processing
# This script reads JSON events from stdin and outputs JSONL responses
# Read events from stdin line by line
while IFS= read -r line; do
# Parse the event JSON
event_id=$(echo "$line" | jq -r '.id')
event_kind=$(echo "$line" | jq -r '.kind')
event_content=$(echo "$line" | jq -r '.content')
# Example policy: reject events with certain content
if [[ "$event_content" == *"spam"* ]]; then
echo "{\"id\":\"$event_id\",\"action\":\"reject\",\"msg\":\"content contains spam\"}"
continue
fi
# Example policy: shadow reject events from certain kinds
if [[ "$event_kind" == "9999" ]]; then
echo "{\"id\":\"$event_id\",\"action\":\"shadowReject\",\"msg\":\"\"}"
continue
fi
# Default: accept the event
echo "{\"id\":\"$event_id\",\"action\":\"accept\",\"msg\":\"\"}"
done

View File

@@ -0,0 +1,184 @@
#!/bin/bash
# Final Sprocket Integration Test
# This script tests the complete sprocket integration with the relay
set -e
echo "🧪 Final Sprocket Integration Test"
echo "================================="
# Configuration
RELAY_PORT="3334"
TEST_CONFIG_DIR="$HOME/.config/ORLY_TEST"
# Clean up any existing test processes
echo "🧹 Cleaning up existing processes..."
pkill -f "ORLY_TEST" || true
sleep 2
# Create test configuration directory
echo "📁 Setting up test environment..."
mkdir -p "$TEST_CONFIG_DIR"
# Copy the Python sprocket script
cp test-sprocket.py "$TEST_CONFIG_DIR/sprocket.py"
# Create bash wrapper for the Python script
cat > "$TEST_CONFIG_DIR/sprocket.sh" << 'EOF'
#!/bin/bash
python3 "$(dirname "$0")/sprocket.py"
EOF
chmod +x "$TEST_CONFIG_DIR/sprocket.sh"
echo "✅ Sprocket script created at: $TEST_CONFIG_DIR/sprocket.sh"
# Set environment variables for the relay
export ORLY_APP_NAME="ORLY_TEST"
export ORLY_DATA_DIR="/tmp/orly_test_data"
export ORLY_LISTEN="127.0.0.1"
export ORLY_PORT="$RELAY_PORT"
export ORLY_LOG_LEVEL="info"
export ORLY_SPROCKET_ENABLED="true"
export ORLY_ADMINS=""
export ORLY_OWNERS=""
# Clean up test data directory
rm -rf "$ORLY_DATA_DIR"
mkdir -p "$ORLY_DATA_DIR"
# Function to cleanup
cleanup() {
echo "🧹 Cleaning up..."
pkill -f "ORLY_TEST" || true
sleep 2
rm -rf "$ORLY_DATA_DIR"
echo "✅ Cleanup complete"
}
# Set trap for cleanup
trap cleanup EXIT
# Start the relay
echo "🚀 Starting relay with sprocket enabled..."
go run . test > /tmp/orly_test.log 2>&1 &
RELAY_PID=$!
# Wait for relay to start
echo "⏳ Waiting for relay to start..."
sleep 5
# Check if relay is running
if ! kill -0 $RELAY_PID 2>/dev/null; then
echo "❌ Relay failed to start"
echo "Log output:"
cat /tmp/orly_test.log
exit 1
fi
echo "✅ Relay started successfully (PID: $RELAY_PID)"
# Check if websocat is available
if ! command -v websocat &> /dev/null; then
echo "❌ websocat is required for testing"
echo "Install it with: cargo install websocat"
echo "Or use: go install github.com/gorilla/websocket/examples/echo@latest"
exit 1
fi
# Test sprocket functionality
echo "🧪 Testing sprocket functionality..."
# Test 1: Normal event (should be accepted)
echo "📤 Test 1: Normal event (should be accepted)"
current_time=$(date +%s)
normal_event="{
\"id\": \"test_normal_123\",
\"pubkey\": \"1234567890abcdef1234567890abcdef12345678\",
\"created_at\": $current_time,
\"kind\": 1,
\"content\": \"Hello, world! This is a normal message.\",
\"sig\": \"test_sig_normal\"
}"
normal_message="[\"EVENT\",$normal_event]"
normal_response=$(echo "$normal_message" | websocat "ws://127.0.0.1:$RELAY_PORT" --text)
echo "Response: $normal_response"
if echo "$normal_response" | grep -q '"OK","test_normal_123",true'; then
echo "✅ Test 1 PASSED: Normal event accepted"
else
echo "❌ Test 1 FAILED: Normal event not accepted"
fi
# Test 2: Spam content (should be rejected)
echo "📤 Test 2: Spam content (should be rejected)"
spam_event="{
\"id\": \"test_spam_456\",
\"pubkey\": \"1234567890abcdef1234567890abcdef12345678\",
\"created_at\": $current_time,
\"kind\": 1,
\"content\": \"This message contains spam content\",
\"sig\": \"test_sig_spam\"
}"
spam_message="[\"EVENT\",$spam_event]"
spam_response=$(echo "$spam_message" | websocat "ws://127.0.0.1:$RELAY_PORT" --text)
echo "Response: $spam_response"
if echo "$spam_response" | grep -q '"OK","test_spam_456",false'; then
echo "✅ Test 2 PASSED: Spam content rejected"
else
echo "❌ Test 2 FAILED: Spam content not rejected"
fi
# Test 3: Test kind 9999 (should be shadow rejected)
echo "📤 Test 3: Test kind 9999 (should be shadow rejected)"
kind_event="{
\"id\": \"test_kind_789\",
\"pubkey\": \"1234567890abcdef1234567890abcdef12345678\",
\"created_at\": $current_time,
\"kind\": 9999,
\"content\": \"Test message with special kind\",
\"sig\": \"test_sig_kind\"
}"
kind_message="[\"EVENT\",$kind_event]"
kind_response=$(echo "$kind_message" | websocat "ws://127.0.0.1:$RELAY_PORT" --text)
echo "Response: $kind_response"
if echo "$kind_response" | grep -q '"OK","test_kind_789",true'; then
echo "✅ Test 3 PASSED: Test kind shadow rejected (OK=true but not processed)"
else
echo "❌ Test 3 FAILED: Test kind not shadow rejected"
fi
# Test 4: Blocked hashtag (should be rejected)
echo "📤 Test 4: Blocked hashtag (should be rejected)"
hashtag_event="{
\"id\": \"test_hashtag_101\",
\"pubkey\": \"1234567890abcdef1234567890abcdef12345678\",
\"created_at\": $current_time,
\"kind\": 1,
\"content\": \"Message with blocked hashtag\",
\"tags\": [[\"t\", \"blocked\"]],
\"sig\": \"test_sig_hashtag\"
}"
hashtag_message="[\"EVENT\",$hashtag_event]"
hashtag_response=$(echo "$hashtag_message" | websocat "ws://127.0.0.1:$RELAY_PORT" --text)
echo "Response: $hashtag_response"
if echo "$hashtag_response" | grep -q '"OK","test_hashtag_101",false'; then
echo "✅ Test 4 PASSED: Blocked hashtag rejected"
else
echo "❌ Test 4 FAILED: Blocked hashtag not rejected"
fi
echo ""
echo "🎉 Sprocket integration test completed!"
echo "📊 Check the results above to verify sprocket functionality"
echo ""
echo "📝 Relay logs are available at: /tmp/orly_test.log"
echo "💡 To view logs: cat /tmp/orly_test.log"

View File

@@ -0,0 +1,115 @@
#!/bin/bash
# Manual Sprocket Test Script
# This script demonstrates sprocket functionality by sending test events
set -e
echo "🧪 Manual Sprocket Test"
echo "======================"
# Configuration
RELAY_HOST="127.0.0.1"
RELAY_PORT="3334"
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
# Check if websocat is available
if ! command -v websocat &> /dev/null; then
echo "❌ websocat is required for this test"
echo "Install it with: cargo install websocat"
exit 1
fi
# Function to send an event and get response
send_event() {
local event_json="$1"
local description="$2"
echo "📤 Testing: $description"
echo "Event: $event_json"
# Send EVENT message
local message="[\"EVENT\",$event_json]"
echo "Sending: $message"
# Send and receive response
local response=$(echo "$message" | websocat "$RELAY_URL" --text)
echo "Response: $response"
echo "---"
}
# Test events
echo "🚀 Starting manual sprocket test..."
echo "Make sure the relay is running with sprocket enabled!"
echo ""
# Test 1: Normal event (should be accepted)
send_event '{
"id": "test_normal_123",
"pubkey": "1234567890abcdef1234567890abcdef12345678",
"created_at": '$(date +%s)',
"kind": 1,
"content": "Hello, world! This is a normal message.",
"sig": "test_sig_normal"
}' "Normal event (should be accepted)"
# Test 2: Spam content (should be rejected)
send_event '{
"id": "test_spam_456",
"pubkey": "1234567890abcdef1234567890abcdef12345678",
"created_at": '$(date +%s)',
"kind": 1,
"content": "This message contains spam content",
"sig": "test_sig_spam"
}' "Spam content (should be rejected)"
# Test 3: Test kind 9999 (should be shadow rejected)
send_event '{
"id": "test_kind_789",
"pubkey": "1234567890abcdef1234567890abcdef12345678",
"created_at": '$(date +%s)',
"kind": 9999,
"content": "Test message with special kind",
"sig": "test_sig_kind"
}' "Test kind 9999 (should be shadow rejected)"
# Test 4: Blocked hashtag (should be rejected)
send_event '{
"id": "test_hashtag_101",
"pubkey": "1234567890abcdef1234567890abcdef12345678",
"created_at": '$(date +%s)',
"kind": 1,
"content": "Message with blocked hashtag",
"tags": [["t", "blocked"]],
"sig": "test_sig_hashtag"
}' "Blocked hashtag (should be rejected)"
# Test 5: Too long content (should be rejected)
send_event '{
"id": "test_long_202",
"pubkey": "1234567890abcdef1234567890abcdef12345678",
"created_at": '$(date +%s)',
"kind": 1,
"content": "'$(printf 'a%.0s' {1..1001})'",
"sig": "test_sig_long"
}' "Too long content (should be rejected)"
# Test 6: Old timestamp (should be rejected)
send_event '{
"id": "test_old_303",
"pubkey": "1234567890abcdef1234567890abcdef12345678",
"created_at": '$(($(date +%s) - 7200))',
"kind": 1,
"content": "Message with old timestamp",
"sig": "test_sig_old"
}' "Old timestamp (should be rejected)"
echo "✅ Manual sprocket test completed!"
echo ""
echo "Expected results:"
echo "- Normal event: OK, true"
echo "- Spam content: OK, false, 'Content contains spam'"
echo "- Test kind 9999: OK, true (but shadow rejected)"
echo "- Blocked hashtag: OK, false, 'Hashtag blocked is not allowed'"
echo "- Too long content: OK, false, 'Content too long'"
echo "- Old timestamp: OK, false, 'Event timestamp too old'"

View File

@@ -0,0 +1,76 @@
#!/bin/bash
# Simple Sprocket Test
# This script demonstrates sprocket functionality
set -e
echo "🧪 Simple Sprocket Test"
echo "======================"
# Configuration
RELAY_PORT="3334"
TEST_CONFIG_DIR="$HOME/.config/ORLY_TEST"
# Clean up any existing test processes
echo "🧹 Cleaning up existing processes..."
pkill -f "ORLY_TEST" || true
sleep 2
# Create test configuration directory
echo "📁 Setting up test environment..."
mkdir -p "$TEST_CONFIG_DIR"
# Copy the Python sprocket script
cp test-sprocket.py "$TEST_CONFIG_DIR/sprocket.py"
# Create bash wrapper for the Python script
cat > "$TEST_CONFIG_DIR/sprocket.sh" << 'EOF'
#!/bin/bash
python3 "$(dirname "$0")/sprocket.py"
EOF
chmod +x "$TEST_CONFIG_DIR/sprocket.sh"
echo "✅ Sprocket script created at: $TEST_CONFIG_DIR/sprocket.sh"
# Test the sprocket script directly first
echo "🧪 Testing sprocket script directly..."
# Test 1: Normal event
echo "📤 Test 1: Normal event"
current_time=$(date +%s)
normal_event="{\"id\":\"test_normal_123\",\"pubkey\":\"1234567890abcdef1234567890abcdef12345678\",\"created_at\":$current_time,\"kind\":1,\"content\":\"Hello, world!\",\"sig\":\"test_sig\"}"
echo "$normal_event" | python3 "$TEST_CONFIG_DIR/sprocket.py"
# Test 2: Spam content
echo "📤 Test 2: Spam content"
spam_event="{\"id\":\"test_spam_456\",\"pubkey\":\"1234567890abcdef1234567890abcdef12345678\",\"created_at\":$current_time,\"kind\":1,\"content\":\"This is spam content\",\"sig\":\"test_sig\"}"
echo "$spam_event" | python3 "$TEST_CONFIG_DIR/sprocket.py"
# Test 3: Test kind 9999
echo "📤 Test 3: Test kind 9999"
kind_event="{\"id\":\"test_kind_789\",\"pubkey\":\"1234567890abcdef1234567890abcdef12345678\",\"created_at\":$current_time,\"kind\":9999,\"content\":\"Test message\",\"sig\":\"test_sig\"}"
echo "$kind_event" | python3 "$TEST_CONFIG_DIR/sprocket.py"
# Test 4: Blocked hashtag
echo "📤 Test 4: Blocked hashtag"
hashtag_event="{\"id\":\"test_hashtag_101\",\"pubkey\":\"1234567890abcdef1234567890abcdef12345678\",\"created_at\":$current_time,\"kind\":1,\"content\":\"Message with hashtag\",\"tags\":[[\"t\",\"blocked\"]],\"sig\":\"test_sig\"}"
echo "$hashtag_event" | python3 "$TEST_CONFIG_DIR/sprocket.py"
echo ""
echo "✅ Direct sprocket script tests completed!"
echo ""
echo "Expected results:"
echo "1. Normal event: {\"id\":\"test_normal_123\",\"action\":\"accept\",\"msg\":\"\"}"
echo "2. Spam content: {\"id\":\"test_spam_456\",\"action\":\"reject\",\"msg\":\"Content contains spam\"}"
echo "3. Test kind 9999: {\"id\":\"test_kind_789\",\"action\":\"shadowReject\",\"msg\":\"\"}"
echo "4. Blocked hashtag: {\"id\":\"test_hashtag_101\",\"action\":\"reject\",\"msg\":\"Hashtag \\\"blocked\\\" is not allowed\"}"
echo ""
echo "💡 To test with the full relay, run:"
echo " export ORLY_SPROCKET_ENABLED=true"
echo " export ORLY_APP_NAME=ORLY_TEST"
echo " go run . test"
echo ""
echo " Then in another terminal:"
echo " ./test-sprocket-manual.sh"

View File

@@ -0,0 +1,209 @@
#!/bin/bash
# Working Sprocket Test
# This script tests sprocket functionality with properly formatted messages
set -e
echo "🧪 Working Sprocket Test"
echo "======================="
# Configuration
RELAY_PORT="3335" # Use different port to avoid conflicts
TEST_CONFIG_DIR="$HOME/.config/ORLY_TEST"
# Clean up any existing test processes
echo "🧹 Cleaning up existing processes..."
pkill -f "ORLY_TEST" || true
sleep 2
# Create test configuration directory
echo "📁 Setting up test environment..."
mkdir -p "$TEST_CONFIG_DIR"
# Copy the Python sprocket script
cp test-sprocket.py "$TEST_CONFIG_DIR/sprocket.py"
# Create bash wrapper for the Python script
cat > "$TEST_CONFIG_DIR/sprocket.sh" << 'EOF'
#!/bin/bash
python3 "$(dirname "$0")/sprocket.py"
EOF
chmod +x "$TEST_CONFIG_DIR/sprocket.sh"
echo "✅ Sprocket script created at: $TEST_CONFIG_DIR/sprocket.sh"
# Set environment variables for the relay
export ORLY_APP_NAME="ORLY_TEST"
export ORLY_DATA_DIR="/tmp/orly_test_data"
export ORLY_LISTEN="127.0.0.1"
export ORLY_PORT="$RELAY_PORT"
export ORLY_LOG_LEVEL="info"
export ORLY_SPROCKET_ENABLED="true"
export ORLY_ADMINS=""
export ORLY_OWNERS=""
# Clean up test data directory
rm -rf "$ORLY_DATA_DIR"
mkdir -p "$ORLY_DATA_DIR"
# Function to cleanup
cleanup() {
echo "🧹 Cleaning up..."
pkill -f "ORLY_TEST" || true
sleep 2
rm -rf "$ORLY_DATA_DIR"
echo "✅ Cleanup complete"
}
# Set trap for cleanup
trap cleanup EXIT
# Start the relay
echo "🚀 Starting relay with sprocket enabled..."
go run . test > /tmp/orly_test.log 2>&1 &
RELAY_PID=$!
# Wait for relay to start
echo "⏳ Waiting for relay to start..."
sleep 5
# Check if relay is running
if ! kill -0 $RELAY_PID 2>/dev/null; then
echo "❌ Relay failed to start"
echo "Log output:"
cat /tmp/orly_test.log
exit 1
fi
echo "✅ Relay started successfully (PID: $RELAY_PID)"
# Test sprocket functionality with a simple Python WebSocket client
echo "🧪 Testing sprocket functionality..."
# Create a simple Python WebSocket test client
cat > /tmp/test_client.py << 'EOF'
#!/usr/bin/env python3
import asyncio
import websockets
import json
import time
async def test_sprocket():
uri = "ws://127.0.0.1:3335"
try:
async with websockets.connect(uri) as websocket:
print("✅ Connected to relay")
# Test 1: Normal event (should be accepted)
print("📤 Test 1: Normal event (should be accepted)")
current_time = int(time.time())
normal_event = {
"id": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"created_at": current_time,
"kind": 1,
"content": "Hello, world! This is a normal message.",
"sig": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}
normal_message = ["EVENT", normal_event]
await websocket.send(json.dumps(normal_message))
response = await websocket.recv()
print(f"Response: {response}")
if '"OK","0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",true' in response:
print("✅ Test 1 PASSED: Normal event accepted")
else:
print("❌ Test 1 FAILED: Normal event not accepted")
# Test 2: Spam content (should be rejected)
print("📤 Test 2: Spam content (should be rejected)")
spam_event = {
"id": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"created_at": current_time,
"kind": 1,
"content": "This message contains spam content",
"sig": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
}
spam_message = ["EVENT", spam_event]
await websocket.send(json.dumps(spam_message))
response = await websocket.recv()
print(f"Response: {response}")
if '"OK","1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",false' in response:
print("✅ Test 2 PASSED: Spam content rejected")
else:
print("❌ Test 2 FAILED: Spam content not rejected")
# Test 3: Test kind 9999 (should be shadow rejected)
print("📤 Test 3: Test kind 9999 (should be shadow rejected)")
kind_event = {
"id": "2345678901bcdef01234567890abcdef01234567890abcdef01234567890abcdef",
"pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"created_at": current_time,
"kind": 9999,
"content": "Test message with special kind",
"sig": "2345678901bcdef01234567890abcdef01234567890abcdef01234567890abcdef2345678901bcdef01234567890abcdef01234567890abcdef01234567890abcdef"
}
kind_message = ["EVENT", kind_event]
await websocket.send(json.dumps(kind_message))
response = await websocket.recv()
print(f"Response: {response}")
if '"OK","2345678901bcdef01234567890abcdef01234567890abcdef01234567890abcdef",true' in response:
print("✅ Test 3 PASSED: Test kind shadow rejected (OK=true but not processed)")
else:
print("❌ Test 3 FAILED: Test kind not shadow rejected")
# Test 4: Blocked hashtag (should be rejected)
print("📤 Test 4: Blocked hashtag (should be rejected)")
hashtag_event = {
"id": "3456789012cdef0123456789012cdef0123456789012cdef0123456789012cdef",
"pubkey": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"created_at": current_time,
"kind": 1,
"content": "Message with blocked hashtag",
"tags": [["t", "blocked"]],
"sig": "3456789012cdef0123456789012cdef0123456789012cdef0123456789012cdef3456789012cdef0123456789012cdef0123456789012cdef0123456789012cdef"
}
hashtag_message = ["EVENT", hashtag_event]
await websocket.send(json.dumps(hashtag_message))
response = await websocket.recv()
print(f"Response: {response}")
if '"OK","3456789012cdef0123456789012cdef0123456789012cdef0123456789012cdef",false' in response:
print("✅ Test 4 PASSED: Blocked hashtag rejected")
else:
print("❌ Test 4 FAILED: Blocked hashtag not rejected")
except Exception as e:
print(f"❌ Error: {e}")
if __name__ == "__main__":
asyncio.run(test_sprocket())
EOF
# Check if websockets is available
if ! python3 -c "import websockets" 2>/dev/null; then
echo "📦 Installing websockets library..."
pip3 install websockets
fi
# Run the test
python3 /tmp/test_client.py
echo ""
echo "🎉 Sprocket integration test completed!"
echo "📝 Relay logs are available at: /tmp/orly_test.log"
echo "💡 To view logs: cat /tmp/orly_test.log"

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
Test sprocket script that processes Nostr events via stdin/stdout JSONL protocol.
This script demonstrates various filtering criteria for testing purposes.
"""
import json
import sys
import re
from datetime import datetime
def process_event(event_json):
"""
Process a single event and return the appropriate response.
Args:
event_json (dict): The parsed event JSON
Returns:
dict: Response with id, action, and msg fields
"""
event_id = event_json.get('id', '')
event_kind = event_json.get('kind', 0)
event_content = event_json.get('content', '')
event_pubkey = event_json.get('pubkey', '')
event_tags = event_json.get('tags', [])
# Test criteria 1: Reject events containing "spam" in content
if 'spam' in event_content.lower():
return {
'id': event_id,
'action': 'reject',
'msg': 'Content contains spam'
}
# Test criteria 2: Shadow reject events with kind 9999 (test kind)
if event_kind == 9999:
return {
'id': event_id,
'action': 'shadowReject',
'msg': ''
}
# Test criteria 3: Reject events with certain hashtags
for tag in event_tags:
if len(tag) >= 2 and tag[0] == 't': # hashtag
hashtag = tag[1].lower()
if hashtag in ['blocked', 'rejected', 'test-block']:
return {
'id': event_id,
'action': 'reject',
'msg': f'Hashtag "{hashtag}" is not allowed'
}
# Test criteria 4: Shadow reject events from specific pubkeys (first 8 chars)
blocked_prefixes = ['00000000', '11111111', '22222222'] # Test prefixes
pubkey_prefix = event_pubkey[:8] if len(event_pubkey) >= 8 else event_pubkey
if pubkey_prefix in blocked_prefixes:
return {
'id': event_id,
'action': 'shadowReject',
'msg': ''
}
# Test criteria 5: Reject events that are too long
if len(event_content) > 1000:
return {
'id': event_id,
'action': 'reject',
'msg': 'Content too long (max 1000 characters)'
}
# Test criteria 6: Reject events with invalid timestamps (too old or too new)
try:
event_time = event_json.get('created_at', 0)
current_time = int(datetime.now().timestamp())
# Reject events more than 1 hour old
if current_time - event_time > 3600:
return {
'id': event_id,
'action': 'reject',
'msg': 'Event timestamp too old'
}
# Reject events more than 5 minutes in the future
if event_time - current_time > 300:
return {
'id': event_id,
'action': 'reject',
'msg': 'Event timestamp too far in future'
}
except (ValueError, TypeError):
pass # Ignore timestamp errors
# Default: accept the event
return {
'id': event_id,
'action': 'accept',
'msg': ''
}
def main():
"""Main function to process events from stdin."""
try:
# Read events from stdin
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
# Parse the event JSON
event = json.loads(line)
# Process the event
response = process_event(event)
# Output the response as JSONL
print(json.dumps(response), flush=True)
except json.JSONDecodeError as e:
# Log error to stderr but continue processing
print(f"Error parsing JSON: {e}", file=sys.stderr)
continue
except Exception as e:
# Log error to stderr but continue processing
print(f"Error processing event: {e}", file=sys.stderr)
continue
except KeyboardInterrupt:
# Graceful shutdown
sys.exit(0)
except Exception as e:
print(f"Fatal error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Test script for sprocket functionality
# This script demonstrates how to set up owner permissions and test sprocket
echo "=== Sprocket Test Setup ==="
echo ""
echo "To test the sprocket functionality, you need to:"
echo ""
echo "1. Generate a test keypair (if you don't have one):"
echo " Use a Nostr client like Amethyst or Nostr Wallet Connect to generate an npub"
echo ""
echo "2. Set the ORLY_OWNERS environment variable:"
echo " export ORLY_OWNERS=\"npub1your-npub-here\""
echo ""
echo "3. Start the relay with owner permissions:"
echo " ORLY_OWNERS=\"npub1your-npub-here\" ./next.orly.dev"
echo ""
echo "4. Log in to the web interface using the corresponding private key"
echo "5. Navigate to the Sprocket tab to access the script editor"
echo ""
echo "Example sprocket script:"
echo "#!/bin/bash"
echo "echo \"Sprocket is running!\""
echo "while true; do"
echo " echo \"Sprocket heartbeat: \$(date)\""
echo " sleep 30"
echo "done"
echo ""
echo "The sprocket script will be stored in ~/.config/ORLY/sprocket.sh"
echo "and will be automatically started when the relay starts."