v0.2.1 - Nip-40 implemented
This commit is contained in:
75
README.md
75
README.md
@@ -14,76 +14,9 @@ Do NOT modify the formatting, add emojis, or change the text. Keep the simple fo
|
||||
- [x] NIP-13: Proof of Work
|
||||
- [x] NIP-15: End of Stored Events Notice
|
||||
- [x] NIP-20: Command Results
|
||||
- [ ] NIP-22: Event `created_at` Limits
|
||||
- [ ] NIP-25: Reactions
|
||||
- [ ] NIP-26: Delegated Event Signing
|
||||
- [ ] NIP-28: Public Chat
|
||||
- [ ] NIP-33: Parameterized Replaceable Events
|
||||
- [ ] NIP-40: Expiration Timestamp
|
||||
- [x] NIP-33: Parameterized Replaceable Events
|
||||
- [x] NIP-40: Expiration Timestamp
|
||||
- [ ] NIP-42: Authentication of clients to relays
|
||||
- [ ] NIP-45: Counting results. [experimental](#count)
|
||||
- [ ] NIP-50: Keywords filter. [experimental](#search)
|
||||
- [ ] NIP-45: Counting results.
|
||||
- [ ] NIP-50: Keywords filter.
|
||||
- [ ] NIP-70: Protected Events
|
||||
|
||||
## NIP-13: Proof of Work Configuration
|
||||
|
||||
The relay supports NIP-13 Proof of Work validation with configurable settings. PoW validation helps prevent spam and ensures computational commitment from event publishers.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Configure PoW validation using these environment variables:
|
||||
|
||||
- `RELAY_POW_ENABLED` - Enable/disable PoW validation (default: `1`)
|
||||
- `1`, `true`, or `yes` to enable
|
||||
- `0`, `false`, or `no` to disable
|
||||
|
||||
- `RELAY_MIN_POW_DIFFICULTY` - Minimum required difficulty (default: `0`)
|
||||
- Range: `0-64` (reasonable bounds)
|
||||
- `0` = no minimum requirement (events without PoW are accepted)
|
||||
- Higher values require more computational work
|
||||
|
||||
- `RELAY_POW_MODE` - Validation mode (default: `basic`)
|
||||
- `basic` - Basic PoW validation
|
||||
- `full` - Full validation with nonce tag requirements
|
||||
- `strict` - Strict anti-spam mode with committed target validation
|
||||
- `disabled` - Disable PoW validation entirely
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Basic setup - accept events with or without PoW
|
||||
export RELAY_POW_ENABLED=1
|
||||
export RELAY_MIN_POW_DIFFICULTY=0
|
||||
export RELAY_POW_MODE=basic
|
||||
|
||||
# Anti-spam setup - require minimum difficulty 16
|
||||
export RELAY_POW_ENABLED=1
|
||||
export RELAY_MIN_POW_DIFFICULTY=16
|
||||
export RELAY_POW_MODE=strict
|
||||
|
||||
# Disable PoW validation completely
|
||||
export RELAY_POW_ENABLED=0
|
||||
```
|
||||
|
||||
### Behavior
|
||||
|
||||
- **min_difficulty=0**: Events without PoW are accepted; events with PoW are validated
|
||||
- **min_difficulty>0**: All events must have valid PoW meeting minimum difficulty
|
||||
- **strict mode**: Additional validation prevents difficulty commitment gaming
|
||||
- **NIP-11 integration**: PoW configuration is advertised via relay information document
|
||||
|
||||
### Testing
|
||||
|
||||
Run the comprehensive PoW test suite:
|
||||
|
||||
```bash
|
||||
./tests/13_nip_test.sh
|
||||
```
|
||||
|
||||
The test suite validates:
|
||||
- NIP-11 PoW support advertisement
|
||||
- Event acceptance without PoW (when min_difficulty=0)
|
||||
- Event validation with valid PoW
|
||||
- Configuration via environment variables
|
||||
- NIP-13 reference event validation
|
||||
|
||||
|
||||
387
admin_spec.md
Normal file
387
admin_spec.md
Normal file
@@ -0,0 +1,387 @@
|
||||
# Ginxsom Admin System - Comprehensive Specification
|
||||
|
||||
## Overview
|
||||
|
||||
The Ginxsom admin system provides both programmatic (API-based) and interactive (web-based) administration capabilities for the Ginxsom Blossom server. The system is designed around Nostr-based authentication and supports multiple administration workflows including first-run setup, ongoing configuration management, and operational monitoring.
|
||||
|
||||
## Architecture Components
|
||||
|
||||
### 1. Configuration System
|
||||
- **File-based configuration**: Signed Nostr events stored as JSON files following XDG Base Directory specification
|
||||
- **Database configuration**: Key-value pairs stored in SQLite for runtime configuration
|
||||
- **Interactive setup**: Command-line wizard for initial server configuration
|
||||
- **Manual setup**: Scripts for generating signed configuration events
|
||||
|
||||
### 2. Authentication & Authorization
|
||||
- **Nostr-based auth**: All admin operations require valid Nostr event signatures
|
||||
- **Admin pubkey verification**: Only configured admin public keys can perform admin operations
|
||||
- **Event validation**: Full cryptographic verification of Nostr events including structure, signature, and expiration
|
||||
- **Method-specific authorization**: Different event types for different operations (upload, admin, delete, etc.)
|
||||
|
||||
### 3. API System
|
||||
- **RESTful endpoints**: `/api/*` routes for programmatic administration
|
||||
- **Command-line testing**: Complete test suite using `nak` and `curl`
|
||||
- **JSON responses**: Structured data for all admin operations
|
||||
- **CORS support**: Cross-origin requests for web admin interface
|
||||
|
||||
### 4. Web Interface (Future)
|
||||
- **Single-page application**: Self-contained HTML file with inline CSS/JS
|
||||
- **Real-time monitoring**: Statistics and system health dashboards
|
||||
- **Configuration management**: GUI for server settings
|
||||
- **File management**: Browse and manage uploaded blobs
|
||||
|
||||
## Configuration System Architecture
|
||||
|
||||
### File-based Configuration (Priority 1)
|
||||
|
||||
**Location**: Follows XDG Base Directory Specification
|
||||
- `$XDG_CONFIG_HOME/ginxsom/ginxsom_config_event.json`
|
||||
- Falls back to `$HOME/.config/ginxsom/ginxsom_config_event.json`
|
||||
|
||||
**Format**: Signed Nostr event containing server configuration
|
||||
```json
|
||||
{
|
||||
"kind": 33333,
|
||||
"created_at": 1704067200,
|
||||
"tags": [
|
||||
["server_privkey", "server_private_key_hex"],
|
||||
["cdn_origin", "https://cdn.example.com"],
|
||||
["max_file_size", "104857600"],
|
||||
["nip94_enabled", "true"]
|
||||
],
|
||||
"content": "Ginxsom server configuration",
|
||||
"pubkey": "admin_public_key_hex",
|
||||
"id": "event_id_hash",
|
||||
"sig": "event_signature"
|
||||
}
|
||||
```
|
||||
|
||||
**Loading Process**:
|
||||
1. Check for file-based config at XDG location
|
||||
2. Validate Nostr event structure and signature
|
||||
3. Extract configuration from event tags
|
||||
4. Apply settings to server (database storage)
|
||||
5. Fall back to database-only config if file missing/invalid
|
||||
|
||||
### Database Configuration (Priority 2)
|
||||
|
||||
**Table**: `server_config`
|
||||
```sql
|
||||
CREATE TABLE server_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
**Key Configuration Items**:
|
||||
- `admin_pubkey`: Authorized admin public key
|
||||
- `admin_enabled`: Enable/disable admin interface
|
||||
- `cdn_origin`: Base URL for blob access
|
||||
- `max_file_size`: Maximum upload size in bytes
|
||||
- `nip94_enabled`: Enable NIP-94 metadata emission
|
||||
- `auth_rules_enabled`: Enable authentication rules system
|
||||
|
||||
### Setup Workflows
|
||||
|
||||
#### Interactive Setup (Command Line)
|
||||
```bash
|
||||
# First-run detection
|
||||
if [[ ! -f "$XDG_CONFIG_HOME/ginxsom/ginxsom_config_event.json" ]]; then
|
||||
echo "=== Ginxsom First-Time Setup Required ==="
|
||||
echo "1. Run interactive setup wizard"
|
||||
echo "2. Exit and create config manually"
|
||||
read -p "Choice (1/2): " choice
|
||||
|
||||
if [[ "$choice" == "1" ]]; then
|
||||
./scripts/setup.sh
|
||||
else
|
||||
echo "Manual setup: Run ./scripts/generate_config.sh"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
#### Manual Setup (Script-based)
|
||||
```bash
|
||||
# Generate configuration event
|
||||
./scripts/generate_config.sh --admin-key <admin_pubkey> \
|
||||
--server-key <server_privkey> \
|
||||
--cdn-origin "https://cdn.example.com" \
|
||||
--output "$XDG_CONFIG_HOME/ginxsom/ginxsom_config_event.json"
|
||||
```
|
||||
|
||||
### C Implementation Functions
|
||||
|
||||
#### Configuration Loading
|
||||
```c
|
||||
// Get XDG-compliant config file path
|
||||
int get_config_file_path(char* path, size_t path_size);
|
||||
|
||||
// Load and validate config event from file
|
||||
int load_server_config(const char* config_path);
|
||||
|
||||
// Extract config from validated event and apply to server
|
||||
int apply_config_from_event(cJSON* event);
|
||||
|
||||
// Interactive setup runner for first-run
|
||||
int run_interactive_setup(const char* config_path);
|
||||
```
|
||||
|
||||
#### Security Features
|
||||
- Server private key stored only in memory (never in database)
|
||||
- Config file must be signed Nostr event
|
||||
- Full cryptographic validation of config events
|
||||
- Admin pubkey verification for all operations
|
||||
|
||||
## Admin API Specification
|
||||
|
||||
### Authentication Model
|
||||
|
||||
All admin API endpoints (except `/api/health`) require Nostr authentication:
|
||||
|
||||
**Authorization Header Format**:
|
||||
```
|
||||
Authorization: Nostr <base64-encoded-event>
|
||||
```
|
||||
|
||||
**Required Event Structure**:
|
||||
```json
|
||||
{
|
||||
"kind": 24242,
|
||||
"created_at": 1704067200,
|
||||
"tags": [
|
||||
["t", "GET"],
|
||||
["expiration", "1704070800"]
|
||||
],
|
||||
"content": "admin_request",
|
||||
"pubkey": "admin_public_key",
|
||||
"id": "event_id",
|
||||
"sig": "event_signature"
|
||||
}
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### GET /api/health
|
||||
**Purpose**: System health check (no authentication required)
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"database": "connected",
|
||||
"blob_directory": "accessible",
|
||||
"server_time": 1704067200,
|
||||
"uptime": 3600,
|
||||
"disk_usage": {
|
||||
"total_bytes": 1073741824,
|
||||
"used_bytes": 536870912,
|
||||
"available_bytes": 536870912,
|
||||
"usage_percent": 50.0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /api/stats
|
||||
**Purpose**: Server statistics and metrics
|
||||
**Authentication**: Required (admin pubkey)
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"total_files": 1234,
|
||||
"total_bytes": 104857600,
|
||||
"total_size_mb": 100.0,
|
||||
"unique_uploaders": 56,
|
||||
"first_upload": 1693929600,
|
||||
"last_upload": 1704067200,
|
||||
"avg_file_size": 85049,
|
||||
"file_types": {
|
||||
"image/png": 45,
|
||||
"image/jpeg": 32,
|
||||
"application/pdf": 12,
|
||||
"other": 8
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /api/config
|
||||
**Purpose**: Retrieve current server configuration
|
||||
**Authentication**: Required (admin pubkey)
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"cdn_origin": "http://localhost:9001",
|
||||
"max_file_size": "104857600",
|
||||
"nip94_enabled": "true",
|
||||
"auth_rules_enabled": "false",
|
||||
"auth_cache_ttl": "300"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### PUT /api/config
|
||||
**Purpose**: Update server configuration
|
||||
**Authentication**: Required (admin pubkey)
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"max_file_size": "209715200",
|
||||
"nip94_enabled": "true",
|
||||
"cdn_origin": "https://cdn.example.com"
|
||||
}
|
||||
```
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Configuration updated successfully",
|
||||
"updated_keys": ["max_file_size", "cdn_origin"]
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /api/files
|
||||
**Purpose**: List recent files with pagination
|
||||
**Authentication**: Required (admin pubkey)
|
||||
**Parameters**:
|
||||
- `limit` (default: 50): Number of files to return
|
||||
- `offset` (default: 0): Pagination offset
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"files": [
|
||||
{
|
||||
"sha256": "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553",
|
||||
"size": 184292,
|
||||
"type": "application/pdf",
|
||||
"uploaded_at": 1725105921,
|
||||
"uploader_pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
||||
"filename": "document.pdf",
|
||||
"url": "http://localhost:9001/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf"
|
||||
}
|
||||
],
|
||||
"total": 1234,
|
||||
"limit": 50,
|
||||
"offset": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### ✅ Completed Components
|
||||
1. **Database-based configuration loading** - Implemented in main.c
|
||||
2. **Admin API authentication system** - Implemented in admin_api.c
|
||||
3. **Nostr event validation** - Full cryptographic verification
|
||||
4. **Admin pubkey verification** - Database-backed authorization
|
||||
5. **Basic API endpoints** - Health, stats, config, files
|
||||
|
||||
### ✅ Recently Completed Components
|
||||
1. **File-based configuration system** - Fully implemented in main.c with XDG compliance
|
||||
2. **Interactive setup wizard** - Complete shell script with guided setup process (`scripts/setup.sh`)
|
||||
3. **Manual config generation** - Full-featured command-line config generator (`scripts/generate_config.sh`)
|
||||
4. **Testing infrastructure** - Comprehensive admin API test suite (`scripts/test_admin.sh`)
|
||||
5. **Documentation system** - Complete setup and usage documentation (`scripts/README.md`)
|
||||
|
||||
### 📋 Planned Components
|
||||
1. **Web admin interface** - Single-page HTML application
|
||||
2. **Enhanced monitoring** - Real-time statistics dashboard
|
||||
3. **Bulk operations** - Multi-file management APIs
|
||||
4. **Configuration validation** - Advanced config checking
|
||||
5. **Audit logging** - Admin action tracking
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Enable Admin Interface
|
||||
```bash
|
||||
# Configure admin pubkey and enable interface
|
||||
sqlite3 db/ginxsom.db << EOF
|
||||
INSERT OR REPLACE INTO server_config (key, value, description) VALUES
|
||||
('admin_pubkey', 'your_admin_public_key_here', 'Authorized admin public key'),
|
||||
('admin_enabled', 'true', 'Enable admin interface');
|
||||
EOF
|
||||
```
|
||||
|
||||
### 2. Test API Access
|
||||
```bash
|
||||
# Generate admin authentication event
|
||||
ADMIN_PRIVKEY="your_admin_private_key"
|
||||
EVENT=$(nak event -k 24242 -c "admin_request" \
|
||||
--tag t="GET" \
|
||||
--tag expiration="$(date -d '+1 hour' +%s)" \
|
||||
--sec "$ADMIN_PRIVKEY")
|
||||
|
||||
# Test admin API
|
||||
AUTH_HEADER="Nostr $(echo "$EVENT" | base64 -w 0)"
|
||||
curl -H "Authorization: $AUTH_HEADER" http://localhost:9001/api/stats
|
||||
```
|
||||
|
||||
### 3. Configure File-based Setup (Future)
|
||||
```bash
|
||||
# Create XDG config directory
|
||||
mkdir -p "$XDG_CONFIG_HOME/ginxsom"
|
||||
|
||||
# Generate signed config event
|
||||
./scripts/generate_config.sh \
|
||||
--admin-key "your_admin_pubkey" \
|
||||
--server-key "generated_server_privkey" \
|
||||
--output "$XDG_CONFIG_HOME/ginxsom/ginxsom_config_event.json"
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication Security
|
||||
- **Event expiration**: All admin events must include expiration timestamps
|
||||
- **Signature validation**: Full secp256k1 cryptographic verification
|
||||
- **Replay protection**: Event IDs tracked to prevent reuse
|
||||
- **Admin key rotation**: Support for updating admin pubkeys
|
||||
|
||||
### Configuration Security
|
||||
- **File permissions**: Config files should be readable only by server user
|
||||
- **Private key handling**: Server private keys never stored in database
|
||||
- **Config validation**: All configuration changes validated before application
|
||||
- **Backup verification**: Config events cryptographically verifiable
|
||||
|
||||
### Operational Security
|
||||
- **Access logging**: All admin operations logged with timestamps
|
||||
- **Rate limiting**: API endpoints protected against abuse
|
||||
- **Input validation**: All user input sanitized and validated
|
||||
- **Database security**: Prepared statements prevent SQL injection
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### 1. Web Admin Interface
|
||||
- Self-contained HTML file with inline CSS/JavaScript
|
||||
- Real-time monitoring dashboards
|
||||
- Visual configuration management
|
||||
- File upload/management interface
|
||||
|
||||
### 2. Advanced Monitoring
|
||||
- Performance metrics collection
|
||||
- Alert system for critical events
|
||||
- Historical data trending
|
||||
- Resource usage tracking
|
||||
|
||||
### 3. Multi-admin Support
|
||||
- Multiple authorized admin pubkeys
|
||||
- Role-based permissions (read-only vs full admin)
|
||||
- Admin action audit trails
|
||||
- Delegation capabilities
|
||||
|
||||
### 4. Integration Features
|
||||
- Nostr relay integration for admin events
|
||||
- Webhook notifications for admin actions
|
||||
- External authentication providers
|
||||
- API key management for programmatic access
|
||||
|
||||
This specification represents the current understanding and planned development of the Ginxsom admin system, focusing on security, usability, and maintainability.
|
||||
BIN
c-relay-x86_64
BIN
c-relay-x86_64
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
86
relay.log
86
relay.log
@@ -3,6 +3,8 @@
|
||||
[32m[SUCCESS][0m Relay information initialized with default values
|
||||
[34m[INFO][0m Initializing NIP-13 Proof of Work configuration
|
||||
[34m[INFO][0m PoW Configuration: enabled=true, min_difficulty=0, validation_flags=0x1, mode=full
|
||||
[34m[INFO][0m Initializing NIP-40 Expiration Timestamp configuration
|
||||
[34m[INFO][0m Expiration Configuration: enabled=true, strict_mode=true, filter_responses=true, grace_period=300 seconds
|
||||
[34m[INFO][0m Starting relay server...
|
||||
[34m[INFO][0m Starting libwebsockets-based Nostr relay server...
|
||||
[32m[SUCCESS][0m WebSocket relay started on ws://127.0.0.1:8888
|
||||
@@ -21,37 +23,14 @@
|
||||
[34m[INFO][0m WebSocket connection established
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Handling EVENT message with full NIP-01 validation
|
||||
[34m[INFO][0m PoW validated: difficulty=10, target=8, nonce=1839
|
||||
[32m[SUCCESS][0m Event stored in database
|
||||
[32m[SUCCESS][0m Event validated and stored successfully
|
||||
[34m[INFO][0m WebSocket connection closed
|
||||
[34m[INFO][0m HTTP request received
|
||||
[34m[INFO][0m Handling NIP-11 relay information request
|
||||
[32m[SUCCESS][0m NIP-11 relay information served successfully
|
||||
[34m[INFO][0m WebSocket connection established
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m WebSocket connection closed
|
||||
[34m[INFO][0m WebSocket connection established
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Handling EVENT message with full NIP-01 validation
|
||||
[34m[INFO][0m PoW validated: difficulty=21, target=20, nonce=776797
|
||||
[32m[SUCCESS][0m Event stored in database
|
||||
[32m[SUCCESS][0m Event validated and stored successfully
|
||||
[33m[WARNING][0m Event rejected: expired timestamp
|
||||
[34m[INFO][0m WebSocket connection closed
|
||||
[34m[INFO][0m HTTP request received
|
||||
[34m[INFO][0m Handling NIP-11 relay information request
|
||||
[32m[SUCCESS][0m NIP-11 relay information served successfully
|
||||
[34m[INFO][0m HTTP request received
|
||||
[34m[INFO][0m Handling NIP-11 relay information request
|
||||
[32m[SUCCESS][0m NIP-11 relay information served successfully
|
||||
[34m[INFO][0m WebSocket connection established
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Handling EVENT message with full NIP-01 validation
|
||||
@@ -61,37 +40,9 @@
|
||||
[34m[INFO][0m WebSocket connection established
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Handling EVENT message with full NIP-01 validation
|
||||
[34m[INFO][0m PoW validated: difficulty=8, target=8, nonce=385
|
||||
[32m[SUCCESS][0m Event stored in database
|
||||
[32m[SUCCESS][0m Event validated and stored successfully
|
||||
[34m[INFO][0m WebSocket connection closed
|
||||
[34m[INFO][0m HTTP request received
|
||||
[34m[INFO][0m Handling NIP-11 relay information request
|
||||
[32m[SUCCESS][0m NIP-11 relay information served successfully
|
||||
[34m[INFO][0m WebSocket connection established
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m WebSocket connection closed
|
||||
[34m[INFO][0m WebSocket connection established
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Handling EVENT message with full NIP-01 validation
|
||||
[34m[INFO][0m PoW validated: difficulty=21, target=20, nonce=776797
|
||||
[33m[WARNING][0m Event already exists in database
|
||||
[32m[SUCCESS][0m Event validated and stored successfully
|
||||
[34m[INFO][0m WebSocket connection closed
|
||||
[34m[INFO][0m HTTP request received
|
||||
[34m[INFO][0m Handling NIP-11 relay information request
|
||||
[32m[SUCCESS][0m NIP-11 relay information served successfully
|
||||
[34m[INFO][0m HTTP request received
|
||||
[34m[INFO][0m Handling NIP-11 relay information request
|
||||
[32m[SUCCESS][0m NIP-11 relay information served successfully
|
||||
[34m[INFO][0m WebSocket connection established
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Handling EVENT message with full NIP-01 validation
|
||||
@@ -101,17 +52,32 @@
|
||||
[34m[INFO][0m WebSocket connection established
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Handling EVENT message with full NIP-01 validation
|
||||
[34m[INFO][0m PoW validated: difficulty=8, target=8, nonce=1669
|
||||
[33m[WARNING][0m Event rejected: expired timestamp
|
||||
[34m[INFO][0m WebSocket connection closed
|
||||
[34m[INFO][0m WebSocket connection established
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Handling REQ message for persistent subscription
|
||||
[34m[INFO][0m Added subscription 'filter_test' (total: 1)
|
||||
[34m[INFO][0m Executing SQL: SELECT id, pubkey, created_at, kind, content, sig, tags FROM events WHERE 1=1 AND kind IN (1) ORDER BY created_at DESC LIMIT 10
|
||||
[34m[INFO][0m Query returned 10 rows
|
||||
[34m[INFO][0m Total events sent: 10
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Removed subscription 'filter_test' (total: 0)
|
||||
[34m[INFO][0m Closed subscription: filter_test
|
||||
[34m[INFO][0m WebSocket connection closed
|
||||
[33m[WARNING][0m Subscription '<27><><15>a' not found for removal
|
||||
[34m[INFO][0m WebSocket connection established
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Handling EVENT message with full NIP-01 validation
|
||||
[32m[SUCCESS][0m Event stored in database
|
||||
[32m[SUCCESS][0m Event validated and stored successfully
|
||||
[34m[INFO][0m WebSocket connection closed
|
||||
[34m[INFO][0m WebSocket connection established
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Handling EVENT message with full NIP-01 validation
|
||||
[32m[SUCCESS][0m Event stored in database
|
||||
[32m[SUCCESS][0m Event validated and stored successfully
|
||||
[34m[INFO][0m WebSocket connection closed
|
||||
[34m[INFO][0m HTTP request received
|
||||
[34m[INFO][0m Handling NIP-11 relay information request
|
||||
[32m[SUCCESS][0m NIP-11 relay information served successfully
|
||||
[34m[INFO][0m WebSocket connection established
|
||||
[34m[INFO][0m Received WebSocket message
|
||||
[34m[INFO][0m Handling EVENT message with full NIP-01 validation
|
||||
[34m[INFO][0m PoW validated: difficulty=21, target=20, nonce=776797
|
||||
[33m[WARNING][0m Event already exists in database
|
||||
[32m[SUCCESS][0m Event validated and stored successfully
|
||||
[34m[INFO][0m WebSocket connection closed
|
||||
|
||||
205
src/main.c
205
src/main.c
@@ -97,6 +97,24 @@ static struct pow_config g_pow_config = {
|
||||
.anti_spam_mode = 0 // Basic validation by default
|
||||
};
|
||||
|
||||
// NIP-40 Expiration configuration structure
|
||||
struct expiration_config {
|
||||
int enabled; // 0 = disabled, 1 = enabled
|
||||
int strict_mode; // 1 = reject expired events on submission
|
||||
int filter_responses; // 1 = filter expired events from responses
|
||||
int delete_expired; // 1 = delete expired events from DB (future feature)
|
||||
long grace_period; // Grace period in seconds for clock skew
|
||||
};
|
||||
|
||||
// Global expiration configuration instance
|
||||
static struct expiration_config g_expiration_config = {
|
||||
.enabled = 1, // Enable expiration handling by default
|
||||
.strict_mode = 1, // Reject expired events on submission by default
|
||||
.filter_responses = 1, // Filter expired events from responses by default
|
||||
.delete_expired = 0, // Don't delete by default (keep for audit)
|
||||
.grace_period = 300 // 5 minutes grace period for clock skew
|
||||
};
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -217,6 +235,12 @@ int handle_nip11_http_request(struct lws* wsi, const char* accept_header);
|
||||
void init_pow_config();
|
||||
int validate_event_pow(cJSON* event, char* error_message, size_t error_size);
|
||||
|
||||
// Forward declarations for NIP-40 expiration handling
|
||||
void init_expiration_config();
|
||||
long extract_expiration_timestamp(cJSON* tags);
|
||||
int is_event_expired(cJSON* event, time_t current_time);
|
||||
int validate_event_expiration(cJSON* event, char* error_message, size_t error_size);
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -651,6 +675,19 @@ int broadcast_event_to_subscriptions(cJSON* event) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Check if event is expired and should not be broadcast (NIP-40)
|
||||
if (g_expiration_config.enabled && g_expiration_config.filter_responses) {
|
||||
time_t current_time = time(NULL);
|
||||
if (is_event_expired(event, current_time)) {
|
||||
char debug_msg[256];
|
||||
cJSON* event_id_obj = cJSON_GetObjectItem(event, "id");
|
||||
const char* event_id = event_id_obj ? cJSON_GetStringValue(event_id_obj) : "unknown";
|
||||
snprintf(debug_msg, sizeof(debug_msg), "Skipping broadcast of expired event: %.16s", event_id);
|
||||
log_info(debug_msg);
|
||||
return 0; // Don't broadcast expired events
|
||||
}
|
||||
}
|
||||
|
||||
int broadcasts = 0;
|
||||
|
||||
pthread_mutex_lock(&g_subscription_manager.subscriptions_lock);
|
||||
@@ -1268,6 +1305,7 @@ void init_relay_info() {
|
||||
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(13)); // NIP-13: Proof of Work
|
||||
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(15)); // NIP-15: EOSE
|
||||
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(20)); // NIP-20: Command results
|
||||
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(40)); // NIP-40: Expiration Timestamp
|
||||
}
|
||||
|
||||
// Initialize server limitations
|
||||
@@ -1757,6 +1795,145 @@ int validate_event_pow(cJSON* event, char* error_message, size_t error_size) {
|
||||
return 0; // Success
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
// NIP-40 EXPIRATION TIMESTAMP HANDLING
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Initialize expiration configuration with environment variables and defaults
|
||||
void init_expiration_config() {
|
||||
log_info("Initializing NIP-40 Expiration Timestamp configuration");
|
||||
|
||||
// Check environment variables for configuration
|
||||
const char* exp_enabled_env = getenv("RELAY_EXPIRATION_ENABLED");
|
||||
if (exp_enabled_env) {
|
||||
g_expiration_config.enabled = (strcmp(exp_enabled_env, "1") == 0 ||
|
||||
strcmp(exp_enabled_env, "true") == 0 ||
|
||||
strcmp(exp_enabled_env, "yes") == 0);
|
||||
}
|
||||
|
||||
const char* exp_strict_env = getenv("RELAY_EXPIRATION_STRICT");
|
||||
if (exp_strict_env) {
|
||||
g_expiration_config.strict_mode = (strcmp(exp_strict_env, "1") == 0 ||
|
||||
strcmp(exp_strict_env, "true") == 0 ||
|
||||
strcmp(exp_strict_env, "yes") == 0);
|
||||
}
|
||||
|
||||
const char* exp_filter_env = getenv("RELAY_EXPIRATION_FILTER");
|
||||
if (exp_filter_env) {
|
||||
g_expiration_config.filter_responses = (strcmp(exp_filter_env, "1") == 0 ||
|
||||
strcmp(exp_filter_env, "true") == 0 ||
|
||||
strcmp(exp_filter_env, "yes") == 0);
|
||||
}
|
||||
|
||||
const char* exp_delete_env = getenv("RELAY_EXPIRATION_DELETE");
|
||||
if (exp_delete_env) {
|
||||
g_expiration_config.delete_expired = (strcmp(exp_delete_env, "1") == 0 ||
|
||||
strcmp(exp_delete_env, "true") == 0 ||
|
||||
strcmp(exp_delete_env, "yes") == 0);
|
||||
}
|
||||
|
||||
const char* exp_grace_env = getenv("RELAY_EXPIRATION_GRACE_PERIOD");
|
||||
if (exp_grace_env) {
|
||||
long grace_period = atol(exp_grace_env);
|
||||
if (grace_period >= 0 && grace_period <= 86400) { // Max 24 hours
|
||||
g_expiration_config.grace_period = grace_period;
|
||||
}
|
||||
}
|
||||
|
||||
// Log final configuration
|
||||
char config_msg[512];
|
||||
snprintf(config_msg, sizeof(config_msg),
|
||||
"Expiration Configuration: enabled=%s, strict_mode=%s, filter_responses=%s, grace_period=%ld seconds",
|
||||
g_expiration_config.enabled ? "true" : "false",
|
||||
g_expiration_config.strict_mode ? "true" : "false",
|
||||
g_expiration_config.filter_responses ? "true" : "false",
|
||||
g_expiration_config.grace_period);
|
||||
log_info(config_msg);
|
||||
}
|
||||
|
||||
// Extract expiration timestamp from event tags
|
||||
long extract_expiration_timestamp(cJSON* tags) {
|
||||
if (!tags || !cJSON_IsArray(tags)) {
|
||||
return 0; // No expiration
|
||||
}
|
||||
|
||||
cJSON* tag = NULL;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) {
|
||||
cJSON* tag_name = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* tag_value = cJSON_GetArrayItem(tag, 1);
|
||||
|
||||
if (cJSON_IsString(tag_name) && cJSON_IsString(tag_value)) {
|
||||
const char* name = cJSON_GetStringValue(tag_name);
|
||||
const char* value = cJSON_GetStringValue(tag_value);
|
||||
|
||||
if (name && value && strcmp(name, "expiration") == 0) {
|
||||
long expiration_ts = atol(value);
|
||||
if (expiration_ts > 0) {
|
||||
return expiration_ts;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0; // No valid expiration tag found
|
||||
}
|
||||
|
||||
// Check if event is currently expired
|
||||
int is_event_expired(cJSON* event, time_t current_time) {
|
||||
if (!event) {
|
||||
return 0; // Invalid event, not expired
|
||||
}
|
||||
|
||||
cJSON* tags = cJSON_GetObjectItem(event, "tags");
|
||||
long expiration_ts = extract_expiration_timestamp(tags);
|
||||
|
||||
if (expiration_ts == 0) {
|
||||
return 0; // No expiration timestamp, not expired
|
||||
}
|
||||
|
||||
// Check if current time exceeds expiration + grace period
|
||||
return (current_time > (expiration_ts + g_expiration_config.grace_period));
|
||||
}
|
||||
|
||||
// Validate event expiration according to NIP-40
|
||||
int validate_event_expiration(cJSON* event, char* error_message, size_t error_size) {
|
||||
if (!g_expiration_config.enabled) {
|
||||
return 0; // Expiration validation disabled
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
snprintf(error_message, error_size, "expiration: null event");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Check if event is expired
|
||||
time_t current_time = time(NULL);
|
||||
if (is_event_expired(event, current_time)) {
|
||||
if (g_expiration_config.strict_mode) {
|
||||
cJSON* tags = cJSON_GetObjectItem(event, "tags");
|
||||
long expiration_ts = extract_expiration_timestamp(tags);
|
||||
|
||||
snprintf(error_message, error_size,
|
||||
"invalid: event expired (expiration=%ld, current=%ld, grace=%ld)",
|
||||
expiration_ts, (long)current_time, g_expiration_config.grace_period);
|
||||
log_warning("Event rejected: expired timestamp");
|
||||
return -1;
|
||||
} else {
|
||||
// In non-strict mode, log but allow expired events
|
||||
char debug_msg[256];
|
||||
snprintf(debug_msg, sizeof(debug_msg),
|
||||
"Accepting expired event (strict_mode disabled)");
|
||||
log_info(debug_msg);
|
||||
}
|
||||
}
|
||||
|
||||
return 0; // Success
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
// DATABASE FUNCTIONS
|
||||
@@ -2165,6 +2342,9 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
|
||||
char* sql_ptr = sql + strlen(sql);
|
||||
int remaining = sizeof(sql) - strlen(sql);
|
||||
|
||||
// Note: Expiration filtering will be done at application level
|
||||
// after retrieving events to ensure compatibility with all SQLite versions
|
||||
|
||||
// Handle kinds filter
|
||||
cJSON* kinds = cJSON_GetObjectItem(filter, "kinds");
|
||||
if (kinds && cJSON_IsArray(kinds)) {
|
||||
@@ -2293,6 +2473,16 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
|
||||
}
|
||||
cJSON_AddItemToObject(event, "tags", tags);
|
||||
|
||||
// Check expiration filtering (NIP-40) at application level
|
||||
if (g_expiration_config.enabled && g_expiration_config.filter_responses) {
|
||||
time_t current_time = time(NULL);
|
||||
if (is_event_expired(event, current_time)) {
|
||||
// Skip this expired event
|
||||
cJSON_Delete(event);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Send EVENT message
|
||||
cJSON* event_msg = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(event_msg, cJSON_CreateString("EVENT"));
|
||||
@@ -2388,14 +2578,20 @@ int handle_event_message(cJSON* event, char* error_message, size_t error_size) {
|
||||
return pow_result; // PoW validation failed, error message already set
|
||||
}
|
||||
|
||||
// Step 4: Complete event validation (combines structure + signature + additional checks)
|
||||
// Step 4: Validate expiration timestamp (NIP-40) if enabled
|
||||
int expiration_result = validate_event_expiration(event, error_message, error_size);
|
||||
if (expiration_result != 0) {
|
||||
return expiration_result; // Expiration validation failed, error message already set
|
||||
}
|
||||
|
||||
// Step 5: Complete event validation (combines structure + signature + additional checks)
|
||||
int validation_result = nostr_validate_event(event);
|
||||
if (validation_result != NOSTR_SUCCESS) {
|
||||
snprintf(error_message, error_size, "invalid: complete event validation failed");
|
||||
return validation_result;
|
||||
}
|
||||
|
||||
// Step 5: Check for special event types and handle accordingly
|
||||
// Step 6: Check for special event types and handle accordingly
|
||||
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
|
||||
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
|
||||
cJSON* created_at_obj = cJSON_GetObjectItem(event, "created_at");
|
||||
@@ -2435,7 +2631,7 @@ int handle_event_message(cJSON* event, char* error_message, size_t error_size) {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Store event in database
|
||||
// Step 7: Store event in database
|
||||
if (store_event(event) == 0) {
|
||||
error_message[0] = '\0'; // Success - empty error message
|
||||
log_success("Event validated and stored successfully");
|
||||
@@ -2805,6 +3001,9 @@ int main(int argc, char* argv[]) {
|
||||
// Initialize NIP-13 PoW configuration
|
||||
init_pow_config();
|
||||
|
||||
// Initialize NIP-40 expiration configuration
|
||||
init_expiration_config();
|
||||
|
||||
log_info("Starting relay server...");
|
||||
|
||||
// Start WebSocket Nostr relay server
|
||||
|
||||
539
tests/40_nip_test.sh
Executable file
539
tests/40_nip_test.sh
Executable file
@@ -0,0 +1,539 @@
|
||||
#!/bin/bash
|
||||
|
||||
# NIP-40 Expiration Timestamp Test Suite for C Nostr Relay
|
||||
# Tests expiration timestamp handling in the relay's event processing pipeline
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# Color constants
|
||||
RED='\033[31m'
|
||||
GREEN='\033[32m'
|
||||
YELLOW='\033[33m'
|
||||
BLUE='\033[34m'
|
||||
BOLD='\033[1m'
|
||||
RESET='\033[0m'
|
||||
|
||||
# Test configuration
|
||||
RELAY_URL="ws://127.0.0.1:8888"
|
||||
HTTP_URL="http://127.0.0.1:8888"
|
||||
TEST_COUNT=0
|
||||
PASSED_COUNT=0
|
||||
FAILED_COUNT=0
|
||||
|
||||
# Test results tracking
|
||||
declare -a TEST_RESULTS=()
|
||||
|
||||
print_info() {
|
||||
echo -e "${BLUE}[INFO]${RESET} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}${BOLD}[SUCCESS]${RESET} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${RESET} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}${BOLD}[ERROR]${RESET} $1"
|
||||
}
|
||||
|
||||
print_test_header() {
|
||||
TEST_COUNT=$((TEST_COUNT + 1))
|
||||
echo ""
|
||||
echo -e "${BOLD}=== TEST $TEST_COUNT: $1 ===${RESET}"
|
||||
}
|
||||
|
||||
record_test_result() {
|
||||
local test_name="$1"
|
||||
local result="$2"
|
||||
local details="$3"
|
||||
|
||||
TEST_RESULTS+=("$test_name|$result|$details")
|
||||
|
||||
if [ "$result" = "PASS" ]; then
|
||||
PASSED_COUNT=$((PASSED_COUNT + 1))
|
||||
print_success "PASS: $test_name"
|
||||
else
|
||||
FAILED_COUNT=$((FAILED_COUNT + 1))
|
||||
print_error "FAIL: $test_name"
|
||||
if [ -n "$details" ]; then
|
||||
echo " Details: $details"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if relay is running
|
||||
check_relay_running() {
|
||||
print_info "Checking if relay is running..."
|
||||
|
||||
if ! curl -s -H "Accept: application/nostr+json" "$HTTP_URL/" >/dev/null 2>&1; then
|
||||
print_error "Relay is not running or not accessible at $HTTP_URL"
|
||||
print_info "Please start the relay with: ./make_and_restart_relay.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_success "Relay is running and accessible"
|
||||
}
|
||||
|
||||
# Test NIP-11 relay information includes NIP-40
|
||||
test_nip11_expiration_support() {
|
||||
print_test_header "NIP-11 Expiration Support Advertisement"
|
||||
|
||||
print_info "Fetching relay information..."
|
||||
RELAY_INFO=$(curl -s -H "Accept: application/nostr+json" "$HTTP_URL/")
|
||||
|
||||
echo "Relay Info Response:"
|
||||
echo "$RELAY_INFO" | jq '.'
|
||||
echo ""
|
||||
|
||||
# Check if NIP-40 is in supported_nips
|
||||
if echo "$RELAY_INFO" | jq -e '.supported_nips | index(40)' >/dev/null 2>&1; then
|
||||
print_success "✓ NIP-40 found in supported_nips array"
|
||||
NIP40_SUPPORTED=true
|
||||
else
|
||||
print_error "✗ NIP-40 not found in supported_nips array"
|
||||
NIP40_SUPPORTED=false
|
||||
fi
|
||||
|
||||
if [ "$NIP40_SUPPORTED" = true ]; then
|
||||
record_test_result "NIP-11 Expiration Support Advertisement" "PASS" "NIP-40 advertised in relay info"
|
||||
return 0
|
||||
else
|
||||
record_test_result "NIP-11 Expiration Support Advertisement" "FAIL" "NIP-40 not advertised"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Helper function to create event with expiration tag
|
||||
create_event_with_expiration() {
|
||||
local content="$1"
|
||||
local expiration_timestamp="$2"
|
||||
local private_key="91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"
|
||||
|
||||
if ! command -v nak &> /dev/null; then
|
||||
echo ""
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Create event with expiration tag
|
||||
nak event --sec "$private_key" -c "$content" -t "expiration=$expiration_timestamp" --ts $(date +%s)
|
||||
}
|
||||
|
||||
# Helper function to send event and check response
|
||||
send_event_and_check() {
|
||||
local event_json="$1"
|
||||
local expected_result="$2" # "accept" or "reject"
|
||||
local description="$3"
|
||||
|
||||
if [ -z "$event_json" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Create EVENT message
|
||||
local event_message="[\"EVENT\",$event_json]"
|
||||
|
||||
# Send to relay
|
||||
if command -v websocat &> /dev/null; then
|
||||
local response=$(echo "$event_message" | timeout 5s websocat "$RELAY_URL" 2>&1 || echo "Connection failed")
|
||||
|
||||
print_info "Relay response: $response"
|
||||
|
||||
if [[ "$response" == *"Connection failed"* ]]; then
|
||||
print_error "✗ Failed to connect to relay"
|
||||
return 1
|
||||
elif [[ "$expected_result" == "accept" && "$response" == *"true"* ]]; then
|
||||
print_success "✓ $description accepted as expected"
|
||||
return 0
|
||||
elif [[ "$expected_result" == "reject" && "$response" == *"false"* ]]; then
|
||||
print_success "✓ $description rejected as expected"
|
||||
return 0
|
||||
elif [[ "$expected_result" == "accept" && "$response" == *"false"* ]]; then
|
||||
print_error "✗ $description unexpectedly rejected: $response"
|
||||
return 1
|
||||
elif [[ "$expected_result" == "reject" && "$response" == *"true"* ]]; then
|
||||
print_error "✗ $description unexpectedly accepted: $response"
|
||||
return 1
|
||||
else
|
||||
print_warning "? Unclear response for $description: $response"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
print_error "websocat not found - required for testing"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Test event without expiration tag
|
||||
test_event_without_expiration() {
|
||||
print_test_header "Event Submission Without Expiration Tag"
|
||||
|
||||
if ! command -v nak &> /dev/null; then
|
||||
print_warning "nak command not found - skipping expiration tests"
|
||||
record_test_result "Event Submission Without Expiration Tag" "SKIP" "nak not available"
|
||||
return 0
|
||||
fi
|
||||
|
||||
print_info "Creating event without expiration tag..."
|
||||
|
||||
local private_key="91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"
|
||||
local event_json=$(nak event --sec "$private_key" -c "Test event without expiration" --ts $(date +%s))
|
||||
|
||||
print_info "Generated event:"
|
||||
echo "$event_json" | jq '.'
|
||||
echo ""
|
||||
|
||||
if send_event_and_check "$event_json" "accept" "Event without expiration tag"; then
|
||||
record_test_result "Event Submission Without Expiration Tag" "PASS" "Non-expiring event accepted"
|
||||
return 0
|
||||
else
|
||||
record_test_result "Event Submission Without Expiration Tag" "FAIL" "Non-expiring event handling failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Test event with future expiration (should be accepted)
|
||||
test_event_with_future_expiration() {
|
||||
print_test_header "Event Submission With Future Expiration"
|
||||
|
||||
if ! command -v nak &> /dev/null; then
|
||||
record_test_result "Event Submission With Future Expiration" "SKIP" "nak not available"
|
||||
return 0
|
||||
fi
|
||||
|
||||
print_info "Creating event with future expiration (1 hour from now)..."
|
||||
|
||||
local future_timestamp=$(($(date +%s) + 3600)) # 1 hour from now
|
||||
local event_json=$(create_event_with_expiration "Test event expiring in 1 hour" "$future_timestamp")
|
||||
|
||||
if [ -z "$event_json" ]; then
|
||||
record_test_result "Event Submission With Future Expiration" "FAIL" "Failed to create event"
|
||||
return 1
|
||||
fi
|
||||
|
||||
print_info "Generated event (expires at $future_timestamp):"
|
||||
echo "$event_json" | jq '.'
|
||||
echo ""
|
||||
|
||||
if send_event_and_check "$event_json" "accept" "Event with future expiration"; then
|
||||
record_test_result "Event Submission With Future Expiration" "PASS" "Future-expiring event accepted"
|
||||
return 0
|
||||
else
|
||||
record_test_result "Event Submission With Future Expiration" "FAIL" "Future-expiring event rejected"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Test event with past expiration (should be rejected in strict mode)
|
||||
test_event_with_past_expiration() {
|
||||
print_test_header "Event Submission With Past Expiration"
|
||||
|
||||
if ! command -v nak &> /dev/null; then
|
||||
record_test_result "Event Submission With Past Expiration" "SKIP" "nak not available"
|
||||
return 0
|
||||
fi
|
||||
|
||||
print_info "Creating event with past expiration (1 hour ago)..."
|
||||
|
||||
local past_timestamp=$(($(date +%s) - 3600)) # 1 hour ago
|
||||
local event_json=$(create_event_with_expiration "Test event expired 1 hour ago" "$past_timestamp")
|
||||
|
||||
if [ -z "$event_json" ]; then
|
||||
record_test_result "Event Submission With Past Expiration" "FAIL" "Failed to create event"
|
||||
return 1
|
||||
fi
|
||||
|
||||
print_info "Generated event (expired at $past_timestamp):"
|
||||
echo "$event_json" | jq '.'
|
||||
echo ""
|
||||
|
||||
# In strict mode (default), this should be rejected
|
||||
if send_event_and_check "$event_json" "reject" "Event with past expiration"; then
|
||||
record_test_result "Event Submission With Past Expiration" "PASS" "Expired event correctly rejected in strict mode"
|
||||
return 0
|
||||
else
|
||||
record_test_result "Event Submission With Past Expiration" "FAIL" "Expired event handling failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Test event with expiration within grace period
|
||||
test_event_within_grace_period() {
|
||||
print_test_header "Event Submission Within Grace Period"
|
||||
|
||||
if ! command -v nak &> /dev/null; then
|
||||
record_test_result "Event Submission Within Grace Period" "SKIP" "nak not available"
|
||||
return 0
|
||||
fi
|
||||
|
||||
print_info "Creating event with expiration within grace period (2 minutes ago, grace period is 5 minutes)..."
|
||||
|
||||
local grace_timestamp=$(($(date +%s) - 120)) # 2 minutes ago (within 5 minute grace period)
|
||||
local event_json=$(create_event_with_expiration "Test event within grace period" "$grace_timestamp")
|
||||
|
||||
if [ -z "$event_json" ]; then
|
||||
record_test_result "Event Submission Within Grace Period" "FAIL" "Failed to create event"
|
||||
return 1
|
||||
fi
|
||||
|
||||
print_info "Generated event (expired at $grace_timestamp, within grace period):"
|
||||
echo "$event_json" | jq '.'
|
||||
echo ""
|
||||
|
||||
# Should be accepted due to grace period
|
||||
if send_event_and_check "$event_json" "accept" "Event within grace period"; then
|
||||
record_test_result "Event Submission Within Grace Period" "PASS" "Event within grace period accepted"
|
||||
return 0
|
||||
else
|
||||
record_test_result "Event Submission Within Grace Period" "FAIL" "Grace period handling failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Test event filtering in subscriptions
|
||||
test_expiration_filtering_in_subscriptions() {
|
||||
print_test_header "Expiration Filtering in Subscriptions"
|
||||
|
||||
if ! command -v nak &> /dev/null || ! command -v websocat &> /dev/null; then
|
||||
record_test_result "Expiration Filtering in Subscriptions" "SKIP" "Required tools not available"
|
||||
return 0
|
||||
fi
|
||||
|
||||
print_info "Setting up test events for subscription filtering..."
|
||||
|
||||
# First, create a few events with different expiration times
|
||||
local private_key="91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"
|
||||
|
||||
# Event 1: No expiration (should be returned)
|
||||
local event1=$(nak event --sec "$private_key" -c "Event without expiration for filtering test" --ts $(date +%s))
|
||||
|
||||
# Event 2: Future expiration (should be returned)
|
||||
local future_timestamp=$(($(date +%s) + 1800)) # 30 minutes from now
|
||||
local event2=$(create_event_with_expiration "Event with future expiration for filtering test" "$future_timestamp")
|
||||
|
||||
# Event 3: Past expiration (should NOT be returned if filtering is enabled)
|
||||
local past_timestamp=$(($(date +%s) - 3600)) # 1 hour ago
|
||||
local event3=$(create_event_with_expiration "Event with past expiration for filtering test" "$past_timestamp")
|
||||
|
||||
print_info "Publishing test events..."
|
||||
|
||||
# Note: We expect event3 to be rejected on submission in strict mode,
|
||||
# so we'll create it with a slightly more recent expiration that might get through
|
||||
local recent_past=$(($(date +%s) - 600)) # 10 minutes ago (outside grace period)
|
||||
local event3_recent=$(create_event_with_expiration "Recently expired event for filtering test" "$recent_past")
|
||||
|
||||
# Try to submit all events (some may be rejected)
|
||||
echo "[\"EVENT\",$event1]" | timeout 3s websocat "$RELAY_URL" >/dev/null 2>&1 || true
|
||||
echo "[\"EVENT\",$event2]" | timeout 3s websocat "$RELAY_URL" >/dev/null 2>&1 || true
|
||||
echo "[\"EVENT\",$event3_recent]" | timeout 3s websocat "$RELAY_URL" >/dev/null 2>&1 || true
|
||||
|
||||
sleep 2 # Let events settle
|
||||
|
||||
print_info "Testing subscription filtering..."
|
||||
|
||||
# Create subscription for recent events
|
||||
local req_message='["REQ","filter_test",{"kinds":[1],"limit":10}]'
|
||||
local response=$(echo -e "$req_message\n[\"CLOSE\",\"filter_test\"]" | timeout 5s websocat "$RELAY_URL" 2>/dev/null || echo "")
|
||||
|
||||
print_info "Subscription response:"
|
||||
echo "$response"
|
||||
echo ""
|
||||
|
||||
# Count events that contain our test content
|
||||
local no_exp_count=0
|
||||
local future_exp_count=0
|
||||
local past_exp_count=0
|
||||
|
||||
if echo "$response" | grep -q "Event without expiration for filtering test"; then
|
||||
no_exp_count=1
|
||||
print_success "✓ Event without expiration found in subscription results"
|
||||
fi
|
||||
|
||||
if echo "$response" | grep -q "Event with future expiration for filtering test"; then
|
||||
future_exp_count=1
|
||||
print_success "✓ Event with future expiration found in subscription results"
|
||||
fi
|
||||
|
||||
if echo "$response" | grep -q "Recently expired event for filtering test"; then
|
||||
past_exp_count=1
|
||||
print_warning "✗ Recently expired event found in subscription results (should be filtered)"
|
||||
else
|
||||
print_success "✓ Recently expired event properly filtered from subscription results"
|
||||
fi
|
||||
|
||||
# Evaluate results
|
||||
local expected_events=$((no_exp_count + future_exp_count))
|
||||
if [ $expected_events -ge 1 ] && [ $past_exp_count -eq 0 ]; then
|
||||
record_test_result "Expiration Filtering in Subscriptions" "PASS" "Expired events properly filtered from subscriptions"
|
||||
return 0
|
||||
else
|
||||
record_test_result "Expiration Filtering in Subscriptions" "FAIL" "Expiration filtering not working properly in subscriptions"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Test malformed expiration tags
|
||||
test_malformed_expiration_tags() {
|
||||
print_test_header "Handling of Malformed Expiration Tags"
|
||||
|
||||
if ! command -v nak &> /dev/null; then
|
||||
record_test_result "Handling of Malformed Expiration Tags" "SKIP" "nak not available"
|
||||
return 0
|
||||
fi
|
||||
|
||||
print_info "Testing events with malformed expiration tags..."
|
||||
|
||||
local private_key="91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"
|
||||
|
||||
# Test 1: Non-numeric expiration value
|
||||
local event1=$(nak event --sec "$private_key" -c "Event with non-numeric expiration" -t "expiration=not_a_number" --ts $(date +%s))
|
||||
|
||||
# Test 2: Empty expiration value
|
||||
local event2=$(nak event --sec "$private_key" -c "Event with empty expiration" -t "expiration=" --ts $(date +%s))
|
||||
|
||||
print_info "Testing non-numeric expiration value..."
|
||||
if send_event_and_check "$event1" "accept" "Event with non-numeric expiration (should be treated as no expiration)"; then
|
||||
print_success "✓ Non-numeric expiration handled gracefully"
|
||||
malformed_test1=true
|
||||
else
|
||||
malformed_test1=false
|
||||
fi
|
||||
|
||||
print_info "Testing empty expiration value..."
|
||||
if send_event_and_check "$event2" "accept" "Event with empty expiration (should be treated as no expiration)"; then
|
||||
print_success "✓ Empty expiration handled gracefully"
|
||||
malformed_test2=true
|
||||
else
|
||||
malformed_test2=false
|
||||
fi
|
||||
|
||||
if [ "$malformed_test1" = true ] && [ "$malformed_test2" = true ]; then
|
||||
record_test_result "Handling of Malformed Expiration Tags" "PASS" "Malformed expiration tags handled gracefully"
|
||||
return 0
|
||||
else
|
||||
record_test_result "Handling of Malformed Expiration Tags" "FAIL" "Malformed expiration tag handling failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Test configuration via environment variables
|
||||
test_expiration_configuration() {
|
||||
print_test_header "Expiration Configuration Via Environment Variables"
|
||||
|
||||
print_info "Testing expiration configuration from relay logs..."
|
||||
|
||||
if [ -f "relay.log" ]; then
|
||||
print_info "Current configuration from logs:"
|
||||
grep "Expiration Configuration:" relay.log | tail -1 || print_warning "No expiration configuration found in logs"
|
||||
else
|
||||
print_warning "No relay.log found"
|
||||
fi
|
||||
|
||||
# The relay should be running with default configuration
|
||||
print_info "Default configuration should be:"
|
||||
print_info " enabled=true"
|
||||
print_info " strict_mode=true (rejects expired events on submission)"
|
||||
print_info " filter_responses=true (filters expired events from responses)"
|
||||
print_info " grace_period=300 seconds (5 minutes)"
|
||||
|
||||
# Test current behavior matches expected default configuration
|
||||
print_info "Configuration test based on observed behavior:"
|
||||
|
||||
# Check if NIP-40 is advertised (indicates enabled=true)
|
||||
if curl -s -H "Accept: application/nostr+json" "$HTTP_URL/" | jq -e '.supported_nips | index(40)' >/dev/null 2>&1; then
|
||||
print_success "✓ NIP-40 support advertised (enabled=true)"
|
||||
config_test=true
|
||||
else
|
||||
print_error "✗ NIP-40 not advertised (may be disabled)"
|
||||
config_test=false
|
||||
fi
|
||||
|
||||
if [ "$config_test" = true ]; then
|
||||
record_test_result "Expiration Configuration Via Environment Variables" "PASS" "Expiration configuration is accessible and working"
|
||||
return 0
|
||||
else
|
||||
record_test_result "Expiration Configuration Via Environment Variables" "FAIL" "Expiration configuration issues detected"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Print test summary
|
||||
print_test_summary() {
|
||||
echo ""
|
||||
echo -e "${BOLD}=== TEST SUMMARY ===${RESET}"
|
||||
echo "Total tests run: $TEST_COUNT"
|
||||
echo -e "${GREEN}Passed: $PASSED_COUNT${RESET}"
|
||||
echo -e "${RED}Failed: $FAILED_COUNT${RESET}"
|
||||
|
||||
if [ $FAILED_COUNT -gt 0 ]; then
|
||||
echo ""
|
||||
echo -e "${RED}${BOLD}Failed tests:${RESET}"
|
||||
for result in "${TEST_RESULTS[@]}"; do
|
||||
IFS='|' read -r name status details <<< "$result"
|
||||
if [ "$status" = "FAIL" ]; then
|
||||
echo -e " ${RED}✗ $name${RESET}"
|
||||
if [ -n "$details" ]; then
|
||||
echo " $details"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ $FAILED_COUNT -eq 0 ]; then
|
||||
echo -e "${GREEN}${BOLD}🎉 ALL TESTS PASSED!${RESET}"
|
||||
echo -e "${GREEN}✅ NIP-40 Expiration Timestamp support is working correctly in the relay${RESET}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}${BOLD}❌ SOME TESTS FAILED${RESET}"
|
||||
echo "Please review the output above and check relay logs for more details."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Main test execution
|
||||
main() {
|
||||
echo -e "${BOLD}=== NIP-40 Expiration Timestamp Relay Test Suite ===${RESET}"
|
||||
echo "Testing NIP-40 Expiration Timestamp support in the C Nostr Relay"
|
||||
echo "Relay URL: $RELAY_URL"
|
||||
echo ""
|
||||
|
||||
# Check prerequisites
|
||||
if ! command -v curl &> /dev/null; then
|
||||
print_error "curl is required but not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
print_error "jq is required but not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v websocat &> /dev/null; then
|
||||
print_warning "websocat not found - WebSocket tests will be skipped"
|
||||
fi
|
||||
|
||||
if ! command -v nak &> /dev/null; then
|
||||
print_warning "nak not found - Event generation tests will be skipped"
|
||||
print_info "Install with: go install github.com/fiatjaf/nak@latest"
|
||||
fi
|
||||
|
||||
# Run tests
|
||||
check_relay_running
|
||||
test_nip11_expiration_support
|
||||
test_event_without_expiration
|
||||
test_event_with_future_expiration
|
||||
test_event_with_past_expiration
|
||||
test_event_within_grace_period
|
||||
test_expiration_filtering_in_subscriptions
|
||||
test_malformed_expiration_tags
|
||||
test_expiration_configuration
|
||||
|
||||
# Print summary
|
||||
print_test_summary
|
||||
exit $?
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user