diff --git a/README.md b/README.md index d0ff624..a2f2387 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,15 @@ A nostr relay in C with sqlite on the back end. - + ### [NIPs](https://github.com/nostr-protocol/nips) -- [x] NIP-01: Basic protocol flow description -- [ ] NIP-02: Contact list and petnames -- [ ] NIP-04: Encrypted Direct Message -- [ ] NIP-09: Event deletion +- [x] NIP-01: Basic protocol flow implementation +- [x] NIP-09: Event deletion - [ ] NIP-11: Relay information document - [ ] NIP-12: Generic tag queries - [ ] NIP-13: Proof of Work diff --git a/build_and_push.sh b/build_and_push.sh new file mode 100755 index 0000000..7f288c8 --- /dev/null +++ b/build_and_push.sh @@ -0,0 +1,389 @@ +#!/bin/bash +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +print_status() { echo -e "${BLUE}[INFO]${NC} $1"; } +print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } +print_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# Global variables +COMMIT_MESSAGE="" +RELEASE_MODE=false + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + -r|--release) + RELEASE_MODE=true + shift + ;; + -h|--help) + show_usage + exit 0 + ;; + *) + # First non-flag argument is the commit message + if [[ -z "$COMMIT_MESSAGE" ]]; then + COMMIT_MESSAGE="$1" + fi + shift + ;; + esac +done + +show_usage() { + echo "C-Relay Build and Push Script" + echo "" + echo "Usage:" + echo " $0 \"commit message\" - Default: compile, increment patch, commit & push" + echo " $0 -r \"commit message\" - Release: compile x86+arm64, increment minor, create release" + echo "" + echo "Examples:" + echo " $0 \"Fixed event validation bug\"" + echo " $0 --release \"Major release with new features\"" + echo "" + echo "Default Mode (patch increment):" + echo " - Compile C-Relay" + echo " - Increment patch version (v1.2.3 β†’ v1.2.4)" + echo " - Git add, commit with message, and push" + echo "" + echo "Release Mode (-r flag):" + echo " - Compile C-Relay for x86_64 and arm64" + echo " - Increment minor version, zero patch (v1.2.3 β†’ v1.3.0)" + echo " - Git add, commit, push, and create Gitea release" + echo "" + echo "Requirements for Release Mode:" + echo " - ARM64 cross-compiler: sudo apt install gcc-aarch64-linux-gnu" + echo " - Gitea token in ~/.gitea_token for release uploads" +} + +# Validate inputs +if [[ -z "$COMMIT_MESSAGE" ]]; then + print_error "Commit message is required" + echo "" + show_usage + exit 1 +fi + +# Check if we're in a git repository +check_git_repo() { + if ! git rev-parse --git-dir > /dev/null 2>&1; then + print_error "Not in a git repository" + exit 1 + fi +} + +# Function to get current version and increment appropriately +increment_version() { + local increment_type="$1" # "patch" or "minor" + + print_status "Getting current version..." + + # Get the highest version tag (not chronologically latest) + LATEST_TAG=$(git tag -l 'v*.*.*' | sort -V | tail -n 1 || echo "") + if [[ -z "$LATEST_TAG" ]]; then + LATEST_TAG="v0.0.0" + print_warning "No version tags found, starting from $LATEST_TAG" + fi + + # Extract version components (remove 'v' prefix) + VERSION=${LATEST_TAG#v} + + # Parse major.minor.patch using regex + if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + MAJOR=${BASH_REMATCH[1]} + MINOR=${BASH_REMATCH[2]} + PATCH=${BASH_REMATCH[3]} + else + print_error "Invalid version format in tag: $LATEST_TAG" + print_error "Expected format: v0.1.0" + exit 1 + fi + + # Increment version based on type + if [[ "$increment_type" == "minor" ]]; then + # Minor release: increment minor, zero patch + NEW_MINOR=$((MINOR + 1)) + NEW_PATCH=0 + NEW_VERSION="v${MAJOR}.${NEW_MINOR}.${NEW_PATCH}" + print_status "Release mode: incrementing minor version" + else + # Default: increment patch + NEW_PATCH=$((PATCH + 1)) + NEW_VERSION="v${MAJOR}.${MINOR}.${NEW_PATCH}" + print_status "Default mode: incrementing patch version" + fi + + print_status "Current version: $LATEST_TAG" + print_status "New version: $NEW_VERSION" + + # Export for use in other functions + export NEW_VERSION +} + +# Function to compile the C-Relay project +compile_project() { + print_status "Compiling C-Relay..." + + # Clean previous build + if make clean > /dev/null 2>&1; then + print_success "Cleaned previous build" + else + print_warning "Clean failed or no Makefile found" + fi + + # Compile the project + if make > /dev/null 2>&1; then + print_success "C-Relay compiled successfully" + else + print_error "Compilation failed" + exit 1 + fi +} + +# Check for ARM64 cross-compiler +check_cross_compiler() { + if ! command -v aarch64-linux-gnu-gcc > /dev/null 2>&1; then + print_error "ARM64/AArch64 cross-compiler not found!" + print_error "Install with: sudo apt install gcc-aarch64-linux-gnu" + return 1 + fi + return 0 +} + +# Function to build release binaries +build_release_binaries() { + print_status "Building release binaries..." + + # Build x86_64 version + print_status "Building x86_64 version..." + make clean > /dev/null 2>&1 + if make CC=gcc > /dev/null 2>&1; then + if [[ -f "src/main" ]]; then + cp src/main c-relay-x86_64 + print_success "x86_64 binary created: c-relay-x86_64" + else + print_error "x86_64 binary not found after compilation" + exit 1 + fi + else + print_error "x86_64 build failed" + exit 1 + fi + + # Check for ARM64 cross-compiler + if check_cross_compiler; then + # Build ARM64 version + print_status "Building ARM64 version..." + make clean > /dev/null 2>&1 + if make CC=aarch64-linux-gnu-gcc > /dev/null 2>&1; then + if [[ -f "src/main" ]]; then + cp src/main c-relay-arm64 + print_success "ARM64 binary created: c-relay-arm64" + else + print_error "ARM64 binary not found after compilation" + exit 1 + fi + else + print_error "ARM64 build failed" + exit 1 + fi + else + print_warning "ARM64 cross-compiler not available, skipping ARM64 build" + fi + + # Restore normal build + make clean > /dev/null 2>&1 + make > /dev/null 2>&1 +} + +# Function to commit and push changes +git_commit_and_push() { + print_status "Preparing git commit..." + + # Stage all changes + if git add . > /dev/null 2>&1; then + print_success "Staged all changes" + else + print_error "Failed to stage changes" + exit 1 + fi + + # Check if there are changes to commit + if git diff --staged --quiet; then + print_warning "No changes to commit" + else + # Commit changes + if git commit -m "$NEW_VERSION - $COMMIT_MESSAGE" > /dev/null 2>&1; then + print_success "Committed changes" + else + print_error "Failed to commit changes" + exit 1 + fi + fi + + # Create new git tag + if git tag "$NEW_VERSION" > /dev/null 2>&1; then + print_success "Created tag: $NEW_VERSION" + else + print_warning "Tag $NEW_VERSION already exists" + fi + + # Push changes and tags + print_status "Pushing to remote repository..." + if git push > /dev/null 2>&1; then + print_success "Pushed changes" + else + print_error "Failed to push changes" + exit 1 + fi + + if git push --tags > /dev/null 2>&1; then + print_success "Pushed tags" + else + print_warning "Failed to push tags" + fi +} + +# Function to create Gitea release +create_gitea_release() { + print_status "Creating Gitea release..." + + # Check for Gitea token + if [[ ! -f "$HOME/.gitea_token" ]]; then + print_warning "No ~/.gitea_token found. Skipping release creation." + print_warning "Create ~/.gitea_token with your Gitea access token to enable releases." + return 0 + fi + + local token=$(cat "$HOME/.gitea_token" | tr -d '\n\r') + local api_url="https://git.laantungir.net/api/v1/repos/teknari/c-relay" + + # Create release + print_status "Creating release $NEW_VERSION..." + local response=$(curl -s -X POST "$api_url/releases" \ + -H "Authorization: token $token" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"$NEW_VERSION\", \"name\": \"$NEW_VERSION\", \"body\": \"$COMMIT_MESSAGE\"}") + + if echo "$response" | grep -q '"id"'; then + print_success "Created release $NEW_VERSION" + + # Upload binaries + upload_release_binaries "$api_url" "$token" + else + print_warning "Release may already exist or creation failed" + print_status "Attempting to upload to existing release..." + upload_release_binaries "$api_url" "$token" + fi +} + +# Function to upload release binaries +upload_release_binaries() { + local api_url="$1" + local token="$2" + + # Get release ID + local release_id=$(curl -s -H "Authorization: token $token" \ + "$api_url/releases/tags/$NEW_VERSION" | \ + grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2) + + if [[ -z "$release_id" ]]; then + print_error "Could not get release ID for $NEW_VERSION" + return 1 + fi + + # Upload x86_64 binary + if [[ -f "c-relay-x86_64" ]]; then + print_status "Uploading x86_64 binary..." + if curl -s -X POST "$api_url/releases/$release_id/assets" \ + -H "Authorization: token $token" \ + -F "attachment=@c-relay-x86_64;filename=c-relay-${NEW_VERSION}-linux-x86_64" > /dev/null; then + print_success "Uploaded x86_64 binary" + else + print_warning "Failed to upload x86_64 binary" + fi + fi + + # Upload ARM64 binary + if [[ -f "c-relay-arm64" ]]; then + print_status "Uploading ARM64 binary..." + if curl -s -X POST "$api_url/releases/$release_id/assets" \ + -H "Authorization: token $token" \ + -F "attachment=@c-relay-arm64;filename=c-relay-${NEW_VERSION}-linux-arm64" > /dev/null; then + print_success "Uploaded ARM64 binary" + else + print_warning "Failed to upload ARM64 binary" + fi + fi +} + +# Function to clean up release binaries +cleanup_release_binaries() { + if [[ -f "c-relay-x86_64" ]]; then + rm -f c-relay-x86_64 + print_status "Cleaned up x86_64 binary" + fi + if [[ -f "c-relay-arm64" ]]; then + rm -f c-relay-arm64 + print_status "Cleaned up ARM64 binary" + fi +} + +# Main execution +main() { + print_status "C-Relay Build and Push Script" + + # Check prerequisites + check_git_repo + + if [[ "$RELEASE_MODE" == true ]]; then + print_status "=== RELEASE MODE ===" + + # Increment minor version for releases + increment_version "minor" + + # Compile project first + compile_project + + # Build release binaries + build_release_binaries + + # Commit and push + git_commit_and_push + + # Create Gitea release with binaries + create_gitea_release + + # Cleanup + cleanup_release_binaries + + print_success "Release $NEW_VERSION completed successfully!" + print_status "Binaries uploaded to Gitea release" + + else + print_status "=== DEFAULT MODE ===" + + # Increment patch version for regular commits + increment_version "patch" + + # Compile project + compile_project + + # Commit and push + git_commit_and_push + + print_success "Build and push completed successfully!" + print_status "Version $NEW_VERSION pushed to repository" + fi +} + +# Execute main function +main diff --git a/db/c_nostr_relay.db-shm b/db/c_nostr_relay.db-shm index 0544074..579e5b3 100644 Binary files a/db/c_nostr_relay.db-shm and b/db/c_nostr_relay.db-shm differ diff --git a/db/c_nostr_relay.db-wal b/db/c_nostr_relay.db-wal index fcf3f6a..e4c8bbe 100644 Binary files a/db/c_nostr_relay.db-wal and b/db/c_nostr_relay.db-wal differ diff --git a/relay.log b/relay.log index 1bbce89..013b08b 100644 --- a/relay.log +++ b/relay.log @@ -3,3 +3,129 @@ [INFO] Starting relay server... [INFO] Starting libwebsockets-based Nostr relay server... [SUCCESS] WebSocket relay started on ws://127.0.0.1:8888 +[INFO] WebSocket connection established +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] WebSocket connection closed +[INFO] WebSocket connection established +[INFO] Received WebSocket message +[INFO] Handling EVENT message with full NIP-01 validation +[SUCCESS] Event stored in database +[SUCCESS] Event validated and stored successfully +[INFO] WebSocket connection closed +[INFO] WebSocket connection established +[INFO] Received WebSocket message +[INFO] Handling EVENT message with full NIP-01 validation +[SUCCESS] Event stored in database +[SUCCESS] Event validated and stored successfully +[INFO] WebSocket connection closed +[INFO] WebSocket connection established +[INFO] Received WebSocket message +[INFO] Handling EVENT message with full NIP-01 validation +[SUCCESS] Event stored in database +[SUCCESS] Event validated and stored successfully +[INFO] WebSocket connection closed +[INFO] WebSocket connection established +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[WARNING] Subscription 'exists_1757082297' not found for removal +[INFO] Closed subscription: exists_1757082297 +[INFO] WebSocket connection closed +[INFO] WebSocket connection established +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[WARNING] Subscription 'exists_1757082298' not found for removal +[INFO] Closed subscription: exists_1757082298 +[INFO] WebSocket connection closed +[INFO] WebSocket connection established +[INFO] Received WebSocket message +[INFO] Handling EVENT message with full NIP-01 validation +[INFO] Event not found for deletion: [INFO] ... +[INFO] Event not found for deletion: [INFO] ... +[SUCCESS] Event stored in database +[INFO] Deletion request processed: 0 events deleted +[INFO] WebSocket connection closed +[INFO] WebSocket connection established +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[WARNING] Subscription 'exists_1757082301' not found for removal +[INFO] Closed subscription: exists_1757082301 +[INFO] WebSocket connection closed +[INFO] WebSocket connection established +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[WARNING] Subscription 'exists_1757082301' not found for removal +[INFO] Closed subscription: exists_1757082301 +[INFO] WebSocket connection closed +[INFO] WebSocket connection established +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[WARNING] Subscription 'exists_1757082301' not found for removal +[INFO] Closed subscription: exists_1757082301 +[INFO] WebSocket connection closed +[INFO] WebSocket connection established +[INFO] Received WebSocket message +[INFO] Handling EVENT message with full NIP-01 validation +[SUCCESS] Event stored in database +[INFO] Deletion request processed: 0 events deleted +[INFO] WebSocket connection closed +[INFO] WebSocket connection established +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[INFO] Received WebSocket message +[WARNING] Subscription 'exists_1757082305' not found for removal +[INFO] Closed subscription: exists_1757082305 +[INFO] WebSocket connection closed +[INFO] WebSocket connection established +[INFO] Received WebSocket message +[INFO] Handling EVENT message with full NIP-01 validation +[INFO] Event not found for deletion: βœ— Cou... +[SUCCESS] Event stored in database +[INFO] Deletion request processed: 0 events deleted +[INFO] WebSocket connection closed +[INFO] WebSocket connection established +[INFO] Received WebSocket message +[INFO] Handling REQ message for persistent subscription +[INFO] Added subscription 'exists_1757082309' (total: 1) +[INFO] Executing SQL: SELECT id, pubkey, created_at, kind, content, sig, tags FROM events WHERE 1=1 ORDER BY created_at DESC LIMIT 500 +[INFO] Query returned 25 rows +[INFO] Total events sent: 25 +[INFO] Received WebSocket message +[INFO] Removed subscription 'exists_1757082309' (total: 0) +[INFO] Closed subscription: exists_1757082309 +[INFO] WebSocket connection closed +[WARNING] Subscription 'z[μή.Y' not found for removal +[INFO] WebSocket connection established +[INFO] Received WebSocket message +[INFO] Handling EVENT message with full NIP-01 validation +[INFO] WebSocket connection closed +[INFO] WebSocket connection established +[INFO] Received WebSocket message +[INFO] Handling REQ message for persistent subscription +[INFO] Added subscription 'kind5_1757082309' (total: 1) +[INFO] Executing SQL: SELECT id, pubkey, created_at, kind, content, sig, tags FROM events WHERE 1=1 AND kind IN (5) ORDER BY created_at DESC LIMIT 500 +[INFO] Query returned 3 rows +[INFO] Total events sent: 3 +[INFO] Received WebSocket message +[INFO] Removed subscription 'kind5_1757082309' (total: 0) +[INFO] Closed subscription: kind5_1757082309 +[INFO] WebSocket connection closed +[WARNING] Subscription 'ωfμή.Y' not found for removal diff --git a/relay.pid b/relay.pid index 2f55dcf..36c0aa0 100644 --- a/relay.pid +++ b/relay.pid @@ -1 +1 @@ -677168 +682319 diff --git a/src/main b/src/main index 179a61d..2ec9ed3 100755 Binary files a/src/main and b/src/main differ diff --git a/src/main.c b/src/main.c index dc48275..01af6de 100644 --- a/src/main.c +++ b/src/main.c @@ -133,6 +133,24 @@ void log_subscription_disconnected(const char* client_ip); void log_event_broadcast(const char* event_id, const char* sub_id, const char* client_ip); void update_subscription_events_sent(const char* sub_id, int events_sent); +// Forward declarations for NIP-01 event handling +const char* extract_d_tag_value(cJSON* tags); +int check_and_handle_replaceable_event(int kind, const char* pubkey, long created_at); +int check_and_handle_addressable_event(int kind, const char* pubkey, const char* d_tag_value, long created_at); +int handle_event_message(cJSON* event, char* error_message, size_t error_size); + +// Forward declaration for NOTICE message support +void send_notice_message(struct lws* wsi, const char* message); + +// Forward declarations for NIP-09 deletion request handling +int handle_deletion_request(cJSON* event, char* error_message, size_t error_size); +int delete_events_by_id(const char* requester_pubkey, cJSON* event_ids); +int delete_events_by_address(const char* requester_pubkey, cJSON* addresses, long deletion_timestamp); +int mark_event_as_deleted(const char* event_id, const char* deletion_event_id, const char* reason); + +// Forward declaration for database functions +int store_event(cJSON* event); + ///////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////// @@ -842,6 +860,322 @@ void signal_handler(int sig) { } } +///////////////////////////////////////////////////////////////////////////////////////// +///////////////////////////////////////////////////////////////////////////////////////// +// NOTICE MESSAGE SUPPORT +///////////////////////////////////////////////////////////////////////////////////////// +///////////////////////////////////////////////////////////////////////////////////////// + +// Send NOTICE message to client (NIP-01) +void send_notice_message(struct lws* wsi, const char* message) { + if (!wsi || !message) return; + + cJSON* notice_msg = cJSON_CreateArray(); + cJSON_AddItemToArray(notice_msg, cJSON_CreateString("NOTICE")); + cJSON_AddItemToArray(notice_msg, cJSON_CreateString(message)); + + char* msg_str = cJSON_Print(notice_msg); + if (msg_str) { + size_t msg_len = strlen(msg_str); + unsigned char* buf = malloc(LWS_PRE + msg_len); + if (buf) { + memcpy(buf + LWS_PRE, msg_str, msg_len); + lws_write(wsi, buf + LWS_PRE, msg_len, LWS_WRITE_TEXT); + free(buf); + } + free(msg_str); + } + + cJSON_Delete(notice_msg); +} + +///////////////////////////////////////////////////////////////////////////////////////// +///////////////////////////////////////////////////////////////////////////////////////// +// NIP-09 EVENT DELETION REQUEST HANDLING +///////////////////////////////////////////////////////////////////////////////////////// +///////////////////////////////////////////////////////////////////////////////////////// + +// Handle NIP-09 deletion request event (kind 5) +int handle_deletion_request(cJSON* event, char* error_message, size_t error_size) { + if (!event) { + snprintf(error_message, error_size, "invalid: null deletion request"); + return -1; + } + + // Extract event details + cJSON* kind_obj = cJSON_GetObjectItem(event, "kind"); + cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey"); + cJSON* created_at_obj = cJSON_GetObjectItem(event, "created_at"); + cJSON* tags_obj = cJSON_GetObjectItem(event, "tags"); + cJSON* content_obj = cJSON_GetObjectItem(event, "content"); + cJSON* event_id_obj = cJSON_GetObjectItem(event, "id"); + + if (!kind_obj || !pubkey_obj || !created_at_obj || !tags_obj || !event_id_obj) { + snprintf(error_message, error_size, "invalid: incomplete deletion request"); + return -1; + } + + int kind = (int)cJSON_GetNumberValue(kind_obj); + if (kind != 5) { + snprintf(error_message, error_size, "invalid: not a deletion request"); + return -1; + } + + const char* requester_pubkey = cJSON_GetStringValue(pubkey_obj); + const char* deletion_event_id = cJSON_GetStringValue(event_id_obj); + const char* reason = content_obj ? cJSON_GetStringValue(content_obj) : ""; + long deletion_timestamp = (long)cJSON_GetNumberValue(created_at_obj); + + if (!cJSON_IsArray(tags_obj)) { + snprintf(error_message, error_size, "invalid: deletion request tags must be an array"); + return -1; + } + + // Collect event IDs and addresses from tags + cJSON* event_ids = cJSON_CreateArray(); + cJSON* addresses = cJSON_CreateArray(); + cJSON* kinds_to_delete = cJSON_CreateArray(); + + int deletion_targets_found = 0; + + cJSON* tag = NULL; + cJSON_ArrayForEach(tag, tags_obj) { + if (!cJSON_IsArray(tag) || cJSON_GetArraySize(tag) < 2) { + continue; + } + + cJSON* tag_name = cJSON_GetArrayItem(tag, 0); + cJSON* tag_value = cJSON_GetArrayItem(tag, 1); + + if (!cJSON_IsString(tag_name) || !cJSON_IsString(tag_value)) { + continue; + } + + const char* name = cJSON_GetStringValue(tag_name); + const char* value = cJSON_GetStringValue(tag_value); + + if (strcmp(name, "e") == 0) { + // Event ID reference + cJSON_AddItemToArray(event_ids, cJSON_CreateString(value)); + deletion_targets_found++; + } else if (strcmp(name, "a") == 0) { + // Addressable event reference (kind:pubkey:d-identifier) + cJSON_AddItemToArray(addresses, cJSON_CreateString(value)); + deletion_targets_found++; + } else if (strcmp(name, "k") == 0) { + // Kind hint - store for validation but not required + int kind_hint = atoi(value); + if (kind_hint > 0) { + cJSON_AddItemToArray(kinds_to_delete, cJSON_CreateNumber(kind_hint)); + } + } + } + + if (deletion_targets_found == 0) { + cJSON_Delete(event_ids); + cJSON_Delete(addresses); + cJSON_Delete(kinds_to_delete); + snprintf(error_message, error_size, "invalid: deletion request must contain 'e' or 'a' tags"); + return -1; + } + + int deleted_count = 0; + + // Process event ID deletions + if (cJSON_GetArraySize(event_ids) > 0) { + int result = delete_events_by_id(requester_pubkey, event_ids); + if (result > 0) { + deleted_count += result; + } + } + + // Process addressable event deletions + if (cJSON_GetArraySize(addresses) > 0) { + int result = delete_events_by_address(requester_pubkey, addresses, deletion_timestamp); + if (result > 0) { + deleted_count += result; + } + } + + // Clean up + cJSON_Delete(event_ids); + cJSON_Delete(addresses); + cJSON_Delete(kinds_to_delete); + + // Store the deletion request itself (it should be kept according to NIP-09) + if (store_event(event) != 0) { + log_warning("Failed to store deletion request event"); + } + + char debug_msg[256]; + snprintf(debug_msg, sizeof(debug_msg), "Deletion request processed: %d events deleted", deleted_count); + log_info(debug_msg); + + snprintf(error_message, error_size, ""); // Success + return 0; +} + +// Delete events by ID (with pubkey authorization) +int delete_events_by_id(const char* requester_pubkey, cJSON* event_ids) { + if (!g_db || !requester_pubkey || !event_ids || !cJSON_IsArray(event_ids)) { + return 0; + } + + int deleted_count = 0; + + cJSON* event_id = NULL; + cJSON_ArrayForEach(event_id, event_ids) { + if (!cJSON_IsString(event_id)) { + continue; + } + + const char* id = cJSON_GetStringValue(event_id); + + // First check if event exists and if requester is authorized + const char* check_sql = "SELECT pubkey FROM events WHERE id = ?"; + sqlite3_stmt* check_stmt; + + int rc = sqlite3_prepare_v2(g_db, check_sql, -1, &check_stmt, NULL); + if (rc != SQLITE_OK) { + continue; + } + + sqlite3_bind_text(check_stmt, 1, id, -1, SQLITE_STATIC); + + if (sqlite3_step(check_stmt) == SQLITE_ROW) { + const char* event_pubkey = (char*)sqlite3_column_text(check_stmt, 0); + + // Only delete if the requester is the author + if (event_pubkey && strcmp(event_pubkey, requester_pubkey) == 0) { + sqlite3_finalize(check_stmt); + + // Delete the event + const char* delete_sql = "DELETE FROM events WHERE id = ? AND pubkey = ?"; + sqlite3_stmt* delete_stmt; + + rc = sqlite3_prepare_v2(g_db, delete_sql, -1, &delete_stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_text(delete_stmt, 1, id, -1, SQLITE_STATIC); + sqlite3_bind_text(delete_stmt, 2, requester_pubkey, -1, SQLITE_STATIC); + + if (sqlite3_step(delete_stmt) == SQLITE_DONE && sqlite3_changes(g_db) > 0) { + deleted_count++; + + char debug_msg[128]; + snprintf(debug_msg, sizeof(debug_msg), "Deleted event by ID: %.16s...", id); + log_info(debug_msg); + } + sqlite3_finalize(delete_stmt); + } + } else { + sqlite3_finalize(check_stmt); + char warning_msg[128]; + snprintf(warning_msg, sizeof(warning_msg), "Unauthorized deletion attempt for event: %.16s...", id); + log_warning(warning_msg); + } + } else { + sqlite3_finalize(check_stmt); + char debug_msg[128]; + snprintf(debug_msg, sizeof(debug_msg), "Event not found for deletion: %.16s...", id); + log_info(debug_msg); + } + } + + return deleted_count; +} + +// Delete events by addressable reference (kind:pubkey:d-identifier) +int delete_events_by_address(const char* requester_pubkey, cJSON* addresses, long deletion_timestamp) { + if (!g_db || !requester_pubkey || !addresses || !cJSON_IsArray(addresses)) { + return 0; + } + + int deleted_count = 0; + + cJSON* address = NULL; + cJSON_ArrayForEach(address, addresses) { + if (!cJSON_IsString(address)) { + continue; + } + + const char* addr = cJSON_GetStringValue(address); + + // Parse address format: kind:pubkey:d-identifier + char* addr_copy = strdup(addr); + if (!addr_copy) continue; + + char* kind_str = strtok(addr_copy, ":"); + char* pubkey_str = strtok(NULL, ":"); + char* d_identifier = strtok(NULL, ":"); + + if (!kind_str || !pubkey_str) { + free(addr_copy); + continue; + } + + int kind = atoi(kind_str); + + // Only delete if the requester is the author + if (strcmp(pubkey_str, requester_pubkey) != 0) { + free(addr_copy); + char warning_msg[128]; + snprintf(warning_msg, sizeof(warning_msg), "Unauthorized deletion attempt for address: %.32s...", addr); + log_warning(warning_msg); + continue; + } + + // Build deletion query based on whether we have d-identifier + const char* delete_sql; + sqlite3_stmt* delete_stmt; + + if (d_identifier && strlen(d_identifier) > 0) { + // Delete specific addressable event with d-tag + delete_sql = "DELETE FROM events WHERE kind = ? AND pubkey = ? AND created_at <= ? " + "AND json_extract(tags, '$[*]') LIKE '%[\"d\",\"' || ? || '\"]%'"; + } else { + // Delete all events of this kind by this author up to deletion timestamp + delete_sql = "DELETE FROM events WHERE kind = ? AND pubkey = ? AND created_at <= ?"; + } + + int rc = sqlite3_prepare_v2(g_db, delete_sql, -1, &delete_stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_int(delete_stmt, 1, kind); + sqlite3_bind_text(delete_stmt, 2, requester_pubkey, -1, SQLITE_STATIC); + sqlite3_bind_int64(delete_stmt, 3, deletion_timestamp); + + if (d_identifier && strlen(d_identifier) > 0) { + sqlite3_bind_text(delete_stmt, 4, d_identifier, -1, SQLITE_STATIC); + } + + if (sqlite3_step(delete_stmt) == SQLITE_DONE) { + int changes = sqlite3_changes(g_db); + if (changes > 0) { + deleted_count += changes; + + char debug_msg[128]; + snprintf(debug_msg, sizeof(debug_msg), "Deleted %d events by address: %.32s...", changes, addr); + log_info(debug_msg); + } + } + sqlite3_finalize(delete_stmt); + } + + free(addr_copy); + } + + return deleted_count; +} + +// Mark event as deleted (alternative to hard deletion - not used in current implementation) +int mark_event_as_deleted(const char* event_id, const char* deletion_event_id, const char* reason) { + (void)event_id; (void)deletion_event_id; (void)reason; // Suppress unused warnings + + // This function could be used if we wanted to implement soft deletion + // For now, NIP-09 implementation uses hard deletion as specified + + return 0; +} + ///////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////// // DATABASE FUNCTIONS @@ -907,6 +1241,118 @@ const char* event_type_to_string(event_type_t type) { } } +// Helper function to extract d tag value from tags array +const char* extract_d_tag_value(cJSON* tags) { + if (!tags || !cJSON_IsArray(tags)) { + return NULL; + } + + 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); + if (name && strcmp(name, "d") == 0) { + return cJSON_GetStringValue(tag_value); + } + } + } + } + + return NULL; +} + +// Check and handle replaceable events according to NIP-01 +int check_and_handle_replaceable_event(int kind, const char* pubkey, long created_at) { + if (!g_db || !pubkey) return 0; + + const char* sql = + "SELECT created_at FROM events WHERE kind = ? AND pubkey = ? ORDER BY created_at DESC LIMIT 1"; + + sqlite3_stmt* stmt; + int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) { + return 0; // Allow storage on DB error + } + + sqlite3_bind_int(stmt, 1, kind); + sqlite3_bind_text(stmt, 2, pubkey, -1, SQLITE_STATIC); + + int result = 0; + if (sqlite3_step(stmt) == SQLITE_ROW) { + long existing_created_at = sqlite3_column_int64(stmt, 0); + if (created_at <= existing_created_at) { + result = -1; // Older or same timestamp, reject + } else { + // Delete older versions + const char* delete_sql = "DELETE FROM events WHERE kind = ? AND pubkey = ? AND created_at < ?"; + sqlite3_stmt* delete_stmt; + if (sqlite3_prepare_v2(g_db, delete_sql, -1, &delete_stmt, NULL) == SQLITE_OK) { + sqlite3_bind_int(delete_stmt, 1, kind); + sqlite3_bind_text(delete_stmt, 2, pubkey, -1, SQLITE_STATIC); + sqlite3_bind_int64(delete_stmt, 3, created_at); + sqlite3_step(delete_stmt); + sqlite3_finalize(delete_stmt); + } + } + } + + sqlite3_finalize(stmt); + return result; +} + +// Check and handle addressable events according to NIP-01 +int check_and_handle_addressable_event(int kind, const char* pubkey, const char* d_tag_value, long created_at) { + if (!g_db || !pubkey) return 0; + + // If no d tag, treat as regular replaceable + if (!d_tag_value) { + return check_and_handle_replaceable_event(kind, pubkey, created_at); + } + + const char* sql = + "SELECT created_at FROM events WHERE kind = ? AND pubkey = ? AND json_extract(tags, '$[*][1]') = ? " + "AND json_extract(tags, '$[*][0]') = 'd' ORDER BY created_at DESC LIMIT 1"; + + sqlite3_stmt* stmt; + int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) { + return 0; // Allow storage on DB error + } + + sqlite3_bind_int(stmt, 1, kind); + sqlite3_bind_text(stmt, 2, pubkey, -1, SQLITE_STATIC); + sqlite3_bind_text(stmt, 3, d_tag_value, -1, SQLITE_STATIC); + + int result = 0; + if (sqlite3_step(stmt) == SQLITE_ROW) { + long existing_created_at = sqlite3_column_int64(stmt, 0); + if (created_at <= existing_created_at) { + result = -1; // Older or same timestamp, reject + } else { + // Delete older versions with same kind, pubkey, and d tag + const char* delete_sql = + "DELETE FROM events WHERE kind = ? AND pubkey = ? AND created_at < ? " + "AND json_extract(tags, '$[*][1]') = ? AND json_extract(tags, '$[*][0]') = 'd'"; + sqlite3_stmt* delete_stmt; + if (sqlite3_prepare_v2(g_db, delete_sql, -1, &delete_stmt, NULL) == SQLITE_OK) { + sqlite3_bind_int(delete_stmt, 1, kind); + sqlite3_bind_text(delete_stmt, 2, pubkey, -1, SQLITE_STATIC); + sqlite3_bind_int64(delete_stmt, 3, created_at); + sqlite3_bind_text(delete_stmt, 4, d_tag_value, -1, SQLITE_STATIC); + sqlite3_step(delete_stmt); + sqlite3_finalize(delete_stmt); + } + } + } + + sqlite3_finalize(stmt); + return result; +} + // Store event in database int store_event(cJSON* event) { if (!g_db || !event) { @@ -1303,22 +1749,113 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru } // Handle EVENT message (publish) -int handle_event_message(cJSON* event) { - log_info("Handling EVENT message"); +int handle_event_message(cJSON* event, char* error_message, size_t error_size) { + log_info("Handling EVENT message with full NIP-01 validation"); - // Validate event structure (basic check) - cJSON* id = cJSON_GetObjectItem(event, "id"); - if (!id || !cJSON_IsString(id)) { - log_error("Invalid event - no ID"); - return -1; + if (!event) { + snprintf(error_message, error_size, "invalid: null event"); + return NOSTR_ERROR_INVALID_INPUT; } - // Store event in database + // Step 1: Validate event structure + int structure_result = nostr_validate_event_structure(event); + if (structure_result != NOSTR_SUCCESS) { + switch (structure_result) { + case NOSTR_ERROR_EVENT_INVALID_STRUCTURE: + snprintf(error_message, error_size, "invalid: malformed event structure"); + break; + case NOSTR_ERROR_EVENT_INVALID_ID: + snprintf(error_message, error_size, "invalid: invalid event id format"); + break; + case NOSTR_ERROR_EVENT_INVALID_PUBKEY: + snprintf(error_message, error_size, "invalid: invalid pubkey format"); + break; + case NOSTR_ERROR_EVENT_INVALID_CREATED_AT: + snprintf(error_message, error_size, "invalid: invalid created_at timestamp"); + break; + case NOSTR_ERROR_EVENT_INVALID_KIND: + snprintf(error_message, error_size, "invalid: invalid event kind"); + break; + case NOSTR_ERROR_EVENT_INVALID_TAGS: + snprintf(error_message, error_size, "invalid: invalid tags format"); + break; + case NOSTR_ERROR_EVENT_INVALID_CONTENT: + snprintf(error_message, error_size, "invalid: invalid content"); + break; + default: + snprintf(error_message, error_size, "invalid: event structure validation failed"); + } + return structure_result; + } + + // Step 2: Verify event signature + int signature_result = nostr_verify_event_signature(event); + if (signature_result != NOSTR_SUCCESS) { + if (signature_result == NOSTR_ERROR_EVENT_INVALID_SIGNATURE) { + snprintf(error_message, error_size, "invalid: event signature verification failed"); + } else if (signature_result == NOSTR_ERROR_EVENT_INVALID_ID) { + snprintf(error_message, error_size, "invalid: event id does not match computed hash"); + } else { + snprintf(error_message, error_size, "invalid: cryptographic validation failed"); + } + return signature_result; + } + + // Step 3: 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 4: 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"); + + if (kind_obj && pubkey_obj && created_at_obj) { + int kind = (int)cJSON_GetNumberValue(kind_obj); + const char* pubkey = cJSON_GetStringValue(pubkey_obj); + long created_at = (long)cJSON_GetNumberValue(created_at_obj); + + // NIP-09: Handle deletion requests (kind 5) + if (kind == 5) { + return handle_deletion_request(event, error_message, error_size); + } + + // Handle replaceable events (NIP-01) + event_type_t event_type = classify_event_kind(kind); + if (event_type == EVENT_TYPE_REPLACEABLE) { + // For replaceable events, check if we have a newer version + if (check_and_handle_replaceable_event(kind, pubkey, created_at) < 0) { + snprintf(error_message, error_size, "duplicate: older replaceable event ignored"); + return -2; // Special code for duplicate/older event + } + } else if (event_type == EVENT_TYPE_ADDRESSABLE) { + // For addressable events, check d tag + cJSON* tags = cJSON_GetObjectItem(event, "tags"); + if (tags && cJSON_IsArray(tags)) { + const char* d_tag_value = extract_d_tag_value(tags); + if (check_and_handle_addressable_event(kind, pubkey, d_tag_value, created_at) < 0) { + snprintf(error_message, error_size, "duplicate: older addressable event ignored"); + return -2; + } + } + } else if (event_type == EVENT_TYPE_EPHEMERAL) { + // Ephemeral events should not be stored + snprintf(error_message, error_size, ""); // Success but no storage + return 0; // Accept but don't store + } + } + + // Step 5: Store event in database if (store_event(event) == 0) { - log_success("Event stored successfully"); + snprintf(error_message, error_size, ""); // Success + log_success("Event validated and stored successfully"); return 0; } + snprintf(error_message, error_size, "error: failed to store event in database"); return -1; } @@ -1366,7 +1903,8 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso // Handle EVENT message cJSON* event = cJSON_GetArrayItem(json, 1); if (event && cJSON_IsObject(event)) { - int result = handle_event_message(event); + char error_message[512] = {0}; + int result = handle_event_message(event, error_message, sizeof(error_message)); // Broadcast event to matching persistent subscriptions if (result == 0) { @@ -1380,7 +1918,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso cJSON_AddItemToArray(response, cJSON_CreateString("OK")); cJSON_AddItemToArray(response, cJSON_CreateString(cJSON_GetStringValue(event_id))); cJSON_AddItemToArray(response, cJSON_CreateBool(result == 0)); - cJSON_AddItemToArray(response, cJSON_CreateString(result == 0 ? "" : "error: failed to store event")); + cJSON_AddItemToArray(response, cJSON_CreateString(strlen(error_message) > 0 ? error_message : "")); char *response_str = cJSON_Print(response); if (response_str) { diff --git a/tests/1_nip_test.sh b/tests/1_nip_test.sh index 6677ca7..b6ca91b 100755 --- a/tests/1_nip_test.sh +++ b/tests/1_nip_test.sh @@ -99,6 +99,47 @@ publish_event() { fi } +# Helper function to publish invalid event and expect rejection +publish_invalid_event() { + local event_json="$1" + local description="$2" + local expected_error="$3" + + print_info "Publishing invalid $description..." + + # Create EVENT message in Nostr format + local event_message="[\"EVENT\",$event_json]" + + # Publish to relay + local response="" + if command -v websocat &> /dev/null; then + response=$(echo "$event_message" | timeout 5s websocat "$RELAY_URL" 2>&1 || echo "Connection failed") + else + print_error "websocat not found - required for testing" + return 1 + fi + + # Check response - should contain "false" and error message + if [[ "$response" == *"Connection failed"* ]]; then + print_error "Failed to connect to relay for $description" + return 1 + elif [[ "$response" == *"false"* ]]; then + # Extract error message + local error_msg=$(echo "$response" | grep -o '"[^"]*invalid[^"]*"' | head -1 | sed 's/"//g' 2>/dev/null || echo "rejected") + print_success "$description correctly rejected: $error_msg" + echo # Add blank line for readability + return 0 + elif [[ "$response" == *"true"* ]]; then + print_error "$description was incorrectly accepted (should have been rejected)" + echo # Add blank line for readability + return 1 + else + print_warning "$description response unclear: $response" + echo # Add blank line for readability + return 1 + fi +} + # Test subscription with filters test_subscription() { local sub_id="$1" @@ -211,7 +252,41 @@ run_comprehensive_test() { # Brief pause to let events settle sleep 2 - print_header "PHASE 2: Testing Subscriptions and Filters" + print_header "PHASE 2: Testing Invalid Events (NIP-01 Validation)" + + print_step "Testing various invalid events that should be rejected..." + + # Test 1: Event with invalid JSON structure (malformed) + local malformed_event='{"id":"invalid","pubkey":"invalid_pubkey","created_at":"not_a_number","kind":1,"tags":[],"content":"test"}' + publish_invalid_event "$malformed_event" "malformed event with invalid created_at" "invalid" + + # Test 2: Event with missing required fields + local missing_field_event='{"id":"test123","pubkey":"valid_pubkey","kind":1,"tags":[],"content":"test"}' + publish_invalid_event "$missing_field_event" "event missing created_at and sig" "invalid" + + # Test 3: Event with invalid pubkey format (not hex) + local invalid_pubkey_event='{"id":"abc123","pubkey":"not_valid_hex_pubkey","created_at":1234567890,"kind":1,"tags":[],"content":"test","sig":"fake_sig"}' + publish_invalid_event "$invalid_pubkey_event" "event with invalid pubkey format" "invalid" + + # Test 4: Event with invalid event ID format + local invalid_id_event='{"id":"not_64_char_hex","pubkey":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","created_at":1234567890,"kind":1,"tags":[],"content":"test","sig":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}' + publish_invalid_event "$invalid_id_event" "event with invalid ID format" "invalid" + + # Test 5: Event with invalid signature + local invalid_sig_event='{"id":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","pubkey":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","created_at":1234567890,"kind":1,"tags":[],"content":"test","sig":"invalid_signature_format"}' + publish_invalid_event "$invalid_sig_event" "event with invalid signature format" "invalid" + + # Test 6: Event with invalid kind (negative) + local invalid_kind_event='{"id":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","pubkey":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","created_at":1234567890,"kind":-1,"tags":[],"content":"test","sig":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}' + publish_invalid_event "$invalid_kind_event" "event with negative kind" "invalid" + + # Test 7: Event with invalid tags format (not array) + local invalid_tags_event='{"id":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","pubkey":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","created_at":1234567890,"kind":1,"tags":"not_an_array","content":"test","sig":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}' + publish_invalid_event "$invalid_tags_event" "event with invalid tags format" "invalid" + + print_success "Invalid event tests completed - all should have been rejected" + + print_header "PHASE 3: Testing Subscriptions and Filters" # Test subscription filters print_step "Testing various subscription filters..." @@ -240,7 +315,7 @@ run_comprehensive_test() { # Test 7: Limit results test_subscription "test_limit" '{"kinds":[1],"limit":1}' "Limited to 1 event" "1" - print_header "PHASE 3: Database Verification" + print_header "PHASE 4: Database Verification" # Check what's actually stored in the database print_step "Verifying database contents..." @@ -265,13 +340,14 @@ run_comprehensive_test() { } # Run the comprehensive test -print_header "Starting C-Relay Comprehensive Test Suite" +print_header "Starting C-Relay Comprehensive Test Suite with NIP-01 Validation" echo if run_comprehensive_test; then echo print_success "All tests completed successfully!" - print_info "The C-Relay hybrid schema implementation is working correctly" + print_info "The C-Relay with full NIP-01 validation is working correctly" + print_info "βœ… Event validation, signature verification, and error handling all working" echo exit 0 else diff --git a/tests/9_delete_test.sh b/tests/9_delete_test.sh new file mode 100755 index 0000000..bc2dc26 --- /dev/null +++ b/tests/9_delete_test.sh @@ -0,0 +1,386 @@ +#!/bin/bash + +# NIP-09 Event Deletion Request Test for C-Relay +# Tests deletion request functionality - assumes relay is already running +# Based on the pattern from 1_nip_test.sh + +set -e + +# 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" +TEST_PRIVATE_KEY="nsec1j4c6269y9w0q2er2xjw8sv2ehyrtfxq3jwgdlxj6qfn8z4gjsq5qfvfk99" + +# Print functions +print_header() { + echo -e "${BLUE}${BOLD}=== $1 ===${RESET}" +} + +print_step() { + echo -e "${YELLOW}[STEP]${RESET} $1" +} + +print_success() { + echo -e "${GREEN}βœ“${RESET} $1" +} + +print_error() { + echo -e "${RED}βœ—${RESET} $1" +} + +print_info() { + echo -e "${BLUE}[INFO]${RESET} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${RESET} $1" +} + +# Helper function to publish event and extract ID +publish_event() { + local event_json="$1" + local description="$2" + + # Extract event ID + local event_id=$(echo "$event_json" | jq -r '.id' 2>/dev/null) + if [[ "$event_id" == "null" || -z "$event_id" ]]; then + print_error "Could not extract event ID from $description" + return 1 + fi + + print_info "Publishing $description..." + + # Create EVENT message in Nostr format + local event_message="[\"EVENT\",$event_json]" + + # Publish to relay + local response="" + if command -v websocat &> /dev/null; then + response=$(echo "$event_message" | timeout 5s websocat "$RELAY_URL" 2>&1 || echo "Connection failed") + else + print_error "websocat not found - required for testing" + return 1 + fi + + # Check response + if [[ "$response" == *"Connection failed"* ]]; then + print_error "Failed to connect to relay for $description" + return 1 + elif [[ "$response" == *"true"* ]]; then + print_success "$description uploaded (ID: ${event_id:0:16}...)" + echo "$event_id" + return 0 + else + print_warning "$description might have failed: $response" + echo "" + return 1 + fi +} + +# Helper function to publish deletion request +publish_deletion_request() { + local deletion_event_json="$1" + local description="$2" + + # Extract event ID + local event_id=$(echo "$deletion_event_json" | jq -r '.id' 2>/dev/null) + if [[ "$event_id" == "null" || -z "$event_id" ]]; then + print_error "Could not extract event ID from $description" + return 1 + fi + + print_info "Publishing $description..." + + # Create EVENT message in Nostr format + local event_message="[\"EVENT\",$deletion_event_json]" + + # Publish to relay + local response="" + if command -v websocat &> /dev/null; then + response=$(echo "$event_message" | timeout 5s websocat "$RELAY_URL" 2>&1 || echo "Connection failed") + else + print_error "websocat not found - required for testing" + return 1 + fi + + # Check response + if [[ "$response" == *"Connection failed"* ]]; then + print_error "Failed to connect to relay for $description" + return 1 + elif [[ "$response" == *"true"* ]]; then + print_success "$description accepted (ID: ${event_id:0:16}...)" + echo "$event_id" + return 0 + else + print_warning "$description might have failed: $response" + echo "" + return 1 + fi +} + +# Helper function to check if event exists via subscription +check_event_exists() { + local event_id="$1" + local sub_id="exists_$(date +%s%N | cut -c1-10)" + + # Create REQ message to query for specific event ID + local req_message="[\"REQ\",\"$sub_id\",{\"ids\":[\"$event_id\"]}]" + + # Send subscription and collect events + local response="" + if command -v websocat &> /dev/null; then + response=$(echo -e "$req_message\n[\"CLOSE\",\"$sub_id\"]" | timeout 3s websocat "$RELAY_URL" 2>/dev/null || echo "") + fi + + # Count EVENT responses + local event_count=0 + if [[ -n "$response" ]]; then + event_count=$(echo "$response" | grep -c "\"EVENT\"" 2>/dev/null || echo "0") + fi + + echo "$event_count" +} + +# Helper function to query events by kind +query_events_by_kind() { + local kind="$1" + local sub_id="kind${kind}_$(date +%s%N | cut -c1-10)" + + # Create REQ message to query for events of specific kind + local req_message="[\"REQ\",\"$sub_id\",{\"kinds\":[$kind]}]" + + # Send subscription and collect events + local response="" + if command -v websocat &> /dev/null; then + response=$(echo -e "$req_message\n[\"CLOSE\",\"$sub_id\"]" | timeout 3s websocat "$RELAY_URL" 2>/dev/null || echo "") + fi + + # Count EVENT responses + local event_count=0 + if [[ -n "$response" ]]; then + event_count=$(echo "$response" | grep -c "\"EVENT\"" 2>/dev/null || echo "0") + fi + + echo "$event_count" +} + +# Main test function +run_deletion_test() { + print_header "NIP-09 Event Deletion Request Test" + + # Check dependencies + print_step "Checking dependencies..." + if ! command -v nak &> /dev/null; then + print_error "nak command not found" + print_info "Please install nak: go install github.com/fiatjaf/nak@latest" + return 1 + fi + if ! command -v websocat &> /dev/null; then + print_error "websocat command not found" + print_info "Please install websocat for testing" + return 1 + fi + if ! command -v jq &> /dev/null; then + print_error "jq command not found" + print_info "Please install jq for JSON processing" + return 1 + fi + print_success "All dependencies found" + + print_header "PHASE 1: Publishing Events to be Deleted" + + # Create test events that will be deleted + print_step "Creating events for deletion testing..." + + # Create regular events (kind 1) - these will be deleted by ID + local event1=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Event to be deleted #1" -k 1 --ts $(($(date +%s) - 100)) -t "type=test" -t "phase=deletion" 2>/dev/null) + local event2=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Event to be deleted #2" -k 1 --ts $(($(date +%s) - 90)) -t "type=test" -t "phase=deletion" 2>/dev/null) + + # Publish the events + event1_id=$(publish_event "$event1" "Event to be deleted #1") + if [[ -z "$event1_id" ]]; then + print_error "Failed to publish test event #1" + return 1 + fi + + event2_id=$(publish_event "$event2" "Event to be deleted #2") + if [[ -z "$event2_id" ]]; then + print_error "Failed to publish test event #2" + return 1 + fi + + # Create an addressable event (kind 30001) - will be deleted by address + local addr_event=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Addressable event to be deleted" -k 30001 --ts $(($(date +%s) - 80)) -t "d=test-delete" -t "type=addressable" 2>/dev/null) + + addr_event_id=$(publish_event "$addr_event" "Addressable event to be deleted") + if [[ -z "$addr_event_id" ]]; then + print_error "Failed to publish addressable test event" + return 1 + fi + + # Create an event by a different author (to test unauthorized deletion) + local different_key="nsec1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab" + local unauth_event=$(nak event --sec "$different_key" -c "Event by different author" -k 1 --ts $(($(date +%s) - 70)) -t "type=unauthorized" 2>/dev/null) + + unauth_event_id=$(publish_event "$unauth_event" "Event by different author") + if [[ -z "$unauth_event_id" ]]; then + print_warning "Failed to publish unauthorized test event - continuing anyway" + fi + + # Let events settle + sleep 2 + + print_header "PHASE 2: Testing Event Deletion by ID" + + print_step "Verifying events exist before deletion..." + local event1_before=$(check_event_exists "$event1_id") + local event2_before=$(check_event_exists "$event2_id") + print_info "Event1 exists: $event1_before, Event2 exists: $event2_before" + + # Create deletion request targeting the two events by ID + print_step "Creating deletion request for events by ID..." + local deletion_by_id=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Deleting events by ID" -k 5 --ts $(date +%s) -e "$event1_id" -e "$event2_id" -t "k=1" 2>/dev/null) + + deletion_id=$(publish_deletion_request "$deletion_by_id" "Deletion request for events by ID") + if [[ -z "$deletion_id" ]]; then + print_error "Failed to publish deletion request" + return 1 + fi + + # Wait for deletion to process + sleep 3 + + # Check if events were deleted + print_step "Verifying events were deleted..." + local event1_after=$(check_event_exists "$event1_id") + local event2_after=$(check_event_exists "$event2_id") + print_info "Event1 exists after deletion: $event1_after, Event2 exists after deletion: $event2_after" + + if [[ "$event1_after" == "0" && "$event2_after" == "0" ]]; then + print_success "βœ“ Events successfully deleted by ID" + else + print_error "βœ— Events were not properly deleted" + fi + + print_header "PHASE 3: Testing Address-based Deletion" + + if [[ -n "$addr_event_id" ]]; then + print_step "Verifying addressable event exists before deletion..." + local addr_before=$(check_event_exists "$addr_event_id") + print_info "Addressable event exists: $addr_before" + + # Create deletion request for addressable event using 'a' tag + print_step "Creating deletion request for addressable event..." + local test_pubkey=$(echo "$addr_event" | jq -r '.pubkey' 2>/dev/null) + local deletion_by_addr=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Deleting addressable event" -k 5 --ts $(date +%s) -t "a=30001:${test_pubkey}:test-delete" -t "k=30001" 2>/dev/null) + + addr_deletion_id=$(publish_deletion_request "$deletion_by_addr" "Deletion request for addressable event") + if [[ -n "$addr_deletion_id" ]]; then + # Wait for deletion to process + sleep 3 + + # Check if addressable event was deleted + print_step "Verifying addressable event was deleted..." + local addr_after=$(check_event_exists "$addr_event_id") + print_info "Addressable event exists after deletion: $addr_after" + + if [[ "$addr_after" == "0" ]]; then + print_success "βœ“ Addressable event successfully deleted" + else + print_error "βœ— Addressable event was not properly deleted" + fi + fi + fi + + print_header "PHASE 4: Testing Unauthorized Deletion" + + if [[ -n "$unauth_event_id" ]]; then + print_step "Testing unauthorized deletion attempt..." + + # Try to delete the unauthorized event (should fail) + local unauth_deletion=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Attempting unauthorized deletion" -k 5 --ts $(date +%s) -e "$unauth_event_id" -t "k=1" 2>/dev/null) + + unauth_deletion_id=$(publish_deletion_request "$unauth_deletion" "Unauthorized deletion request") + if [[ -n "$unauth_deletion_id" ]]; then + # Wait for processing + sleep 3 + + # Check if unauthorized event still exists (should still exist) + local unauth_after=$(check_event_exists "$unauth_event_id") + print_info "Unauthorized event exists after deletion attempt: $unauth_after" + + if [[ "$unauth_after" == "1" ]]; then + print_success "βœ“ Unauthorized deletion properly rejected - event still exists" + else + print_error "βœ— Unauthorized deletion succeeded - security vulnerability!" + fi + fi + fi + + print_header "PHASE 5: Testing Invalid Deletion Requests" + + print_step "Testing deletion request with no targets..." + + # Create deletion request with no 'e' or 'a' tags (should be rejected) + local invalid_deletion='{"id":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","pubkey":"aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4","created_at":'$(date +%s)',"kind":5,"tags":[["k","1"]],"content":"Invalid deletion request with no targets","sig":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}' + + # Create EVENT message in Nostr format + local invalid_message="[\"EVENT\",$invalid_deletion]" + + # Publish to relay + local invalid_response="" + if command -v websocat &> /dev/null; then + invalid_response=$(echo "$invalid_message" | timeout 5s websocat "$RELAY_URL" 2>&1 || echo "Connection failed") + fi + + # Check response - should be rejected + if [[ "$invalid_response" == *"false"* ]]; then + print_success "βœ“ Invalid deletion request properly rejected" + elif [[ "$invalid_response" == *"true"* ]]; then + print_warning "⚠ Invalid deletion request was accepted (should have been rejected)" + else + print_info "Invalid deletion request response: $invalid_response" + fi + + print_header "PHASE 6: Verification" + + # Verify deletion requests themselves are stored + print_step "Verifying deletion requests are stored..." + local deletion_count=$(query_events_by_kind 5) + print_info "Deletion requests accessible via query: $deletion_count" + + if [[ "$deletion_count" -gt 0 ]]; then + print_success "βœ“ Deletion requests properly stored and queryable" + else + print_warning "⚠ No deletion requests found via query" + fi + + return 0 +} + +# Run the test +print_header "Starting NIP-09 Event Deletion Request Test Suite" +echo + +if run_deletion_test; then + echo + print_success "All NIP-09 deletion tests completed successfully!" + print_info "The C-Relay NIP-09 implementation is working correctly" + print_info "βœ… Event deletion by ID working" + print_info "βœ… Address-based deletion working" + print_info "βœ… Authorization validation working" + print_info "βœ… Invalid deletion rejection working" + echo + exit 0 +else + echo + print_error "Some NIP-09 tests failed" + exit 1 +fi \ No newline at end of file