Compare commits

...

24 Commits

Author SHA1 Message Date
Your Name
cb3171b390 v1.0.7 - Added a clean startup to erase subscriptions. 2025-12-06 18:26:22 -04:00
Your Name
03f036d60d v1.0.6 - Working on cleaning up subscriptions which were piling up. Set a startup cleanup, and a connection age limit. 2025-12-05 07:37:57 -04:00
Your Name
9b35f463ae . 2025-11-21 11:52:56 -04:00
Your Name
7b74486519 v1.0.5 - Just catching up 2025-11-11 17:01:39 -04:00
Your Name
b276b44ded v1.0.4 - Fixed web socket limitation with the number of npubs in a subscription 2025-11-07 19:59:34 -05:00
laan tungir
3792649ed9 v1.0.3 - From remote 2025-11-07 14:07:46 -05:00
laan tungir
5f08956605 removed some files 2025-11-03 07:33:12 -05:00
Your Name
643d89ed7b v1.0.0 - First major release 2025-11-01 07:04:56 -04:00
Your Name
8ca459593c Reset version back to v0.8.6 2025-11-01 07:03:39 -04:00
Your Name
ee4208cc19 v7.0.0 - Version 1.0.0 2025-11-01 06:56:02 -04:00
Your Name
f6330e4bb8 v7.0.0 - Version 1.0.0 2025-10-31 11:17:34 -04:00
Your Name
4f3cf10a5c v6.0.0 - Test fixed output redirection 2025-10-31 11:17:19 -04:00
Your Name
aa1954e81e v6.0.0 - Version 1.0.0 2025-10-31 11:15:32 -04:00
Your Name
482597bd0e v5.0.0 - Test fixed release ID extraction 2025-10-31 11:15:16 -04:00
Your Name
d4b90e681c v5.0.0 - Version 1.0.0 2025-10-31 11:13:41 -04:00
Your Name
fcf9e43c4c v4.0.0 - Debug upload with existing binary 2025-10-31 11:13:21 -04:00
Your Name
b8d8cd19d3 v4.0.0 - Debug upload issue 2025-10-31 11:12:58 -04:00
Your Name
536c2d966c v4.0.0 - Version 1.0.0 2025-10-31 11:11:31 -04:00
Your Name
f49cb0a5ac v3.0.0 - Test release upload fix 2025-10-31 11:10:53 -04:00
Your Name
cef6bb2340 v3.0.0 - Version 1.0.0 2025-10-31 11:08:18 -04:00
Your Name
4c03253b30 v2.0.0 - Version 1.0.0 2025-10-31 11:06:27 -04:00
Your Name
ed09bb7370 v1.0.0 - Version 1.0.0) 2025-10-31 10:39:06 -04:00
Your Name
5c46a25173 v0.8.5 - cleanup 2025-10-30 07:06:48 -04:00
Your Name
d1538f00df v0.8.4 - Updated increment_and_push.sh 2025-10-30 07:03:22 -04:00
30 changed files with 3977 additions and 220 deletions

1
.gitignore vendored
View File

@@ -11,4 +11,3 @@ copy_executable_local.sh
nostr_login_lite/
style_guide/
nostr-tools

612
STATIC_MUSL_GUIDE.md Normal file
View File

