Browse Source

Refactor project to modularize constants and utilities.

Moved reusable constants and helper functions to dedicated modules for improved maintainability and reusability. Improved build configuration to differentiate output directories for development and production. Enhanced server error handling and added safeguards for disabled web UI scenarios.
main
mleku 1 month ago
parent
commit
8ef3114f5c
No known key found for this signature in database
  1. 3
      .claude/settings.local.json
  2. 377
      README.md
  3. 21
      app/server.go
  4. 3
      app/web/.gitignore
  5. 2
      app/web/package.json
  6. 10
      app/web/rollup.config.js
  7. 278
      app/web/src/App.svelte
  8. 12
      app/web/src/ComposeView.svelte
  9. 1
      app/web/src/EventsView.svelte
  10. 8
      app/web/src/ExportView.svelte
  11. 4
      app/web/src/ImportView.svelte
  12. 1
      app/web/src/PolicyView.svelte
  13. 1
      app/web/src/RecoveryView.svelte
  14. 34
      app/web/src/Sidebar.svelte
  15. 370
      app/web/src/api.js
  16. 190
      app/web/src/constants.js
  17. 88
      app/web/src/stores.js
  18. 119
      app/web/src/utils.js

3
.claude/settings.local.json

@ -13,7 +13,8 @@
"Bash(go build:*)", "Bash(go build:*)",
"Bash(go test:*)", "Bash(go test:*)",
"Bash(./scripts/test.sh:*)", "Bash(./scripts/test.sh:*)",
"Bash(./scripts/update-embedded-web.sh:*)" "Bash(./scripts/update-embedded-web.sh:*)",
"Bash(bun run build:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

377
readme.adoc → README.md

