Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
27af174753
|
|||
|
cad366795a
|
|||
|
e14b89bc8b
|
|||
|
5b4dd9ea60
|
|||
|
bae1d09f8d
|
|||
|
f1f3236196
|
|||
|
f01cd562f8
|
|||
|
d2d0821d19
|
|||
|
09b00c76ed
|
|||
|
de57fd7bc4
|
|||
|
b7c2e609f6
|
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
11
app/main.go
11
app/main.go
@@ -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()
|
||||
|
||||
|
||||
243
app/server.go
243
app/server.go
@@ -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
613
app/sprocket.go
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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 # 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) {
|
||||
|
||||
@@ -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
1
go.mod
@@ -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
2
go.sum
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
v0.11.1
|
||||
v0.12.3
|
||||
336
readme.adoc
336
readme.adoc
@@ -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.
|
||||
|
||||
228
scripts/sprocket/SPROCKET_TEST_README.md
Normal file
228
scripts/sprocket/SPROCKET_TEST_README.md
Normal 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.
|
||||
50
scripts/sprocket/run-sprocket-test.sh
Executable file
50
scripts/sprocket/run-sprocket-test.sh
Executable 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!"
|
||||
209
scripts/sprocket/test-sprocket-complete.sh
Normal file
209
scripts/sprocket/test-sprocket-complete.sh
Normal 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"
|
||||
143
scripts/sprocket/test-sprocket-demo.sh
Normal file
143
scripts/sprocket/test-sprocket-demo.sh
Normal 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)"
|
||||
28
scripts/sprocket/test-sprocket-example.sh
Normal file
28
scripts/sprocket/test-sprocket-example.sh
Normal 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
|
||||
184
scripts/sprocket/test-sprocket-final.sh
Normal file
184
scripts/sprocket/test-sprocket-final.sh
Normal 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"
|
||||
115
scripts/sprocket/test-sprocket-manual.sh
Normal file
115
scripts/sprocket/test-sprocket-manual.sh
Normal 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'"
|
||||
76
scripts/sprocket/test-sprocket-simple.sh
Normal file
76
scripts/sprocket/test-sprocket-simple.sh
Normal 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"
|
||||
209
scripts/sprocket/test-sprocket-working.sh
Normal file
209
scripts/sprocket/test-sprocket-working.sh
Normal 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"
|
||||
139
scripts/sprocket/test-sprocket.py
Normal file
139
scripts/sprocket/test-sprocket.py
Normal 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()
|
||||
31
scripts/sprocket/test-sprocket.sh
Normal file
31
scripts/sprocket/test-sprocket.sh
Normal 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."
|
||||
Reference in New Issue
Block a user