@@ -0,0 +1,612 @@
# Static MUSL Build Guide for C Programs
## Overview
This guide explains how to build truly portable static binaries using Alpine Linux and MUSL libc. These binaries have **zero runtime dependencies** and work on any Linux distribution without modification.
This guide is specifically tailored for C programs that use:
- **nostr_core_lib** - Nostr protocol implementation
- **nostr_login_lite** - Nostr authentication library
- Common dependencies: libwebsockets, OpenSSL, SQLite, curl, secp256k1
## Why MUSL Static Binaries?
### Advantages Over glibc
| Feature | MUSL Static | glibc Static | glibc Dynamic |
|---------|-------------|--------------|---------------|
| **Portability** | ✓ Any Linux | ⚠ glibc only | ✗ Requires matching libs |
| **Binary Size** | ~7-10 MB | ~12-15 MB | ~2-3 MB |
| **Dependencies** | None | NSS libs | Many system libs |
| **Deployment** | Single file | Single file + NSS | Binary + libraries |
| **Compatibility** | Universal | glibc version issues | Library version hell |
### Key Benefits
1. **True Portability**: Works on Alpine, Ubuntu, Debian, CentOS, Arch, etc.
2. **No Library Hell**: No `GLIBC_2.XX not found` errors
3. **Simple Deployment**: Just copy one file
4. **Reproducible Builds**: Same Docker image = same binary
5. **Security**: No dependency on system libraries with vulnerabilities
## Quick Start
### Prerequisites
- Docker installed and running
- Your C project with source code
- Internet connection for downloading dependencies
### Basic Build Process
```bash
# 1. Copy the Dockerfile template (see below)
cp /path/to/c-relay/Dockerfile.alpine-musl ./Dockerfile.static
# 2. Customize for your project (see Customization section)
vim Dockerfile.static
# 3. Build the static binary
docker build --platform linux/amd64 -f Dockerfile.static -t my-app-builder .
# 4. Extract the binary
docker create --name temp-container my-app-builder
docker cp temp-container:/build/my_app_static ./my_app_static
docker rm temp-container
# 5. Verify it's static
ldd ./my_app_static # Should show "not a dynamic executable"
```
## Dockerfile Template
Here's a complete Dockerfile template you can customize for your project:
```dockerfile
# Alpine-based MUSL static binary builder
# Produces truly portable binaries with zero runtime dependencies
FROM alpine:3.19 AS builder
# Install build dependencies
RUN apk add --no-cache \
build-base \
musl-dev \
git \
cmake \
pkgconfig \
autoconf \
automake \
libtool \
openssl-dev \
openssl-libs-static \
zlib-dev \
zlib-static \
curl-dev \
curl-static \
sqlite-dev \
sqlite-static \
linux-headers \
wget \
bash
WORKDIR /build
# Build libsecp256k1 static (required for Nostr)
RUN cd /tmp && \
git clone https://github.com/bitcoin-core/secp256k1.git && \
cd secp256k1 && \
./autogen.sh && \
./configure --enable-static --disable-shared --prefix=/usr \
CFLAGS="-fPIC" && \
make -j$(nproc) && \
make install && \
rm -rf /tmp/secp256k1
# Build libwebsockets static (if needed for WebSocket support)
RUN cd /tmp && \
git clone --depth 1 --branch v4.3.3 https://github.com/warmcat/libwebsockets.git && \
cd libwebsockets && \
mkdir build && cd build && \
cmake .. \
-DLWS_WITH_STATIC=ON \
-DLWS_WITH_SHARED=OFF \
-DLWS_WITH_SSL=ON \
-DLWS_WITHOUT_TESTAPPS=ON \
-DLWS_WITHOUT_TEST_SERVER=ON \
-DLWS_WITHOUT_TEST_CLIENT=ON \
-DLWS_WITHOUT_TEST_PING=ON \
-DLWS_WITH_HTTP2=OFF \
-DLWS_WITH_LIBUV=OFF \
-DLWS_WITH_LIBEVENT=OFF \
-DLWS_IPV6=ON \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_C_FLAGS="-fPIC" && \
make -j$(nproc) && \
make install && \
rm -rf /tmp/libwebsockets
# Copy git configuration for submodules
COPY .gitmodules /build/.gitmodules
COPY .git /build/.git
# Initialize submodules
RUN git submodule update --init --recursive
# Copy and build nostr_core_lib
COPY nostr_core_lib /build/nostr_core_lib/
RUN cd nostr_core_lib && \
chmod +x build.sh && \
sed -i 's/CFLAGS="-Wall -Wextra -std=c99 -fPIC -O2"/CFLAGS="-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -fPIC -O2"/' build.sh && \
rm -f *.o *.a 2>/dev/null || true && \
./build.sh --nips=1,6,13,17,19,44,59
# Copy and build nostr_login_lite (if used)
# COPY nostr_login_lite /build/nostr_login_lite/
# RUN cd nostr_login_lite && make static
# Copy your application source
COPY src/ /build/src/
COPY Makefile /build/Makefile
# Build your application with full static linking
RUN gcc -static -O2 -Wall -Wextra -std=c99 \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
-I. -Inostr_core_lib -Inostr_core_lib/nostr_core \
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
src/*.c \
-o /build/my_app_static \
nostr_core_lib/libnostr_core_x64.a \
-lwebsockets -lssl -lcrypto -lsqlite3 -lsecp256k1 \
-lcurl -lz -lpthread -lm -ldl && \
strip /build/my_app_static
# Verify it's truly static
RUN echo "=== Binary Information ===" && \
file /build/my_app_static && \
ls -lh /build/my_app_static && \
echo "=== Checking for dynamic dependencies ===" && \
(ldd /build/my_app_static 2>&1 || echo "Binary is static")
# Output stage - just the binary
FROM scratch AS output
COPY --from=builder /build/my_app_static /my_app_static
```
## Customization Guide
### 1. Adjust Dependencies
**Add dependencies** by modifying the `apk add` section:
```dockerfile
RUN apk add --no-cache \
build-base \
musl-dev \
# Add your dependencies here:
libpng-dev \
libpng-static \
libjpeg-turbo-dev \
libjpeg-turbo-static
```
**Remove unused dependencies** to speed up builds:
- Remove `libwebsockets` section if you don't need WebSocket support
- Remove `sqlite` if you don't use databases
- Remove `curl` if you don't make HTTP requests
### 2. Configure nostr_core_lib NIPs
Specify which NIPs your application needs:
```bash
./build.sh --nips=1,6,19 # Minimal: Basic protocol, keys, bech32
./build.sh --nips=1,6,13,17,19,44,59 # Full: All common NIPs
./build.sh --nips=all # Everything available
```
**Common NIP combinations:**
- **Basic client**: `1,6,19` (events, keys, bech32)
- **With encryption**: `1,6,19,44` (add modern encryption)
- **With DMs**: `1,6,17,19,44,59` (add private messages)
- **Relay/server**: `1,6,13,17,19,42,44,59` (add PoW, auth)
### 3. Modify Compilation Flags
**For your application:**
```dockerfile
RUN gcc -static -O2 -Wall -Wextra -std=c99 \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \ # REQUIRED for MUSL
-I. -Inostr_core_lib \ # Include paths
src/*.c \ # Your source files
-o /build/my_app_static \ # Output binary
nostr_core_lib/libnostr_core_x64.a \ # Nostr library
-lwebsockets -lssl -lcrypto \ # Link libraries
-lsqlite3 -lsecp256k1 -lcurl \
-lz -lpthread -lm -ldl
```
**Debug build** (with symbols, no optimization):
```dockerfile
RUN gcc -static -g -O0 -DDEBUG \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
# ... rest of flags
```
### 4. Multi-Architecture Support
Build for different architectures:
```bash
# x86_64 (Intel/AMD)
docker build --platform linux/amd64 -f Dockerfile.static -t my-app-x86 .
# ARM64 (Apple Silicon, Raspberry Pi 4+)
docker build --platform linux/arm64 -f Dockerfile.static -t my-app-arm64 .
```
## Build Script Template
Create a `build_static.sh` script for convenience:
```bash
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
DOCKERFILE="$SCRIPT_DIR/Dockerfile.static"
# Detect architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64)
PLATFORM="linux/amd64"
OUTPUT_NAME="my_app_static_x86_64"
;;
aarch64|arm64)
PLATFORM="linux/arm64"
OUTPUT_NAME="my_app_static_arm64"
;;
*)
echo "Unknown architecture: $ARCH"
exit 1
;;
esac
echo "Building for platform: $PLATFORM"
mkdir -p "$BUILD_DIR"
# Build Docker image
docker build \
--platform "$PLATFORM" \
-f "$DOCKERFILE" \
-t my-app-builder:latest \
--progress=plain \
.
# Extract binary
CONTAINER_ID=$(docker create my-app-builder:latest)
docker cp "$CONTAINER_ID:/build/my_app_static" "$BUILD_DIR/$OUTPUT_NAME"
docker rm "$CONTAINER_ID"
chmod +x "$BUILD_DIR/$OUTPUT_NAME"
echo "✓ Build complete: $BUILD_DIR/$OUTPUT_NAME"
echo "✓ Size: $(du -h "$BUILD_DIR/$OUTPUT_NAME" | cut -f1)"
# Verify
if ldd "$BUILD_DIR/$OUTPUT_NAME" 2>&1 | grep -q "not a dynamic executable"; then
echo "✓ Binary is fully static"
else
echo "⚠ Warning: Binary may have dynamic dependencies"
fi
```
Make it executable:
```bash
chmod +x build_static.sh
./build_static.sh
```
## Common Issues and Solutions
### Issue 1: Fortification Errors
**Error:**
```
undefined reference to '__snprintf_chk'
undefined reference to '__fprintf_chk'
```
**Cause**: GCC's `-O2` enables fortification by default, which uses glibc-specific functions.
**Solution**: Add these flags to **all** compilation commands:
```bash
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0
```
This must be applied to:
1. nostr_core_lib build.sh
2. Your application compilation
3. Any other libraries you build
### Issue 2: Missing Symbols from nostr_core_lib
**Error:**
```
undefined reference to 'nostr_create_event'
undefined reference to 'nostr_sign_event'
```
**Cause**: Required NIPs not included in nostr_core_lib build.
**Solution**: Add missing NIPs:
```bash
./build.sh --nips=1,6,19 # Add the NIPs you need
```
### Issue 3: Docker Permission Denied
**Error:**
```
permission denied while trying to connect to the Docker daemon socket
```
**Solution**:
```bash
sudo usermod -aG docker $USER
newgrp docker # Or logout and login
```
### Issue 4: Binary Won't Run on Target System
**Checks**:
```bash
# 1. Verify it's static
ldd my_app_static # Should show "not a dynamic executable"
# 2. Check architecture
file my_app_static # Should match target system
# 3. Test on different distributions
docker run --rm -v $(pwd):/app alpine:latest /app/my_app_static --version
docker run --rm -v $(pwd):/app ubuntu:latest /app/my_app_static --version
```
## Project Structure Example
Organize your project for easy static builds:
```
my-nostr-app/
├── src/
│ ├── main.c
│ ├── handlers.c
│ └── utils.c
├── nostr_core_lib/ # Git submodule
├── nostr_login_lite/ # Git submodule (if used)
├── Dockerfile.static # Static build Dockerfile
├── build_static.sh # Build script
├── Makefile # Regular build
└── README.md
```
### Makefile Integration
Add static build targets to your Makefile:
```makefile
# Regular dynamic build
all: my_app
my_app: src/*.c
gcc -O2 src/*.c -o my_app \
nostr_core_lib/libnostr_core_x64.a \
-lssl -lcrypto -lsecp256k1 -lz -lpthread -lm
# Static MUSL build via Docker
static:
./build_static.sh
# Clean
clean:
rm -f my_app build/my_app_static_*
.PHONY: all static clean
```
## Deployment
### Single Binary Deployment
```bash
# Copy to server
scp build/my_app_static_x86_64 user@server:/opt/my-app/
# Run (no dependencies needed!)
ssh user@server
/opt/my-app/my_app_static_x86_64
```
### SystemD Service
```ini
[Unit]
Description=My Nostr Application
After=network.target
[Service]
Type=simple
User=myapp
WorkingDirectory=/opt/my-app
ExecStart=/opt/my-app/my_app_static_x86_64
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
### Docker Container (Minimal)
```dockerfile
FROM scratch
COPY my_app_static_x86_64 /app
ENTRYPOINT ["/app"]
```
Build and run:
```bash
docker build -t my-app:latest .
docker run --rm my-app:latest --help
```
## Reusing c-relay Files
You can directly copy these files from c-relay:
### 1. Dockerfile.alpine-musl
```bash
cp /path/to/c-relay/Dockerfile.alpine-musl ./Dockerfile.static
```
Then customize:
- Change binary name (line 125)
- Adjust source files (line 122-124)
- Modify include paths (line 120-121)
### 2. build_static.sh
```bash
cp /path/to/c-relay/build_static.sh ./
```
Then customize:
- Change `OUTPUT_NAME` variable (lines 66, 70)
- Update Docker image name (line 98)
- Modify verification commands (lines 180-184)
### 3. .dockerignore (Optional)
```bash
cp /path/to/c-relay/.dockerignore ./
```
Helps speed up Docker builds by excluding unnecessary files.
## Best Practices
1. **Version Control**: Commit your Dockerfile and build script
2. **Tag Builds**: Include git commit hash in binary version
3. **Test Thoroughly**: Verify on multiple distributions
4. **Document Dependencies**: List required NIPs and libraries
5. **Automate**: Use CI/CD to build on every commit
6. **Archive Binaries**: Keep old versions for rollback
## Performance Comparison
| Metric | MUSL Static | glibc Dynamic |
|--------|-------------|---------------|
| Binary Size | 7-10 MB | 2-3 MB + libs |
| Startup Time | ~50ms | ~40ms |
| Memory Usage | Similar | Similar |
| Portability | ✓ Universal | ✗ System-dependent |
| Deployment | Single file | Binary + libraries |
## References
- [MUSL libc](https://musl.libc.org/)
- [Alpine Linux](https://alpinelinux.org/)
- [nostr_core_lib](https://github.com/chebizarro/nostr_core_lib)
- [Static Linking Best Practices](https://www.musl-libc.org/faq.html)
- [c-relay Implementation](./docs/musl_static_build.md)
## Example: Minimal Nostr Client
Here's a complete example of building a minimal Nostr client:
```c
// minimal_client.c
#include "nostr_core/nostr_core.h"
#include <stdio.h>
int main() {
// Generate keypair
char nsec[64], npub[64];
nostr_generate_keypair(nsec, npub);
printf("Generated keypair:\n");
printf("Private key (nsec): %s\n", nsec);
printf("Public key (npub): %s\n", npub);
// Create event
cJSON *event = nostr_create_event(1, "Hello, Nostr!", NULL);
nostr_sign_event(event, nsec);
char *json = cJSON_Print(event);
printf("\nSigned event:\n%s\n", json);
free(json);
cJSON_Delete(event);
return 0;
}
```
**Dockerfile.static:**
```dockerfile
FROM alpine:3.19 AS builder
RUN apk add --no-cache build-base musl-dev git autoconf automake libtool \
openssl-dev openssl-libs-static zlib-dev zlib-static
WORKDIR /build
# Build secp256k1
RUN cd /tmp && git clone https://github.com/bitcoin-core/secp256k1.git && \
cd secp256k1 && ./autogen.sh && \
./configure --enable-static --disable-shared --prefix=/usr CFLAGS="-fPIC" && \
make -j$(nproc) && make install
# Copy and build nostr_core_lib
COPY nostr_core_lib /build/nostr_core_lib/
RUN cd nostr_core_lib && \
sed -i 's/CFLAGS="-Wall/CFLAGS="-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall/' build.sh && \
./build.sh --nips=1,6,19
# Build application
COPY minimal_client.c /build/
RUN gcc -static -O2 -Wall -std=c99 \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
-Inostr_core_lib -Inostr_core_lib/nostr_core -Inostr_core_lib/cjson \
minimal_client.c -o /build/minimal_client_static \
nostr_core_lib/libnostr_core_x64.a \
-lssl -lcrypto -lsecp256k1 -lz -lpthread -lm -ldl && \
strip /build/minimal_client_static
FROM scratch
COPY --from=builder /build/minimal_client_static /minimal_client_static
```
**Build and run:**
```bash
docker build -f Dockerfile.static -t minimal-client .
docker create --name temp minimal-client
docker cp temp:/minimal_client_static ./
docker rm temp
./minimal_client_static
```
## Conclusion
Static MUSL binaries provide the best portability for C applications. While they're slightly larger than dynamic binaries, the benefits of zero dependencies and universal compatibility make them ideal for:
- Server deployments across different Linux distributions
- Embedded systems and IoT devices
- Docker containers (FROM scratch)
- Distribution to users without dependency management
- Long-term archival and reproducibility
Follow this guide to create portable, self-contained binaries for your Nostr applications!

View File

@@ -1261,3 +1261,50 @@ body.dark-mode .sql-results-table tbody tr:nth-child(even) {
.header-title.clickable:hover {
opacity: 0.8;
}
/* ================================
SUBSCRIPTION TABLE COLLAPSIBLE GROUPS
================================ */
/* Subscription group header styles */
.subscription-group-header {
font-weight: 500;
cursor: pointer;
user-select: none;
}
.subscription-group-header:hover {
background-color: var(--secondary-color);
}
.expand-icon {
display: inline-block;
width: 20px;
transition: transform 0.2s ease;
font-size: 12px;
}
/* Detail row styles */
.subscription-detail-row {
/* background-color: var(--secondary-color); */
}
.subscription-detail-row:hover {
background-color: var(--muted-color);
}
/* Detail row cell styles */
.subscription-detail-prefix {
padding-left: 30px;
font-family: 'Courier New', monospace;
font-size: 11px;
color: var(--muted-color);
}
.subscription-detail-id {
font-family: 'Courier New', monospace;
font-size: 12px;
}

View File

@@ -212,18 +212,9 @@
<div class="input-group">
<div class="config-table-container">
<table class="config-table" id="subscription-details-table">
<thead>
<tr>
<th>Subscription ID</th>
<th>Client IP</th>
<th>WSI Pointer</th>
<th>Duration</th>
<th>Filters</th>
</tr>
</thead>
<tbody id="subscription-details-table-body">
<tr>
<td colspan="5" style="text-align: center; font-style: italic;">No subscriptions active</td>
<td colspan="4" style="text-align: center; font-style: italic;">No subscriptions active</td>
</tr>
</tbody>
</table>

View File

@@ -4279,101 +4279,185 @@ function populateSubscriptionDetailsTable(subscriptionsData) {
const tableBody = document.getElementById('subscription-details-table-body');
if (!tableBody || !subscriptionsData || !Array.isArray(subscriptionsData)) return;
// Store current expand/collapse state before rebuilding
const expandedGroups = new Set();
const headerRows = tableBody.querySelectorAll('.subscription-group-header');
headerRows.forEach(header => {
const wsiPointer = header.getAttribute('data-wsi-pointer');
const isExpanded = header.getAttribute('data-expanded') === 'true';
if (isExpanded) {
expandedGroups.add(wsiPointer);
}
});
tableBody.innerHTML = '';
if (subscriptionsData.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="5" style="text-align: center; font-style: italic;">No active subscriptions</td>';
row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No active subscriptions</td>';
tableBody.appendChild(row);
return;
}
subscriptionsData.forEach((subscription, index) => {
const row = document.createElement('tr');
// Calculate duration
const now = Math.floor(Date.now() / 1000);
const duration = now - subscription.created_at;
const durationStr = formatDuration(duration);
// Format client IP (show full IP for admin view)
const clientIP = subscription.client_ip || 'unknown';
// Format wsi_pointer (show full pointer)
const wsiPointer = subscription.wsi_pointer || 'N/A';
// Format filters (show actual filter details)
let filtersDisplay = 'None';
if (subscription.filters && subscription.filters.length > 0) {
const filterDetails = [];
subscription.filters.forEach((filter, index) => {
const parts = [];
// Add kinds if present
if (filter.kinds && Array.isArray(filter.kinds) && filter.kinds.length > 0) {
parts.push(`kinds:[${filter.kinds.join(',')}]`);
}
// Add authors if present (truncate for display)
if (filter.authors && Array.isArray(filter.authors) && filter.authors.length > 0) {
const authorCount = filter.authors.length;
if (authorCount === 1) {
const shortPubkey = filter.authors[0].substring(0, 8) + '...';
parts.push(`authors:[${shortPubkey}]`);
} else {
parts.push(`authors:[${authorCount} pubkeys]`);
}
}
// Add ids if present
if (filter.ids && Array.isArray(filter.ids) && filter.ids.length > 0) {
const idCount = filter.ids.length;
parts.push(`ids:[${idCount} event${idCount > 1 ? 's' : ''}]`);
}
// Add time range if present
const timeParts = [];
if (filter.since && filter.since > 0) {
const sinceDate = new Date(filter.since * 1000).toLocaleString();
timeParts.push(`since:${sinceDate}`);
}
if (filter.until && filter.until > 0) {
const untilDate = new Date(filter.until * 1000).toLocaleString();
timeParts.push(`until:${untilDate}`);
}
if (timeParts.length > 0) {
parts.push(timeParts.join(', '));
}
// Add limit if present
if (filter.limit && filter.limit > 0) {
parts.push(`limit:${filter.limit}`);
}
// Add tag filters if present
if (filter.tag_filters && Array.isArray(filter.tag_filters) && filter.tag_filters.length > 0) {
parts.push(`tags:[${filter.tag_filters.length} filter${filter.tag_filters.length > 1 ? 's' : ''}]`);
}
if (parts.length > 0) {
filterDetails.push(parts.join(', '));
} else {
filterDetails.push('empty filter');
}
});
filtersDisplay = filterDetails.join(' | ');
}
row.innerHTML = `
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${subscription.id || 'N/A'}</td>
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${clientIP}</td>
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${wsiPointer}</td>
<td>${durationStr}</td>
<td>${filtersDisplay}</td>
`;
tableBody.appendChild(row);
// Sort subscriptions by wsi_pointer to group them together
subscriptionsData.sort((a, b) => {
const wsiA = a.wsi_pointer || '';
const wsiB = b.wsi_pointer || '';
return wsiA.localeCompare(wsiB);
});
// Group subscriptions by wsi_pointer
const groupedSubscriptions = {};
subscriptionsData.forEach(sub => {
const wsiKey = sub.wsi_pointer || 'N/A';
if (!groupedSubscriptions[wsiKey]) {
groupedSubscriptions[wsiKey] = [];
}
groupedSubscriptions[wsiKey].push(sub);
});
// Create rows for each group
Object.entries(groupedSubscriptions).forEach(([wsiPointer, subscriptions]) => {
// Calculate group summary
const subCount = subscriptions.length;
const now = Math.floor(Date.now() / 1000);
const oldestDuration = Math.max(...subscriptions.map(s => now - s.created_at));
const oldestDurationStr = formatDuration(oldestDuration);
// Create header row (summary)
const headerRow = document.createElement('tr');
headerRow.className = 'subscription-group-header';
headerRow.setAttribute('data-wsi-pointer', wsiPointer);
const wasExpanded = expandedGroups.has(wsiPointer);
headerRow.setAttribute('data-expanded', wasExpanded ? 'true' : 'false');
headerRow.innerHTML = `
<td colspan="4" style="padding: 8px;">
<span class="expand-icon" style="display: inline-block; width: 20px; transition: transform 0.2s;">▶</span>
<strong style="font-family: 'Courier New', monospace; font-size: 12px;">Websocket: ${wsiPointer}</strong>
<span style="color: #666; margin-left: 15px;">
Subscriptions: ${subCount} | Oldest: ${oldestDurationStr}
</span>
</td>
`;
// Add click handler to toggle expansion
headerRow.addEventListener('click', () => toggleSubscriptionGroup(wsiPointer));
tableBody.appendChild(headerRow);
// Create detail rows (initially hidden)
subscriptions.forEach((subscription, index) => {
const detailRow = document.createElement('tr');
detailRow.className = 'subscription-detail-row';
detailRow.setAttribute('data-wsi-group', wsiPointer);
detailRow.style.display = 'none';
// Calculate duration
const duration = now - subscription.created_at;
const durationStr = formatDuration(duration);
// Format filters
let filtersDisplay = 'None';
if (subscription.filters && subscription.filters.length > 0) {
const filterDetails = [];
subscription.filters.forEach((filter) => {
const parts = [];
if (filter.kinds && Array.isArray(filter.kinds) && filter.kinds.length > 0) {
parts.push(`kinds:[${filter.kinds.join(',')}]`);
}
if (filter.authors && Array.isArray(filter.authors) && filter.authors.length > 0) {
const authorCount = filter.authors.length;
if (authorCount === 1) {
const shortPubkey = filter.authors[0].substring(0, 8) + '...';
parts.push(`authors:[${shortPubkey}]`);
} else {
parts.push(`authors:[${authorCount} pubkeys]`);
}
}
if (filter.ids && Array.isArray(filter.ids) && filter.ids.length > 0) {
const idCount = filter.ids.length;
parts.push(`ids:[${idCount} event${idCount > 1 ? 's' : ''}]`);
}
const timeParts = [];
if (filter.since && filter.since > 0) {
const sinceDate = new Date(filter.since * 1000).toLocaleString();
timeParts.push(`since:${sinceDate}`);
}
if (filter.until && filter.until > 0) {
const untilDate = new Date(filter.until * 1000).toLocaleString();
timeParts.push(`until:${untilDate}`);
}
if (timeParts.length > 0) {
parts.push(timeParts.join(', '));
}
if (filter.limit && filter.limit > 0) {
parts.push(`limit:${filter.limit}`);
}
if (filter.tag_filters && Array.isArray(filter.tag_filters) && filter.tag_filters.length > 0) {
parts.push(`tags:[${filter.tag_filters.length} filter${filter.tag_filters.length > 1 ? 's' : ''}]`);
}
if (parts.length > 0) {
filterDetails.push(parts.join(', '));
} else {
filterDetails.push('empty filter');
}
});
filtersDisplay = filterDetails.join(' | ');
}
detailRow.innerHTML = `
<td class="subscription-detail-prefix">└─</td>
<td class="subscription-detail-id">${subscription.id || 'N/A'}</td>
<td class="subscription-detail-duration">${durationStr}</td>
<td class="subscription-detail-filters">${filtersDisplay}</td>
`;
tableBody.appendChild(detailRow);
// Restore expand/collapse state after adding all rows
if (wasExpanded) {
const detailRows = tableBody.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`);
detailRows.forEach(row => row.style.display = 'table-row');
const expandIcon = headerRow.querySelector('.expand-icon');
if (expandIcon) {
expandIcon.textContent = '▼';
expandIcon.style.transform = 'rotate(90deg)';
}
}
});
});
}
// Toggle function for expanding/collapsing groups
function toggleSubscriptionGroup(wsiPointer) {
const headerRow = document.querySelector(`.subscription-group-header[data-wsi-pointer="${wsiPointer}"]`);
const detailRows = document.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`);
const expandIcon = headerRow.querySelector('.expand-icon');
const isExpanded = headerRow.getAttribute('data-expanded') === 'true';
if (isExpanded) {
// Collapse
detailRows.forEach(row => row.style.display = 'none');
expandIcon.textContent = '▶';
expandIcon.style.transform = 'rotate(0deg)';
headerRow.setAttribute('data-expanded', 'false');
} else {
// Expand
detailRows.forEach(row => row.style.display = 'table-row');
expandIcon.textContent = '▼';
expandIcon.style.transform = 'rotate(90deg)';
headerRow.setAttribute('data-expanded', 'true');
}
}
// Helper function to format duration in human-readable format

View File

@@ -3,17 +3,17 @@
# Copy the binary to the deployment location
cp build/c_relay_x86 ~/Storage/c_relay/crelay
# Copy the local service file to systemd
# sudo cp systemd/c-relay-local.service /etc/systemd/system/
# Copy the service file to systemd (use the main service file)
sudo cp systemd/c-relay.service /etc/systemd/system/c-relay-local.service
# Reload systemd daemon to pick up the new service
# sudo systemctl daemon-reload
sudo systemctl daemon-reload
# Enable the service (if not already enabled)
# sudo systemctl enable c-relay-local.service
sudo systemctl enable c-relay-local.service
# Restart the service
# sudo systemctl restart c-relay-local.service
sudo systemctl restart c-relay-local.service
# Show service status
# sudo systemctl status c-relay-local.service --no-pager -l
sudo systemctl status c-relay-local.service --no-pager -l

View File

@@ -0,0 +1,532 @@
# Subscription Cleanup - Simplified Design
## Problem Summary
The c-relay Nostr relay experienced severe performance degradation (90-100% CPU) due to subscription accumulation in the database. Investigation revealed **323,644 orphaned subscriptions** that were never properly closed when WebSocket connections dropped.
## Solution: Two-Component Approach
This simplified design focuses on two pragmatic solutions that align with Nostr's stateless design:
1. **Startup Cleanup**: Close all subscriptions on relay restart
2. **Connection Age Limit**: Disconnect clients after a configurable time period
Both solutions force clients to reconnect and re-establish subscriptions, which is standard Nostr behavior.
---
## Component 1: Startup Cleanup
### Purpose
Ensure clean state on every relay restart by closing all subscriptions in the database.
### Implementation
**File:** [`src/subscriptions.c`](src/subscriptions.c)
**New Function:**
```c
void cleanup_all_subscriptions_on_startup(void) {
if (!g_db) {
DEBUG_ERROR("Database not initialized for startup cleanup");
return;
}
DEBUG_LOG("Startup cleanup: Marking all active subscriptions as disconnected");
// Mark all 'created' subscriptions as disconnected
const char* update_sql =
"UPDATE subscriptions "
"SET ended_at = strftime('%s', 'now') "
"WHERE event_type = 'created' AND ended_at IS NULL";
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, update_sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("Failed to prepare startup cleanup query: %s", sqlite3_errmsg(g_db));
return;
}
rc = sqlite3_step(stmt);
int updated_count = sqlite3_changes(g_db);
sqlite3_finalize(stmt);
if (updated_count > 0) {
// Log a single 'disconnected' event for the startup cleanup
const char* insert_sql =
"INSERT INTO subscriptions (subscription_id, wsi_pointer, client_ip, event_type) "
"VALUES ('startup_cleanup', '', 'system', 'disconnected')";
rc = sqlite3_prepare_v2(g_db, insert_sql, -1, &stmt, NULL);
if (rc == SQLITE_OK) {
sqlite3_step(stmt);
sqlite3_finalize(stmt);
}
DEBUG_LOG("Startup cleanup: Marked %d subscriptions as disconnected", updated_count);
} else {
DEBUG_LOG("Startup cleanup: No active subscriptions found");
}
}
```
**Integration Point:** [`src/main.c:1810`](src/main.c:1810)
```c
// Initialize subscription manager mutexes
if (pthread_mutex_init(&g_subscription_manager.subscriptions_lock, NULL) != 0) {
DEBUG_ERROR("Failed to initialize subscriptions mutex");
sqlite3_close(g_db);
return 1;
}
if (pthread_mutex_init(&g_subscription_manager.ip_tracking_lock, NULL) != 0) {
DEBUG_ERROR("Failed to initialize IP tracking mutex");
pthread_mutex_destroy(&g_subscription_manager.subscriptions_lock);
sqlite3_close(g_db);
return 1;
}
// **NEW: Startup cleanup - close all subscriptions**
cleanup_all_subscriptions_on_startup();
// Start WebSocket relay server
DEBUG_LOG("Starting WebSocket relay server...");
if (start_websocket_relay(port_override, strict_port) != 0) {
DEBUG_ERROR("Failed to start WebSocket relay");
// ... cleanup code
}
```
### Benefits
- **Immediate relief**: Restart relay to fix subscription issues
- **Clean slate**: Every restart starts with zero active subscriptions
- **Simple**: Single SQL UPDATE statement
- **Nostr-aligned**: Clients are designed to reconnect after relay restart
- **No configuration needed**: Always runs on startup
---
## Component 2: Connection Age Limit
### Purpose
Automatically disconnect clients after a configurable time period, forcing them to reconnect and re-establish subscriptions.
### Why Disconnect Instead of Just Closing Subscriptions?
**Option 1: Send CLOSED message (keep connection)**
- ❌ Not all clients handle `CLOSED` messages properly
- ❌ Silent failure - client thinks it's subscribed but isn't
- ❌ Partial cleanup - connection still consumes resources
- ❌ More complex to implement
**Option 2: Disconnect client entirely (force reconnection)**
- ✅ Universal compatibility - all clients handle WebSocket reconnection
- ✅ Complete resource cleanup (memory, file descriptors, etc.)
- ✅ Simple implementation - single operation
- ✅ Well-tested code path (same as network interruptions)
- ✅ Forces re-authentication if needed
### Implementation
**File:** [`src/websockets.c`](src/websockets.c)
**New Function:**
```c
/**
* Check connection age and disconnect clients that have been connected too long.
* This forces clients to reconnect and re-establish subscriptions.
*
* Uses libwebsockets' lws_vhost_foreach_wsi() to iterate through all active
* connections and checks their connection_established timestamp from per_session_data.
*/
void check_connection_age(int max_connection_seconds) {
if (max_connection_seconds <= 0 || !ws_context) {
return;
}
time_t now = time(NULL);
time_t cutoff = now - max_connection_seconds;
// Get the default vhost
struct lws_vhost *vhost = lws_get_vhost_by_name(ws_context, "default");
if (!vhost) {
DEBUG_ERROR("Failed to get vhost for connection age check");
return;
}
// Iterate through all active WebSocket connections
// Note: lws_vhost_foreach_wsi() calls our callback for each connection
struct lws *wsi = NULL;
while ((wsi = lws_vhost_foreach_wsi(vhost, wsi)) != NULL) {
// Get per-session data which contains connection_established timestamp
struct per_session_data *pss = (struct per_session_data *)lws_wsi_user(wsi);
if (pss && pss->connection_established > 0) {
// Check if connection is older than cutoff
if (pss->connection_established < cutoff) {
// Connection is too old - close it
long age_seconds = now - pss->connection_established;
DEBUG_LOG("Closing connection from %s (age: %lds, limit: %ds)",
pss->client_ip, age_seconds, max_connection_seconds);
// Close with normal status and reason message
lws_close_reason(wsi, LWS_CLOSE_STATUS_NORMAL,
(unsigned char *)"connection age limit", 21);
}
}
}
}
```
**Key Implementation Details:**
1. **No database needed**: Active connections are tracked by libwebsockets itself
2. **Uses existing timestamp**: `pss->connection_established` is already set on line 456 of websockets.c
3. **Built-in iterator**: `lws_vhost_foreach_wsi()` safely iterates through all active connections
4. **Per-session data**: Each connection's `per_session_data` is accessible via `lws_wsi_user()`
5. **Safe closure**: `lws_close_reason()` properly closes the WebSocket with a status code and message
**Integration Point:** [`src/websockets.c:2176`](src/websockets.c:2176) - in existing event loop
```c
// Main event loop with proper signal handling
while (g_server_running && !g_shutdown_flag) {
int result = lws_service(ws_context, 1000);
if (result < 0) {
DEBUG_ERROR("libwebsockets service error");
break;
}
// Check if it's time to post status update
time_t current_time = time(NULL);
int status_post_hours = get_config_int("kind_1_status_posts_hours", 0);
if (status_post_hours > 0) {
int seconds_interval = status_post_hours * 3600;
if (current_time - last_status_post_time >= seconds_interval) {
last_status_post_time = current_time;
generate_and_post_status_event();
}
}
// **NEW: Check for connection age limit**
int max_connection_seconds = get_config_int("max_connection_seconds", 86400); // Default 24 hours
if (max_connection_seconds > 0) {
check_connection_age(max_connection_seconds);
}
}
```
### Configuration
**Parameter:** `max_connection_seconds`
- **Default:** `86400` (24 hours)
- **Range:** `0` = disabled, `>0` = disconnect after X seconds
- **Units:** Seconds (for consistency with other time-based configs)
**Example configurations:**
```json
{
"max_connection_seconds": 86400 // 86400 seconds = 24 hours (default)
}
```
```json
{
"max_connection_seconds": 43200 // 43200 seconds = 12 hours
}
```
```json
{
"max_connection_seconds": 3600 // 3600 seconds = 1 hour
}
```
```json
{
"max_connection_seconds": 0 // Disabled
}
```
### Client Behavior
When disconnected due to age limit, clients will:
1. Detect WebSocket closure
2. Wait briefly (exponential backoff)
3. Reconnect to relay
4. Re-authenticate if needed (NIP-42)
5. Re-establish all subscriptions
6. Resume normal operation
This is **exactly what happens** during network interruptions, so it's a well-tested code path in all Nostr clients.
### Benefits
- **No new threads**: Uses existing event loop
- **Minimal overhead**: Check runs once per second (same as `lws_service`)
- **Simple implementation**: Iterate through active connections
- **Consistent pattern**: Matches existing status post checking
- **Universal compatibility**: All clients handle reconnection
- **Complete cleanup**: Frees all resources associated with connection
- **Configurable**: Can be adjusted per relay needs or disabled entirely
---
## Implementation Plan
### Phase 1: Startup Cleanup (1-2 hours)
1. **Add `cleanup_all_subscriptions_on_startup()` function**
- File: [`src/subscriptions.c`](src/subscriptions.c)
- SQL UPDATE to mark all active subscriptions as disconnected
- Add logging for cleanup count
2. **Integrate in main()**
- File: [`src/main.c:1810`](src/main.c:1810)
- Call after mutex initialization, before WebSocket server start
3. **Test**
- Create subscriptions in database
- Restart relay
- Verify all subscriptions marked as disconnected
- Verify `active_subscriptions_log` shows 0 subscriptions
**Estimated Time:** 1-2 hours
### Phase 2: Connection Age Limit (2-3 hours)
1. **Add `check_connection_age()` function**
- File: [`src/websockets.c`](src/websockets.c)
- Iterate through active connections
- Close connections older than limit
2. **Integrate in event loop**
- File: [`src/websockets.c:2176`](src/websockets.c:2176)
- Add check after status post check
- Use same pattern as status posts
3. **Add configuration parameter**
- Add `max_connection_seconds` to default config
- Default: 86400 (24 hours)
4. **Test**
- Connect client
- Wait for timeout (or reduce timeout for testing)
- Verify client disconnected
- Verify client reconnects automatically
- Verify subscriptions re-established
**Estimated Time:** 2-3 hours
---
## Testing Strategy
### Startup Cleanup Tests
```bash
# Test 1: Clean startup with existing subscriptions
- Create 100 active subscriptions in database
- Restart relay
- Verify all subscriptions marked as disconnected
- Verify active_subscriptions_log shows 0 subscriptions
# Test 2: Clean startup with no subscriptions
- Start relay with empty database
- Verify no errors
- Verify startup cleanup logs "No active subscriptions found"
# Test 3: Clients reconnect after restart
- Create subscriptions before restart
- Restart relay
- Connect clients and create new subscriptions
- Verify new subscriptions tracked correctly
```
### Connection Age Limit Tests
```bash
# Test 1: Connection disconnected after timeout
- Set max_connection_seconds to 60 (for testing)
- Connect client
- Wait 61 seconds
- Verify client disconnected
- Verify client reconnects automatically
# Test 2: Subscriptions re-established after reconnection
- Connect client with subscriptions
- Wait for timeout
- Verify client reconnects
- Verify subscriptions re-established
- Verify events still delivered
# Test 3: Disabled when set to 0
- Set max_connection_seconds to 0
- Connect client
- Wait extended period
- Verify client NOT disconnected
```
### Integration Tests
```bash
# Test 1: Combined behavior
- Start relay (startup cleanup runs)
- Connect multiple clients
- Create subscriptions
- Wait for connection timeout
- Verify clients reconnect
- Restart relay
- Verify clean state
# Test 2: Load test
- Connect 100 clients
- Each creates 5 subscriptions
- Wait for connection timeout
- Verify all clients reconnect
- Verify all subscriptions re-established
- Monitor CPU usage (should remain low)
```
---
## Success Criteria
### Component 1: Startup Cleanup
- ✅ Relay starts with zero active subscriptions
- ✅ All previous subscriptions marked as disconnected on startup
- ✅ Clients successfully reconnect and re-establish subscriptions
- ✅ Relay restart can be used as emergency fix for subscription issues
- ✅ No errors during startup cleanup process
### Component 2: Connection Age Limit
- ✅ Clients disconnected after configured time period
- ✅ Clients automatically reconnect
- ✅ Subscriptions re-established after reconnection
- ✅ No impact on relay performance
- ✅ Configuration parameter works correctly (including disabled state)
### Overall Success
- ✅ CPU usage remains low (<10%)
- No orphaned subscriptions accumulate
- Database size remains stable
- No manual intervention required
---
## Configuration Reference
**New Configuration Parameters:**
```json
{
"max_connection_seconds": 86400
}
```
**Recommended Settings:**
- **Production:**
- `max_connection_seconds: 86400` (24 hours)
- **Development:**
- `max_connection_seconds: 3600` (1 hour for faster testing)
- **High-traffic:**
- `max_connection_seconds: 43200` (12 hours)
- **Disabled:**
- `max_connection_seconds: 0`
---
## Rollback Plan
If issues arise after deployment:
1. **Disable connection age limit:**
- Set `max_connection_seconds: 0` in config
- Restart relay
- Monitor for stability
2. **Revert code changes:**
- Remove `check_connection_age()` call from event loop
- Remove `cleanup_all_subscriptions_on_startup()` call from main
- Restart relay
3. **Database cleanup (if needed):**
- Manually clean up orphaned subscriptions using SQL:
```sql
UPDATE subscriptions
SET ended_at = strftime('%s', 'now')
WHERE event_type = 'created' AND ended_at IS NULL;
```
---
## Comparison with Original Design
### Original Design (5 Components)
1. Startup cleanup
2. Fix WebSocket disconnection logging
3. Enhance subscription removal with reason parameter
4. Periodic cleanup task (background thread)
5. Optimize database VIEW
6. Subscription expiration (optional)
### Simplified Design (2 Components)
1. Startup cleanup
2. Connection age limit
### Why Simplified is Better
**Advantages:**
- **Simpler**: 2 components vs 5-6 components
- **Faster to implement**: 3-5 hours vs 11-17 hours
- **Easier to maintain**: Less code, fewer moving parts
- **More reliable**: Fewer potential failure points
- **Nostr-aligned**: Leverages client reconnection behavior
- **No new threads**: Uses existing event loop
- **Universal compatibility**: All clients handle reconnection
**What We're Not Losing:**
- Startup cleanup is identical in both designs
- Connection age limit achieves the same goal as periodic cleanup + expiration
- Disconnection forces complete cleanup (better than just logging)
- Database VIEW optimization not needed if subscriptions don't accumulate
**Trade-offs:**
- Less granular logging (but simpler)
- No historical subscription analytics (but cleaner database)
- Clients must reconnect periodically (but this is standard Nostr behavior)
---
## Conclusion
This simplified design solves the subscription accumulation problem with two pragmatic solutions:
1. **Startup cleanup** ensures every relay restart starts with a clean slate
2. **Connection age limit** prevents long-term accumulation by forcing periodic reconnection
Both solutions align with Nostr's stateless design where clients are expected to handle reconnection. The implementation is simple, maintainable, and leverages existing code patterns.
**Key Benefits:**
- Solves the root problem (subscription accumulation)
- Simple to implement (3-5 hours total)
- Easy to maintain (minimal code)
- Universal compatibility (all clients handle reconnection)
- No new threads or background tasks
- Configurable and can be disabled if needed
- Relay restart as emergency fix
**Next Steps:**
1. Implement Component 1 (Startup Cleanup)
2. Test thoroughly
3. Implement Component 2 (Connection Age Limit)
4. Test thoroughly
5. Deploy to production
6. Monitor CPU usage and subscription counts

View File

@@ -0,0 +1,358 @@
# Subscription Table Collapsible Groups Implementation Plan
## Objective
Add collapsible/expandable functionality to subscription groups, where:
1. Each WSI Pointer group starts collapsed, showing only a summary row
2. Clicking the summary row expands to show all subscription details for that WSI Pointer
3. Clicking again collapses the group back to the summary row
## Current Behavior
- All subscriptions are displayed in a flat list
- WSI Pointer value shown only on first row of each group
- No interaction or collapse functionality
## Desired Behavior
- **Collapsed state**: One row per WSI Pointer showing summary information
- **Expanded state**: Header row + detail rows for each subscription in that group
- **Toggle**: Click on header row to expand/collapse
- **Visual indicator**: Arrow or icon showing expand/collapse state
## Design Approach
### Option 1: Summary Row + Detail Rows (Recommended)
```
[▶] WSI Pointer: 0x12345678 | Subscriptions: 3 | Total Duration: 15m
(detail rows hidden)
When clicked:
[▼] WSI Pointer: 0x12345678 | Subscriptions: 3 | Total Duration: 15m
| sub-001 | 5m 30s | kinds:[1]
| sub-002 | 3m 15s | kinds:[3]
| sub-003 | 6m 15s | kinds:[1,3]
```
### Option 2: First Row as Header (Alternative)
```
[▶] 0x12345678 | sub-001 | 5m 30s | kinds:[1] (+ 2 more)
When clicked:
[▼] 0x12345678 | sub-001 | 5m 30s | kinds:[1]
| sub-002 | 3m 15s | kinds:[3]
| sub-003 | 6m 15s | kinds:[1,3]
```
**Recommendation**: Option 1 provides clearer UX and better summary information.
## Implementation Details
### Files to Modify
1. `api/index.js` - Function `populateSubscriptionDetailsTable()` (lines 4277-4384)
2. `api/index.css` - Add styles for collapsible rows
### Data Structure Changes
Need to group subscriptions by WSI Pointer first:
```javascript
// Group subscriptions by wsi_pointer
const groupedSubscriptions = {};
subscriptionsData.forEach(sub => {
const wsiKey = sub.wsi_pointer || 'N/A';
if (!groupedSubscriptions[wsiKey]) {
groupedSubscriptions[wsiKey] = [];
}
groupedSubscriptions[wsiKey].push(sub);
});
```
### HTML Structure
#### Summary Row (Header)
```html
<tr class="subscription-group-header" data-wsi-pointer="0x12345678" data-expanded="false">
<td colspan="4" style="cursor: pointer; user-select: none;">
<span class="expand-icon"></span>
<strong>WSI Pointer:</strong> 0x12345678
<span class="group-summary">
| Subscriptions: 3 | Oldest: 15m 30s
</span>
</td>
</tr>
```
#### Detail Rows (Initially Hidden)
```html
<tr class="subscription-detail-row" data-wsi-group="0x12345678" style="display: none;">
<td style="padding-left: 30px;"><!-- Empty for WSI Pointer --></td>
<td>sub-001</td>
<td>5m 30s</td>
<td>kinds:[1]</td>
</tr>
```
### JavaScript Implementation
#### Modified populateSubscriptionDetailsTable Function
```javascript
function populateSubscriptionDetailsTable(subscriptionsData) {
const tableBody = document.getElementById('subscription-details-table-body');
if (!tableBody || !subscriptionsData || !Array.isArray(subscriptionsData)) return;
tableBody.innerHTML = '';
if (subscriptionsData.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No active subscriptions</td>';
tableBody.appendChild(row);
return;
}
// Sort subscriptions by wsi_pointer to group them together
subscriptionsData.sort((a, b) => {
const wsiA = a.wsi_pointer || '';
const wsiB = b.wsi_pointer || '';
return wsiA.localeCompare(wsiB);
});
// Group subscriptions by wsi_pointer
const groupedSubscriptions = {};
subscriptionsData.forEach(sub => {
const wsiKey = sub.wsi_pointer || 'N/A';
if (!groupedSubscriptions[wsiKey]) {
groupedSubscriptions[wsiKey] = [];
}
groupedSubscriptions[wsiKey].push(sub);
});
// Create rows for each group
Object.entries(groupedSubscriptions).forEach(([wsiPointer, subscriptions]) => {
// Calculate group summary
const subCount = subscriptions.length;
const now = Math.floor(Date.now() / 1000);
const oldestDuration = Math.max(...subscriptions.map(s => now - s.created_at));
const oldestDurationStr = formatDuration(oldestDuration);
// Create header row (summary)
const headerRow = document.createElement('tr');
headerRow.className = 'subscription-group-header';
headerRow.setAttribute('data-wsi-pointer', wsiPointer);
headerRow.setAttribute('data-expanded', 'false');
headerRow.style.cursor = 'pointer';
headerRow.style.userSelect = 'none';
headerRow.style.backgroundColor = 'var(--hover-color, #f5f5f5)';
headerRow.innerHTML = `
<td colspan="4" style="padding: 8px;">
<span class="expand-icon" style="display: inline-block; width: 20px; transition: transform 0.2s;">▶</span>
<strong style="font-family: 'Courier New', monospace; font-size: 12px;">WSI: ${wsiPointer}</strong>
<span style="color: #666; margin-left: 15px;">
Subscriptions: ${subCount} | Oldest: ${oldestDurationStr}
</span>
</td>
`;
// Add click handler to toggle expansion
headerRow.addEventListener('click', () => toggleSubscriptionGroup(wsiPointer));
tableBody.appendChild(headerRow);
// Create detail rows (initially hidden)
subscriptions.forEach((subscription, index) => {
const detailRow = document.createElement('tr');
detailRow.className = 'subscription-detail-row';
detailRow.setAttribute('data-wsi-group', wsiPointer);
detailRow.style.display = 'none';
// Calculate duration
const duration = now - subscription.created_at;
const durationStr = formatDuration(duration);
// Format filters
let filtersDisplay = 'None';
if (subscription.filters && subscription.filters.length > 0) {
const filterDetails = [];
subscription.filters.forEach((filter) => {
const parts = [];
if (filter.kinds && Array.isArray(filter.kinds) && filter.kinds.length > 0) {
parts.push(`kinds:[${filter.kinds.join(',')}]`);
}
if (filter.authors && Array.isArray(filter.authors) && filter.authors.length > 0) {
const authorCount = filter.authors.length;
if (authorCount === 1) {
const shortPubkey = filter.authors[0].substring(0, 8) + '...';
parts.push(`authors:[${shortPubkey}]`);
} else {
parts.push(`authors:[${authorCount} pubkeys]`);
}
}
if (filter.ids && Array.isArray(filter.ids) && filter.ids.length > 0) {
const idCount = filter.ids.length;
parts.push(`ids:[${idCount} event${idCount > 1 ? 's' : ''}]`);
}
const timeParts = [];
if (filter.since && filter.since > 0) {
const sinceDate = new Date(filter.since * 1000).toLocaleString();
timeParts.push(`since:${sinceDate}`);
}
if (filter.until && filter.until > 0) {
const untilDate = new Date(filter.until * 1000).toLocaleString();
timeParts.push(`until:${untilDate}`);
}
if (timeParts.length > 0) {
parts.push(timeParts.join(', '));
}
if (filter.limit && filter.limit > 0) {
parts.push(`limit:${filter.limit}`);
}
if (filter.tag_filters && Array.isArray(filter.tag_filters) && filter.tag_filters.length > 0) {
parts.push(`tags:[${filter.tag_filters.length} filter${filter.tag_filters.length > 1 ? 's' : ''}]`);
}
if (parts.length > 0) {
filterDetails.push(parts.join(', '));
} else {
filterDetails.push('empty filter');
}
});
filtersDisplay = filterDetails.join(' | ');
}
detailRow.innerHTML = `
<td style="padding-left: 30px; font-family: 'Courier New', monospace; font-size: 11px; color: #999;">└─</td>
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${subscription.id || 'N/A'}</td>
<td>${durationStr}</td>
<td>${filtersDisplay}</td>
`;
tableBody.appendChild(detailRow);
});
});
}
// Toggle function for expanding/collapsing groups
function toggleSubscriptionGroup(wsiPointer) {
const headerRow = document.querySelector(`.subscription-group-header[data-wsi-pointer="${wsiPointer}"]`);
const detailRows = document.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`);
const expandIcon = headerRow.querySelector('.expand-icon');
const isExpanded = headerRow.getAttribute('data-expanded') === 'true';
if (isExpanded) {
// Collapse
detailRows.forEach(row => row.style.display = 'none');
expandIcon.textContent = '▶';
expandIcon.style.transform = 'rotate(0deg)';
headerRow.setAttribute('data-expanded', 'false');
} else {
// Expand
detailRows.forEach(row => row.style.display = 'table-row');
expandIcon.textContent = '▼';
expandIcon.style.transform = 'rotate(90deg)';
headerRow.setAttribute('data-expanded', 'true');
}
}
```
### CSS Additions (api/index.css)
```css
/* Subscription group header styles */
.subscription-group-header {
background-color: var(--hover-color, #f5f5f5);
font-weight: 500;
transition: background-color 0.2s;
}
.subscription-group-header:hover {
background-color: var(--primary-color-light, #e8e8e8);
}
.expand-icon {
display: inline-block;
width: 20px;
transition: transform 0.2s ease;
font-size: 12px;
}
/* Detail row styles */
.subscription-detail-row {
background-color: var(--background-color, #ffffff);
}
.subscription-detail-row:hover {
background-color: var(--hover-color-light, #fafafa);
}
/* Dark mode support */
.dark-mode .subscription-group-header {
background-color: var(--hover-color-dark, #2a2a2a);
}
.dark-mode .subscription-group-header:hover {
background-color: var(--primary-color-dark, #333333);
}
.dark-mode .subscription-detail-row {
background-color: var(--background-color-dark, #1a1a1a);
}
.dark-mode .subscription-detail-row:hover {
background-color: var(--hover-color-dark, #252525);
}
```
## Features Included
1.**Collapsible groups**: Each WSI Pointer group can be collapsed/expanded
2.**Visual indicator**: Arrow icon (▶/▼) shows current state
3.**Summary information**: Shows subscription count and oldest duration
4.**Smooth transitions**: Icon rotation animation
5.**Hover effects**: Visual feedback on header rows
6.**Tree structure**: Detail rows indented with └─ character
7.**Dark mode support**: Proper styling for both themes
8.**Keyboard accessible**: Can be enhanced with keyboard navigation
## User Experience Flow
1. **Initial load**: All groups collapsed, showing summary rows only
2. **Click header**: Group expands, showing all subscription details
3. **Click again**: Group collapses back to summary
4. **Multiple groups**: Each group can be independently expanded/collapsed
5. **Visual feedback**: Hover effects and smooth animations
## Testing Checklist
1. ✅ Verify groups start collapsed by default
2. ✅ Verify clicking header expands group
3. ✅ Verify clicking again collapses group
4. ✅ Verify multiple groups can be expanded simultaneously
5. ✅ Verify summary information is accurate
6. ✅ Verify detail rows display correctly when expanded
7. ✅ Verify styling works in both light and dark modes
8. ✅ Verify no console errors
9. ✅ Verify performance with many subscriptions
## Optional Enhancements
1. **Expand/Collapse All**: Add buttons to expand or collapse all groups at once
2. **Remember State**: Store expansion state in localStorage
3. **Keyboard Navigation**: Add keyboard shortcuts (Space/Enter to toggle)
4. **Animation**: Add slide-down/up animation for detail rows
5. **Search/Filter**: Add ability to search within subscriptions
6. **Export**: Add ability to export subscription data
## Next Steps
1. Review this plan
2. Switch to Code mode
3. Implement the changes in `api/index.js`
4. Add CSS styles in `api/index.css`
5. Test the collapsible functionality
6. Verify all edge cases work correctly

View File

@@ -0,0 +1,155 @@
# Subscription Table WSI Pointer Grouping Implementation Plan
## Objective
Modify the subscription details table to show the WSI Pointer value only once per group - on the first row of each WSI Pointer group, leaving it blank for subsequent rows with the same WSI Pointer.
## Current Behavior
- All rows show the WSI Pointer value
- Rows are sorted by WSI Pointer (grouping is working)
- Visual grouping is not clear
## Desired Behavior
- First row of each WSI Pointer group shows the full WSI Pointer value
- Subsequent rows in the same group have an empty cell for WSI Pointer
- This creates a clear visual grouping effect
## Implementation Details
### File to Modify
`api/index.js` - Function `populateSubscriptionDetailsTable()` (lines 4277-4384)
### Code Changes Required
#### Current Code (lines 4291-4383):
```javascript
// Sort subscriptions by wsi_pointer to group them together
subscriptionsData.sort((a, b) => {
const wsiA = a.wsi_pointer || '';
const wsiB = b.wsi_pointer || '';
return wsiA.localeCompare(wsiB);
});
subscriptionsData.forEach((subscription, index) => {
const row = document.createElement('tr');
// Calculate duration
const now = Math.floor(Date.now() / 1000);
const duration = now - subscription.created_at;
const durationStr = formatDuration(duration);
// Format client IP (show full IP for admin view)
const clientIP = subscription.client_ip || 'unknown';
// Format wsi_pointer (show full pointer)
const wsiPointer = subscription.wsi_pointer || 'N/A';
// Format filters (show actual filter details)
let filtersDisplay = 'None';
// ... filter formatting code ...
row.innerHTML = `
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${wsiPointer}</td>
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${subscription.id || 'N/A'}</td>
<!-- <td style="font-family: 'Courier New', monospace; font-size: 12px;">${clientIP}</td> -->
<td>${durationStr}</td>
<td>${filtersDisplay}</td>
`;
tableBody.appendChild(row);
});
```
#### Modified Code:
```javascript
// Sort subscriptions by wsi_pointer to group them together
subscriptionsData.sort((a, b) => {
const wsiA = a.wsi_pointer || '';
const wsiB = b.wsi_pointer || '';
return wsiA.localeCompare(wsiB);
});
// Track previous WSI Pointer to detect group changes
let previousWsiPointer = null;
subscriptionsData.forEach((subscription, index) => {
const row = document.createElement('tr');
// Calculate duration
const now = Math.floor(Date.now() / 1000);
const duration = now - subscription.created_at;
const durationStr = formatDuration(duration);
// Format client IP (show full IP for admin view)
const clientIP = subscription.client_ip || 'unknown';
// Format wsi_pointer - only show if it's different from previous row
const currentWsiPointer = subscription.wsi_pointer || 'N/A';
let wsiPointerDisplay = '';
if (currentWsiPointer !== previousWsiPointer) {
// This is the first row of a new group - show the WSI Pointer
wsiPointerDisplay = currentWsiPointer;
previousWsiPointer = currentWsiPointer;
} else {
// This is a continuation of the same group - leave blank
wsiPointerDisplay = '';
}
// Format filters (show actual filter details)
let filtersDisplay = 'None';
// ... filter formatting code remains the same ...
row.innerHTML = `
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${wsiPointerDisplay}</td>
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${subscription.id || 'N/A'}</td>
<!-- <td style="font-family: 'Courier New', monospace; font-size: 12px;">${clientIP}</td> -->
<td>${durationStr}</td>
<td>${filtersDisplay}</td>
`;
tableBody.appendChild(row);
});
```
### Key Changes Explained
1. **Add tracking variable**: `let previousWsiPointer = null;` before the forEach loop
2. **Store current WSI Pointer**: `const currentWsiPointer = subscription.wsi_pointer || 'N/A';`
3. **Compare with previous**: Check if `currentWsiPointer !== previousWsiPointer`
4. **Conditional display**:
- If different: Show the WSI Pointer value and update `previousWsiPointer`
- If same: Show empty string (blank cell)
5. **Use display variable**: Replace `${wsiPointer}` with `${wsiPointerDisplay}` in the row HTML
### Visual Result
**Before:**
```
WSI Pointer | Subscription ID | Duration | Filters
0x12345678 | sub-001 | 5m 30s | kinds:[1]
0x12345678 | sub-002 | 3m 15s | kinds:[3]
0x87654321 | sub-003 | 1m 45s | kinds:[1,3]
```
**After:**
```
WSI Pointer | Subscription ID | Duration | Filters
0x12345678 | sub-001 | 5m 30s | kinds:[1]
| sub-002 | 3m 15s | kinds:[3]
0x87654321 | sub-003 | 1m 45s | kinds:[1,3]
```
## Testing Checklist
1. ✅ Verify first row of each group shows WSI Pointer
2. ✅ Verify subsequent rows in same group are blank
3. ✅ Verify grouping works with multiple subscriptions per WSI Pointer
4. ✅ Verify single subscription per WSI Pointer still shows the value
5. ✅ Verify empty/null WSI Pointers are handled correctly
6. ✅ Verify table still displays correctly when no subscriptions exist
## Next Steps
1. Review this plan
2. Switch to Code mode
3. Implement the changes in `api/index.js`
4. Test the implementation
5. Verify the visual grouping effect

View File

@@ -8,10 +8,10 @@ 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"; }
print_status() { echo -e "${BLUE}[INFO]${NC} $1" >&2; }
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
# Global variables
COMMIT_MESSAGE=""
@@ -21,31 +21,42 @@ VERSION_INCREMENT_TYPE="patch" # "patch", "minor", or "major"
show_usage() {
echo "C-Relay Increment and Push Script"
echo ""
echo "USAGE:"
echo " $0 [OPTIONS] \"commit message\""
echo ""
echo "COMMANDS:"
echo " $0 \"commit message\" - Default: increment patch, commit & push"
echo " $0 --patch \"commit message\" - Increment patch version"
echo " $0 --minor \"commit message\" - Increment minor version"
echo " $0 --major \"commit message\" - Increment major version"
echo " $0 -r \"commit message\" - Release: increment minor, create release with assets"
echo " $0 -h - Show this help message"
echo " $0 \"commit message\" Default: increment patch, commit & push"
echo " $0 -p \"commit message\" Increment patch version"
echo " $0 -m \"commit message\" Increment minor version"
echo " $0 -M \"commit message\" Increment major version"
echo " $0 -r \"commit message\" Create release with assets (no version increment)"
echo " $0 -r -m \"commit message\" Create release with minor version increment"
echo " $0 -h Show this help message"
echo ""
echo "OPTIONS:"
echo " -p, --patch Increment patch version (default)"
echo " -m, --minor Increment minor version"
echo " -M, --major Increment major version"
echo " -r, --release Create release with assets"
echo " -h, --help Show this help message"
echo ""
echo "EXAMPLES:"
echo " $0 \"Fixed event validation bug\""
echo " $0 --patch \"Fixed event validation bug\""
echo " $0 --minor \"Added new features\""
echo " $0 --major \"Breaking API changes\""
echo " $0 --release \"Major release with new features\""
echo " $0 -m \"Added new features\""
echo " $0 -M \"Breaking API changes\""
echo " $0 -r \"Release current version\""
echo " $0 -r -m \"Release with minor increment\""
echo ""
echo "VERSION INCREMENT MODES:"
echo " --patch (default): Increment patch version (v1.2.3 → v1.2.4)"
echo " --minor: Increment minor version, zero patch (v1.2.3 → v1.3.0)"
echo " --major: Increment major version, zero minor+patch (v1.2.3 → v2.0.0)"
echo " -p, --patch (default): Increment patch version (v1.2.3 → v1.2.4)"
echo " -m, --minor: Increment minor version, zero patch (v1.2.3 → v1.3.0)"
echo " -M, --major: Increment major version, zero minor+patch (v1.2.3 → v2.0.0)"
echo ""
echo "RELEASE MODE (-r flag):"
echo " - Increment minor version, zero patch (v1.2.3 → v1.3.0)"
echo " - Build static binary using build_static.sh"
echo " - Create source tarball"
echo " - Git add, commit, push, and create Gitea release with assets"
echo " - Can be combined with version increment flags"
echo ""
echo "REQUIREMENTS FOR RELEASE MODE:"
echo " - Gitea token in ~/.gitea_token for release uploads"
@@ -57,18 +68,17 @@ while [[ $# -gt 0 ]]; do
case $1 in
-r|--release)
RELEASE_MODE=true
VERSION_INCREMENT_TYPE="minor"
shift
;;
--patch)
-p|--patch)
VERSION_INCREMENT_TYPE="patch"
shift
;;
--minor)
-m|--minor)
VERSION_INCREMENT_TYPE="minor"
shift
;;
--major)
-M|--major)
VERSION_INCREMENT_TYPE="major"
shift
;;
@@ -356,20 +366,38 @@ upload_release_assets() {
local token=$(cat "$HOME/.gitea_token" | tr -d '\n\r')
local api_url="https://git.laantungir.net/api/v1/repos/laantungir/c-relay"
local assets_url="$api_url/releases/$release_id/assets"
print_status "Assets URL: $assets_url"
# Upload binary
if [[ -f "$binary_path" ]]; then
print_status "Uploading binary: $(basename "$binary_path")"
local binary_response=$(curl -s -X POST "$api_url/releases/$release_id/assets" \
-H "Authorization: token $token" \
-H "Content-Type: application/octet-stream" \
-F "attachment=@$binary_path;filename=$(basename "$binary_path")")
if echo "$binary_response" | grep -q '"id"'; then
print_success "Uploaded binary successfully"
else
print_warning "Failed to upload binary: $binary_response"
fi
# Retry loop for eventual consistency
local max_attempts=3
local attempt=1
while [[ $attempt -le $max_attempts ]]; do
print_status "Upload attempt $attempt/$max_attempts"
local binary_response=$(curl -fS -X POST "$assets_url" \
-H "Authorization: token $token" \
-F "attachment=@$binary_path;filename=$(basename "$binary_path")" \
-F "name=$(basename "$binary_path")")
if echo "$binary_response" | grep -q '"id"'; then
print_success "Uploaded binary successfully"
break
else
print_warning "Upload attempt $attempt failed"
if [[ $attempt -lt $max_attempts ]]; then
print_status "Retrying in 2 seconds..."
sleep 2
else
print_error "Failed to upload binary after $max_attempts attempts"
print_error "Response: $binary_response"
fi
fi
((attempt++))
done
fi
# Upload source tarball
@@ -377,7 +405,6 @@ upload_release_assets() {
print_status "Uploading source tarball: $(basename "$tarball_path")"
local tarball_response=$(curl -s -X POST "$api_url/releases/$release_id/assets" \
-H "Authorization: token $token" \
-H "Content-Type: application/octet-stream" \
-F "attachment=@$tarball_path;filename=$(basename "$tarball_path")")
if echo "$tarball_response" | grep -q '"id"'; then
@@ -412,18 +439,20 @@ create_gitea_release() {
if echo "$response" | grep -q '"id"'; then
print_success "Created release $NEW_VERSION"
# Extract release ID for asset uploads
local release_id=$(echo "$response" | grep -o '"id":[0-9]*' | cut -d':' -f2)
return $release_id
local release_id=$(echo "$response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
echo $release_id
elif echo "$response" | grep -q "already exists"; then
print_warning "Release $NEW_VERSION already exists"
# Try to get existing release ID
local check_response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION")
if echo "$check_response" | grep -q '"id"'; then
local release_id=$(echo "$check_response" | grep -o '"id":[0-9]*' | cut -d':' -f2)
local release_id=$(echo "$check_response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
print_status "Using existing release ID: $release_id"
return $release_id
echo $release_id
else
print_error "Could not find existing release ID"
return 1
fi
return 0
else
print_error "Failed to create release $NEW_VERSION"
print_error "Response: $response"
@@ -433,8 +462,8 @@ create_gitea_release() {
local check_response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION")
if echo "$check_response" | grep -q '"id"'; then
print_warning "Release exists but creation response was unexpected"
local release_id=$(echo "$check_response" | grep -o '"id":[0-9]*' | cut -d':' -f2)
return $release_id
local release_id=$(echo "$check_response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
echo $release_id
else
print_error "Release does not exist and creation failed"
return 1
@@ -452,8 +481,15 @@ main() {
if [[ "$RELEASE_MODE" == true ]]; then
print_status "=== RELEASE MODE ==="
# Increment version based on type (default to minor for releases)
increment_version "$VERSION_INCREMENT_TYPE"
# Only increment version if explicitly requested (not just because of -r flag)
if [[ "$VERSION_INCREMENT_TYPE" != "patch" ]]; then
increment_version "$VERSION_INCREMENT_TYPE"
else
# In release mode without version increment, get current version
LATEST_TAG=$(git tag -l 'v*.*.*' | sort -V | tail -n 1 || echo "v0.0.0")
NEW_VERSION="$LATEST_TAG"
export NEW_VERSION
fi
# Create new git tag BEFORE compilation so version.h picks it up
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
@@ -472,7 +508,13 @@ main() {
local binary_path="build/c_relay_static_x86_64"
else
print_warning "Binary build failed, continuing with release creation"
binary_path=""
# Check if binary exists from previous build
if [[ -f "build/c_relay_static_x86_64" ]]; then
print_status "Using existing binary from previous build"
binary_path="build/c_relay_static_x86_64"
else
binary_path=""
fi
fi
# Create source tarball
@@ -486,11 +528,17 @@ main() {
# Create Gitea release
local release_id=""
if release_id=$(create_gitea_release); then
# Upload assets if we have a release ID and assets
if [[ -n "$release_id" && (-n "$binary_path" || -n "$tarball_path") ]]; then
upload_release_assets "$release_id" "$binary_path" "$tarball_path"
# Validate release_id is numeric
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
# Upload assets if we have a release ID and assets
if [[ -n "$release_id" && (-n "$binary_path" || -n "$tarball_path") ]]; then
upload_release_assets "$release_id" "$binary_path" "$tarball_path"
fi
print_success "Release $NEW_VERSION completed successfully!"
else
print_error "Invalid release_id: $release_id"
exit 1
fi
print_success "Release $NEW_VERSION completed successfully!"
else
print_error "Release creation failed"
fi

1
nips

Submodule nips deleted from 8c45ff5d96

View File

@@ -1 +1 @@
2726527
1505647

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
screenshots/raffles.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

View File

@@ -1503,6 +1503,32 @@ char* generate_config_text(void) {
return config_text;
}
// Helper function to format numbers with commas
void format_number_with_commas(long long num, char* buffer, size_t buffer_size) {
char temp[32];
int len = snprintf(temp, sizeof(temp), "%lld", num);
int comma_count = (len - 1) / 3;
int result_len = len + comma_count;
if (result_len >= (int)buffer_size) {
buffer[0] = '\0';
return;
}
int j = result_len - 1;
int k = len - 1;
int comma_pos = 0;
while (k >= 0) {
buffer[j--] = temp[k--];
comma_pos++;
if (comma_pos % 3 == 0 && k >= 0) {
buffer[j--] = ',';
}
}
buffer[result_len] = '\0';
}
// Generate human-readable stats text
char* generate_stats_text(void) {
char* stats_json = generate_stats_json();
@@ -1575,17 +1601,17 @@ char* generate_stats_text(void) {
// Database Overview section
offset += snprintf(stats_text + offset, 16384 - offset,
"Database Size %.2f MB\n"
"Total Events %lld\n"
"Active Subscriptions %d\n"
"Oldest Event %s\n"
"Newest Event %s\n"
"\n",
db_mb, db_bytes, total, active_subs, oldest_str, newest_str);
"Database Size %.2f MB (%lld bytes)\n"
"Total Events %lld\n"
"Active Subscriptions %d\n"
"Oldest Event %s\n"
"Newest Event %s\n"
"\n",
db_mb, db_bytes, total, active_subs, oldest_str, newest_str);
// Event Kind Distribution section
offset += snprintf(stats_text + offset, 16384 - offset,
"Event Kind Distribution:\n");
"Event_Kind_Distribution:\n");
if (event_kinds && cJSON_IsArray(event_kinds)) {
cJSON* kind_item = NULL;
@@ -1595,11 +1621,45 @@ char* generate_stats_text(void) {
cJSON* percentage = cJSON_GetObjectItem(kind_item, "percentage");
if (kind && count && percentage) {
// Format event kind (right-justified, minimum 5 chars wide with underscores)
char kind_str[16];
int kind_val = (int)cJSON_GetNumberValue(kind);
char temp_kind[16];
snprintf(temp_kind, sizeof(temp_kind), "%d", kind_val);
// Calculate padding needed (minimum 5 chars, but more if kind is longer)
int kind_len = strlen(temp_kind);
int min_width = 5;
int total_width = (kind_len > min_width) ? kind_len : min_width;
int padding = total_width - kind_len;
// Create padded string with underscores
memset(kind_str, '_', padding);
strcpy(kind_str + padding, temp_kind);
// Format count with commas (right-justified, 11 chars wide with underscores)
char count_str[16];
format_number_with_commas((long long)cJSON_GetNumberValue(count), count_str, sizeof(count_str));
char count_formatted[16];
snprintf(count_formatted, sizeof(count_formatted), "%11s", count_str);
// Replace spaces with underscores for alignment
for (int i = 0; count_formatted[i]; i++) {
if (count_formatted[i] == ' ') count_formatted[i] = '_';
}
// Format percentage (right-justified, 7 chars wide including %, with underscores)
double pct = cJSON_GetNumberValue(percentage);
char pct_str[8];
snprintf(pct_str, sizeof(pct_str), "%.1f%%", pct);
// Pad with underscores to make 7 chars total (right-justified)
char pct_formatted[8];
int pct_len = strlen(pct_str);
int underscores_needed = 7 - pct_len;
memset(pct_formatted, '_', underscores_needed);
strcpy(pct_formatted + underscores_needed, pct_str);
offset += snprintf(stats_text + offset, 16384 - offset,
"%lld\t%lld\t%.1f%%\n",
(long long)cJSON_GetNumberValue(kind),
(long long)cJSON_GetNumberValue(count),
cJSON_GetNumberValue(percentage));
"%s%s_%s\n", kind_str, count_formatted, pct_formatted);
}
}
} else {

View File

@@ -9,6 +9,9 @@
#ifdef VERSION
#undef VERSION
#endif
#ifdef VERSION_MAJOR
#undef VERSION_MAJOR
#endif
#ifdef VERSION_MINOR
#undef VERSION_MINOR
#endif

View File

@@ -65,6 +65,9 @@ static const struct {
{"max_total_subscriptions", "5000"},
{"max_filters_per_subscription", "10"},
// Connection Management
{"max_connection_seconds", "86400"}, // 24 hours (0 = disabled)
// Event Processing Limits
{"max_event_tags", "100"},
{"max_content_length", "8196"},

File diff suppressed because one or more lines are too long

View File

@@ -1807,7 +1807,8 @@ int main(int argc, char* argv[]) {
return 1;
}
// Cleanup orphaned subscriptions from previous runs
cleanup_all_subscriptions_on_startup();
// Start WebSocket Nostr relay server (port from CLI override or configuration)
int result = start_websocket_relay(cli_options.port_override, cli_options.strict_port); // Use CLI port override if specified, otherwise config

View File

@@ -10,10 +10,14 @@
#define MAIN_H
// Version information (auto-updated by build system)
#define VERSION_MAJOR 0
#define VERSION_MINOR 8
#define VERSION_PATCH 3
#define VERSION "v0.8.3"
#define VERSION_MAJOR 1
#define VERSION_MINOR 0
#define VERSION_PATCH 7
#define VERSION "v1.0.7"
// Avoid VERSION_MAJOR redefinition warning from nostr_core_lib
#undef VERSION_MAJOR
#define VERSION_MAJOR 1
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay"

View File

@@ -999,6 +999,44 @@ void update_subscription_events_sent(const char* sub_id, int events_sent) {
}
}
// Cleanup all subscriptions on startup
void cleanup_all_subscriptions_on_startup(void) {
if (!g_db) {
DEBUG_ERROR("Database not available for startup cleanup");
return;
}
DEBUG_LOG("Performing startup subscription cleanup");
// Mark all active subscriptions as disconnected
const char* sql =
"UPDATE subscriptions "
"SET ended_at = strftime('%s', 'now') "
"WHERE event_type = 'created' AND ended_at IS NULL";
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR("Failed to prepare startup cleanup query");
return;
}
rc = sqlite3_step(stmt);
int changes = sqlite3_changes(g_db);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
DEBUG_ERROR("Failed to execute startup cleanup");
return;
}
if (changes > 0) {
DEBUG_LOG("Startup cleanup: marked %d orphaned subscriptions as disconnected", changes);
} else {
DEBUG_LOG("Startup cleanup: no orphaned subscriptions found");
}
}
///////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////

View File

@@ -120,4 +120,7 @@ void update_subscription_events_sent(const char* sub_id, int events_sent);
// Subscription query functions
int has_subscriptions_for_kind(int event_kind);
// Startup cleanup function
void cleanup_all_subscriptions_on_startup(void);
#endif // SUBSCRIPTIONS_H

View File

@@ -471,18 +471,720 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
case LWS_CALLBACK_RECEIVE:
if (len > 0) {
DEBUG_TRACE("LWS_CALLBACK_RECEIVE: received %zu bytes", len);
// Check if client is rate limited for malformed requests
if (is_client_rate_limited_for_malformed_requests(pss)) {
send_notice_message(wsi, "error: too many malformed requests - temporarily blocked");
return 0;
}
// Check if this is a fragmented message
int is_first_fragment = lws_is_first_fragment(wsi);
int is_final_fragment = lws_is_final_fragment(wsi);
size_t remaining_payload = lws_remaining_packet_payload(wsi);
DEBUG_TRACE("Fragment info: first=%d, final=%d, remaining=%zu, reassembly_active=%d",
is_first_fragment, is_final_fragment, remaining_payload, pss->reassembly_active);
// Handle message reassembly for fragmented messages
// Only use reassembly if message is actually fragmented (not both first and final)
int is_fragmented = (is_first_fragment && !is_final_fragment) || pss->reassembly_active;
if (is_fragmented) {
// Start or continue reassembly
if (is_first_fragment) {
// First fragment - initialize reassembly buffer
if (pss->reassembly_buffer) {
DEBUG_WARN("Starting new reassembly but buffer already exists - cleaning up");
free(pss->reassembly_buffer);
pss->reassembly_buffer = NULL; // Ensure buffer is NULL after cleanup
}
pss->reassembly_size = 0;
pss->reassembly_capacity = 0;
pss->reassembly_active = 1;
DEBUG_TRACE("Starting message reassembly");
}
// Ensure buffer has enough capacity
size_t needed_capacity = pss->reassembly_size + len + 1; // +1 for null terminator
if (needed_capacity > pss->reassembly_capacity) {
size_t new_capacity = pss->reassembly_capacity == 0 ? 8192 : pss->reassembly_capacity * 2;
while (new_capacity < needed_capacity) {
new_capacity *= 2;
}
char* new_buffer = realloc(pss->reassembly_buffer, new_capacity);
if (!new_buffer) {
DEBUG_ERROR("Failed to allocate reassembly buffer (capacity %zu)", new_capacity);
// Clean up and abort reassembly
free(pss->reassembly_buffer);
pss->reassembly_buffer = NULL;
pss->reassembly_size = 0;
pss->reassembly_capacity = 0;
pss->reassembly_active = 0;
send_notice_message(wsi, "error: message too large - memory allocation failed");
return 0;
}
pss->reassembly_buffer = new_buffer;
pss->reassembly_capacity = new_capacity;
DEBUG_TRACE("Expanded reassembly buffer to %zu bytes", new_capacity);
}
// Append fragment to buffer
memcpy(pss->reassembly_buffer + pss->reassembly_size, in, len);
pss->reassembly_size += len;
// Check if this is the final fragment
if (is_final_fragment) {
// Message complete - process it
pss->reassembly_buffer[pss->reassembly_size] = '\0';
pss->reassembly_active = 0;
DEBUG_TRACE("Message reassembly complete: total size %zu bytes", pss->reassembly_size);
// Process the complete message
char* complete_message = pss->reassembly_buffer;
size_t message_len = pss->reassembly_size;
// Reset reassembly state (keep buffer pointer for processing)
pss->reassembly_size = 0;
pss->reassembly_active = 0;
// Parse JSON message
DEBUG_TRACE("Parsing reassembled JSON message of length %zu", message_len);
cJSON* json = cJSON_Parse(complete_message);
// Process the message (same logic as before)
if (json && cJSON_IsArray(json)) {
// Get message type
cJSON* type = cJSON_GetArrayItem(json, 0);
if (type && cJSON_IsString(type)) {
const char* msg_type = cJSON_GetStringValue(type);
if (strcmp(msg_type, "EVENT") == 0) {
// Handle EVENT message
cJSON* event = cJSON_GetArrayItem(json, 1);
if (event && cJSON_IsObject(event)) {
// Extract event JSON string for unified validator
char *event_json_str = cJSON_Print(event);
if (!event_json_str) {
DEBUG_ERROR("Failed to serialize event JSON for validation");
cJSON* error_response = cJSON_CreateArray();
cJSON_AddItemToArray(error_response, cJSON_CreateString("OK"));
cJSON_AddItemToArray(error_response, cJSON_CreateString("unknown"));
cJSON_AddItemToArray(error_response, cJSON_CreateBool(0));
cJSON_AddItemToArray(error_response, cJSON_CreateString("error: failed to process event"));
char *error_str = cJSON_Print(error_response);
if (error_str) {
size_t error_len = strlen(error_str);
// Use proper message queue system instead of direct lws_write
if (queue_message(wsi, pss, error_str, error_len, LWS_WRITE_TEXT) != 0) {
DEBUG_ERROR("Failed to queue error response message");
}
free(error_str);
}
cJSON_Delete(error_response);
cJSON_Delete(json);
// Note: complete_message points to reassembly_buffer, which is managed separately
// and should not be freed here - it will be cleaned up in LWS_CALLBACK_CLOSED
return 0;
}
// Call unified validator with JSON string
size_t event_json_len = strlen(event_json_str);
int validation_result = nostr_validate_unified_request(event_json_str, event_json_len);
// Map validation result to old result format (0 = success, -1 = failure)
int result = (validation_result == NOSTR_SUCCESS) ? 0 : -1;
// Generate error message based on validation result
char error_message[512] = {0};
if (result != 0) {
switch (validation_result) {
case NOSTR_ERROR_INVALID_INPUT:
strncpy(error_message, "invalid: malformed event structure", sizeof(error_message) - 1);
break;
case NOSTR_ERROR_EVENT_INVALID_SIGNATURE:
strncpy(error_message, "invalid: signature verification failed", sizeof(error_message) - 1);
break;
case NOSTR_ERROR_EVENT_INVALID_ID:
strncpy(error_message, "invalid: event id verification failed", sizeof(error_message) - 1);
break;
case NOSTR_ERROR_EVENT_INVALID_PUBKEY:
strncpy(error_message, "invalid: invalid pubkey format", sizeof(error_message) - 1);
break;
case -103: // NOSTR_ERROR_EVENT_EXPIRED
strncpy(error_message, "rejected: event expired", sizeof(error_message) - 1);
break;
case -102: // NOSTR_ERROR_NIP42_DISABLED
strncpy(error_message, "auth-required: NIP-42 authentication required", sizeof(error_message) - 1);
break;
case -101: // NOSTR_ERROR_AUTH_REQUIRED
strncpy(error_message, "blocked: pubkey not authorized", sizeof(error_message) - 1);
break;
default:
strncpy(error_message, "error: validation failed", sizeof(error_message) - 1);
break;
}
}
// Cleanup event JSON string
free(event_json_str);
// Check for NIP-70 protected events
if (result == 0) {
// Check if event has protected tag ["-"]
int is_protected_event = 0;
cJSON* tags = cJSON_GetObjectItem(event, "tags");
if (tags && cJSON_IsArray(tags)) {
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 1) {
cJSON* tag_name = cJSON_GetArrayItem(tag, 0);
if (tag_name && cJSON_IsString(tag_name) &&
strcmp(cJSON_GetStringValue(tag_name), "-") == 0) {
is_protected_event = 1;
break;
}
}
}
}
if (is_protected_event) {
// Check if protected events are enabled using config
int protected_events_enabled = get_config_bool("nip70_protected_events_enabled", 0);
if (!protected_events_enabled) {
// Protected events not supported
result = -1;
strncpy(error_message, "blocked: protected events not supported", sizeof(error_message) - 1);
error_message[sizeof(error_message) - 1] = '\0';
DEBUG_WARN("Protected event rejected: protected events not enabled");
} else {
// Protected events enabled - check authentication
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
const char* event_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!pss || !pss->authenticated ||
!event_pubkey || strcmp(pss->authenticated_pubkey, event_pubkey) != 0) {
// Not authenticated or pubkey mismatch
result = -1;
strncpy(error_message, "auth-required: protected event requires authentication", sizeof(error_message) - 1);
error_message[sizeof(error_message) - 1] = '\0';
DEBUG_WARN("Protected event rejected: authentication required");
}
}
}
}
// Check for admin events (kind 23456) and intercept them
if (result == 0) {
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
if (kind_obj && cJSON_IsNumber(kind_obj)) {
int event_kind = (int)cJSON_GetNumberValue(kind_obj);
DEBUG_TRACE("Processing event kind %d, message length: %zu", event_kind, message_len);
// Log reception of Kind 23456 events
if (event_kind == 23456) {
DEBUG_LOG("Admin event (kind 23456) received");
}
if (event_kind == 23456) {
// Enhanced admin event security - check authorization first
char auth_error[512] = {0};
int auth_result = is_authorized_admin_event(event, auth_error, sizeof(auth_error));
if (auth_result != 0) {
// Authorization failed - log and reject
DEBUG_WARN("Admin event authorization failed");
result = -1;
size_t error_len = strlen(auth_error);
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
memcpy(error_message, auth_error, copy_len);
error_message[copy_len] = '\0';
} else {
// Authorization successful - process through admin API
char admin_error[512] = {0};
int admin_result = process_admin_event_in_config(event, admin_error, sizeof(admin_error), wsi);
// Log results for Kind 23456 events
if (event_kind == 23456) {
if (admin_result != 0) {
char error_result_msg[512];
if (strlen(admin_error) > 0) {
// Safely truncate admin_error if too long
size_t max_error_len = sizeof(error_result_msg) - 50; // Leave room for prefix
size_t error_len = strlen(admin_error);
if (error_len > max_error_len) {
error_len = max_error_len;
}
char truncated_error[512];
memcpy(truncated_error, admin_error, error_len);
truncated_error[error_len] = '\0';
// Use a safer approach to avoid truncation warning
size_t prefix_len = snprintf(error_result_msg, sizeof(error_result_msg),
"ERROR: Kind %d event processing failed: ", event_kind);
if (prefix_len < sizeof(error_result_msg)) {
size_t remaining = sizeof(error_result_msg) - prefix_len;
size_t copy_len = strlen(truncated_error);
if (copy_len >= remaining) {
copy_len = remaining - 1;
}
memcpy(error_result_msg + prefix_len, truncated_error, copy_len);
error_result_msg[prefix_len + copy_len] = '\0';
}
} else {
snprintf(error_result_msg, sizeof(error_result_msg),
"ERROR: Kind %d event processing failed", event_kind);
}
DEBUG_ERROR(error_result_msg);
}
}
if (admin_result != 0) {
DEBUG_ERROR("Failed to process admin event");
result = -1;
size_t error_len = strlen(admin_error);
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
memcpy(error_message, admin_error, copy_len);
error_message[copy_len] = '\0';
} else {
// Admin events are processed by the admin API, not broadcast to subscriptions
}
}
} else if (event_kind == 1059) {
// Check for NIP-17 gift wrap admin messages
char nip17_error[512] = {0};
cJSON* response_event = process_nip17_admin_message(event, nip17_error, sizeof(nip17_error), wsi);
if (!response_event) {
// Check if this is an error or if the command was already handled
if (strlen(nip17_error) > 0) {
// There was an actual error
DEBUG_ERROR("NIP-17 admin message processing failed");
result = -1;
size_t error_len = strlen(nip17_error);
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
memcpy(error_message, nip17_error, copy_len);
error_message[copy_len] = '\0';
} else {
// No error message means the command was already handled (plain text commands)
// Store the original gift wrap event in database
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store gift wrap event in database");
result = -1;
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
}
}
} else {
// Store the original gift wrap event in database (unlike kind 23456)
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store gift wrap event in database");
result = -1;
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
cJSON_Delete(response_event);
} else {
// Broadcast RESPONSE event to matching persistent subscriptions
broadcast_event_to_subscriptions(response_event);
// Clean up response event
cJSON_Delete(response_event);
}
}
} else if (event_kind == 14) {
// Check for DM stats commands addressed to relay
char dm_error[512] = {0};
int dm_result = process_dm_stats_command(event, dm_error, sizeof(dm_error), wsi);
if (dm_result != 0) {
DEBUG_ERROR("DM stats command processing failed");
result = -1;
size_t error_len = strlen(dm_error);
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
memcpy(error_message, dm_error, copy_len);
error_message[copy_len] = '\0';
} else {
// Store the DM event in database
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store DM event in database");
result = -1;
strncpy(error_message, "error: failed to store DM event", sizeof(error_message) - 1);
} else {
// Broadcast DM event to matching persistent subscriptions
broadcast_event_to_subscriptions(event);
}
}
} else {
// Check if this is an ephemeral event (kinds 20000-29999)
// Per NIP-01: ephemeral events are broadcast but never stored
if (event_kind >= 20000 && event_kind < 30000) {
DEBUG_TRACE("Ephemeral event (kind %d) - broadcasting without storage", event_kind);
// Broadcast directly to subscriptions without database storage
broadcast_event_to_subscriptions(event);
} else {
DEBUG_TRACE("Storing regular event in database");
// Regular event - store in database and broadcast
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store event in database");
result = -1;
strncpy(error_message, "error: failed to store event", sizeof(error_message) - 1);
} else {
DEBUG_LOG("Event stored and broadcast (kind %d)", event_kind);
// Broadcast event to matching persistent subscriptions
broadcast_event_to_subscriptions(event);
}
}
}
} else {
// Event without valid kind - try normal storage
DEBUG_WARN("Event without valid kind - trying normal storage");
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store event without kind in database");
result = -1;
strncpy(error_message, "error: failed to store event", sizeof(error_message) - 1);
} else {
broadcast_event_to_subscriptions(event);
}
}
}
// Send OK response
cJSON* event_id = cJSON_GetObjectItem(event, "id");
if (event_id && cJSON_IsString(event_id)) {
cJSON* response = cJSON_CreateArray();
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(strlen(error_message) > 0 ? error_message : ""));
char *response_str = cJSON_Print(response);
if (response_str) {
size_t response_len = strlen(response_str);
// DEBUG: Log WebSocket frame details before sending
DEBUG_TRACE("WS_FRAME_SEND: type=OK len=%zu data=%.100s%s",
response_len,
response_str,
response_len > 100 ? "..." : "");
// Queue message for proper libwebsockets pattern
if (queue_message(wsi, pss, response_str, response_len, LWS_WRITE_TEXT) != 0) {
DEBUG_ERROR("Failed to queue OK response message");
}
free(response_str);
}
cJSON_Delete(response);
}
}
} else if (strcmp(msg_type, "REQ") == 0) {
DEBUG_TRACE("REQ message received, starting processing");
// Check NIP-42 authentication for REQ subscriptions if required
if (pss && pss->nip42_auth_required_subscriptions && !pss->authenticated) {
DEBUG_TRACE("REQ rejected: NIP-42 authentication required");
if (!pss->auth_challenge_sent) {
send_nip42_auth_challenge(wsi, pss);
} else {
send_notice_message(wsi, "NIP-42 authentication required for subscriptions");
DEBUG_WARN("REQ rejected: NIP-42 authentication required");
}
cJSON_Delete(json);
// Note: complete_message points to reassembly_buffer, which is managed separately
// and should not be freed here - it will be cleaned up in LWS_CALLBACK_CLOSED
return 0;
}
DEBUG_TRACE("REQ message passed authentication check");
// Handle REQ message
cJSON* sub_id = cJSON_GetArrayItem(json, 1);
if (sub_id && cJSON_IsString(sub_id)) {
const char* subscription_id = cJSON_GetStringValue(sub_id);
DEBUG_TRACE("Processing REQ message for subscription %s", subscription_id);
// Validate subscription ID before processing
if (!subscription_id) {
DEBUG_TRACE("REQ rejected: NULL subscription ID");
send_notice_message(wsi, "error: invalid subscription ID");
DEBUG_WARN("REQ rejected: NULL subscription ID");
record_malformed_request(pss);
cJSON_Delete(json);
// Note: complete_message points to reassembly_buffer, which is managed separately
// and should not be freed here - it will be cleaned up in LWS_CALLBACK_CLOSED
return 0;
}
// Validate subscription ID
if (!validate_subscription_id(subscription_id)) {
DEBUG_TRACE("REQ rejected: invalid subscription ID format");
send_notice_message(wsi, "error: invalid subscription ID");
DEBUG_WARN("REQ rejected: invalid subscription ID");
cJSON_Delete(json);
// Note: complete_message points to reassembly_buffer, which is managed separately
// and should not be freed here - it will be cleaned up in LWS_CALLBACK_CLOSED
return 0;
}
DEBUG_TRACE("REQ subscription ID validated: %s", subscription_id);
// Create array of filter objects from position 2 onwards
cJSON* filters = cJSON_CreateArray();
if (!filters) {
DEBUG_TRACE("REQ failed: could not create filters array");
send_notice_message(wsi, "error: failed to process filters");
DEBUG_ERROR("REQ failed: could not create filters array");
cJSON_Delete(json);
// Note: complete_message points to reassembly_buffer, which is managed separately
// and should not be freed here - it will be cleaned up in LWS_CALLBACK_CLOSED
return 0;
}
int json_size = cJSON_GetArraySize(json);
int filter_count = 0;
for (int i = 2; i < json_size; i++) {
cJSON* filter = cJSON_GetArrayItem(json, i);
if (filter) {
cJSON_AddItemToArray(filters, cJSON_Duplicate(filter, 1));
filter_count++;
}
}
DEBUG_TRACE("REQ created %d filters from message", filter_count);
// Validate filters before processing
char filter_error[512] = {0};
if (!validate_filter_array(filters, filter_error, sizeof(filter_error))) {
DEBUG_TRACE("REQ rejected: filter validation failed - %s", filter_error);
send_notice_message(wsi, filter_error);
DEBUG_WARN("REQ rejected: invalid filters");
record_malformed_request(pss);
cJSON_Delete(filters);
cJSON_Delete(json);
// Note: complete_message points to reassembly_buffer, which is managed separately
// and should not be freed here - it will be cleaned up in LWS_CALLBACK_CLOSED
return 0;
}
DEBUG_TRACE("REQ filters validated successfully");
DEBUG_TRACE("About to call handle_req_message for subscription %s", subscription_id);
handle_req_message(subscription_id, filters, wsi, pss);
DEBUG_TRACE("handle_req_message completed for subscription %s", subscription_id);
// Clean up the filters array we created
cJSON_Delete(filters);
DEBUG_LOG("REQ subscription %s processed, sending EOSE", subscription_id);
// Send EOSE (End of Stored Events)
cJSON* eose_response = cJSON_CreateArray();
if (eose_response) {
cJSON_AddItemToArray(eose_response, cJSON_CreateString("EOSE"));
cJSON_AddItemToArray(eose_response, cJSON_CreateString(subscription_id));
char *eose_str = cJSON_Print(eose_response);
if (eose_str) {
size_t eose_len = strlen(eose_str);
// DEBUG: Log WebSocket frame details before sending
DEBUG_TRACE("WS_FRAME_SEND: type=EOSE len=%zu data=%.100s%s",
eose_len,
eose_str,
eose_len > 100 ? "..." : "");
// Queue message for proper libwebsockets pattern
if (queue_message(wsi, pss, eose_str, eose_len, LWS_WRITE_TEXT) != 0) {
DEBUG_ERROR("Failed to queue EOSE message");
}
free(eose_str);
}
cJSON_Delete(eose_response);
}
} else {
send_notice_message(wsi, "error: missing or invalid subscription ID in REQ");
DEBUG_WARN("REQ rejected: missing or invalid subscription ID");
}
} else if (strcmp(msg_type, "COUNT") == 0) {
// Check NIP-42 authentication for COUNT requests if required
if (pss && pss->nip42_auth_required_subscriptions && !pss->authenticated) {
if (!pss->auth_challenge_sent) {
send_nip42_auth_challenge(wsi, pss);
} else {
send_notice_message(wsi, "NIP-42 authentication required for count requests");
DEBUG_WARN("COUNT rejected: NIP-42 authentication required");
}
cJSON_Delete(json);
// Note: complete_message points to reassembly_buffer, which is managed separately
// and should not be freed here - it will be cleaned up in LWS_CALLBACK_CLOSED
return 0;
}
// Handle COUNT message
cJSON* sub_id = cJSON_GetArrayItem(json, 1);
if (sub_id && cJSON_IsString(sub_id)) {
const char* subscription_id = cJSON_GetStringValue(sub_id);
// Create array of filter objects from position 2 onwards
cJSON* filters = cJSON_CreateArray();
int json_size = cJSON_GetArraySize(json);
for (int i = 2; i < json_size; i++) {
cJSON* filter = cJSON_GetArrayItem(json, i);
if (filter) {
cJSON_AddItemToArray(filters, cJSON_Duplicate(filter, 1));
}
}
// Validate filters before processing
char filter_error[512] = {0};
if (!validate_filter_array(filters, filter_error, sizeof(filter_error))) {
send_notice_message(wsi, filter_error);
DEBUG_WARN("COUNT rejected: invalid filters");
record_malformed_request(pss);
cJSON_Delete(filters);
cJSON_Delete(json);
// Note: complete_message points to reassembly_buffer, which is managed separately
// and should not be freed here - it will be cleaned up in LWS_CALLBACK_CLOSED
return 0;
}
handle_count_message(subscription_id, filters, wsi, pss);
// Clean up the filters array we created
cJSON_Delete(filters);
}
} else if (strcmp(msg_type, "CLOSE") == 0) {
// Handle CLOSE message
cJSON* sub_id = cJSON_GetArrayItem(json, 1);
if (sub_id && cJSON_IsString(sub_id)) {
const char* subscription_id = cJSON_GetStringValue(sub_id);
// Validate subscription ID before processing
if (!subscription_id) {
send_notice_message(wsi, "error: invalid subscription ID in CLOSE");
DEBUG_WARN("CLOSE rejected: NULL subscription ID");
cJSON_Delete(json);
// Note: complete_message points to reassembly_buffer, which is managed separately
// and should not be freed here - it will be cleaned up in LWS_CALLBACK_CLOSED
return 0;
}
// Validate subscription ID
if (!validate_subscription_id(subscription_id)) {
send_notice_message(wsi, "error: invalid subscription ID in CLOSE");
DEBUG_WARN("CLOSE rejected: invalid subscription ID");
cJSON_Delete(json);
// Note: complete_message points to reassembly_buffer, which is managed separately
// and should not be freed here - it will be cleaned up in LWS_CALLBACK_CLOSED
return 0;
}
// CRITICAL FIX: Mark subscription as inactive in global manager FIRST
// This prevents other threads from accessing it during removal
pthread_mutex_lock(&g_subscription_manager.subscriptions_lock);
subscription_t* target_sub = g_subscription_manager.active_subscriptions;
while (target_sub) {
if (strcmp(target_sub->id, subscription_id) == 0 && target_sub->wsi == wsi) {
target_sub->active = 0; // Mark as inactive immediately
break;
}
target_sub = target_sub->next;
}
pthread_mutex_unlock(&g_subscription_manager.subscriptions_lock);
// Now safe to remove from session list
if (pss) {
pthread_mutex_lock(&pss->session_lock);
struct subscription** current = &pss->subscriptions;
while (*current) {
if (strcmp((*current)->id, subscription_id) == 0) {
struct subscription* to_remove = *current;
*current = to_remove->session_next;
pss->subscription_count--;
break;
}
current = &((*current)->session_next);
}
pthread_mutex_unlock(&pss->session_lock);
}
// Finally remove from global manager (which will free it)
remove_subscription_from_manager(subscription_id, wsi);
// Subscription closed
} else {
send_notice_message(wsi, "error: missing or invalid subscription ID in CLOSE");
DEBUG_WARN("CLOSE rejected: missing or invalid subscription ID");
}
} else if (strcmp(msg_type, "AUTH") == 0) {
// Handle NIP-42 AUTH message
if (cJSON_GetArraySize(json) >= 2) {
cJSON* auth_payload = cJSON_GetArrayItem(json, 1);
if (cJSON_IsString(auth_payload)) {
// AUTH challenge response: ["AUTH", <challenge>] (unusual)
handle_nip42_auth_challenge_response(wsi, pss, cJSON_GetStringValue(auth_payload));
} else if (cJSON_IsObject(auth_payload)) {
// AUTH signed event: ["AUTH", <event>] (standard NIP-42)
handle_nip42_auth_signed_event(wsi, pss, auth_payload);
} else {
send_notice_message(wsi, "Invalid AUTH message format");
DEBUG_WARN("Received AUTH message with invalid payload type");
}
} else {
send_notice_message(wsi, "AUTH message requires payload");
DEBUG_WARN("Received AUTH message without payload");
}
} else {
// Unknown message type
char unknown_msg[128];
snprintf(unknown_msg, sizeof(unknown_msg), "Unknown message type: %.32s", msg_type);
DEBUG_WARN(unknown_msg);
send_notice_message(wsi, "Unknown message type");
}
}
}
// Clean up the reassembled message
if (json) cJSON_Delete(json);
// Free the reassembly buffer now that processing is complete
free(complete_message);
pss->reassembly_buffer = NULL;
pss->reassembly_capacity = 0;
return 0; // Fragmented message processed
} else {
// Not the final fragment - continue accumulating
DEBUG_TRACE("Accumulated %zu bytes so far, waiting for more fragments", pss->reassembly_size);
return 0;
}
}
// Handle non-fragmented messages (original code path)
char *message = malloc(len + 1);
if (message) {
memcpy(message, in, len);
message[len] = '\0';
// Parse JSON message (this is the normal program flow)
DEBUG_TRACE("Parsing JSON message of length %zu", strlen(message));
cJSON* json = cJSON_Parse(message);
if (json && cJSON_IsArray(json)) {
// Get message type
@@ -682,7 +1384,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
if (kind_obj && cJSON_IsNumber(kind_obj)) {
int event_kind = (int)cJSON_GetNumberValue(kind_obj);
DEBUG_TRACE("Processing event kind %d", event_kind);
DEBUG_TRACE("Processing event kind %d, message length: %zu", event_kind, strlen(message));
// Log reception of Kind 23456 events
if (event_kind == 23456) {
@@ -885,10 +1587,13 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
}
}
} else if (strcmp(msg_type, "REQ") == 0) {
DEBUG_TRACE("REQ message received, starting processing");
// Log the full REQ message for debugging
// Check NIP-42 authentication for REQ subscriptions if required
if (pss && pss->nip42_auth_required_subscriptions && !pss->authenticated) {
DEBUG_TRACE("REQ rejected: NIP-42 authentication required");
if (!pss->auth_challenge_sent) {
send_nip42_auth_challenge(wsi, pss);
} else {
@@ -900,6 +1605,8 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
return 0;
}
DEBUG_TRACE("REQ message passed authentication check");
// Handle REQ message
cJSON* sub_id = cJSON_GetArrayItem(json, 1);
@@ -910,6 +1617,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
// Validate subscription ID before processing
if (!subscription_id) {
DEBUG_TRACE("REQ rejected: NULL subscription ID");
send_notice_message(wsi, "error: invalid subscription ID");
DEBUG_WARN("REQ rejected: NULL subscription ID");
record_malformed_request(pss);
@@ -920,6 +1628,7 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
// Validate subscription ID
if (!validate_subscription_id(subscription_id)) {
DEBUG_TRACE("REQ rejected: invalid subscription ID format");
send_notice_message(wsi, "error: invalid subscription ID");
DEBUG_WARN("REQ rejected: invalid subscription ID");
cJSON_Delete(json);
@@ -927,9 +1636,12 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
return 0;
}
DEBUG_TRACE("REQ subscription ID validated: %s", subscription_id);
// Create array of filter objects from position 2 onwards
cJSON* filters = cJSON_CreateArray();
if (!filters) {
DEBUG_TRACE("REQ failed: could not create filters array");
send_notice_message(wsi, "error: failed to process filters");
DEBUG_ERROR("REQ failed: could not create filters array");
cJSON_Delete(json);
@@ -938,16 +1650,21 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
}
int json_size = cJSON_GetArraySize(json);
int filter_count = 0;
for (int i = 2; i < json_size; i++) {
cJSON* filter = cJSON_GetArrayItem(json, i);
if (filter) {
cJSON_AddItemToArray(filters, cJSON_Duplicate(filter, 1));
filter_count++;
}
}
DEBUG_TRACE("REQ created %d filters from message", filter_count);
// Validate filters before processing
char filter_error[512] = {0};
if (!validate_filter_array(filters, filter_error, sizeof(filter_error))) {
DEBUG_TRACE("REQ rejected: filter validation failed - %s", filter_error);
send_notice_message(wsi, filter_error);
DEBUG_WARN("REQ rejected: invalid filters");
record_malformed_request(pss);
@@ -957,7 +1674,11 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
return 0;
}
DEBUG_TRACE("REQ filters validated successfully");
DEBUG_TRACE("About to call handle_req_message for subscription %s", subscription_id);
handle_req_message(subscription_id, filters, wsi, pss);
DEBUG_TRACE("handle_req_message completed for subscription %s", subscription_id);
// Clean up the filters array we created
cJSON_Delete(filters);
@@ -1191,6 +1912,15 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
pss->message_queue_count = 0;
pss->writeable_requested = 0;
// Clean up message reassembly buffer
if (pss->reassembly_buffer) {
free(pss->reassembly_buffer);
pss->reassembly_buffer = NULL;
}
pss->reassembly_size = 0;
pss->reassembly_capacity = 0;
pss->reassembly_active = 0;
// Clean up session subscriptions - copy IDs first to avoid use-after-free
pthread_mutex_lock(&pss->session_lock);
@@ -1254,13 +1984,80 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
return 0;
}
// Check and disconnect connections that have exceeded max age
// This function works by checking connection age through the subscription system
static void check_connection_age(int max_connection_seconds) {
if (max_connection_seconds <= 0) {
return; // Feature disabled
}
time_t current_time = time(NULL);
// Lock the subscription manager to safely iterate through subscriptions
pthread_mutex_lock(&g_subscription_manager.subscriptions_lock);
// Track unique WSI pointers we've already checked to avoid duplicate checks
struct lws** checked_wsis = NULL;
int checked_count = 0;
int checked_capacity = 0;
subscription_t* sub = g_subscription_manager.active_subscriptions;
while (sub) {
if (!sub->active || !sub->wsi) {
sub = sub->next;
continue;
}
// Check if we've already processed this WSI
int already_checked = 0;
for (int i = 0; i < checked_count; i++) {
if (checked_wsis[i] == sub->wsi) {
already_checked = 1;
break;
}
}
if (!already_checked) {
// Get per-session data to check connection age
struct per_session_data *pss = (struct per_session_data *)lws_wsi_user(sub->wsi);
if (pss && pss->connection_established > 0) {
time_t connection_age = current_time - pss->connection_established;
if (connection_age >= max_connection_seconds) {
DEBUG_LOG("Disconnecting client %s: connection age %ld seconds exceeds limit %d seconds",
pss->client_ip, connection_age, max_connection_seconds);
// Close connection with normal closure status
lws_close_reason(sub->wsi, LWS_CLOSE_STATUS_NORMAL,
(unsigned char*)"Connection age limit reached", 28);
}
}
// Add to checked list
if (checked_count >= checked_capacity) {
checked_capacity = checked_capacity == 0 ? 64 : checked_capacity * 2;
checked_wsis = realloc(checked_wsis, checked_capacity * sizeof(struct lws*));
}
checked_wsis[checked_count++] = sub->wsi;
}
sub = sub->next;
}
pthread_mutex_unlock(&g_subscription_manager.subscriptions_lock);
// Cleanup
free(checked_wsis);
}
// WebSocket protocol definition
static struct lws_protocols protocols[] = {
{
"nostr-relay-protocol",
nostr_relay_callback,
sizeof(struct per_session_data),
4096, // rx buffer size
65536, // rx buffer size
0, NULL, 0
},
{ NULL, NULL, 0, 0, 0, NULL, 0 } // terminator
@@ -1422,6 +2219,9 @@ int start_websocket_relay(int port_override, int strict_port) {
// Static variable for status post timing (initialize to 0 for immediate first post)
static time_t last_status_post_time = 0;
// Static variable for connection age check timing
static time_t last_connection_age_check = 0;
// Main event loop with proper signal handling
while (g_server_running && !g_shutdown_flag) {
@@ -1444,6 +2244,13 @@ int start_websocket_relay(int port_override, int strict_port) {
generate_and_post_status_event();
}
}
// Check connection age limits (every 60 seconds)
int max_connection_seconds = get_config_int("max_connection_seconds", 86400);
if (max_connection_seconds > 0 && (current_time - last_connection_age_check >= 60)) {
last_connection_age_check = current_time;
check_connection_age(max_connection_seconds);
}
}
lws_context_destroy(ws_context);

View File

@@ -21,10 +21,10 @@
// Filter validation constants
#define MAX_FILTERS_PER_REQUEST 10
#define MAX_AUTHORS_PER_FILTER 100
#define MAX_IDS_PER_FILTER 100
#define MAX_KINDS_PER_FILTER 50
#define MAX_TAG_VALUES_PER_FILTER 100
#define MAX_AUTHORS_PER_FILTER 1000
#define MAX_IDS_PER_FILTER 1000
#define MAX_KINDS_PER_FILTER 500
#define MAX_TAG_VALUES_PER_FILTER 1000
#define MAX_KIND_VALUE 65535
#define MAX_TIMESTAMP_VALUE 2147483647 // Max 32-bit signed int
#define MAX_LIMIT_VALUE 5000
@@ -73,6 +73,12 @@ struct per_session_data {
struct message_queue_node* message_queue_tail; // Tail of message queue
int message_queue_count; // Number of messages in queue
int writeable_requested; // Flag: 1 if writeable callback requested
// Message reassembly for handling fragmented WebSocket messages
char* reassembly_buffer; // Buffer for accumulating message fragments (NULL when not reassembling)
size_t reassembly_size; // Current size of accumulated data
size_t reassembly_capacity; // Allocated capacity of reassembly buffer
int reassembly_active; // Flag: 1 if currently reassembling a message
};
// NIP-11 HTTP session data structure for managing buffer lifetime

View File

@@ -1,40 +0,0 @@
[Unit]
Description=C Nostr Relay Server (Local Development)
Documentation=https://github.com/your-repo/c-relay
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=teknari
WorkingDirectory=/home/teknari/Storage/c_relay
Environment=DEBUG_LEVEL=0
ExecStart=/home/teknari/Storage/c_relay/crelay --port 7777 --debug-level=$DEBUG_LEVEL
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=c-relay-local
# Security settings (relaxed for local development)
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/home/teknari/Storage/c_relay
PrivateTmp=true
# Network security
PrivateNetwork=false
RestrictAddressFamilies=AF_INET AF_INET6
# Resource limits
LimitNOFILE=65536
LimitNPROC=4096
# Event-based configuration system
# No environment variables needed - all configuration is stored as Nostr events
# Database files (<relay_pubkey>.db) are created automatically in WorkingDirectory
# Admin keys are generated and displayed only during first startup
[Install]
WantedBy=multi-user.target

5
tests/.test_keys.txt Normal file
View File

@@ -0,0 +1,5 @@
# Test key configuration (from make_and_restart_relay.sh -t)
ADMIN_PRIVATE_KEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
ADMIN_PUBLIC_KEY="6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3"
RELAY_PUBLIC_KEY="4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
RELAY_URL="ws://localhost:8888"

View File

@@ -0,0 +1,295 @@
#!/bin/bash
# Subscription Cleanup Testing Suite for C-Relay
# Tests startup cleanup and connection age limit features
set -e
# Load test keys
source "$(dirname "$0")/.test_keys.txt"
# Configuration
RELAY_HOST="127.0.0.1"
RELAY_PORT="8888"
RELAY_URL="ws://${RELAY_HOST}:${RELAY_PORT}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Test counters
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE}Subscription Cleanup Test Suite${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# Function to print test header
print_test_header() {
echo -e "${BLUE}=== Test $1: $2 ===${NC}"
}
# Function to print test result
print_result() {
local status=$1
local message=$2
TOTAL_TESTS=$((TOTAL_TESTS + 1))
if [ "$status" = "PASS" ]; then
echo -e "${GREEN}[PASS]${NC} $message"
PASSED_TESTS=$((PASSED_TESTS + 1))
elif [ "$status" = "FAIL" ]; then
echo -e "${RED}[FAIL]${NC} $message"
FAILED_TESTS=$((FAILED_TESTS + 1))
else
echo -e "${YELLOW}[WARN]${NC} $message"
PASSED_TESTS=$((PASSED_TESTS + 1))
fi
}
# Function to check if relay is running
check_relay_running() {
# Send a simple REQ and check for EOSE response
local response=$(echo '["REQ","ping",{}]' | timeout 2 websocat -n1 "$RELAY_URL" 2>/dev/null)
if echo "$response" | grep -q "EOSE\|EVENT"; then
return 0
else
return 1
fi
}
# Function to create a subscription
create_subscription() {
local sub_id=$1
local filter=${2:-"{}"}
echo "[\"REQ\",\"$sub_id\",$filter]" | timeout 5 websocat -n1 "$RELAY_URL" 2>/dev/null || echo "TIMEOUT"
}
# Function to close a subscription
close_subscription() {
local sub_id=$1
echo "[\"CLOSE\",\"$sub_id\"]" | timeout 5 websocat -n1 "$RELAY_URL" 2>/dev/null || echo "TIMEOUT"
}
# Function to query subscription count from database
get_subscription_count() {
local db_file=$(find . -name "*.db" -type f 2>/dev/null | head -1)
if [ -z "$db_file" ]; then
echo "0"
return
fi
sqlite3 "$db_file" "SELECT COUNT(*) FROM subscriptions WHERE event_type='created' AND ended_at IS NULL;" 2>/dev/null || echo "0"
}
# Test 1: Basic Connectivity
print_test_header "1" "Basic Connectivity"
if check_relay_running; then
print_result "PASS" "Relay is running and accepting connections"
else
print_result "FAIL" "Cannot connect to relay at $RELAY_URL"
echo ""
echo -e "${RED}ERROR: Relay must be running for tests to proceed${NC}"
exit 1
fi
echo ""
# Test 2: Create Multiple Subscriptions
print_test_header "2" "Create Multiple Subscriptions"
echo "[INFO] Creating 5 test subscriptions..."
for i in {1..5}; do
response=$(create_subscription "cleanup_test_$i")
if echo "$response" | grep -q "EOSE"; then
echo "[INFO] Subscription cleanup_test_$i created successfully"
else
print_result "WARN" "Subscription cleanup_test_$i may not have been created: $response"
fi
done
# Give subscriptions time to be logged
sleep 2
# Check subscription count in database
active_subs=$(get_subscription_count)
echo "[INFO] Active subscriptions in database: $active_subs"
if [ "$active_subs" -ge 5 ]; then
print_result "PASS" "Multiple subscriptions created and logged ($active_subs active)"
else
print_result "WARN" "Expected at least 5 subscriptions, found $active_subs"
fi
echo ""
# Test 3: Simulate Orphaned Subscriptions (disconnect without CLOSE)
print_test_header "3" "Simulate Orphaned Subscriptions"
echo "[INFO] Creating subscriptions and disconnecting abruptly..."
# Create subscriptions in background and kill the connection
for i in {6..10}; do
(echo "[\"REQ\",\"orphan_test_$i\",{}]" | timeout 2 websocat "$RELAY_URL" &>/dev/null) &
pid=$!
sleep 0.5
kill -9 $pid 2>/dev/null || true
done
sleep 2
orphaned_subs=$(get_subscription_count)
echo "[INFO] Subscriptions after abrupt disconnects: $orphaned_subs"
if [ "$orphaned_subs" -gt "$active_subs" ]; then
print_result "PASS" "Orphaned subscriptions detected ($orphaned_subs total, was $active_subs)"
else
print_result "WARN" "No increase in orphaned subscriptions detected"
fi
echo ""
# Test 4: Startup Cleanup (requires relay restart)
print_test_header "4" "Startup Cleanup Feature"
echo "[INFO] This test requires relay restart to verify startup cleanup"
echo "[INFO] Current orphaned subscriptions: $orphaned_subs"
echo ""
echo -e "${YELLOW}[ACTION REQUIRED]${NC} Please restart the relay now with:"
echo " ./make_and_restart_relay.sh"
echo ""
echo -n "Press Enter after relay has restarted to continue..."
read
# Wait for relay to be ready
echo "[INFO] Waiting for relay to be ready..."
sleep 3
if ! check_relay_running; then
print_result "FAIL" "Relay not responding after restart"
exit 1
fi
# Check if orphaned subscriptions were cleaned up
cleaned_subs=$(get_subscription_count)
echo "[INFO] Active subscriptions after restart: $cleaned_subs"
if [ "$cleaned_subs" -eq 0 ]; then
print_result "PASS" "Startup cleanup removed all orphaned subscriptions"
elif [ "$cleaned_subs" -lt "$orphaned_subs" ]; then
print_result "PASS" "Startup cleanup reduced orphaned subscriptions (from $orphaned_subs to $cleaned_subs)"
else
print_result "FAIL" "Startup cleanup did not reduce orphaned subscriptions"
fi
echo ""
# Test 5: Connection Age Limit (requires configuration)
print_test_header "5" "Connection Age Limit Feature"
echo "[INFO] Testing connection age limit feature..."
echo "[INFO] Default max_connection_seconds is 86400 (24 hours)"
echo ""
echo -e "${YELLOW}[INFO]${NC} To test connection age limit with shorter timeout:"
echo " 1. Set max_connection_seconds to 60 (1 minute) via admin event"
echo " 2. Create a subscription and wait 61 seconds"
echo " 3. Connection should be automatically closed"
echo ""
echo "[INFO] For this test, we'll verify the feature is enabled in config"
# Create a test subscription to verify connection works
response=$(create_subscription "age_test_1")
if echo "$response" | grep -q "EOSE"; then
print_result "PASS" "Connection age limit feature is operational (subscription created)"
else
print_result "WARN" "Could not verify connection age limit feature"
fi
echo ""
# Test 6: Verify Client Reconnection
print_test_header "6" "Client Reconnection After Cleanup"
echo "[INFO] Testing that clients can reconnect after cleanup..."
# Create a subscription
response=$(create_subscription "reconnect_test_1")
if echo "$response" | grep -q "EOSE"; then
echo "[INFO] First connection successful"
# Close and reconnect
sleep 1
response=$(create_subscription "reconnect_test_2")
if echo "$response" | grep -q "EOSE"; then
print_result "PASS" "Client can reconnect and create new subscriptions"
else
print_result "FAIL" "Client reconnection failed"
fi
else
print_result "FAIL" "Initial connection failed"
fi
echo ""
# Test 7: Verify Disabled State (max_connection_seconds = 0)
print_test_header "7" "Verify Feature Can Be Disabled"
echo "[INFO] Connection age limit can be disabled by setting max_connection_seconds=0"
echo "[INFO] When disabled, connections remain open indefinitely"
echo "[INFO] This is the recommended setting for most relays"
# Create a long-lived subscription
response=$(create_subscription "disabled_test_1")
if echo "$response" | grep -q "EOSE"; then
print_result "PASS" "Subscriptions work normally when feature is disabled/default"
else
print_result "WARN" "Could not verify disabled state"
fi
echo ""
# Test 8: Database Integrity Check
print_test_header "8" "Database Integrity After Cleanup"
echo "[INFO] Checking database integrity..."
db_file=$(find . -name "*.db" -type f 2>/dev/null | head -1)
if [ -n "$db_file" ]; then
# Check if database is accessible
if sqlite3 "$db_file" "PRAGMA integrity_check;" 2>/dev/null | grep -q "ok"; then
print_result "PASS" "Database integrity check passed"
else
print_result "FAIL" "Database integrity check failed"
fi
# Check subscription table structure
if sqlite3 "$db_file" "SELECT COUNT(*) FROM subscriptions;" &>/dev/null; then
print_result "PASS" "Subscription table is accessible"
else
print_result "FAIL" "Subscription table is not accessible"
fi
else
print_result "WARN" "No database file found"
fi
echo ""
# Final Summary
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE}Test Summary${NC}"
echo -e "${BLUE}========================================${NC}"
echo "Total Tests: $TOTAL_TESTS"
echo -e "${GREEN}Passed: $PASSED_TESTS${NC}"
echo -e "${RED}Failed: $FAILED_TESTS${NC}"
echo ""
if [ $FAILED_TESTS -eq 0 ]; then
echo -e "${GREEN}All tests passed!${NC}"
exit 0
else
echo -e "${RED}Some tests failed. Please review the output above.${NC}"
exit 1
fi

747
tests/test_requests.mjs Normal file
View File

@@ -0,0 +1,747 @@
/**
* Nostr Relay Pubkey Filter Test
* Tests how many pubkeys different relays can handle in a single filter request
*/
import { WebSocket } from 'ws';
// Configuration
const RELAYS = [
// "wss://relay.laantungir.net"
"ws://127.0.0.1:8888"
];
// Test parameters
const STEP_SIZE = 25; // Increment pubkey count by 25 each test
const MAX_PUBKEYS = 500; // Maximum pubkeys to test
const EVENT_KIND = 1; // Kind 1 = text notes
const EVENT_LIMIT = 2; // Only request 2 events per test
// Generate test pubkey arrays of increasing sizes
function generateTestPubkeyArrays() {
const testArrays = [];
for (let count = STEP_SIZE; count <= MAX_PUBKEYS; count += STEP_SIZE) {
testArrays.push(PUBKEYS.slice(0, count));
}
return testArrays;
}
const PUBKEYS = [
"85080d3bad70ccdcd7f74c29a44f55bb85cbcd3dd0cbb957da1d215bdb931204",
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
"916b7aca250f43b9f842faccc831db4d155088632a8c27c0d140f2043331ba57",
"2645caf5706a31767c921532975a079f85950e1006bd5065f5dd0213e6848a96",
"8fe3f243e91121818107875d51bca4f3fcf543437aa9715150ec8036358939c5",
"83e818dfbeccea56b0f551576b3fd39a7a50e1d8159343500368fa085ccd964b",
"a341f45ff9758f570a21b000c17d4e53a3a497c8397f26c0e6d61e5acffc7a98",
"e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411",
"2cde0e02bda47eaeeed65e341619cc5f2afce990164669da4e1e5989180a96b9",
"edcd20558f17d99327d841e4582f9b006331ac4010806efa020ef0d40078e6da",
"34d2f5274f1958fcd2cb2463dabeaddf8a21f84ace4241da888023bf05cc8095",
"c48b5cced5ada74db078df6b00fa53fc1139d73bf0ed16de325d52220211dbd5",
"04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9",
"e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42",
"1306edd66f1da374adc417cf884bbcff57c6399656236c1f872ee10403c01b2d",
"eaf27aa104833bcd16f671488b01d65f6da30163b5848aea99677cc947dd00aa",
"472f440f29ef996e92a186b8d320ff180c855903882e59d50de1b8bd5669301e",
"be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479",
"c49d52a573366792b9a6e4851587c28042fb24fa5625c6d67b8c95c8751aca15",
"c037a6897df86bfd4df5496ca7e2318992b4766897fb18fbd1d347a4f4459f5e",
"e41e883f1ef62485a074c1a1fa1d0a092a5d678ad49bedc2f955ab5e305ba94e",
"020f2d21ae09bf35fcdfb65decf1478b846f5f728ab30c5eaabcd6d081a81c3e",
"e2f28c1ac6dff5a7b755635af4c8436d2fec89b888a9d9548a51b2c63f779555",
"29fbc05acee671fb579182ca33b0e41b455bb1f9564b90a3d8f2f39dee3f2779",
"090254801a7e8e5085b02e711622f0dfa1a85503493af246aa42af08f5e4d2df",
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
"6b0d4c8d9dc59e110d380b0429a02891f1341a0fa2ba1b1cf83a3db4d47e3964",
"b0b8fbd9578ac23e782d97a32b7b3a72cda0760761359bd65661d42752b4090a",
"b7996c183e036df27802945b80bbdc8b0bf5971b6621a86bf3569c332117f07d",
"1833ee04459feb2ca4ae690d5f31269ad488c69e5fe903a42b532c677c4a8170",
"4adb4ff2dc72bbf1f6da19fc109008a25013c837cf712016972fad015b19513f",
"c4eabae1be3cf657bc1855ee05e69de9f059cb7a059227168b80b89761cbc4e0",
"368f4e0027fd223fdb69b6ec6e1c06d1f027a611b1ed38eeb32493eb2878bb35",
"703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898",
"50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63",
"6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3",
"a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f",
"1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b",
"19fefd7f39c96d2ff76f87f7627ae79145bc971d8ab23205005939a5a913bc2f",
"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
"a3b0ce5d70d0db22885706b2b1f144c6864a7e4828acff3f8f01ca6b3f54ad15",
"aef0d6b212827f3ba1de6189613e6d4824f181f567b1205273c16895fdaf0b23",
"826e9f895b81ab41a4522268b249e68d02ca81608def562a493cee35ffc5c759",
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
"e1055729d51e037b3c14e8c56e2c79c22183385d94aadb32e5dc88092cd0fef4",
"27f211f4542fd89d673cfad15b6d838cc5d525615aae8695ed1dcebc39b2dadb",
"eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f",
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed",
"00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700",
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194",
"22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954",
"175f568d77fb0cb7400f0ddd8aed1738cd797532b314ef053a1669d4dba7433a",
"6c535d95a8659b234d5a0805034f5f0a67e3c0ceffcc459f61f680fe944424bf",
"9579444852221038dcba34512257b66a1c6e5bdb4339b6794826d4024b3e4ce9",
"58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196",
"b8e6bf46e109314616fe24e6c7e265791a5f2f4ec95ae8aa15d7107ad250dc63",
"84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240",
"387519cafd325668ecffe59577f37238638da4cf2d985b82f932fc81d33da1e8",
"b9e76546ba06456ed301d9e52bc49fa48e70a6bf2282be7a1ae72947612023dc",
"4d62dd5e6ac55ae2405940f59f6f030a994ec2b3ecc5556c8dc542cce20e46dd",
"9c612f8b770f0e3fd35cdac2bc57fcee8561e560504ea25c8b9eff8e03512b3e",
"3eeb3de14ec5c48c6c4c9ff80908c4186170eabb74b2a6705a7db9f9922cd61e",
"51d7f1b736d1958fa56f113e82a27987a3aca4f0e6d237fa8fc369cc1608c5c0",
"c2622c916d9b90e10a81b2ba67b19bdfc5d6be26c25756d1f990d3785ce1361b",
"b111d517452f9ef015e16d60ae623a6b66af63024eec941b0653bfee0dd667d4",
"d897efcd971f8e5eae08c86b7da66f89b30e761a4a86ac41d907425d15b630fe",
"9dea27855974a08fceb48c40fab8432c1a8e3a53a1da22a1ad568595d6010649",
"47b630bbcdfa88b1c85f84aa3b68fe6c0102b651ba5d9a23cbd2d07b4f6eecc1",
"eb0dc09a61fdfc0df5db1f20c7fc7d83f00c690580fea2e5bac8f99c13f65065",
"5c0775b1ae0a5140da9599aa9cd1c5beea55c2d55a5d681808525eb5fce37b32",
"b474e6999980aa9e8c9dd6e3720fb03136bfa05aba5fab1634dc0bd8767d412f",
"759f7abf05ca710bf2c8da7ad7a9a7df6d0c85db7b2217da524e94e3627b2fbd",
"060e7c6ed0dbeb9c8cdc61445ee38b9b08d899d6b617e28064b0916e243ddddb",
"f728d9e6e7048358e70930f5ca64b097770d989ccd86854fe618eda9c8a38106",
"bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce",
"b99dbca0184a32ce55904cb267b22e434823c97f418f36daf5d2dff0dd7b5c27",
"c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6",
"ad46db12ee250a108756ab4f0f3007b04d7e699f45eac3ab696077296219d207",
"59fbee7369df7713dbbfa9bbdb0892c62eba929232615c6ff2787da384cb770f",
"d7f0e3917c466f1e2233e9624fbd6d4bd1392dbcfcaf3574f457569d496cb731",
"e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2",
"6f35047caf7432fc0ab54a28fed6c82e7b58230bf98302bf18350ff71e10430a",
"fdd5e8f6ae0db817be0b71da20498c1806968d8a6459559c249f322fa73464a7",
"883fea4c071fda4406d2b66be21cb1edaf45a3e058050d6201ecf1d3596bbc39",
"a1808558470389142e297d4729e081ab8bdff1ab50d0ebe22ffa78958f7a6ab7",
"330fb1431ff9d8c250706bbcdc016d5495a3f744e047a408173e92ae7ee42dac",
"a4cb51f4618cfcd16b2d3171c466179bed8e197c43b8598823b04de266cef110",
"9c163c7351f8832b08b56cbb2e095960d1c5060dd6b0e461e813f0f07459119e",
"0a722ca20e1ccff0adfdc8c2abb097957f0e0bf32db18c4281f031756d50eb8d",
"5cad82c898ee66013711945d687f7d9549f645a0118467dae2f5e274c598d6ff",
"03b593ef3d95102b54bdff77728131a7c3bdfe9007b0b92cd7c4ad4a0021de25",
"d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c",
"60d53675f07dee9e7d77910efa44682d87cb532313ba66b8f4449d649172296b",
"d3ab33199eb48c6f785072b4a66a8e57814e35d31375cca8c3ceeecc171f30ba",
"772bd267dffbff318d1a89f257c3371410111a8b89571dbbefa77af6bfa179f3",
"11b9a89404dbf3034e7e1886ba9dc4c6d376f239a118271bd2ec567a889850ce",
"0497384b57b43c107a778870462901bf68e0e8583b32e2816563543c059784a4",
"5d9ba2c5ee0e86e2c4477b145eb301f2df06063a19f4c4ab9042bd347188ec8e",
"5683ffc7ff8a732565135aad56cdff94ebacd9a616d1313aea8ad48a446bfe99",
"3004d45a0ab6352c61a62586a57c50f11591416c29db1143367a4f0623b491ca",
"b24e32ee9a1c18f2771b53345ed8dbc55b59cbe958e5a165dc01704c3aaa6196",
"0a2df905acd5b5be3214a84cb2d4f61b0efb4d9bf05739d51112252504959688",
"95361a2b42a26c22bac3b6b6ba4c5cac4d36906eb0cfb98268681c45a301c518",
"b07d216f2f0422ec0252dd81a6513b8d0b0c7ef85291fbf5a85ef23f8df78fa7",
"064de2497ce621aee2a5b4b926a08b1ca01bce9da85b0c714e883e119375140c",
"5a8e581f16a012e24d2a640152ad562058cb065e1df28e907c1bfa82c150c8ba",
"a36bdc7952e973b31cb32d4ce3ce21447db66c3149c1b7a3d2450f77f9c7e8f9",
"e03cfe011d81424bb60a12e9eb0cb0c9c688c34712c3794c0752e0718b369ef2",
"2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884",
"b9003833fabff271d0782e030be61b7ec38ce7d45a1b9a869fbdb34b9e2d2000",
"4379e76bfa76a80b8db9ea759211d90bb3e67b2202f8880cc4f5ffe2065061ad",
"76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa",
"d307643547703537dfdef811c3dea96f1f9e84c8249e200353425924a9908cf8",
"da0cc82154bdf4ce8bf417eaa2d2fa99aa65c96c77867d6656fccdbf8e781b18",
"3511ad63cd9ad760780044b7c815ee55e8e00722b5de271c47ff29367653456c",
"f9acb0b034c4c1177e985f14639f317ef0fedee7657c060b146ee790024317ec",
"0c371f5ed95076613443e8331c4b60828ed67bcdefaa1698fb5ce9d7b3285ffb",
"ee11a5dff40c19a555f41fe42b48f00e618c91225622ae37b6c2bb67b76c4e49",
"053935081a69624466034446eda3374d905652ddbf8217c88708182687a33066",
"a305cc8926861bdde5c71bbb6fd394bb4cea6ef5f5f86402b249fc5ceb0ce220",
"03a6e50be223dbb49e282764388f6f2ca8826eae8c5a427aa82bb1b61e51d5e6",
"a197639863cf175adc96348382a73b4a4a361c6b2e6fc1de61a14244a2f926a1",
"3ca7ca157b5975ace02225caf99fdce43f11207c072cb4899c80a414a9c7539d",
"02d9f5676fffc339ffe94dfab38bebe21ce117c6f1509d9922a82d454f420da2",
"08634a74c9d14479b462389b307695815f9a189e8fb6e058b92e18bd3f537405",
"ec7de4aa8758ba9e09a8c89d2757a1fa0e2cc61c20b757af52ae058931c1a33f",
"2250f69694c2a43929e77e5de0f6a61ae5e37a1ee6d6a3baef1706ed9901248b",
"a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be",
"bd625f1b8c49a79f075f3ebd2d111ff625504cf2ad12442fd70d191dd2f4a562",
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb",
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5",
"11d0b66747887ba9a6d34b23eb31287374b45b1a1b161eac54cb183c53e00ef7",
"2544cfcb89d7c2f8d3a31ea2ed386ac5189a18f484672436580eec215f9b039c",
"d4338b7c3306491cfdf54914d1a52b80a965685f7361311eae5f3eaff1d23a5b",
"c43e382ee4835010b9fad18e0a6f50f1ae143b98e089b8bb974232fce4d1f295",
"92de68b21302fa2137b1cbba7259b8ba967b535a05c6d2b0847d9f35ff3cf56a",
"55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185",
"2af01e0d6bd1b9fbb9e3d43157d64590fb27dcfbcabe28784a5832e17befb87b",
"35b23cd02d2d75e55cee38fdee26bc82f1d15d3c9580800b04b0da2edb7517ea",
"7e0c255fd3d0f9b48789a944baf19bf42c205a9c55199805eb13573b32137488",
"ee0e01eb17fc6cb4cd2d9300d2f8945b51056514f156c6bc6d491b74496d161a",
"cbc5ef6b01cbd1ffa2cb95a954f04c385a936c1a86e1bb9ccdf2cf0f4ebeaccb",
"ec6e36d5c9eb874f1db4253ef02377f7cc70697cda40fbfb24ded6b5d14cce4c",
"2779f3d9f42c7dee17f0e6bcdcf89a8f9d592d19e3b1bbd27ef1cffd1a7f98d1",
"976713246c36db1a4364f917b98633cbe0805d46af880f6b50a505d4eb32ed47",
"8766a54ef9a170b3860bc66fd655abb24b5fda75d7d7ff362f44442fbdeb47b9",
"ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14",
"e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb",
"07eced8b63b883cedbd8520bdb3303bf9c2b37c2c7921ca5c59f64e0f79ad2a6",
"532d830dffe09c13e75e8b145c825718fc12b0003f61d61e9077721c7fff93cb",
"1afe0c74e3d7784eba93a5e3fa554a6eeb01928d12739ae8ba4832786808e36d",
"c708943ea349519dcf56b2a5c138fd9ed064ad65ddecae6394eabd87a62f1770",
"ccaa58e37c99c85bc5e754028a718bd46485e5d3cb3345691ecab83c755d48cc",
"5b0e8da6fdfba663038690b37d216d8345a623cc33e111afd0f738ed7792bc54",
"f2c96c97f6419a538f84cf3fa72e2194605e1848096e6e5170cce5b76799d400",
"aa55a479ad6934d0fd78f3dbd88515cd1ca0d7a110812e711380d59df7598935",
"bd9eb657c25b4f6cda68871ce26259d1f9bc62420487e3224905b674a710a45a",
"69a80567e79b6b9bc7282ad595512df0b804784616bedb623c122fad420a2635",
"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
"df173277182f3155d37b330211ba1de4a81500c02d195e964f91be774ec96708",
"675b84fe75e216ab947c7438ee519ca7775376ddf05dadfba6278bd012e1d728",
"91c9a5e1a9744114c6fe2d61ae4de82629eaaa0fb52f48288093c7e7e036f832",
"24ebb6b58d0b984a965b76f82ce9eff8795cc95085a4d09dedc56949ed596ada",
"bb1cf5250435ff475cd8b32acb23e3ee7bbe8fc38f6951704b4798513947672c",
"922945779f93fd0b3759f1157e3d9fa20f3fd24c4b8f2bcf520cacf649af776d",
"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d",
"e8d67c435a4a59304e1414280e952efe17be4254fca27916bf63f9f73e54aba4",
"c1fc7771f5fa418fd3ac49221a18f19b42ccb7a663da8f04cbbf6c08c80d20b1",
"8eee8f5a002e533e9f9ffef14c713da449c23f56f4415e7995552075a02d1d37",
"c998a5739f04f7fff202c54962aa5782b34ecb10d6f915bdfdd7582963bf9171",
"a536ab1f7f3c0133baadbdf472b1ac7ad4b774ed432c1989284193572788bca0",
"c9b19ffcd43e6a5f23b3d27106ce19e4ad2df89ba1031dd4617f1b591e108965",
"e6ee5b449c220defea6373b8a7e147cabd67c2bdb5016886bf6096a3c7435a61",
"a619cf1a888a73211bbf32e0c438319f23e91444d45d7bc88816ed5fcb7e8fa3",
"56a6b75373c8f7b93c53bcae86d8ffbaba9f2a1b38122054fcdb7f3bf645b727",
"89bfe407c647eb1888871f756516bb1906254fba3132d516ce9099614e37d10c",
"b7ed68b062de6b4a12e51fd5285c1e1e0ed0e5128cda93ab11b4150b55ed32fc",
"4657dfe8965be8980a93072bcfb5e59a65124406db0f819215ee78ba47934b3e",
"d61f3bc5b3eb4400efdae6169a5c17cabf3246b514361de939ce4a1a0da6ef4a",
"58ead82fa15b550094f7f5fe4804e0fe75b779dbef2e9b20511eccd69e6d08f9",
"fcf6fee0e959c7195dadc5f36fe5a873003b389e7033293b06057c821fcbc9c5",
"6681268ace4748d41a4cfcc1e64006fb935bbc359782b3d9611f64d51c6752d9",
"e76450df94f84c1c0b71677a45d75b7918f0b786113c2d038e6ab8841b99f276",
"a44dbc9aaa357176a7d4f5c3106846ea096b66de0b50ee39aff54baab6c4bf4b",
"281e109d2a2899bb0555cf0c3a69b24b3debd61885ca29ef39b95b632be25fe7",
"5be6446aa8a31c11b3b453bf8dafc9b346ff328d1fa11a0fa02a1e6461f6a9b1",
"e1ff3bfdd4e40315959b08b4fcc8245eaa514637e1d4ec2ae166b743341be1af",
"0d6c8388dcb049b8dd4fc8d3d8c3bb93de3da90ba828e4f09c8ad0f346488a33",
"9be0be0e64d38a29a9cec9a5c8ef5d873c2bfa5362a4b558da5ff69bc3cbb81e",
"c48e29f04b482cc01ca1f9ef8c86ef8318c059e0e9353235162f080f26e14c11",
"4c7f826edf647462f744b3f16d485f53c797eabdb21cc8a7bb0713283b88e629",
"d1621db4d91b23180707b8d4eb9b599fa8ec1dfc2453793a1f83878bd4bbc9d8",
"4b74667f89358cd582ad82b16a2d24d5bfcb89ac4b1347ee80e5674a13ba78b2",
"83d999a148625c3d2bb819af3064c0f6a12d7da88f68b2c69221f3a746171d19",
"b6494a74d18a2dfa3f80ced9fadae35807716fce1071e4de19e2d746b6d87606",
"b9c411db4036219e3dfcbe28d60e550b46cce86260fcf2c65d281258e437556f",
"2590201e2919a8aa6568c88900192aa54ef00e6c0974a5b0432f52614a841ec8",
"c15a5a65986e7ab4134dee3ab85254da5c5d4b04e78b4f16c82837192d355185",
"dab6c6065c439b9bafb0b0f1ff5a0c68273bce5c1959a4158ad6a70851f507b6",
"baf27a4cc4da49913e7fdecc951fd3b971c9279959af62b02b761a043c33384c",
"6c237d8b3b120251c38c230c06d9e48f0d3017657c5b65c8c36112eb15c52aeb",
"f173040998481bcb2534a53433eafb8d6ea4c7b0e1fc64572830471fe43fc77d",
"36732cc35fe56185af1b11160a393d6c73a1fe41ddf1184c10394c28ca5d627b",
"126103bfddc8df256b6e0abfd7f3797c80dcc4ea88f7c2f87dd4104220b4d65f",
"457e17b7ea97a845a0d1fa8feda9976596678e3a8af46dc6671d40e050ce857d",
"1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef",
"b676ded7c768d66a757aa3967b1243d90bf57afb09d1044d3219d8d424e4aea0",
"33bd77e5394520747faae1394a4af5fa47f404389676375b6dc7be865ed81452",
"fe7f6bc6f7338b76bbf80db402ade65953e20b2f23e66e898204b63cc42539a3",
"4f83ef69228b3e09b0abc11ded9e6b85319c0b7fef1a044b8ee9970e38441817",
"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0",
"d8f38b894b42f7008305cebf17b48925654f22b180c5861b81141f80ccf72848",
"9c557e253213c127a86e333ff01c9f12f63091957efafd878d220a0e2cb1193e",
"9eab64e92219ccedb15ea9b75ababaa4ae831451019394e0e3336390c3a742d8",
"43dedbafef3748c3f9146b961c9b87a3f5cdb1ccb50b4f5890e408702a27a506",
"17717ad4d20e2a425cda0a2195624a0a4a73c4f6975f16b1593fc87fa46f2d58",
"ee6ea13ab9fe5c4a68eaf9b1a34fe014a66b40117c50ee2a614f4cda959b6e74",
"4d023ce9dfd75a7f3075b8e8e084008be17a1f750c63b5de721e6ef883adc765",
"d91191e30e00444b942c0e82cad470b32af171764c2275bee0bd99377efd4075",
"4eb88310d6b4ed95c6d66a395b3d3cf559b85faec8f7691dafd405a92e055d6d",
"0aeb0814c99a13df50643ca27b831a92aaae6366f54e9c276166347aa037d63a",
"16f1a0100d4cfffbcc4230e8e0e4290cc5849c1adc64d6653fda07c031b1074b",
"148d1366a5e4672b1321adf00321778f86a2371a4bdbe99133f28df0b3d32fa1",
"7076f6592de184f9e912c617c46e5e83bad91d3b7f88b7b54cc73bf8ca493321",
"dea51494fec5947d27ca659b73dd281ff5bdba3f89f5da1977a731ad0c22e725",
"aa97e3d392b97c21327081cdb3cb674dfa8c9c663493db43799a4079555ad1b1",
"b7dfbebf760efb2c756b3cae22baafdbbdf55abb782f85e93b4db804d5cba7e3",
"ca696d7edb4a86be7c7d9872bd2f9a44440cf8e2de7853536cbb3a60ae89641f",
"ff27d01cb1e56fb58580306c7ba76bb037bf211c5b573c56e4e70ca858755af0",
"58dece9ff68d021afe76d549b9e48e6cb7b06a5c14cdf45332c8ed7321d6f211",
"78688c1f371e7b923d95368c9298cca06c1ec0a89ea897aa181bd60091121fea",
"9e8dd91d21e867dec464868a8d1f4a27c0e113c53e32f2bec0a7c6e25ad2e9d5",
"6d028aa49aa1f584b3d35aee9fcee8e3c0d81108114289aa046a7969b21eb5f5",
"2c470abbac95a49cd0ed5b3b9e628ffda1dbb03c14caba1a225a9b8bf1dc9d5f",
"9d7af6946b320b3ba6b4d386de2b2cf3f8ac52fdcb63f3343d1a8362693a3ce5",
"d7a4345c3ead1ea7a34bd6aae43c63cbd81941d9ba019fe972843e5ce78e3187",
"00f471f6312ce408f7eb56592a2b6c6b5f54ac2967c77f4c1498903b598e1b16",
"41e9e2c8398583b90204f8e35c2a4c036aeebac6d05dbdc3e7fb44a1d6bd65a2",
"0d978064b9054c023111926050d983573dac2aff16bb8a7497fde8ad725357c0",
"507f5bf8367c1883f115ddf9ee95f79ea693c720eb5a5a8718443a99fa308954",
"9a090f86adf9fbdc37de2a14745e73d6e1fd096d3da0670b6795ce5ad3cfeea3",
"f2b7c5787424c8f9cf6c4480eb99f4a3770cc06337a4f0d1b109ba849b464193",
"6ffe93bb72d4ac788fd8be2dadf5bb4a2f14a330d530b0e68bd733c9744c6619",
"3ebc74907d1f928f209ef210e872cac033eaf3ff89e6853286d45d91e351ef9e",
"da56c54b5e6749d65ad038c196478794af94e4fa5a4efdc20b49981e4ec566c3",
"266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5",
"5b705e6cb602425c019202dd070a0c009b040ac19960eeef2d8a8fab25c1efe5",
"9ec7a778167afb1d30c4833de9322da0c08ba71a69e1911d5578d3144bb56437",
"e771af0b05c8e95fcdf6feb3500544d2fb1ccd384788e9f490bb3ee28e8ed66f",
"40b9c85fffeafc1cadf8c30a4e5c88660ff6e4971a0dc723d5ab674b5e61b451",
"efe5d120df0cc290fa748727fb45ac487caad346d4f2293ab069e8f01fc51981",
"408f636bd26fcc5f29889033b447cb2411f60ab1b8a5fc8cb3842dab758fdeb5",
"d8a2c33f2e2ff3a9d4ff2a5593f3d5a59e9167fa5ded063d0e49891776611e0c",
"02a11d1545114ab63c29958093c91b9f88618e56fee037b9d2fabcff32f62ea9",
"1bbd7fdf68eaf5c19446c3aaf63b39dd4a8e33548bc96f6bd239a4124d8f229e",
"726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11",
"167e7fe01a76b6bec9d2a9b196b18c72e150e985fbeb46ee651869e7b4032785",
"c88f94f0a391b9aaa1ffefd645253b1a968b0a422a876ea48920a95d45c33f47",
"cd169bd8fbd5179e2a8d498ffc31d3ae0e40825ff2b8a85ea359c4455a107ca8",
"fd38f135ef675eac5e93d5b2a738c41777c250188031caf1dcf07b1687a1fe49",
"6b4c612991132cf4c6c390dceaae75041b9954ba4f9c465aca70beb350815a57",
"8fec426247845bdd26f36ae4f737508c15dbec07d43ce18f8c136ab9e35ac212",
"af551accea482000bdccb34bd3c521558e1f353773a3caed83a147921c369ea1",
"a664a4973cd23e9f3b35a62429f7671aba2c2ae68c03313913b5c2d77269d906",
"18f54af1e10c5bb7a35468b0f62b295d12347903c9f95738d065c84bef1402ef",
"be39043cc12efbddfee564d95da751a71df6c139e2def45c431cadeb4a573ca3",
"01ddee289b1a2e90874ca3428a7a414764a6cad1abfaa985c201e7aada16d38c",
"da25cf7b457bddb6b7bc8e1b0146c0fa85373807d6efdac955199fd01fd53c1f",
"ec380784d96b93d404166a6bf1a778227a94a02bdf499f94b5f48f61f5b1350f",
"6538925ebfb661f418d8c7d074bee2e8afd778701dd89070c2da936d571e55c3",
"9edd72eb23222c969379d90d60ec82891b7c827188bb28510a863f59cb697b0a",
"09222857afceb23c66c99fc93d8e5ebda6d7aad901eb38af73c508f117685012",
"744ecc9a119a92da88b1f448b4030cdbc2fec5c37ea06ebdd026e742b002af7f",
"f531a8672baa2415b271e866dbe11fb08640f6c0e5d98f918bd0308e7169b5b7",
"44dc1c2db9c3fbd7bee9257eceb52be3cf8c40baf7b63f46e56b58a131c74f0b",
"89e14be49ed0073da83b678279cd29ba5ad86cf000b6a3d1a4c3dc4aa4fdd02c",
"8fb140b4e8ddef97ce4b821d247278a1a4353362623f64021484b372f948000c",
"72f9755501e1a4464f7277d86120f67e7f7ec3a84ef6813cc7606bf5e0870ff3",
"3d99feac152027ede63326aa4f43d4ca88e4cd27296b96fe18c55d496a8f6340",
"2540d50aeb9be889c3bd050c9cc849b57b156a2759b48084a83db59aa9056eb4",
"b66be78da89991544a05c3a2b63da1d15eefe8e9a1bb6a4369f8616865bd6b7c",
"2f5de0003db84ecd5449128350c66c7fb63e9d02b250d84af84f463e2f9bcef1",
"2045369fc115b138d1438f98d3c29916986c9fde6b8203f7ff8699f0faee1c93",
"ae1008d23930b776c18092f6eab41e4b09fcf3f03f3641b1b4e6ee3aa166d760",
"1ec454734dcbf6fe54901ce25c0c7c6bca5edd89443416761fadc321d38df139",
"b2d670de53b27691c0c3400225b65c35a26d06093bcc41f48ffc71e0907f9d4a",
"ac3f6afe17593f61810513dac9a1e544e87b9ce91b27d37b88ec58fbaa9014aa",
"d1f3c71639ae3bba17ffc6c8deb1fdb3a56506b3492213d033528cc291523704",
"6b4a29bbd43d1d0eeead384f512dbb591ce9407d27dba48ad54b00d9d2e1972b",
"84de08882b6e36705cf6592ee58e632dd6e092dd61c13192fc80cbbc0cbc82cc",
"d3d74124ddfb5bdc61b8f18d17c3335bbb4f8c71182a35ee27314a49a4eb7b1d",
"a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d",
"eb7246eb8e26b0c48dd4f9c2a822a0f4d5c84138937195090932b61a2d756051",
"683211bd155c7b764e4b99ba263a151d81209be7a566a2bb1971dc1bbd3b715e",
"5468bceeb74ce35cb4173dcc9974bddac9e894a74bf3d44f9ca8b7554605c9ed",
"78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d",
"2754fc862d6bc0b7c3971046612d942563d181c187a391e180ed6b00f80e7e5b",
"f1725586a402c06aec818d1478a45aaa0dc16c7a9c4869d97c350336d16f8e43",
"6a359852238dc902aed19fbbf6a055f9abf21c1ca8915d1c4e27f50df2f290d9",
"9e4954853fca260cecf983f098e5204c68b2bdfebde91f1f7b25c10b566d50f8",
"3356de61b39647931ce8b2140b2bab837e0810c0ef515bbe92de0248040b8bdd",
"3e294d2fd339bb16a5403a86e3664947dd408c4d87a0066524f8a573ae53ca8e",
"21335073401a310cc9179fe3a77e9666710cfdf630dfd840f972c183a244b1ad",
"987096ef8a2853fea1a31b0ed5276503da291536f167bbf7f3f991c9f05d6d7f",
"7a78fbfec68c2b3ab6084f1f808321ba3b5ea47502c41115902013e648e76288",
"c12a2bcb002fd74b4d342f9b942c24c44cc46d5ed39201245a8b6f4162e7efce",
"8867bed93e89c93d0d8ac98b2443c5554799edb9190346946b12e03f13664450",
"9b61cd02adac4b18fbcc06237e7469b07e276faf6ec4ecb34b030c2e385892a0",
"0463223adf38df9a22a7fb07999a638fdd42d8437573e0bf19c43e013b14d673",
"9989500413fb756d8437912cc32be0730dbe1bfc6b5d2eef759e1456c239f905",
"17e2889fba01021d048a13fd0ba108ad31c38326295460c21e69c43fa8fbe515",
"6c6c253fe26a5b2abf440124e35dcaa39e891cd28274431ba49da5c11d89747d",
"9d065f84c0cba7b0ef86f5d2d155e6ce01178a8a33e194f9999b7497b1b2201b",
"5ffb8e1b6b629c0e34a013f9298ebb0759b98a3d24029916321d5eb4255b6735",
"3fc5f8553abd753ac47967c4c468cfd08e8cb9dee71b79e12d5adab205bc04d3",
"ff82c8b53aa53a9705200690b91c572e2e4918f1a88de5d923ac06fa4560fa19",
"4d4ab737e2fbb5af0fd590b4b7e8c6fe76d3a02a9791ef7fdacf601f9e50fad8",
"5eca50a04afaefe55659fb74810b42654e2268c1acca6e53801b9862db74a83a",
"d700fc10d457eeae4f02eb04d715a054837e68a2e2d010971382c5e1016dc99e",
"af321973db865bb33fbc50a4de67fc0e6808d812c6e4dfd9cbc2fd50275b1dfd",
"bbf923aa9246065f88c40c7d9bf61cccc0ff3fcff065a8cb2ff4cfbb62088f1e",
"268b948b5aab4bab0e5430ee49e3cff11776cf183df93b32159f9670ed541495",
"3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594",
"4d4fb5ff0afb8c04e6c6e03f51281b664576f985e5bc34a3a7ee310a1e821f47",
"9b12847f3d28bf8850ebc03f8d495a1ae8f9a2c86dbda295c90556619a3ee831",
"733c5427f55ceba01a0f6607ab0fd11832bbb27d7db17b570e7eb7b68a081d9a",
"4bc7982c4ee4078b2ada5340ae673f18d3b6a664b1f97e8d6799e6074cb5c39d",
"afa0f26dbf3e674630d1cd6499e86c14f316cd4f78c6ab73bb85b00aa9c50a57",
"c301f13372c8f0d9bc8186d874fa45fa33aede13e66f4187a3bd22ee41c95b2a",
"548a29f145187fc97689f8ae67944627723c315c163b0dbb88842e50c681d7ca",
"d0a1ffb8761b974cec4a3be8cbcb2e96a7090dcf465ffeac839aa4ca20c9a59e",
"faaf47af27e3de06e83f346fc6ccea0aabfc7520d82ffe90c48dfcd740c69caa",
"3eacaa768326d7dce80f6ee17ada199bebe7eb3c1a60b39b14e0a58bbac66fe4",
"7f5237e9f77a22c4a89624c7ac31cae797d8ac4144b02493890d54fee7399bcd",
"d84517802a434757c56ae8642bffb4d26e5ade0712053750215680f5896e579b",
"bdb96ad31ac6af123c7683c55775ee2138da0f8f011e3994d56a27270e692575",
"aab1b0caf13b9bd26a62cf8b3b20f9bfaa0e56f3ec42196a00fedf432e07d739",
"c230edd34ca5c8318bf4592ac056cde37519d395c0904c37ea1c650b8ad4a712",
"ce41c1698a8c042218bc586f0b9ec8d5bffa3dcbcea09bd59db9d0d92c3fc0b4",
"b9a537523bba2fcdae857d90d8a760de4f2139c9f90d986f747ce7d0ec0d173d",
"1a6e0aeff1dba7ba121fbeb33bf3162901495df3fcb4e4a40423e1c10edf0dca",
"21b419102da8fc0ba90484aec934bf55b7abcf75eedb39124e8d75e491f41a5e",
"2183e94758481d0f124fbd93c56ccaa45e7e545ceeb8d52848f98253f497b975",
"e3f98bfb9cbeb7563a139983602e50f616cb7ebb06c3295b8ee377328f051206",
"b5b9b84d1723994d06013606227fb5b91f9de8820b04cf848d1dccc23d054f39",
"07adfda9c5adc80881bb2a5220f6e3181e0c043b90fa115c4f183464022968e6",
"facdaf1ce758bdf04cdf1a1fa32a3564a608d4abc2481a286ffc178f86953ef0",
"efc83f01c8fb309df2c8866b8c7924cc8b6f0580afdde1d6e16e2b6107c2862c",
"52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd",
"c6f7077f1699d50cf92a9652bfebffac05fc6842b9ee391089d959b8ad5d48fd",
"e7424ad457e512fdf4764a56bf6d428a06a13a1006af1fb8e0fe32f6d03265c7",
"27797bd4e5ee52db0a197668c92b9a3e7e237e1f9fa73a10c38d731c294cfc9a",
"7bdef7bdebb8721f77927d0e77c66059360fa62371fdf15f3add93923a613229",
"3335d373e6c1b5bc669b4b1220c08728ea8ce622e5a7cfeeb4c0001d91ded1de",
"645681b9d067b1a362c4bee8ddff987d2466d49905c26cb8fec5e6fb73af5c84",
"06b7819d7f1c7f5472118266ed7bca8785dceae09e36ea3a4af665c6d1d8327c",
"7a6b8c7de171955c214ded7e35cc782cd6dddfd141abb1929c632f69348e6f49",
"eb882b0bb659bf72235020a0b884c4a7d817e0af3903715736b146188b1d0868",
"2ae6c71323a225ecfa8cf655600ebbe12b1019ff36bf02726d82d095aab29729",
"c2f85a06279a7bfa7f2477a3cee907990231a13d17b54524738504bd12e0c86c",
"f1b911af1c7a56073e3b83ba7eaa681467040e0fbbdd265445aa80e65c274c22",
"a54c2ae6ec6ac06b4d7b45c483eab86ac226b8ecfa99163ef7cc000da9b40895",
"bbc73cae41502ddad7a4112586dcaf4422810d60aa4b57c637ccd1a746b07844",
"218238431393959d6c8617a3bd899303a96609b44a644e973891038a7de8622d",
"59cacbd83ad5c54ad91dacf51a49c06e0bef730ac0e7c235a6f6fa29b9230f02",
"ba80990666ef0b6f4ba5059347beb13242921e54669e680064ca755256a1e3a6",
"031db544f3158556508b321db59afd62c5bb1592021e5dfd9ff87dca0ad27d8c",
"ee85604f8ec6e4e24f8eaf2a624d042ebd431dae448fe11779adcfb6bb78575e",
"266ee74062e8dae0aeddfcd0f72725107598efaa80c1a7176d6ee6dd302bce4c",
"b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450",
"dbe0605a9c73172bad7523a327b236d55ea4b634e80e78a9013db791f8fd5b2c",
"1e53e900c3bbc5ead295215efe27b2c8d5fbd15fb3dd810da3063674cb7213b2",
"832a2b3cef4b1754c4a7572964a44db64d19edf627ec45179b519d0a5eae8199",
"4c800257a588a82849d049817c2bdaad984b25a45ad9f6dad66e47d3b47e3b2f",
"3743244390be53473a7e3b3b8d04dce83f6c9514b81a997fb3b123c072ef9f78",
"f96c3d76497074c4c83a7b3823380e77dc73d5a9494fd2e053e4a1453e17824b",
"d04ecf33a303a59852fdb681ed8b412201ba85d8d2199aec73cb62681d62aa90",
"0cca6201658d5d98239c1511ef402562ff7d72446fb201a8d1857c39e369c9fa",
"61066504617ee79387021e18c89fb79d1ddbc3e7bff19cf2298f40466f8715e9",
"7adb520c3ac7cb6dc8253508df0ce1d975da49fefda9b5c956744a049d230ace",
"7579076d9aff0a4cfdefa7e2045f2486c7e5d8bc63bfc6b45397233e1bbfcb19",
"9ec078ef9ca31e1bdbb97175dde1cb00bf9f7225e6f622ccc8d367302e220497",
"93e174736c4719f80627854aca8b67efd0b59558c8ece267a8eccbbd2e7c5535",
"e62f419a0e16607b96ff10ecb00249af7d4b69c7d121e4b04130c61cc998c32e",
"171ddd43dab1af0d1fb14029287152a4c89296890e0607cf5e7ba73c73fdf1a5",
"604e96e099936a104883958b040b47672e0f048c98ac793f37ffe4c720279eb2",
"7726c437ccf791f6ded97dbac1846e62019e5fbd24f42e9db2f640f231c3c09a",
"1bf9f239dca1636149bc2f3fc334077ae959ea9607cacf945ef8f8bb227dc5e1",
"fcd818454002a6c47a980393f0549ac6e629d28d5688114bb60d831b5c1832a7",
"56172b53f730750b40e63c501b16068dd96a245e7f0551675c0fec9817ee96e0",
"260d3a820b7f8de20f4972725999b1af88b0cc5554ca38f9681c8d657e043cc3",
"9ba8c688f091ca48de2b0f9bc998e3bc36a0092149f9201767da592849777f1c",
"61594d714aa94fe551f604123578c4a6592145f4228ad8601224b1b89ce401b0",
"416ca193aa5448b8cca1f09642807765cc0ee299609f972df0614cfb8ea2f2b1",
"9b6d95b76a01191a4c778185681ed7f3bced2fffa8e41516ec78240b213285f5",
"ee0304bae0d4679bb34347ce3b1b80482262b9812bd0c0d5e19a5e2445043b75",
"8027a1877f39e603dafc63279e004b4ed9df861d18ce81d9c43c7d7135da8f65",
"42b409ff9b261a985227b1ab92707e706777ac14de24654d7e05f0501b37e003",
"99097983b74c70800b182abc6f64046ab70407e9cabcd6cf570a38ada9ef75d5",
"de8ca7a6b3f7314e91921d4dc5e915fb7bc2bd32129ea6966322effa48050c4c",
"dcb302978215f54f33c3d2d7157ef69fd5058cf488fc43dd75c32b5dcaf47e7a",
"7c765d407d3a9d5ea117cb8b8699628560787fc084a0c76afaa449bfbd121d84",
"06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71",
"59f1b5faf29904fe94a6a042e2d82d80d68fc16ad7651eba90a8de39f63f8fe8",
"174398550d1468a41b98a09f496c38d3694feadef0f0073fd557610384bafb10",
"00d52016bd7e4aae8bf8eaa23f42276b649fe557483b5d7684702633dd0fd944",
"9a39bf837c868d61ed8cce6a4c7a0eb96f5e5bcc082ad6afdd5496cb614a23fb",
"74ffc51cc30150cf79b6cb316d3a15cf332ab29a38fec9eb484ab1551d6d1856",
"97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322",
"6c6e3e05e1c9d2aae0ed2431544aea411771dd9d81017539af0fd818b2389f28",
"23a2cf63ec81e65561acafc655898d2fd0ef190084653fa452413f75e5a3d5bc",
"f1f9b0996d4ff1bf75e79e4cc8577c89eb633e68415c7faf74cf17a07bf80bd8",
"e3aefda887252a72cee3578d33b2dcd90e9fe53b8bed6347ef5e26f74211adbb",
"6b4ec98f02e647e01440b473bbd92a7fae21e01b6aa6c65e32db94a36092272e",
"623ed218de81311783656783d6ce690b521a89c4dc09f28962e5bfd4fa549249",
"9ce71f1506ccf4b99f234af49bd6202be883a80f95a155c6e9a1c36fd7e780c7",
"139fcc6bb304b2879974c59cda61d86d7816ad4ac0f38ee7a724df488060e65d",
"0e8c41eb946657188ea6f2aac36c25e393fff4d4149a83679220d66595ff0faa",
"59ffbe1fc829decf90655438bd2df3a7b746ef4a04634d4ee9e280bb6ce5f14e",
"e4f695f05bb05b231255ccce3d471b8d79c64a65bccc014662d27f0f7e921092",
"39cc53c9e3f7d4980b21bea5ebc8a5b9cdf7fa6539430b5a826e8ad527168656",
"685fb49563864326e78df461468795b7e47849a27e713281cd8bb75c0547936d",
"05e4649832dfb8d1bfa81ea7cbf1c92c4f1cd5052bfc8d5465ba744aa6fa5eb8",
"e5cece49ae3fc2c81f50c8e7a93a5fb1e1585380c467e4822234b64a94add617",
"dda028cd1b806b4d494cc7f2789b6c2bd7e3c28ff6a267d03acc5ac6e69a05e0",
"046c436b2a525059867b40c81e469b6d83001442fc65312c87a7ce7abeb022ff",
"676ffea2ec31426a906d7795d7ebae2ba5e61f0b9fa815995b4a299dd085d510",
"15b5cf6cdf4fd1c02f28bcce0f197cafae4c8c7c66a3e2e23af9fe610875315e",
"c0fb5367cfcb803c5383f98e26524bed9176e6211588f53ec63fe6079cbfd3df",
"7ab00f596b0286b77f78af567ee1be2536feee41daee67bd872f1480b7aa65b9",
"e8d66519e43b1214ac68f9f2bdbc4386d41ac66b20c5a260b9b04102784074e9",
"e6618db6961dc7b91478e0fa78c4c1b6699009981526693bd5e273972550860c",
"b1e1185884a6d14bbfce3899cb53e8183adde642f264d0ff4f1745371e06134c",
"cae5b7ea348afefc4c102bb7b125c4928f114739a27b877c6bcfbe5a79280384",
"ecbe372132a9323b813eeb48f8dfcedaeca00e2887af181b063c6cfa13ed8ea1",
"52387c6b99cc42aac51916b08b7b51d2baddfc19f2ba08d82a48432849dbdfb2",
"3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da",
"f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9",
"fd0266485777bd73e97c7c37f520c83c82e362fe4c25a6be93f3380083d4646b",
"433e80c14ff7b8e16e179ccec35f55833df7dd5a5a063d23117b4b01b6f97170",
"b7c6f6915cfa9a62fff6a1f02604de88c23c6c6c6d1b8f62c7cc10749f307e81",
"ddf03aca85ade039e6742d5bef3df352df199d0d31e22b9858e7eda85cb3bbbe",
"d36e8083fa7b36daee646cb8b3f99feaa3d89e5a396508741f003e21ac0b6bec",
"ec79b568bdea63ca6091f5b84b0c639c10a0919e175fa09a4de3154f82906f25",
"8cd2d0f8310f7009e94f50231870756cb39ba68f37506044910e2f71482b1788",
"0eef96197f5c6be3859b6817e6a5736685856c416e29a2925bd5a15b2a57c8b1",
"04918dfc36c93e7db6cc0d60f37e1522f1c36b64d3f4b424c532d7c595febbc5",
"c8383d81dd24406745b68409be40d6721c301029464067fcc50a25ddf9139549",
"3b3a42d34cf0a1402d18d536c9d2ac2eb1c6019a9153be57084c8165d192e325",
"da18e9860040f3bf493876fc16b1a912ae5a6f6fa8d5159c3de2b8233a0d9851",
"e3fc673fc5f99cc554d0ff47756795647d25cb6e6658f912d114ae6429d35d35",
"3aa5817273c3b2f94f491840e0472f049d0f10009e23de63006166bca9b36ea3",
"bbb5dda0e15567979f0543407bdc2033d6f0bbb30f72512a981cfdb2f09e2747",
"1096f6be0a4d7f0ecc2df4ed2c8683f143efc81eeba3ece6daadd2fca74c7ecc",
"d76726da1b64e8679d8b6e66facf551ba96f2612de5a171fac818ee85ce3e5fe",
"27487c9600b16b24a1bfb0519cfe4a5d1ad84959e3cce5d6d7a99d48660a1f78",
"5d3ab876c206a37ad3b094e20bfc3941df3fa21a15ac8ea76d6918473789669a",
"6b1b8dac34ffc61d464dfeef00e4a84a604e172ef6391fb629293d6f1666148c",
"6fb266012c3008303e54ae55140b46957e9978098401dda34f4d921a275bf8bb",
"53a91e3a64d1f658e983ac1e4f9e0c697f8f33e01d8debe439f4c1a92113f592",
"5082984480f3b27891840a2037512739149678efc2ac981ca8cd016d02304efd",
"7b849efa5604b58d50c419637b9873847dbf957081d526136c3a49b7357cd617",
"f53b9d91a8cd177fb4a1cf081a1b6d58759a381ef120a7c5a18c0e70cae80983",
"cfd7df62799a22e384a4ab5da8c4026c875b119d0f47c2716b20cdac9cc1f1a6",
"d83b5ef189df7e884627294b752969547814c3cfe38995cf207c040e03bbe7a4",
"96f652249b0946e1575d78a8bc7450123c8e64f1c56f6b2f93bc23fb249ed85a",
"d60bdad03468f5f8c85b1b10db977e310a5aafab33750dfadb37488b02bfc8d7",
"9839f160d893daae661c84168e07f46f0e1e9746feb8439a6d76738b4ad32eaa",
"453a656903a031395d450f318211a6ec54cd79049a851f92cd6702c65ff5f5bd",
"1634b87b5fcfd4a6c4ff2f2de17450ccce46f9abe0b02a71876c596ec165bfed",
"24480686b56234a240fd9827209b584847f3d4f9657f0d9a97aec5320a264acb",
"f4d1866e8599563c52ceeedf11c28b8567e465c6e9a91df92add535d57f02ab0",
"805b34f708837dfb3e7f05815ac5760564628b58d5a0ce839ccbb6ef3620fac3",
"659a74f6cfbc7c252c58d93452b9d9575e36c464aa6544c6375227c9166a6ed9",
"75d737c3472471029c44876b330d2284288a42779b591a2ed4daa1c6c07efaf7",
"dac1d8c5a9fe94f224e095b52577c33c2cc2b8f3a2d6ad9cbd46845af8c987f0",
"be7358c4fe50148cccafc02ea205d80145e253889aa3958daafa8637047c840e",
"30e8cbf1427c137fa60674a639431c19a9d6f4c07fd2959df83158e674fccbaa",
"7f573f55d875ce8edc528edf822949fd2ab9f9c65d914a40225663b0a697be07",
"781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5",
"d82a91e1013170b10ca7fa0ec800fd0dc6e9335b70c303dadba603fc36802b5f",
"a4237e420cdb0b3231d171fe879bcae37a2db7abf2f12a337b975337618c3ac2",
"7ff4d89f90845ac4d7a50a259163798e0f446e61d4c943cc89637beff567ad02",
"48dbb5e717a6221d64fd13ba12794bc28e5067ac1d7632ee9437d533772750df",
"efba340bd479176486e5a2281c97ac4a90fdcf86ec9c13a78c3182ab877cd19b",
"1021c8921548fa89abb4cc7e8668a3a8dcebae0a4c323ffeaf570438832d6993",
"c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345",
"4ad6fa2d16e2a9b576c863b4cf7404a70d4dc320c0c447d10ad6ff58993eacc8",
"e568a76a4f8836be296d405eb41034260d55e2361e4b2ef88350a4003bbd5f9b",
"ebdee92945ef05283be0ac3de25787c81a6a58a10f568f9c6b61d9dd513adbad",
"6e8f3edfa28bfc8057d735794f76b697bcf18fb894a5a37a132693ebda31a464",
"576d23dc3db2056d208849462fee358cf9f0f3310a2c63cb6c267a4b9f5848f9",
"18905d0a5d623ab81a98ba98c582bd5f57f2506c6b808905fc599d5a0b229b08",
"a9046cc9175dc5a45fb93a2c890f9a8b18c707fa6d695771aab9300081d3e21a",
"7a69e5f62fcc20e81beea7461a945e6531f8c7944200d0b3cb4cc63556c44106",
"fd0266485777bd73e97c7c37f520c83c82e362fe4c25a6be93f3380083d4646b",
"4b29db7a76f3b4fbc0a4fffc092e41c14f1a1a975a462d87e82827af03719cb2",
"df1a6cb6c95a5bdd2a69e4fa921061da950fc0bb0b3529d04ca75e0c11f871df",
"08bfc00b7f72e015f45c326f486bec16e4d5236b70e44543f1c5e86a8e21c76a",
"1e908fbc1d131c17a87f32069f53f64f45c75f91a2f6d43f8aa6410974da5562",
"b3a737d014a7e75f44b0f5afbd752f9bcc2abe54f60dbbebc3681b6e16611967",
"d3052ca3e3d523b1ec80671eb1bba0517a2f522e195778dc83dd03a8d84a170e",
"b98ded4ceaea20790dbcb3c31400692009d34c7f9927c286835a99b7481a5c22",
"9e1e498420bc7c35f3e3a78d20045b4c8343986dae48739759bccb2a27e88c53",
"141d2053cb29535ad45aa9e865cdec492524f0ec0066496b98b7099daab5d658",
"8722c3843c85ddd6162a5cb506e1cb4d6ab0cafb966034f426e55a2ef89a345e",
"52dfd21724329920c5c95f5361464e468584136d30030eb29247a7fe6c2c6e36",
"d82a91e1013170b10ca7fa0ec800fd0dc6e9335b70c303dadba603fc36802b5f",
"6bbb7d71eaa2544215a877e136cd7f490f4625eb56459a0da856cc8296d5df30",
"1ebb28301aa1a48248d3723a0ea434bb7d4612ec920fa749583e3f41ce25849f",
"00000001505e7e48927046e9bbaa728b1f3b511227e2200c578d6e6bb0c77eb9",
"a01b5ba26421374250442e0d23f96e6a4bce429e0175cd0769ad8c585dd5a892",
"26d6a946675e603f8de4bf6f9cef442037b70c7eee170ff06ed7673fc34c98f1",
"1dd7992ea0ecbda7480ceed35748c3655691133c8c78af947fd24299db8f481f",
"cdee943cbb19c51ab847a66d5d774373aa9f63d287246bb59b0827fa5e637400",
"3d03c53608415b1d718c7786ee10bdb4e67bced32207e32880ee9e44301a19ec",
"170dc4045d6c06275b40bd39f68ca31dbb962094e9763ee460f8341bd40bebca",
"db1abbff170320730e5ace672ad7217161b8935afc1a896ae2fecf903c159932",
"b0ac2c26eabdb0e0a9b0d10fd1458ca73c575b19d65e13f0e7484cbee84038b3",
"c1e7fc21b4f9c199e6086e095639f0f16a4e4884544547ce8a653ed7b5b6c4a7",
"813c2662366a12f6337b951c048552fd3c4894e403cab701634dcd803786dc09",
"fd0bcf8cd1aee83fe75e6c0fdfc543845e5bc3f50d26d2d5e5c6d3fa521f98c0",
"45fd1955f590da87c1fd2edb99d17accf235ec3ebf0afe1d3306ade42693c6e9",
"27938497226683c701e2843c6db580e2f0e25f5a198f4c3397e3a0a27764215d",
"2321edfd415f9558605b4d7a7083c52624e8922ae86bb2ae359fbf829724111a",
"0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd",
"1f8e182bf72d61cb78dcd6b96dd3be8b874b8881da6630757b6508970f67230c",
"c6603b0f1ccfec625d9c08b753e4f774eaf7d1cf2769223125b5fd4da728019e",
"296842eaaed9be5ae0668da09fe48aac0521c4af859ad547d93145e5ac34c17e",
"88f8707a45e825a13ed383332abe6e2f104ab44d877918be22550083a2b59e60",
"27a20b41a66b35d442302a50ca1baad72c2ed844c8d1224c9f6d50a12752084e",
"280e847ef0c82a2a7c4e877c91cd7567474c1431b815d27bbc1017e147d9d77c",
"ad9d42203fd2480ea2e5c4c64593a027708aebe2b02aa60bd7b1d666daa5b08d",
"bb0174ae21a6cac1a0a9c8b4ac6ebfda56ce51605c315b1824970bc275f7239a",
"edb470271297ac5a61f277f3cd14de54c67eb5ccd20ef0d9df29be18685bb004",
"9609b093450dd7e0afb389619acdaf2e6a0d6817c93552f3911e05b50ae73e3d",
"3e33fd7124f174fc535151937f8718634dd9d856143d4cefb5a10ddaf2f615c0",
"463555bb4b0f80fd1376fae628fabfaf7e5e31cd2741d80aa4d225c926bc287e",
"916cb5ff07d3b51cef7f6b6b7f5479b1001b401c0e82558ee1a22504c7d507c9",
"cc5f259f036683798e4a52071dbb97238702ffb6f0c85af6d273c8ddbe5c0afb",
"2ad91f1dca2dcd5fc89e7208d1e5059f0bac0870d63fc3bac21c7a9388fa18fd",
"8bf629b3d519a0f8a8390137a445c0eb2f5f2b4a8ed71151de898051e8006f13",
"94215f42a96335c87fcb9e881a0bbb62b9a795519e109cf5f9d2ef617681f622",
"bcbf9644d3f475d00eb9c6e467385ce16d4546c1a24222ccfa542bf776eaba95",
"ba5115c37b0f911e530ed6c487ccbd9b737da33fd4b88a9f590860378c06af62",
"609f186ca023d658c0fe019570472f59565c8be1dc163b1541fac9d90aa4e8af",
"4e5622b575cdbb4d5ded093e48c68cd3f724fad547142f0c1b0aec2f2b2a0b2e",
"4df7b43b3a4db4b99e3dbad6bd0f84226726efd63ae7e027f91acbd91b4dba48",
"bcbf9644d3f475d00eb9c6e467385ce16d4546c1a24222ccfa542bf776eaba95",
"3a06add309fd8419ea4d4e475e9c0dff5909c635d9769bf0728232f3a0683a84",
"d2384c4ac623eb9f15ae4cb32ee7a9b03e0202802d52f2395f2ee8f6433151aa",
"1d4cc828b657da8c7a101e8657365459b9dc74139bed5d35bd8295b00be2a1ae",
"76fcec0e0638351f1d0e0dc4ebaf6dd3d67404126d664547674070f3175273d9",
"6707c39e6c53ef945c5df29af78667dc941ed80094994bd264fd806a6e0a3230",
"80482e60178c2ce996da6d67577f56a2b2c47ccb1c84c81f2b7960637cb71b78",
"147784df719c09fad62bff0493a60b4f5dbbe8579e73f00d207350e8ffdfd65f",
"afc0295d2c6e0a1820c214c07312070a4070d52083163d7fe410fa02bf85d9d2",
"6a5e3cc17279cbdf051c06d96e3f843cdb296f351d8ca35a6a190c0ab90dbf9a",
"3b7fc823611f1aeaea63ee3bf69b25b8aa16ec6e81d1afc39026808fe194354f",
"d96fe9c5478d1bb36e9ec40cc678b0bf7ff67e017922a151f925640a8884f291",
"1d80e5588de010d137a67c42b03717595f5f510e73e42cfc48f31bae91844d59",
"06dde95f0268ce40128bf73ca6e85567b8567688ea52f24dcd5734e77c50f2d9",
"f683e87035f7ad4f44e0b98cfbd9537e16455a92cd38cefc4cb31db7557f5ef2",
"036533caa872376946d4e4fdea4c1a0441eda38ca2d9d9417bb36006cbaabf58",
"7cc328a08ddb2afdf9f9be77beff4c83489ff979721827d628a542f32a247c0e",
"f240be2b684f85cc81566f2081386af81d7427ea86250c8bde6b7a8500c761ba",
"fb61b93d864e4f0eba766bb8556f2dc0262e8e985012e29ba28508dd52067d98",
"0e52122d1eb95cdd8ba5f65815f7d1c9125a8c14d82989eae52ab369eea6c7e4",
"04dcaf2552801937d1c20b69adf89646f21b53c17906271d22c7be9bcadb96c0",
"0ee827a36e8bb0cfc483cf1872781182c4a16c58acba3ae2d7b155e0370e93b8",
"adc14fa3ad590856dd8b80815d367f7c1e6735ad00fd98a86d002fbe9fb535e1",
"2bc9be7569515701581d5d765422a17ee3500d8e1f4e7aa53f6be86ae6ba274d",
"9a21569255d0a3a9e75f1de2e4c883c9be2e5615887f22b2ecf6b1813bcd587d",
"3f3ff7adb39159c42c0aa16d53c0483bfbfad57df22a9e9e9364a306741eb2cf",
"e9aa50decff01f2cec1ec2b2e0b34332cf9c92cafdac5a7cc0881a6d26b59854",
"c7eda660a6bc8270530e82b4a7712acdea2e31dc0a56f8dc955ac009efd97c86",
"787338757fc25d65cd929394d5e7713cf43638e8d259e8dcf5c73b834eb851f2",
"7e8ffe414a53ce18a85536eda74d3cbda0da6a98d4fe6c514948002472178c95",
"6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3",
"0c371f5ed95076613443e8331c4b60828ed67bcdefaa1698fb5ce9d7b3285ffb",
"daa41bedb68591363bf4407f687cb9789cc543ed024bb77c22d2c84d88f54153",
"12ee03d11684a125dd87be879c28190415be3f3b1eca6b4ed743bd74ffd880e6",
"00dfb20815a71e24572394efdfbf772e56a507921b8287201ab8937496ee8e6d",
"94a6a78a5aebbba40bd1aaa2234810132c2d8004bb9177616c413d3c0ddf320e"
];
const PUBKEY_ARRAYS = generateTestPubkeyArrays();
// Main testing function
async function testRelayPubkeyLimits() {
console.log('Starting Nostr Relay Pubkey Filter Test');
console.log(`Testing ${RELAYS.length} relays with up to ${MAX_PUBKEYS} pubkeys`);
console.log(`Incrementing by ${STEP_SIZE} pubkeys per test, requesting ${EVENT_LIMIT} events per test\n`);
const results = {};
// Initialize results for each relay
for (const relayUrl of RELAYS) {
results[relayUrl] = {
maxPubkeys: 0,
failures: 0,
lastSuccess: 0
};
}
// Test each pubkey array size
for (const pubkeyArray of PUBKEY_ARRAYS) {
const pubkeyCount = pubkeyArray.length;
console.log(`\nTesting with ${pubkeyCount} pubkeys...`);
// Test each relay with this pubkey count
for (const relayUrl of RELAYS) {
if (results[relayUrl].failures >= 2) {
console.log(` ${relayUrl}: Skipping (already failed 2 times)`);
continue;
}
try {
const success = await testRelayWithPubkeys(relayUrl, pubkeyArray);
if (success) {
results[relayUrl].maxPubkeys = pubkeyCount;
results[relayUrl].lastSuccess = pubkeyCount;
results[relayUrl].failures = 0; // Reset failures on success
console.log(` ${relayUrl}: ✓ Success`);
} else {
results[relayUrl].failures++;
console.log(` ${relayUrl}: ✗ Failed (${results[relayUrl].failures}/2)`);
}
} catch (error) {
results[relayUrl].failures++;
console.log(` ${relayUrl}: ✗ Error (${results[relayUrl].failures}/2): ${error.message}`);
}
}
}
// Print final results
console.log('\n=== FINAL RESULTS ===');
for (const relayUrl of RELAYS) {
const result = results[relayUrl];
console.log(`${relayUrl}: ${result.maxPubkeys} pubkeys (last success: ${result.lastSuccess})`);
}
}
// Test a single relay with a specific pubkey array
async function testRelayWithPubkeys(relayUrl, pubkeys) {
return new Promise((resolve) => {
const ws = new WebSocket(relayUrl);
let receivedEOSE = false;
let subscriptionId = 'test_' + Math.random().toString(36).substr(2, 9);
let timeoutId;
const cleanup = () => {
clearTimeout(timeoutId);
ws.close();
};
ws.on('open', () => {
console.log(`Connected to ${relayUrl}`);
// Send subscription request
const filter = {
kinds: [EVENT_KIND],
authors: pubkeys,
limit: EVENT_LIMIT
};
const subscriptionMessage = ['REQ', subscriptionId, filter];
const messageString = JSON.stringify(subscriptionMessage);
const messageSize = Buffer.byteLength(messageString, 'utf8');
console.log(`Sending request with ${pubkeys.length} pubkeys (${messageSize} bytes)`);
ws.send(messageString);
// Set timeout for EOSE response
timeoutId = setTimeout(() => {
if (!receivedEOSE) {
console.log('Timeout waiting for EOSE');
cleanup();
resolve(false);
}
}, 10000); // 10 second timeout
});
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
if (message[0] === 'EVENT' && message[1] === subscriptionId) {
// Skip event content, just log that we received an event
console.log(`Received EVENT for subscription ${subscriptionId}`);
} else if (message[0] === 'EOSE' && message[1] === subscriptionId) {
console.log(`Received EOSE: ${JSON.stringify(message)}`);
receivedEOSE = true;
cleanup();
resolve(true);
} else {
console.log(`Received other message: ${JSON.stringify(message)}`);
}
} catch (e) {
console.log(`Received non-JSON: ${data.toString()}`);
}
});
ws.on('error', (error) => {
console.log(`WebSocket error: ${error.message}`);
cleanup();
resolve(false);
});
// Overall connection timeout
setTimeout(() => {
if (!receivedEOSE) {
cleanup();
resolve(false);
}
}, 15000); // 15 second total timeout
});
}
// Run the test
testRelayPubkeyLimits().catch(console.error);