@ -1,63 +1,61 @@
go= next.orly.dev # next.orly.dev
:toc:
:note-caption: note 👉
image:./docs/orly.png[orly.dev] ![orly.dev](./docs/orly.png)
image:https://img.shields.io/badge/version-v0.24.1-blue.svg[Version v0.24.1] ![Version v0.24.1](https://img.shields.io/badge/version-v0.24.1-blue.svg)
image:https://img.shields.io/badge/godoc-documentation-blue.svg[Documentation,link=https://pkg.go.dev/next.orly.dev] [![Documentation](https://img.shields.io/badge/godoc-documentation-blue.svg)](https://pkg.go.dev/next.orly.dev)
image:https://img.shields.io/badge/donate-geyser_crowdfunding_project_page-orange.svg[Support this project,link=https://geyser.fund/project/orly] [![Support this project](https://img.shields.io/badge/donate-geyser_crowdfunding_project_page-orange.svg)](https://geyser.fund/project/orly)
zap me: ⚡mlekudev@getalby.com
follow me on link:https://jumble.social/users/npub1fjqqy4a93z5zsjwsfxqhc2764kvykfdyttvldkkkdera8dr78vhsmmleku[nostr]
== about zap me: ¡mlekudev@getalby.com
ORLY is a nostr relay written from the ground up to be performant, low latency, and built with a number of features designed to make it well suited for follow me on [nostr](https://jumble.social/users/npub1fjqqy4a93z5zsjwsfxqhc2764kvykfdyttvldkkkdera8dr78vhsmmleku)
## About
ORLY is a nostr relay written from the ground up to be performant, low latency, and built with a number of features designed to make it well suited for:
- personal relays - personal relays
- small community relays - small community relays
- business deployments and RaaS (Relay as a Service) with a nostr-native NWC client to allow accepting payments through NWC capable lightning nodes - business deployments and RaaS (Relay as a Service) with a nostr-native NWC client to allow accepting payments through NWC capable lightning nodes
- high availability clusters for reliability and/or providing a unified data set across multiple regions - high availability clusters for reliability and/or providing a unified data set across multiple regions
== performance & cryptography ## Performance & Cryptography
ORLY leverages high-performance libraries and custom optimizations for exceptional speed: ORLY leverages high-performance libraries and custom optimizations for exceptional speed:
* **SIMD Libraries**: Uses link:https://github.com/minio/sha256-simd[minio/sha256-simd] for accelerated SHA256 hashing - **SIMD Libraries**: Uses [minio/sha256-simd](https://github.com/minio/sha256-simd) for accelerated SHA256 hashing
* **p256k1 Cryptography**: Implements link:https://github.com/p256k1/p256k1[p256k1.mleku.dev] for fast elliptic curve operations optimized for nostr - **p256k1 Cryptography**: Implements [p256k1.mleku.dev](https://github.com/p256k1/p256k1) for fast elliptic curve operations optimized for nostr
* **Fast Message Encoders**: High-performance encoding/decoding with link:https://github.com/templexxx/xhex[templexxx/xhex] for SIMD-accelerated hex operations - **Fast Message Encoders**: High-performance encoding/decoding with [templexxx/xhex](https://github.com/templexxx/xhex) for SIMD-accelerated hex operations
The encoders achieve **24% faster JSON marshaling**, **16% faster canonical encoding**, and **54-91% reduction in memory allocations** through custom buffer pre-allocation and zero-allocation optimization techniques. The encoders achieve **24% faster JSON marshaling**, **16% faster canonical encoding**, and **54-91% reduction in memory allocations** through custom buffer pre-allocation and zero-allocation optimization techniques.
ORLY uses a fast embedded link:https://github.com/hypermodeinc/badger[badger] database with a database designed for high performance querying and event storage. ORLY uses a fast embedded [badger](https://github.com/hypermodeinc/badger) database with a database designed for high performance querying and event storage.
== building ## Building
ORLY is a standard Go application that can be built using the Go toolchain. ORLY is a standard Go application that can be built using the Go toolchain.
=== prerequisites ### Prerequisites
- Go 1.25.3 or later - Go 1.25.3 or later
- Git - Git
- For web UI: link:https://bun.sh/[Bun] JavaScript runtime - For web UI: [Bun](https://bun.sh/) JavaScript runtime
=== basic build ### Basic Build
To build the relay binary only: To build the relay binary only:
[source,bash] ```bash
----
git clone <repository-url> git clone <repository-url>
cd next.orly.dev cd next.orly.dev
go build -o orly go build -o orly
---- ```
=== building with web UI ### Building with Web UI
To build with the embedded web interface: To build with the embedded web interface:
[source,bash] ```bash
----
# Build the Svelte web application # Build the Svelte web application
cd app/web cd app/web
bun install bun install
@ -66,14 +64,13 @@ bun run build
# Build the Go binary from project root # Build the Go binary from project root
cd ../../ cd ../../
go build -o orly go build -o orly
---- ```
The recommended way to build and embed the web UI is using the provided script: The recommended way to build and embed the web UI is using the provided script:
[source,bash] ```bash
----
./scripts/update-embedded-web.sh ./scripts/update-embedded-web.sh
---- ```
This script will: This script will:
- Build the Svelte app in `app/web` to `app/web/dist` using Bun (preferred) or fall back to npm/yarn/pnpm - Build the Svelte app in `app/web` to `app/web/dist` using Bun (preferred) or fall back to npm/yarn/pnpm
@ -82,8 +79,7 @@ This script will:
For manual builds, you can also use: For manual builds, you can also use:
[source,bash] ```bash
----
#!/bin/bash #!/bin/bash
# build.sh # build.sh
echo "Building Svelte app..." echo "Building Svelte app..."
@ -96,86 +92,139 @@ cd ../../
go build -o orly go build -o orly
echo "Build complete!" echo "Build complete!"
---- ```
Make it executable with `chmod +x build.sh` and run with `./build.sh`. Make it executable with `chmod +x build.sh` and run with `./build.sh`.
== core features ## Core Features
### Web UI
ORLY includes a modern web-based user interface built with [Svelte](https://svelte.dev/) for relay management and monitoring.
- **Secure Authentication**: Nostr key pair authentication with challenge-response
- **Event Management**: Browse, export, import, and search events
- **User Administration**: Role-based permissions (guest, user, admin, owner)
- **Sprocket Management**: Upload and monitor event processing scripts
- **Real-time Updates**: Live event streaming and system monitoring
- **Responsive Design**: Works on desktop and mobile devices
- **Dark/Light Themes**: Persistent theme preferences
The web UI is embedded in the relay binary and accessible at the relay's root path.
#### Web UI Development
For development with hot-reloading, ORLY can proxy web requests to a local dev server while still handling WebSocket relay connections and API requests.
**Environment Variables:**
- `ORLY_WEB_DISABLE` - Set to `true` to disable serving the embedded web UI
- `ORLY_WEB_DEV_PROXY_URL` - URL of the dev server to proxy web requests to (e.g., `localhost:8080`)
**Setup:**
1. Start the dev server (in one terminal):
```bash
cd app/web
bun install
bun run dev
```
Note the port sirv is listening on (e.g., `http://localhost:8080`).
2. Start the relay with dev proxy enabled (in another terminal):
```bash
export ORLY_WEB_DISABLE=true
export ORLY_WEB_DEV_PROXY_URL=localhost:8080
./orly
```
The relay will:
- Handle WebSocket connections at `/` for Nostr protocol
- Handle API requests at `/api/*`
- Proxy all other requests (HTML, JS, CSS, assets) to the dev server
=== web UI **With a reverse proxy/tunnel:**
ORLY includes a modern web-based user interface built with link:https://svelte.dev/[Svelte] for relay management and monitoring. If you're running behind a reverse proxy or tunnel (e.g., Caddy, nginx, Cloudflare Tunnel), the setup is the same. The relay listens locally and your reverse proxy forwards traffic to it:
* **Secure Authentication**: Nostr key pair authentication with challenge-response ```
* **Event Management**: Browse, export, import, and search events Browser ’ Reverse Proxy ’ ORLY (port 3334) ’ Dev Server (port 8080)
* **User Administration**: Role-based permissions (guest, user, admin, owner)
* **Sprocket Management**: Upload and monitor event processing scripts WebSocket/API
* **Real-time Updates**: Live event streaming and system monitoring ```
* **Responsive Design**: Works on desktop and mobile devices
* **Dark/Light Themes**: Persistent theme preferences
The web UI is embedded in the relay binary and accessible at the relay's root path. For development with hot-reloading: Example with the relay on port 3334 and sirv on port 8080:
[source,bash] ```bash
---- # Terminal 1: Dev server
export ORLY_WEB_DISABLE_EMBEDDED=true
export ORLY_WEB_DEV_PROXY_URL=localhost:5000
./orly &
cd app/web && bun run dev cd app/web && bun run dev
---- # Output: Your application is ready~!
# Local: http://localhost:8080
=== sprocket event processing # Terminal 2: Relay
export ORLY_WEB_DISABLE=true
export ORLY_WEB_DEV_PROXY_URL=localhost:8080
export ORLY_PORT=3334
./orly
```
**Disabling the web UI without a proxy:**
If you only want to disable the embedded web UI (without proxying to a dev server), just set `ORLY_WEB_DISABLE=true` without setting `ORLY_WEB_DEV_PROXY_URL`. The relay will return 404 for web UI requests while still handling WebSocket and API requests.
### Sprocket Event Processing
ORLY includes a powerful sprocket system for external event processing scripts. Sprocket scripts enable custom filtering, validation, and processing logic for Nostr events before storage. ORLY includes a powerful sprocket system for external event processing scripts. Sprocket scripts enable custom filtering, validation, and processing logic for Nostr events before storage.
* **Real-time Processing**: Scripts receive events via stdin and respond with JSONL decisions - **Real-time Processing**: Scripts receive events via stdin and respond with JSONL decisions
* **Three Actions**: `accept`, `reject`, or `shadowReject` events based on custom logic - **Three Actions**: `accept`, `reject`, or `shadowReject` events based on custom logic
* **Automatic Recovery**: Failed scripts are automatically disabled with periodic recovery attempts - **Automatic Recovery**: Failed scripts are automatically disabled with periodic recovery attempts
* **Web UI Management**: Upload, configure, and monitor scripts through the admin interface - **Web UI Management**: Upload, configure, and monitor scripts through the admin interface
[source,bash] ```bash
----
export ORLY_SPROCKET_ENABLED=true export ORLY_SPROCKET_ENABLED=true
export ORLY_APP_NAME="ORLY" export ORLY_APP_NAME="ORLY"
# Place script at ~/.config/ORLY/sprocket.sh # Place script at ~/.config/ORLY/sprocket.sh
---- ```
For detailed configuration and examples, see the link:docs/sprocket/[sprocket documentation]. For detailed configuration and examples, see the [sprocket documentation](docs/sprocket/).
=== policy system ### Policy System
ORLY includes a comprehensive policy system for fine-grained control over event storage and retrieval. Configure custom validation rules, access controls, size limits, and age restrictions. ORLY includes a comprehensive policy system for fine-grained control over event storage and retrieval. Configure custom validation rules, access controls, size limits, and age restrictions.
* **Access Control**: Allow/deny based on pubkeys, roles, or social relationships - **Access Control**: Allow/deny based on pubkeys, roles, or social relationships
* **Content Filtering**: Size limits, age validation, and custom rules - **Content Filtering**: Size limits, age validation, and custom rules
* **Script Integration**: Execute custom scripts for complex policy logic - **Script Integration**: Execute custom scripts for complex policy logic
* **Real-time Enforcement**: Policies applied to both read and write operations - **Real-time Enforcement**: Policies applied to both read and write operations
[source,bash] ```bash
----
export ORLY_POLICY_ENABLED=true export ORLY_POLICY_ENABLED=true
# Create policy file at ~/.config/ORLY/policy.json # Create policy file at ~/.config/ORLY/policy.json
---- ```
For detailed configuration and examples, see the link:docs/POLICY_USAGE_GUIDE.md[Policy Usage Guide]. For detailed configuration and examples, see the [Policy Usage Guide](docs/POLICY_USAGE_GUIDE.md).
== deployment ## Deployment
ORLY includes an automated deployment script that handles Go installation, dependency setup, building, and systemd service configuration. ORLY includes an automated deployment script that handles Go installation, dependency setup, building, and systemd service configuration.
=== automated deployment ### Automated Deployment
The deployment script (`scripts/deploy.sh`) provides a complete setup solution: The deployment script (`scripts/deploy.sh`) provides a complete setup solution:
[source,bash] ```bash
----
# Clone the repository # Clone the repository
git clone <repository-url> git clone <repository-url>
cd next.orly.dev cd next.orly.dev
# Run the deployment script # Run the deployment script
./scripts/deploy.sh ./scripts/deploy.sh
---- ```
The script will: The script will:
@ -188,17 +237,15 @@ The script will:
After deployment, reload your shell environment: After deployment, reload your shell environment:
[source,bash] ```bash
----
source ~/.bashrc source ~/.bashrc
---- ```
=== TLS configuration ### TLS Configuration
ORLY supports automatic TLS certificate management with Let's Encrypt and custom certificates: ORLY supports automatic TLS certificate management with Let's Encrypt and custom certificates:
[source,bash] ```bash
----
# Enable TLS with Let's Encrypt for specific domains # Enable TLS with Let's Encrypt for specific domains
export ORLY_TLS_DOMAINS=relay.example.com,backup.relay.example.com export ORLY_TLS_DOMAINS=relay.example.com,backup.relay.example.com
@ -209,18 +256,17 @@ export ORLY_CERTS=/path/to/cert1,/path/to/cert2
# - Listen on port 443 for HTTPS/WSS # - Listen on port 443 for HTTPS/WSS
# - Listen on port 80 for ACME challenges # - Listen on port 80 for ACME challenges
# - Ignore ORLY_PORT setting # - Ignore ORLY_PORT setting
---- ```
Certificate files should be named with `.pem` and `.key` extensions: Certificate files should be named with `.pem` and `.key` extensions:
- `/path/to/cert1.pem` (certificate) - `/path/to/cert1.pem` (certificate)
- `/path/to/cert1.key` (private key) - `/path/to/cert1.key` (private key)
=== systemd service management ### systemd Service Management
The deployment script creates a systemd service for easy management: The deployment script creates a systemd service for easy management:
[source,bash] ```bash
----
# Start the service # Start the service
sudo systemctl start orly sudo systemctl start orly
@ -244,60 +290,56 @@ sudo journalctl -u orly -f
# View recent logs # View recent logs
sudo journalctl -u orly --since "1 hour ago" sudo journalctl -u orly --since "1 hour ago"
---- ```
=== remote deployment ### Remote Deployment
You can deploy ORLY on a remote server using SSH: You can deploy ORLY on a remote server using SSH:
[source,bash] ```bash
----
# Deploy to a VPS with SSH key authentication # Deploy to a VPS with SSH key authentication
ssh user@your-server.com << 'EOF' ssh user@your-server.com << 'EOF'
# Clone and deploy # Clone and deploy
git clone <repository-url> git clone <repository-url>
cd next.orly.dev cd next.orly.dev
./scripts/deploy.sh ./scripts/deploy.sh
# Configure your relay # Configure your relay
echo 'export ORLY_TLS_DOMAINS=relay.example.com' >> ~/.bashrc echo 'export ORLY_TLS_DOMAINS=relay.example.com' >> ~/.bashrc
echo 'export ORLY_ADMINS=npub1your_admin_key_here' >> ~/.bashrc echo 'export ORLY_ADMINS=npub1your_admin_key_here' >> ~/.bashrc
# Start the service # Start the service
sudo systemctl start orly --now sudo systemctl start orly --now
EOF EOF
# Check deployment status # Check deployment status
ssh user@your-server.com 'sudo systemctl status orly' ssh user@your-server.com 'sudo systemctl status orly'
---- ```
=== configuration ### Configuration
After deployment, configure your relay by setting environment variables in your shell profile: After deployment, configure your relay by setting environment variables in your shell profile:
[source,bash] ```bash
----
# Add to ~/.bashrc or ~/.profile # Add to ~/.bashrc or ~/.profile
export ORLY_TLS_DOMAINS=relay.example.com export ORLY_TLS_DOMAINS=relay.example.com
export ORLY_ADMINS=npub1your_admin_key export ORLY_ADMINS=npub1your_admin_key
export ORLY_ACL_MODE=follows export ORLY_ACL_MODE=follows
export ORLY_APP_NAME="MyRelay" export ORLY_APP_NAME="MyRelay"
---- ```
Then restart the service: Then restart the service:
[source,bash] ```bash
----
source ~/.bashrc source ~/.bashrc
sudo systemctl restart orly sudo systemctl restart orly
---- ```
=== firewall configuration ### Firewall Configuration
Ensure your firewall allows the necessary ports: Ensure your firewall allows the necessary ports:
[source,bash] ```bash
----
# For TLS-enabled relays # For TLS-enabled relays
sudo ufw allow 80/tcp # HTTP (ACME challenges) sudo ufw allow 80/tcp # HTTP (ACME challenges)
sudo ufw allow 443/tcp # HTTPS/WSS sudo ufw allow 443/tcp # HTTPS/WSS
@ -307,14 +349,13 @@ sudo ufw allow 3334/tcp # Default ORLY port
# Enable firewall if not already enabled # Enable firewall if not already enabled
sudo ufw enable sudo ufw enable
---- ```
=== monitoring ### Monitoring
Monitor your relay using systemd and standard Linux tools: Monitor your relay using systemd and standard Linux tools:
[source,bash] ```bash
----
# Service status and logs # Service status and logs
sudo systemctl status orly sudo systemctl status orly
sudo journalctl -u orly -f sudo journalctl -u orly -f
@ -328,30 +369,29 @@ du -sh ~/.local/share/ORLY/
# Check TLS certificates (if using Let's Encrypt) # Check TLS certificates (if using Let's Encrypt)
ls -la ~/.local/share/ORLY/autocert/ ls -la ~/.local/share/ORLY/autocert/
---- ```
== testing ## Testing
ORLY includes comprehensive testing tools for protocol validation and performance testing. ORLY includes comprehensive testing tools for protocol validation and performance testing.
* **Protocol Testing**: Use `relay-tester` for Nostr protocol compliance validation - **Protocol Testing**: Use `relay-tester` for Nostr protocol compliance validation
* **Stress Testing**: Performance testing under various load conditions - **Stress Testing**: Performance testing under various load conditions
* **Benchmark Suite**: Comparative performance testing across relay implementations - **Benchmark Suite**: Comparative performance testing across relay implementations
For detailed testing instructions, multi-relay testing scenarios, and advanced usage, see the link:docs/RELAY_TESTING_GUIDE.md[Relay Testing Guide]. For detailed testing instructions, multi-relay testing scenarios, and advanced usage, see the [Relay Testing Guide](docs/RELAY_TESTING_GUIDE.md).
The benchmark suite provides comprehensive performance testing and comparison across multiple relay implementations, including throughput, latency, and memory usage metrics. The benchmark suite provides comprehensive performance testing and comparison across multiple relay implementations, including throughput, latency, and memory usage metrics.
== command-line tools ## Command-Line Tools
ORLY includes several command-line utilities in the `cmd/` directory for testing, debugging, and administration. ORLY includes several command-line utilities in the `cmd/` directory for testing, debugging, and administration.
=== relay-tester ### relay-tester
Nostr protocol compliance testing tool. Validates that a relay correctly implements the Nostr protocol specification. Nostr protocol compliance testing tool. Validates that a relay correctly implements the Nostr protocol specification.
[source,bash] ```bash
----
# Run all protocol compliance tests # Run all protocol compliance tests
go run ./cmd/relay-tester -url ws://localhost:3334 go run ./cmd/relay-tester -url ws://localhost:3334
@ -363,14 +403,13 @@ go run ./cmd/relay-tester -url ws://localhost:3334 -test "Basic Event"
# Output results as JSON # Output results as JSON
go run ./cmd/relay-tester -url ws://localhost:3334 -json go run ./cmd/relay-tester -url ws://localhost:3334 -json
---- ```
=== benchmark ### benchmark
Comprehensive relay performance benchmarking tool. Tests event storage, queries, and subscription performance with detailed latency metrics (P90, P95, P99). Comprehensive relay performance benchmarking tool. Tests event storage, queries, and subscription performance with detailed latency metrics (P90, P95, P99).
[source,bash] ```bash
----
# Run benchmarks against local database # Run benchmarks against local database
go run ./cmd/benchmark -data-dir /tmp/bench-db -events 10000 -workers 4 go run ./cmd/benchmark -data-dir /tmp/bench-db -events 10000 -workers 4
@ -380,29 +419,27 @@ go run ./cmd/benchmark -relay ws://localhost:3334 -events 5000
# Use different database backends # Use different database backends
go run ./cmd/benchmark -dgraph -events 10000 go run ./cmd/benchmark -dgraph -events 10000
go run ./cmd/benchmark -neo4j -events 10000 go run ./cmd/benchmark -neo4j -events 10000
---- ```
The `cmd/benchmark/` directory also includes Docker Compose configurations for comparative benchmarks across multiple relay implementations (strfry, nostr-rs-relay, khatru, etc.). The `cmd/benchmark/` directory also includes Docker Compose configurations for comparative benchmarks across multiple relay implementations (strfry, nostr-rs-relay, khatru, etc.).
=== stresstest ### stresstest
Load testing tool for evaluating relay performance under sustained high-traffic conditions. Generates events with random content and tags to simulate realistic workloads. Load testing tool for evaluating relay performance under sustained high-traffic conditions. Generates events with random content and tags to simulate realistic workloads.
[source,bash] ```bash
----
# Run stress test with 10 concurrent workers # Run stress test with 10 concurrent workers
go run ./cmd/stresstest -url ws://localhost:3334 -workers 10 -duration 60s go run ./cmd/stresstest -url ws://localhost:3334 -workers 10 -duration 60s
# Generate events with random p-tags (up to 100 per event) # Generate events with random p-tags (up to 100 per event)
go run ./cmd/stresstest -url ws://localhost:3334 -workers 5 go run ./cmd/stresstest -url ws://localhost:3334 -workers 5
---- ```
=== blossomtest ### blossomtest
Tests the Blossom blob storage protocol (BUD-01/BUD-02) implementation. Validates upload, download, and authentication flows. Tests the Blossom blob storage protocol (BUD-01/BUD-02) implementation. Validates upload, download, and authentication flows.
[source,bash] ```bash
----
# Test with generated key # Test with generated key
go run ./cmd/blossomtest -url http://localhost:3334 -size 1024 go run ./cmd/blossomtest -url http://localhost:3334 -size 1024
@ -411,23 +448,21 @@ go run ./cmd/blossomtest -url http://localhost:3334 -nsec nsec1...
# Test anonymous uploads (no authentication) # Test anonymous uploads (no authentication)
go run ./cmd/blossomtest -url http://localhost:3334 -no-auth go run ./cmd/blossomtest -url http://localhost:3334 -no-auth
---- ```
=== aggregator ### aggregator
Event aggregation utility that fetches events from multiple relays using bloom filters for deduplication. Useful for syncing events across relays with memory-efficient duplicate detection. Event aggregation utility that fetches events from multiple relays using bloom filters for deduplication. Useful for syncing events across relays with memory-efficient duplicate detection.
[source,bash] ```bash
----
go run ./cmd/aggregator -relays wss://relay1.com,wss://relay2.com -output events.jsonl go run ./cmd/aggregator -relays wss://relay1.com,wss://relay2.com -output events.jsonl
---- ```
=== convert ### convert
Key format conversion utility. Converts between hex and bech32 (npub/nsec) formats for Nostr keys. Key format conversion utility. Converts between hex and bech32 (npub/nsec) formats for Nostr keys.
[source,bash] ```bash
----
# Convert npub to hex # Convert npub to hex
go run ./cmd/convert npub1abc... go run ./cmd/convert npub1abc...
@ -436,14 +471,13 @@ go run ./cmd/convert 0123456789abcdef...
# Convert secret key (nsec or hex) - outputs both nsec and derived npub # Convert secret key (nsec or hex) - outputs both nsec and derived npub
go run ./cmd/convert --secret nsec1xyz... go run ./cmd/convert --secret nsec1xyz...
---- ```
=== FIND ### FIND
Free Internet Name Daemon - CLI tool for the distributed naming system. Manages name registration, transfers, and certificate issuance. Free Internet Name Daemon - CLI tool for the distributed naming system. Manages name registration, transfers, and certificate issuance.
[source,bash] ```bash
----
# Validate a name format # Validate a name format
go run ./cmd/FIND verify-name example.nostr go run ./cmd/FIND verify-name example.nostr
@ -455,91 +489,84 @@ go run ./cmd/FIND register myname.nostr
# Transfer a name to a new owner # Transfer a name to a new owner
go run ./cmd/FIND transfer myname.nostr npub1newowner... go run ./cmd/FIND transfer myname.nostr npub1newowner...
---- ```
=== policytest ### policytest
Tests the policy system for event write control. Validates that policy rules correctly allow or reject events based on kind, pubkey, and other criteria. Tests the policy system for event write control. Validates that policy rules correctly allow or reject events based on kind, pubkey, and other criteria.
[source,bash] ```bash
----
go run ./cmd/policytest -url ws://localhost:3334 -type event -kind 4678 go run ./cmd/policytest -url ws://localhost:3334 -type event -kind 4678
go run ./cmd/policytest -url ws://localhost:3334 -type req -kind 1 go run ./cmd/policytest -url ws://localhost:3334 -type req -kind 1
go run ./cmd/policytest -url ws://localhost:3334 -type publish-and-query -count 5 go run ./cmd/policytest -url ws://localhost:3334 -type publish-and-query -count 5
---- ```
=== policyfiltertest ### policyfiltertest
Tests policy-based filtering with authorized and unauthorized pubkeys. Validates access control rules for specific users. Tests policy-based filtering with authorized and unauthorized pubkeys. Validates access control rules for specific users.
[source,bash] ```bash
----
go run ./cmd/policyfiltertest -url ws://localhost:3334 \ go run ./cmd/policyfiltertest -url ws://localhost:3334 \
-allowed-pubkey <hex> -allowed-sec <hex> \ -allowed-pubkey <hex> -allowed-sec <hex> \
-unauthorized-pubkey <hex> -unauthorized-sec <hex> -unauthorized-pubkey <hex> -unauthorized-sec <hex>
---- ```
=== subscription-test ### subscription-test
Tests WebSocket subscription stability over extended periods. Monitors for dropped subscriptions and connection issues. Tests WebSocket subscription stability over extended periods. Monitors for dropped subscriptions and connection issues.
[source,bash] ```bash
----
# Run subscription stability test for 60 seconds # Run subscription stability test for 60 seconds
go run ./cmd/subscription-test -url ws://localhost:3334 -duration 60 -kind 1 go run ./cmd/subscription-test -url ws://localhost:3334 -duration 60 -kind 1
# With verbose output # With verbose output
go run ./cmd/subscription-test -url ws://localhost:3334 -duration 120 -v go run ./cmd/subscription-test -url ws://localhost:3334 -duration 120 -v
---- ```
=== subscription-test-simple ### subscription-test-simple
Simplified subscription stability test that verifies subscriptions remain active without dropping over the test duration. Simplified subscription stability test that verifies subscriptions remain active without dropping over the test duration.
[source,bash] ```bash
----
go run ./cmd/subscription-test-simple -url ws://localhost:3334 -duration 120 go run ./cmd/subscription-test-simple -url ws://localhost:3334 -duration 120
---- ```
== access control ## Access Control
=== follows ACL ### Follows ACL
The follows ACL (Access Control List) system provides flexible relay access control based on social relationships in the Nostr network. The follows ACL (Access Control List) system provides flexible relay access control based on social relationships in the Nostr network.
[source,bash] ```bash
----
export ORLY_ACL_MODE=follows export ORLY_ACL_MODE=follows
export ORLY_ADMINS=npub1fjqqy4a93z5zsjwsfxqhc2764kvykfdyttvldkkkdera8dr78vhsmmleku export ORLY_ADMINS=npub1fjqqy4a93z5zsjwsfxqhc2764kvykfdyttvldkkkdera8dr78vhsmmleku
./orly ./orly
---- ```
The system grants write access to users followed by designated admins, with read-only access for others. Follow lists update dynamically as admins modify their relationships. The system grants write access to users followed by designated admins, with read-only access for others. Follow lists update dynamically as admins modify their relationships.
=== cluster replication ### Cluster Replication
ORLY supports distributed relay clusters using active replication. When configured with peer relays, ORLY will automatically synchronize events between cluster members using efficient HTTP polling. ORLY supports distributed relay clusters using active replication. When configured with peer relays, ORLY will automatically synchronize events between cluster members using efficient HTTP polling.
[source,bash] ```bash
----
export ORLY_RELAY_PEERS=https://peer1.example.com,https://peer2.example.com export ORLY_RELAY_PEERS=https://peer1.example.com,https://peer2.example.com
export ORLY_CLUSTER_ADMINS=npub1cluster_admin_key export ORLY_CLUSTER_ADMINS=npub1cluster_admin_key
---- ```
**Privacy Considerations:** By default, ORLY propagates all events including privileged events (DMs, gift wraps, etc.) to cluster peers for complete synchronization. This ensures no data loss but may expose private communications to other relay operators in your cluster. **Privacy Considerations:** By default, ORLY propagates all events including privileged events (DMs, gift wraps, etc.) to cluster peers for complete synchronization. This ensures no data loss but may expose private communications to other relay operators in your cluster.
To enhance privacy, you can disable propagation of privileged events: To enhance privacy, you can disable propagation of privileged events:
[source,bash] ```bash
----
export ORLY_CLUSTER_PROPAGATE_PRIVILEGED_EVENTS=false export ORLY_CLUSTER_PROPAGATE_PRIVILEGED_EVENTS=false
---- ```
**Important:** When disabled, privileged events will not be replicated to peer relays. This provides better privacy but means these events will only be available on the originating relay. Users should be aware that accessing their privileged events may require connecting directly to the relay where they were originally published. **Important:** When disabled, privileged events will not be replicated to peer relays. This provides better privacy but means these events will only be available on the originating relay. Users should be aware that accessing their privileged events may require connecting directly to the relay where they were originally published.
== developer notes ## Developer Notes
=== binary-optimized tag storage ### Binary-Optimized Tag Storage
The nostr library (`git.mleku.dev/mleku/nostr/encoders/tag`) uses binary optimization for `e` and `p` tags to reduce memory usage and improve comparison performance. The nostr library (`git.mleku.dev/mleku/nostr/encoders/tag`) uses binary optimization for `e` and `p` tags to reduce memory usage and improve comparison performance.
@ -547,16 +574,14 @@ When events are unmarshaled from JSON, 64-character hex values in e/p tags are c
**Important:** When working with e/p tag values in code: **Important:** When working with e/p tag values in code:
* **DO NOT** use `tag.Value()` directly - it returns raw bytes which may be binary, not hex - **DO NOT** use `tag.Value()` directly - it returns raw bytes which may be binary, not hex
* **ALWAYS** use `tag.ValueHex()` to get a hex string regardless of storage format - **ALWAYS** use `tag.ValueHex()` to get a hex string regardless of storage format
* **Use** `tag.ValueBinary()` to get raw 32-byte binary (returns nil if not binary-encoded) - **Use** `tag.ValueBinary()` to get raw 32-byte binary (returns nil if not binary-encoded)
[source,go] ```go
----
// CORRECT: Use ValueHex() for hex decoding // CORRECT: Use ValueHex() for hex decoding
pt, err := hex.Dec(string(pTag.ValueHex())) pt, err := hex.Dec(string(pTag.ValueHex()))
// WRONG: Value() may return binary bytes, not hex // WRONG: Value() may return binary bytes, not hex
pt, err := hex.Dec(string(pTag.Value())) // Will fail for binary-encoded tags! pt, err := hex.Dec(string(pTag.Value())) // Will fail for binary-encoded tags!
---- ```

21
app/server.go

@ -208,6 +208,15 @@ func (s *Server) UserInterface() {
origDirector(req) origDirector(req)
req.Host = target.Host req.Host = target.Host
} }
// Suppress noisy "context canceled" errors from browser navigation
s.devProxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
if r.Context().Err() == context.Canceled {
// Browser canceled the request - this is normal, don't log it
return
}
log.Printf("proxy error: %v", err)
http.Error(w, "Bad Gateway", http.StatusBadGateway)
}
} }
} }
} }
@ -282,6 +291,12 @@ func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) {
return return
} }
// If web UI is disabled without a proxy, return 404
if s.Config != nil && s.Config.WebDisableEmbedded {
http.NotFound(w, r)
return
}
// Serve orly-favicon.png as favicon.ico from embedded web app // Serve orly-favicon.png as favicon.ico from embedded web app
w.Header().Set("Content-Type", "image/png") w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "public, max-age=86400") // Cache for 1 day w.Header().Set("Cache-Control", "public, max-age=86400") // Cache for 1 day
@ -302,6 +317,12 @@ func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) {
return return
} }
// If web UI is disabled without a proxy, return 404
if s.Config != nil && s.Config.WebDisableEmbedded {
http.NotFound(w, r)
return
}
// Serve embedded web interface // Serve embedded web interface
ServeEmbeddedWeb(w, r) ServeEmbeddedWeb(w, r)
} }

3
app/web/.gitignore vendored

@ -1,5 +1,8 @@
node_modules/ node_modules/
dist/ dist/
public/bundle.js
public/bundle.js.map
public/bundle.css
.vite/ .vite/
.tanstack/ .tanstack/
.idea/ .idea/

2
app/web/package.json

@ -6,7 +6,7 @@
"scripts": { "scripts": {
"build": "rollup -c", "build": "rollup -c",
"dev": "rollup -c -w", "dev": "rollup -c -w",
"start": "sirv public --no-clear" "start": "sirv public --no-clear --single"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^24.0.0", "@rollup/plugin-commonjs": "^24.0.0",

10
app/web/rollup.config.js

@ -9,6 +9,10 @@ import copy from "rollup-plugin-copy";
const production = !process.env.ROLLUP_WATCH; const production = !process.env.ROLLUP_WATCH;
// In dev mode, output to public/ so sirv can serve it
// In production, output to dist/ for embedding
const outputDir = production ? "dist" : "public";
function serve() { function serve() {
let server; let server;
@ -36,7 +40,7 @@ export default {
sourcemap: true, sourcemap: true,
format: "iife", format: "iife",
name: "app", name: "app",
file: "dist/bundle.js", file: `${outputDir}/bundle.js`,
}, },
plugins: [ plugins: [
svelte({ svelte({
@ -73,8 +77,8 @@ export default {
// instead of npm run dev), minify // instead of npm run dev), minify
production && terser(), production && terser(),
// Copy static files from public to dist // Copy static files from public to dist (only in production)
copy({ production && copy({
targets: [ targets: [
{ src: 'public/index.html', dest: 'dist' }, { src: 'public/index.html', dest: 'dist' },
{ src: 'public/global.css', dest: 'dist' }, { src: 'public/global.css', dest: 'dist' },

278
app/web/src/App.svelte

@ -1,4 +1,5 @@
<script> <script>
// Svelte component imports
import LoginModal from "./LoginModal.svelte"; import LoginModal from "./LoginModal.svelte";
import ManagedACL from "./ManagedACL.svelte"; import ManagedACL from "./ManagedACL.svelte";
import Header from "./Header.svelte"; import Header from "./Header.svelte";
@ -13,7 +14,14 @@
import SearchResultsView from "./SearchResultsView.svelte"; import SearchResultsView from "./SearchResultsView.svelte";
import FilterBuilder from "./FilterBuilder.svelte"; import FilterBuilder from "./FilterBuilder.svelte";
import FilterDisplay from "./FilterDisplay.svelte"; import FilterDisplay from "./FilterDisplay.svelte";
// Utility imports
import { buildFilter } from "./helpers.tsx"; import { buildFilter } from "./helpers.tsx";
import { replaceableKinds, kindNames, CACHE_DURATION } from "./constants.js";
import { getKindName, truncatePubkey, truncateContent, formatTimestamp, escapeHtml, aboutToHtml, copyToClipboard, showCopyFeedback } from "./utils.js";
import * as api from "./api.js";
// Nostr library imports
import { import {
initializeNostrClient, initializeNostrClient,
fetchUserProfile, fetchUserProfile,
@ -73,7 +81,7 @@
// Global events cache system // Global events cache system
let globalEventsCache = []; // All events cache let globalEventsCache = []; // All events cache
let globalCacheTimestamp = 0; let globalCacheTimestamp = 0;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes // CACHE_DURATION is imported from constants.js
// Events filter toggle // Events filter toggle
let showOnlyMyEvents = false; let showOnlyMyEvents = false;
@ -120,213 +128,10 @@
let recoveryOldestTimestamp = null; let recoveryOldestTimestamp = null;
let recoveryNewestTimestamp = null; let recoveryNewestTimestamp = null;
// Replaceable kinds for the recovery dropdown // replaceableKinds is now imported from constants.js
// Based on NIP-01: kinds 0, 3, and 10000-19999 are replaceable
// kinds 30000-39999 are addressable (parameterized replaceable)
const replaceableKinds = [
// Basic replaceable kinds (0, 3)
{ value: 0, label: "User Metadata (0)" },
{ value: 3, label: "Follows (3)" },
// Replaceable range 10000-19999
{ value: 10000, label: "Mute list (10000)" },
{ value: 10001, label: "Pin list (10001)" },
{ value: 10002, label: "Relay List Metadata (10002)" },
{ value: 10003, label: "Bookmark list (10003)" },
{ value: 10004, label: "Communities list (10004)" },
{ value: 10005, label: "Public chats list (10005)" },
{ value: 10006, label: "Blocked relays list (10006)" },
{ value: 10007, label: "Search relays list (10007)" },
{ value: 10009, label: "User groups (10009)" },
{ value: 10012, label: "Favorite relays list (10012)" },
{ value: 10013, label: "Private event relay list (10013)" },
{ value: 10015, label: "Interests list (10015)" },
{ value: 10019, label: "Nutzap Mint Recommendation (10019)" },
{ value: 10020, label: "Media follows (10020)" },
{ value: 10030, label: "User emoji list (10030)" },
{ value: 10050, label: "Relay list to receive DMs (10050)" },
{ value: 10051, label: "KeyPackage Relays List (10051)" },
{ value: 10063, label: "User server list (10063)" },
{ value: 10096, label: "File storage server list (10096)" },
{ value: 10166, label: "Relay Monitor Announcement (10166)" },
{ value: 10312, label: "Room Presence (10312)" },
{ value: 10377, label: "Proxy Announcement (10377)" },
{ value: 11111, label: "Transport Method Announcement (11111)" },
{ value: 13194, label: "Wallet Info (13194)" },
{ value: 17375, label: "Cashu Wallet Event (17375)" },
// Addressable range 30000-39999 (parameterized replaceable)
{ value: 30000, label: "Follow sets (30000)" },
{ value: 30001, label: "Generic lists (30001)" },
{ value: 30002, label: "Relay sets (30002)" },
{ value: 30003, label: "Bookmark sets (30003)" },
{ value: 30004, label: "Curation sets (30004)" },
{ value: 30005, label: "Video sets (30005)" },
{ value: 30007, label: "Kind mute sets (30007)" },
{ value: 30008, label: "Profile Badges (30008)" },
{ value: 30009, label: "Badge Definition (30009)" },
{ value: 30015, label: "Interest sets (30015)" },
{ value: 30017, label: "Create or update a stall (30017)" },
{ value: 30018, label: "Create or update a product (30018)" },
{ value: 30019, label: "Marketplace UI/UX (30019)" },
{ value: 30020, label: "Product sold as an auction (30020)" },
{ value: 30023, label: "Long-form Content (30023)" },
{ value: 30024, label: "Draft Long-form Content (30024)" },
{ value: 30030, label: "Emoji sets (30030)" },
{ value: 30040, label: "Curated Publication Index (30040)" },
{ value: 30041, label: "Curated Publication Content (30041)" },
{ value: 30063, label: "Release artifact sets (30063)" },
{ value: 30078, label: "Application-specific Data (30078)" },
{ value: 30166, label: "Relay Discovery (30166)" },
{ value: 30267, label: "App curation sets (30267)" },
{ value: 30311, label: "Live Event (30311)" },
{ value: 30312, label: "Interactive Room (30312)" },
{ value: 30313, label: "Conference Event (30313)" },
{ value: 30315, label: "User Statuses (30315)" },
{ value: 30388, label: "Slide Set (30388)" },
{ value: 30402, label: "Classified Listing (30402)" },
{ value: 30403, label: "Draft Classified Listing (30403)" },
{ value: 30617, label: "Repository announcements (30617)" },
{ value: 30618, label: "Repository state announcements (30618)" },
{ value: 30818, label: "Wiki article (30818)" },
{ value: 30819, label: "Redirects (30819)" },
{ value: 31234, label: "Draft Event (31234)" },
{ value: 31388, label: "Link Set (31388)" },
{ value: 31890, label: "Feed (31890)" },
{ value: 31922, label: "Date-Based Calendar Event (31922)" },
{ value: 31923, label: "Time-Based Calendar Event (31923)" },
{ value: 31924, label: "Calendar (31924)" },
{ value: 31925, label: "Calendar Event RSVP (31925)" },
{ value: 31989, label: "Handler recommendation (31989)" },
{ value: 31990, label: "Handler information (31990)" },
{ value: 32267, label: "Software Application (32267)" },
{ value: 34550, label: "Community Definition (34550)" },
{ value: 37516, label: "Geocache listing (37516)" },
{ value: 38172, label: "Cashu Mint Announcement (38172)" },
{ value: 38173, label: "Fedimint Announcement (38173)" },
{ value: 38383, label: "Peer-to-peer Order events (38383)" },
{ value: 39089, label: "Starter packs (39089)" },
{ value: 39092, label: "Media starter packs (39092)" },
{ value: 39701, label: "Web bookmarks (39701)" },
];
// Kind name mapping based on NIP specification // Helper functions imported from utils.js:
// Matches official Nostr event kinds from https://github.com/nostr-protocol/nips // - getKindName, truncatePubkey, truncateContent, formatTimestamp, escapeHtml, copyToClipboard, showCopyFeedback
const kindNames = {
0: "User Metadata",
1: "Short Text Note",
2: "Recommend Relay",
3: "Follows",
4: "Encrypted Direct Messages",
5: "Event Deletion Request",
6: "Repost",
7: "Reaction",
8: "Badge Award",
9: "Chat Message",
10: "Group Chat Threaded Reply",
11: "Thread",
12: "Group Thread Reply",
13: "Seal",
14: "Direct Message",
15: "File Message",
16: "Generic Repost",
17: "Reaction to a website",
20: "Picture",
40: "Channel Creation",
41: "Channel Metadata",
42: "Channel Message",
43: "Channel Hide Message",
44: "Channel Mute User",
1021: "Bid",
1022: "Bid Confirmation",
1040: "OpenTimestamps",
1063: "File Metadata",
1311: "Live Chat Message",
1971: "Problem Tracker",
1984: "Reporting",
1985: "Label",
4550: "Community Post Approval",
5000: "Job Request",
5999: "Job Request",
6000: "Job Result",
6999: "Job Result",
7000: "Job Feedback",
9041: "Zap Goal",
9734: "Zap Request",
9735: "Zap",
9882: "Highlights",
10000: "Mute list",
10001: "Pin list",
10002: "Relay List Metadata",
10003: "Bookmarks list",
10004: "Communities list",
10005: "Public Chats list",
10006: "Blocked Relays list",
10007: "Search Relays list",
10015: "Interests",
10030: "User Emoji list",
10050: "DM relays",
10096: "File Storage Server List",
13194: "Wallet Service Info",
21000: "Lightning pub RPC",
22242: "Client Authentication",
23194: "Wallet Request",
23195: "Wallet Response",
23196: "Wallet Notification",
23197: "Wallet Notification",
24133: "Nostr Connect",
27235: "HTTP Auth",
30000: "Follow sets",
30001: "Generic lists",
30002: "Relay sets",
30003: "Bookmark sets",
30004: "Curation sets",
30008: "Profile Badges",
30009: "Badge Definition",
30015: "Interest sets",
30017: "Stall Definition",
30018: "Product Definition",
30019: "Marketplace UI/UX",
30020: "Product sold as an auction",
30023: "Long-form Content",
30024: "Draft Long-form Content",
30030: "Emoji sets",
30078: "Application-specific Data",
30311: "Live Event",
30315: "User Statuses",
30402: "Classified Listing",
30403: "Draft Classified Listing",
31922: "Date-Based Calendar Event",
31923: "Time-Based Calendar Event",
31924: "Calendar",
31925: "Calendar Event RSVP",
31989: "Handler recommendation",
31990: "Handler information",
34235: "Video Event Horizontal",
34236: "Video Event Vertical",
34550: "Community Definition",
};
function getKindName(kind) {
return kindNames[kind] || `Kind ${kind}`;
}
function truncatePubkey(pubkey) {
if (!pubkey) return "unknown";
return pubkey.slice(0, 8) + "..." + pubkey.slice(-8);
}
function truncateContent(content, maxLength = 100) {
if (!content) return "";
return content.length > maxLength
? content.slice(0, maxLength) + "..."
: content;
}
function formatTimestamp(timestamp) {
if (!timestamp) return "";
return new Date(timestamp * 1000).toLocaleString();
}
function toggleEventExpansion(eventId) { function toggleEventExpansion(eventId) {
if (expandedEvents.has(eventId)) { if (expandedEvents.has(eventId)) {
@ -338,47 +143,12 @@
} }
async function copyEventToClipboard(eventData, clickEvent) { async function copyEventToClipboard(eventData, clickEvent) {
try { const minifiedJson = JSON.stringify(eventData);
// Create minified JSON (no indentation) const success = await copyToClipboard(minifiedJson);
const minifiedJson = JSON.stringify(eventData); const button = clickEvent.target.closest(".copy-json-btn");
await navigator.clipboard.writeText(minifiedJson); showCopyFeedback(button, success);
if (!success) {
// Show temporary feedback alert("Failed to copy to clipboard. Please copy manually.");
const button = clickEvent.target.closest(".copy-json-btn");
if (button) {
const originalText = button.textContent;
button.textContent = "✅";
button.style.backgroundColor = "#4CAF50";
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = "";
}, 2000);
}
} catch (error) {
console.error("Failed to copy to clipboard:", error);
// Fallback for older browsers
try {
const textArea = document.createElement("textarea");
textArea.value = JSON.stringify(eventData);
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
const button = clickEvent.target.closest(".copy-json-btn");
if (button) {
const originalText = button.textContent;
button.textContent = "✅";
button.style.backgroundColor = "#4CAF50";
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = "";
}, 2000);
}
} catch (fallbackError) {
console.error("Fallback copy also failed:", fallbackError);
alert("Failed to copy to clipboard. Please copy manually.");
}
} }
} }
@ -736,15 +506,7 @@
} }
} }
// Safely render "about" text: convert double newlines to a single HTML line break // escapeHtml is imported from utils.js
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Recovery tab functions // Recovery tab functions
async function loadRecoveryEvents() { async function loadRecoveryEvents() {
@ -4329,8 +4091,10 @@
/* Recovery Tab Styles */ /* Recovery Tab Styles */
.recovery-tab { .recovery-tab {
padding: 20px; padding: 20px;
width: 100%;
max-width: 1200px; max-width: 1200px;
margin: 0; margin: 0;
box-sizing: border-box;
} }
.recovery-tab h3 { .recovery-tab h3 {

12
app/web/src/ComposeView.svelte

@ -126,4 +126,16 @@
border-color: var(--accent-color); border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
} }
@media (max-width: 1280px) {
.compose-view {
left: 60px;
}
}
@media (max-width: 640px) {
.compose-view {
left: 160px;
}
}
</style> </style>

1
app/web/src/EventsView.svelte

@ -272,6 +272,7 @@
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-sizing: border-box;
} }
.events-view-content { .events-view-content {

8
app/web/src/ExportView.svelte

@ -57,7 +57,9 @@
border-radius: 8px; border-radius: 8px;
padding: 1em; padding: 1em;
margin: 1em; margin: 1em;
width: 32em; width: 100%;
max-width: 32em;
box-sizing: border-box;
background-color: var(--card-bg); background-color: var(--card-bg);
} }
@ -97,7 +99,9 @@
background-color: var(--card-bg); background-color: var(--card-bg);
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
width: 32em; width: 100%;
max-width: 32em;
box-sizing: border-box;
} }
.login-prompt p { .login-prompt p {

4
app/web/src/ImportView.svelte

@ -72,7 +72,9 @@
padding: 1em; padding: 1em;
border-radius: 8px; border-radius: 8px;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
width: 32em; width: 100%;
max-width: 32em;
box-sizing: border-box;
} }
.import-section h3 { .import-section h3 {

1
app/web/src/PolicyView.svelte

@ -336,6 +336,7 @@
background: var(--header-bg); background: var(--header-bg);
color: var(--text-color); color: var(--text-color);
border-radius: 8px; border-radius: 8px;
box-sizing: border-box;
} }
.policy-view h2 { .policy-view h2 {

1
app/web/src/RecoveryView.svelte

@ -186,6 +186,7 @@
background: var(--header-bg); background: var(--header-bg);
color: var(--text-color); color: var(--text-color);
border-radius: 8px; border-radius: 8px;
box-sizing: border-box;
} }
.recovery-tab h3 { .recovery-tab h3 {

34
app/web/src/Sidebar.svelte

@ -70,6 +70,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.75em; padding: 0.75em;
padding-left: 1em;
background: transparent; background: transparent;
color: var(--text-color); color: var(--text-color);
border: none; border: none;
@ -118,4 +119,37 @@
background-color: var(--warning); background-color: var(--warning);
color: var(--text-color); color: var(--text-color);
} }
@media (max-width: 1280px) {
.sidebar {
width: 60px;
}
.tab-label {
display: none;
}
.tab-close-icon {
display: none;
}
.tab {
/* Keep left alignment so icons stay in same position */
justify-content: flex-start;
}
}
@media (max-width: 640px) {
.sidebar {
width: 160px;
}
.tab-label {
display: block;
}
.tab {
justify-content: flex-start;
}
}
</style> </style>

370
app/web/src/api.js

@ -0,0 +1,370 @@
/**
* API helper functions for ORLY relay management endpoints
*/
/**
* Create NIP-98 authentication header
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {string} method - HTTP method
* @param {string} url - Request URL
* @returns {Promise<string|null>} Base64 encoded auth header or null
*/
export async function createNIP98Auth(signer, pubkey, method, url) {
if (!signer || !pubkey) {
console.log("No signer or pubkey available");
return null;
}
try {
// Create unsigned auth event
const authEvent = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", url],
["method", method],
],
content: "",
};
// Sign using the signer
const signedEvent = await signer.signEvent(authEvent);
return btoa(JSON.stringify(signedEvent));
} catch (error) {
console.error("Error creating NIP-98 auth:", error);
return null;
}
}
/**
* Fetch user role from the relay
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<string>} User role
*/
export async function fetchUserRole(signer, pubkey) {
try {
const url = `${window.location.origin}/api/role`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (response.ok) {
const data = await response.json();
return data.role || "";
}
} catch (error) {
console.error("Error fetching user role:", error);
}
return "";
}
/**
* Fetch ACL mode from the relay
* @returns {Promise<string>} ACL mode
*/
export async function fetchACLMode() {
try {
const response = await fetch(`${window.location.origin}/api/acl-mode`);
if (response.ok) {
const data = await response.json();
return data.mode || "";
}
} catch (error) {
console.error("Error fetching ACL mode:", error);
}
return "";
}
// ==================== Sprocket API ====================
/**
* Load sprocket configuration
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<object>} Sprocket config data
*/
export async function loadSprocketConfig(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket/config`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to load config: ${response.statusText}`);
return await response.json();
}
/**
* Load sprocket status
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<object>} Sprocket status data
*/
export async function loadSprocketStatus(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket/status`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to load status: ${response.statusText}`);
return await response.json();
}
/**
* Load sprocket script
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<string>} Sprocket script content
*/
export async function loadSprocketScript(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (response.status === 404) return "";
if (!response.ok) throw new Error(`Failed to load sprocket: ${response.statusText}`);
return await response.text();
}
/**
* Save sprocket script
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {string} script - Script content
* @returns {Promise<object>} Save result
*/
export async function saveSprocketScript(signer, pubkey, script) {
const url = `${window.location.origin}/api/sprocket`;
const authHeader = await createNIP98Auth(signer, pubkey, "PUT", url);
const response = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "text/plain",
...(authHeader ? { Authorization: `Nostr ${authHeader}` } : {}),
},
body: script,
});
if (!response.ok) throw new Error(`Failed to save: ${response.statusText}`);
return await response.json();
}
/**
* Restart sprocket
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<object>} Restart result
*/
export async function restartSprocket(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket/restart`;
const authHeader = await createNIP98Auth(signer, pubkey, "POST", url);
const response = await fetch(url, {
method: "POST",
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to restart: ${response.statusText}`);
return await response.json();
}
/**
* Delete sprocket
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<object>} Delete result
*/
export async function deleteSprocket(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket`;
const authHeader = await createNIP98Auth(signer, pubkey, "DELETE", url);
const response = await fetch(url, {
method: "DELETE",
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to delete: ${response.statusText}`);
return await response.json();
}
/**
* Load sprocket versions
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<Array>} Version list
*/
export async function loadSprocketVersions(signer, pubkey) {
const url = `${window.location.origin}/api/sprocket/versions`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to load versions: ${response.statusText}`);
return await response.json();
}
/**
* Load specific sprocket version
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {string} version - Version filename
* @returns {Promise<string>} Version content
*/
export async function loadSprocketVersion(signer, pubkey, version) {
const url = `${window.location.origin}/api/sprocket/versions/${encodeURIComponent(version)}`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to load version: ${response.statusText}`);
return await response.text();
}
/**
* Delete sprocket version
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {string} filename - Version filename
* @returns {Promise<object>} Delete result
*/
export async function deleteSprocketVersion(signer, pubkey, filename) {
const url = `${window.location.origin}/api/sprocket/versions/${encodeURIComponent(filename)}`;
const authHeader = await createNIP98Auth(signer, pubkey, "DELETE", url);
const response = await fetch(url, {
method: "DELETE",
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to delete version: ${response.statusText}`);
return await response.json();
}
/**
* Upload sprocket script file
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {File} file - File to upload
* @returns {Promise<object>} Upload result
*/
export async function uploadSprocketScript(signer, pubkey, file) {
const content = await file.text();
return await saveSprocketScript(signer, pubkey, content);
}
// ==================== Policy API ====================
/**
* Load policy configuration
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<object>} Policy config
*/
export async function loadPolicyConfig(signer, pubkey) {
const url = `${window.location.origin}/api/policy/config`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to load policy config: ${response.statusText}`);
return await response.json();
}
/**
* Load policy JSON
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<object>} Policy JSON
*/
export async function loadPolicy(signer, pubkey) {
const url = `${window.location.origin}/api/policy`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to load policy: ${response.statusText}`);
return await response.json();
}
/**
* Validate policy JSON
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {string} policyJson - Policy JSON string
* @returns {Promise<object>} Validation result
*/
export async function validatePolicy(signer, pubkey, policyJson) {
const url = `${window.location.origin}/api/policy/validate`;
const authHeader = await createNIP98Auth(signer, pubkey, "POST", url);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(authHeader ? { Authorization: `Nostr ${authHeader}` } : {}),
},
body: policyJson,
});
return await response.json();
}
/**
* Fetch policy follows whitelist
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @returns {Promise<Array>} List of followed pubkeys
*/
export async function fetchPolicyFollows(signer, pubkey) {
const url = `${window.location.origin}/api/policy/follows`;
const authHeader = await createNIP98Auth(signer, pubkey, "GET", url);
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) throw new Error(`Failed to fetch follows: ${response.statusText}`);
const data = await response.json();
return data.follows || [];
}
// ==================== Export/Import API ====================
/**
* Export events
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {Array} authorPubkeys - Filter by authors (empty for all)
* @returns {Promise<Blob>} JSONL blob
*/
export async function exportEvents(signer, pubkey, authorPubkeys = []) {
const url = `${window.location.origin}/api/export`;
const authHeader = await createNIP98Auth(signer, pubkey, "POST", url);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(authHeader ? { Authorization: `Nostr ${authHeader}` } : {}),
},
body: JSON.stringify({ pubkeys: authorPubkeys }),
});
if (!response.ok) throw new Error(`Export failed: ${response.statusText}`);
return await response.blob();
}
/**
* Import events from file
* @param {object} signer - The signer instance
* @param {string} pubkey - User's pubkey
* @param {File} file - JSONL file to import
* @returns {Promise<object>} Import result
*/
export async function importEvents(signer, pubkey, file) {
const url = `${window.location.origin}/api/import`;
const authHeader = await createNIP98Auth(signer, pubkey, "POST", url);
const formData = new FormData();
formData.append("file", file);
const response = await fetch(url, {
method: "POST",
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
body: formData,
});
if (!response.ok) throw new Error(`Import failed: ${response.statusText}`);
return await response.json();
}

190
app/web/src/constants.js

@ -4,3 +4,193 @@ export const DEFAULT_RELAYS = [
// Automatically use ws:// for http:// and wss:// for https:// // Automatically use ws:// for http:// and wss:// for https://
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/`, `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/`,
]; ];
// Replaceable kinds for the recovery dropdown
// Based on NIP-01: kinds 0, 3, and 10000-19999 are replaceable
// kinds 30000-39999 are addressable (parameterized replaceable)
export const replaceableKinds = [
// Basic replaceable kinds (0, 3)
{ value: 0, label: "User Metadata (0)" },
{ value: 3, label: "Follows (3)" },
// Replaceable range 10000-19999
{ value: 10000, label: "Mute list (10000)" },
{ value: 10001, label: "Pin list (10001)" },
{ value: 10002, label: "Relay List Metadata (10002)" },
{ value: 10003, label: "Bookmark list (10003)" },
{ value: 10004, label: "Communities list (10004)" },
{ value: 10005, label: "Public chats list (10005)" },
{ value: 10006, label: "Blocked relays list (10006)" },
{ value: 10007, label: "Search relays list (10007)" },
{ value: 10009, label: "User groups (10009)" },
{ value: 10012, label: "Favorite relays list (10012)" },
{ value: 10013, label: "Private event relay list (10013)" },
{ value: 10015, label: "Interests list (10015)" },
{ value: 10019, label: "Nutzap Mint Recommendation (10019)" },
{ value: 10020, label: "Media follows (10020)" },
{ value: 10030, label: "User emoji list (10030)" },
{ value: 10050, label: "Relay list to receive DMs (10050)" },
{ value: 10051, label: "KeyPackage Relays List (10051)" },
{ value: 10063, label: "User server list (10063)" },
{ value: 10096, label: "File storage server list (10096)" },
{ value: 10166, label: "Relay Monitor Announcement (10166)" },
{ value: 10312, label: "Room Presence (10312)" },
{ value: 10377, label: "Proxy Announcement (10377)" },
{ value: 11111, label: "Transport Method Announcement (11111)" },
{ value: 13194, label: "Wallet Info (13194)" },
{ value: 17375, label: "Cashu Wallet Event (17375)" },
// Addressable range 30000-39999 (parameterized replaceable)
{ value: 30000, label: "Follow sets (30000)" },
{ value: 30001, label: "Generic lists (30001)" },
{ value: 30002, label: "Relay sets (30002)" },
{ value: 30003, label: "Bookmark sets (30003)" },
{ value: 30004, label: "Curation sets (30004)" },
{ value: 30005, label: "Video sets (30005)" },
{ value: 30007, label: "Kind mute sets (30007)" },
{ value: 30008, label: "Profile Badges (30008)" },
{ value: 30009, label: "Badge Definition (30009)" },
{ value: 30015, label: "Interest sets (30015)" },
{ value: 30017, label: "Create or update a stall (30017)" },
{ value: 30018, label: "Create or update a product (30018)" },
{ value: 30019, label: "Marketplace UI/UX (30019)" },
{ value: 30020, label: "Product sold as an auction (30020)" },
{ value: 30023, label: "Long-form Content (30023)" },
{ value: 30024, label: "Draft Long-form Content (30024)" },
{ value: 30030, label: "Emoji sets (30030)" },
{ value: 30040, label: "Curated Publication Index (30040)" },
{ value: 30041, label: "Curated Publication Content (30041)" },
{ value: 30063, label: "Release artifact sets (30063)" },
{ value: 30078, label: "Application-specific Data (30078)" },
{ value: 30166, label: "Relay Discovery (30166)" },
{ value: 30267, label: "App curation sets (30267)" },
{ value: 30311, label: "Live Event (30311)" },
{ value: 30312, label: "Interactive Room (30312)" },
{ value: 30313, label: "Conference Event (30313)" },
{ value: 30315, label: "User Statuses (30315)" },
{ value: 30388, label: "Slide Set (30388)" },
{ value: 30402, label: "Classified Listing (30402)" },
{ value: 30403, label: "Draft Classified Listing (30403)" },
{ value: 30617, label: "Repository announcements (30617)" },
{ value: 30618, label: "Repository state announcements (30618)" },
{ value: 30818, label: "Wiki article (30818)" },
{ value: 30819, label: "Redirects (30819)" },
{ value: 31234, label: "Draft Event (31234)" },
{ value: 31388, label: "Link Set (31388)" },
{ value: 31890, label: "Feed (31890)" },
{ value: 31922, label: "Date-Based Calendar Event (31922)" },
{ value: 31923, label: "Time-Based Calendar Event (31923)" },
{ value: 31924, label: "Calendar (31924)" },
{ value: 31925, label: "Calendar Event RSVP (31925)" },
{ value: 31989, label: "Handler recommendation (31989)" },
{ value: 31990, label: "Handler information (31990)" },
{ value: 32267, label: "Software Application (32267)" },
{ value: 34550, label: "Community Definition (34550)" },
{ value: 37516, label: "Geocache listing (37516)" },
{ value: 38172, label: "Cashu Mint Announcement (38172)" },
{ value: 38173, label: "Fedimint Announcement (38173)" },
{ value: 38383, label: "Peer-to-peer Order events (38383)" },
{ value: 39089, label: "Starter packs (39089)" },
{ value: 39092, label: "Media starter packs (39092)" },
{ value: 39701, label: "Web bookmarks (39701)" },
];
// Kind name mapping based on NIP specification
// Matches official Nostr event kinds from https://github.com/nostr-protocol/nips
export const kindNames = {
0: "User Metadata",
1: "Short Text Note",
2: "Recommend Relay",
3: "Follows",
4: "Encrypted Direct Messages",
5: "Event Deletion Request",
6: "Repost",
7: "Reaction",
8: "Badge Award",
9: "Chat Message",
10: "Group Chat Threaded Reply",
11: "Thread",
12: "Group Thread Reply",
13: "Seal",
14: "Direct Message",
15: "File Message",
16: "Generic Repost",
17: "Reaction to a website",
20: "Picture",
40: "Channel Creation",
41: "Channel Metadata",
42: "Channel Message",
43: "Channel Hide Message",
44: "Channel Mute User",
1021: "Bid",
1022: "Bid Confirmation",
1040: "OpenTimestamps",
1063: "File Metadata",
1311: "Live Chat Message",
1971: "Problem Tracker",
1984: "Reporting",
1985: "Label",
4550: "Community Post Approval",
5000: "Job Request",
5999: "Job Request",
6000: "Job Result",
6999: "Job Result",
7000: "Job Feedback",
9041: "Zap Goal",
9734: "Zap Request",
9735: "Zap",
9882: "Highlights",
10000: "Mute list",
10001: "Pin list",
10002: "Relay List Metadata",
10003: "Bookmarks list",
10004: "Communities list",
10005: "Public Chats list",
10006: "Blocked Relays list",
10007: "Search Relays list",
10015: "Interests",
10030: "User Emoji list",
10050: "DM relays",
10096: "File Storage Server List",
13194: "Wallet Service Info",
21000: "Lightning pub RPC",
22242: "Client Authentication",
23194: "Wallet Request",
23195: "Wallet Response",
23196: "Wallet Notification",
23197: "Wallet Notification",
24133: "Nostr Connect",
27235: "HTTP Auth",
30000: "Follow sets",
30001: "Generic lists",
30002: "Relay sets",
30003: "Bookmark sets",
30004: "Curation sets",
30008: "Profile Badges",
30009: "Badge Definition",
30015: "Interest sets",
30017: "Stall Definition",
30018: "Product Definition",
30019: "Marketplace UI/UX",
30020: "Product sold as an auction",
30023: "Long-form Content",
30024: "Draft Long-form Content",
30030: "Emoji sets",
30078: "Application-specific Data",
30311: "Live Event",
30315: "User Statuses",
30402: "Classified Listing",
30403: "Draft Classified Listing",
31922: "Date-Based Calendar Event",
31923: "Time-Based Calendar Event",
31924: "Calendar",
31925: "Calendar Event RSVP",
31989: "Handler recommendation",
31990: "Handler information",
34235: "Video Event Horizontal",
34236: "Video Event Vertical",
34550: "Community Definition",
};
// Cache configuration
export const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes

88
app/web/src/stores.js

@ -0,0 +1,88 @@
import { writable, derived } from 'svelte/store';
// ==================== User/Auth State ====================
export const isLoggedIn = writable(false);
export const userPubkey = writable("");
export const userProfile = writable(null);
export const userRole = writable("");
export const userSigner = writable(null);
export const authMethod = writable("");
// View-as role for permission testing
export const viewAsRole = writable("");
// Derived: effective role (actual or view-as)
export const currentEffectiveRole = derived(
[userRole, viewAsRole],
([$userRole, $viewAsRole]) => $viewAsRole || $userRole
);
// ==================== UI State ====================
export const isDarkTheme = writable(false);
export const showLoginModal = writable(false);
export const showSettingsDrawer = writable(false);
export const selectedTab = writable(localStorage.getItem("selectedTab") || "export");
export const showFilterBuilder = writable(false);
// ==================== ACL State ====================
export const aclMode = writable("");
export const isPolicyAdmin = writable(false);
export const policyEnabled = writable(false);
// ==================== Events Cache ====================
export const globalEventsCache = writable([]);
export const globalCacheTimestamp = writable(0);
// ==================== Search State ====================
export const searchQuery = writable("");
export const searchTabs = writable([]);
export const searchResults = writable(new Map());
// ==================== Helper Functions ====================
/**
* Reset all auth-related stores on logout
*/
export function resetAuthState() {
isLoggedIn.set(false);
userPubkey.set("");
userProfile.set(null);
userRole.set("");
userSigner.set(null);
authMethod.set("");
viewAsRole.set("");
isPolicyAdmin.set(false);
}
/**
* Clear the events cache
*/
export function clearEventsCache() {
globalEventsCache.set([]);
globalCacheTimestamp.set(0);
}
/**
* Update the events cache
* @param {Array} events - Events to cache
*/
export function updateEventsCache(events) {
globalEventsCache.set(events);
globalCacheTimestamp.set(Date.now());
}
/**
* Check if cache is still valid
* @param {number} cacheDuration - Cache duration in ms
* @returns {boolean}
*/
export function isCacheValid(cacheDuration = 5 * 60 * 1000) {
let timestamp;
globalCacheTimestamp.subscribe(v => timestamp = v)();
return Date.now() - timestamp < cacheDuration;
}

119
app/web/src/utils.js

@ -0,0 +1,119 @@
import { kindNames } from './constants.js';
/**
* Get human-readable name for a Nostr event kind
* @param {number} kind - The event kind number
* @returns {string} Human readable kind name
*/
export function getKindName(kind) {
return kindNames[kind] || `Kind ${kind}`;
}
/**
* Truncate a pubkey for display
* @param {string} pubkey - The full pubkey hex string
* @returns {string} Truncated pubkey
*/
export function truncatePubkey(pubkey) {
if (!pubkey) return "unknown";
return pubkey.slice(0, 8) + "..." + pubkey.slice(-8);
}
/**
* Truncate content for preview display
* @param {string} content - The content to truncate
* @param {number} maxLength - Maximum length before truncation
* @returns {string} Truncated content
*/
export function truncateContent(content, maxLength = 100) {
if (!content) return "";
return content.length > maxLength
? content.slice(0, maxLength) + "..."
: content;
}
/**
* Format a Unix timestamp for display
* @param {number} timestamp - Unix timestamp in seconds
* @returns {string} Formatted date/time string
*/
export function formatTimestamp(timestamp) {
if (!timestamp) return "";
return new Date(timestamp * 1000).toLocaleString();
}
/**
* Escape HTML special characters to prevent XSS
* @param {string} str - String to escape
* @returns {string} Escaped string
*/
export function escapeHtml(str) {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* Convert "about" text to safe HTML with line breaks
* @param {string} text - The about text
* @returns {string} HTML with line breaks
*/
export function aboutToHtml(text) {
if (!text) return "";
return escapeHtml(text).replace(/\n\n/g, "<br>");
}
/**
* Copy text to clipboard with fallback for older browsers
* @param {string} text - Text to copy
* @returns {Promise<boolean>} Whether copy succeeded
*/
export async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (error) {
console.error("Failed to copy to clipboard:", error);
// Fallback for older browsers
try {
const textArea = document.createElement("textarea");
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
return true;
} catch (fallbackError) {
console.error("Fallback copy also failed:", fallbackError);
return false;
}
}
}
/**
* Show copy feedback on a button element
* @param {HTMLElement} button - The button element
* @param {boolean} success - Whether copy succeeded
*/
export function showCopyFeedback(button, success = true) {
if (!button) return;
const originalText = button.textContent;
const originalBg = button.style.backgroundColor;
if (success) {
button.textContent = "";
button.style.backgroundColor = "#4CAF50";
} else {
button.textContent = "L";
button.style.backgroundColor = "#f44336";
}
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = originalBg;
}, 2000);
}
Loading…
Cancel
Save