Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ec5ed9bd4 | ||
|
|
bd5150cd09 | ||
|
|
2e1d731de4 | ||
|
|
75d983b2df | ||
|
|
a766f08738 | ||
|
|
23c37abe3a | ||
|
|
5b3ccfd1f4 | ||
|
|
38a027e626 | ||
|
|
a042aca524 | ||
|
|
20902e1c5d | ||
|
|
7233818da9 | ||
|
|
86cbf7ff16 | ||
|
|
3035e71ca5 | ||
|
|
179b160cf3 | ||
|
|
aedd22436e | ||
|
|
8fdc362a8b | ||
|
|
67cf3973c5 | ||
|
|
c02ae2be5b | ||
|
|
fd3efdd01f | ||
|
|
232f93c16f | ||
|
|
9a9365dcfa | ||
|
|
5577b57149 | ||
|
|
22c943d495 | ||
|
|
7e3d9b6825 | ||
|
|
547d22d09f | ||
|
|
ce9ae5a488 | ||
|
|
9b248b740f | ||
|
|
0f77aeb72b | ||
|
|
bb64651a0c | ||
|
|
17be9c2b03 | ||
|
|
f396c622b6 | ||
|
|
cbd260c1ae | ||
|
|
bc9ac290ab | ||
|
|
192eeb248d | ||
|
|
da6c505420 | ||
|
|
c077c209e5 | ||
|
|
9bf02702c2 | ||
|
|
0920cc092d | ||
|
|
a89460be5d | ||
|
|
ff2a3aa335 | ||
|
|
94b61e8a7c | ||
|
|
b0c0754e83 | ||
|
|
3265e3d114 | ||
|
|
927659ece1 | ||
|
|
b6ff4150b4 | ||
|
|
63bc526163 | ||
|
|
a1f712236a | ||
|
|
06e6c17b7b | ||
|
|
0751a7c55c | ||
|
|
f1728932a9 | ||
|
|
ef8bdef2a8 | ||
|
|
c11a8ba292 | ||
|
|
0f124fe575 | ||
|
|
6b20452fab | ||
|
|
10c19bc243 | ||
|
|
3d7aa2196f | ||
|
|
a416c3f275 | ||
|
|
bba9baabc3 | ||
|
|
31187c4c4f | ||
|
|
55f862b879 | ||
|
|
76c9b3fcf0 | ||
|
|
929bd09164 |
+1
-1
@@ -11,4 +11,4 @@ copy_executable_local.sh
|
||||
nostr_login_lite/
|
||||
style_guide/
|
||||
nostr-tools
|
||||
.test_keys
|
||||
.test_keysadmin/cache/
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
[submodule "nostr_core_lib"]
|
||||
path = nostr_core_lib
|
||||
url = https://git.laantungir.net/laantungir/nostr_core_lib.git
|
||||
[submodule "c_utils_lib"]
|
||||
path = c_utils_lib
|
||||
url = ssh://git@git.laantungir.net:2222/laantungir/c_utils_lib.git
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ADMIN_PUBKEY='6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3'
|
||||
SERVER_PRIVKEY='1111111111111111111111111111111111111111111111111111111111111111'
|
||||
+11
-11
@@ -1,12 +1,12 @@
|
||||
|
||||
# AGENTS.md - AI Agent Integration Guide for Architect Mode
|
||||
|
||||
**Project-Specific Information for AI Agents Working with C-Relay in Architect Mode**
|
||||
**Project-Specific Information for AI Agents Working with C-Relay-PG in Architect Mode**
|
||||
|
||||
## Critical Architecture Understanding
|
||||
|
||||
### System Architecture Overview
|
||||
C-Relay implements a **unique event-based configuration architecture** that fundamentally differs from traditional Nostr relays:
|
||||
C-Relay-PG implements a **unique event-based configuration architecture** that fundamentally differs from traditional Nostr relays:
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
@@ -51,9 +51,9 @@ C-Relay implements a **unique event-based configuration architecture** that fund
|
||||
## Architectural Decision Analysis
|
||||
|
||||
### Configuration System Design
|
||||
**Traditional Approach vs C-Relay:**
|
||||
**Traditional Approach vs C-Relay-PG:**
|
||||
```
|
||||
Traditional: C-Relay:
|
||||
Traditional: C-Relay-PG:
|
||||
config.json → kind 33334 events
|
||||
ENV variables → cryptographically signed tags
|
||||
File watching → database polling/restart
|
||||
@@ -119,18 +119,18 @@ File watching → database polling/restart
|
||||
```
|
||||
Developer Machine:
|
||||
├── ./make_and_restart_relay.sh
|
||||
├── build/c_relay_x86
|
||||
├── build/c_relay_pg_x86
|
||||
├── build/<relay_pubkey>.db
|
||||
└── relay.log
|
||||
```
|
||||
|
||||
### Production SystemD Deployment
|
||||
```
|
||||
/opt/c-relay/:
|
||||
├── c_relay_x86
|
||||
/opt/c-relay-pg/:
|
||||
├── c_relay_pg_x86
|
||||
├── <relay_pubkey>.db
|
||||
├── systemd service (c-relay.service)
|
||||
└── c-relay user isolation
|
||||
├── systemd service (c-relay-pg.service)
|
||||
└── c-relay-pg user isolation
|
||||
```
|
||||
|
||||
### Container Deployment Architecture
|
||||
@@ -144,7 +144,7 @@ Container:
|
||||
|
||||
### Reverse Proxy Architecture
|
||||
```
|
||||
Internet → Nginx/HAProxy → C-Relay
|
||||
Internet → Nginx/HAProxy → C-Relay-PG
|
||||
├── WebSocket upgrade handling
|
||||
├── SSL termination
|
||||
└── Rate limiting
|
||||
@@ -292,7 +292,7 @@ Admin Signs Event → WebSocket Submit → Validate → Store → Restart Requir
|
||||
**Decision**: Same port serves both protocols
|
||||
**Consequences**: Simplified deployment, protocol detection overhead, libwebsockets dependency
|
||||
|
||||
These architectural decisions form the foundation of C-Relay's unique approach to Nostr relay implementation and should be carefully considered when planning extensions or modifications.
|
||||
These architectural decisions form the foundation of C-Relay-PG's unique approach to Nostr relay implementation and should be carefully considered when planning extensions or modifications.
|
||||
**
|
||||
|
||||
[Response interrupted by a tool use result. Only one tool may be used at a time and should be placed at the end of the message.]
|
||||
@@ -0,0 +1,2 @@
|
||||
ADMIN_PUBKEY='8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e'
|
||||
SERVER_PRIVKEY='1111111111111111111111111111111111111111111111111111111111111111'
|
||||
Executable
BIN
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md - AI Agent Integration Guide
|
||||
|
||||
**Project-Specific Information for AI Agents Working with C-Relay**
|
||||
**Project-Specific Information for AI Agents Working with C-Relay-PG**
|
||||
|
||||
## Critical Build Commands
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
- Starts relay in background with proper logging
|
||||
|
||||
### Architecture-Specific Binary Outputs
|
||||
- **x86_64**: `./build/c_relay_x86`
|
||||
- **ARM64**: `./build/c_relay_arm64`
|
||||
- **Other**: `./build/c_relay_$(ARCH)`
|
||||
- **x86_64**: `./build/c_relay_pg_x86`
|
||||
- **ARM64**: `./build/c_relay_pg_arm64`
|
||||
- **Other**: `./build/c_relay_pg_$(ARCH)`
|
||||
|
||||
### Database File Naming Convention
|
||||
- **Format**: `<relay_pubkey>.db` (NOT `.nrdb` as shown in docs)
|
||||
@@ -75,10 +75,10 @@
|
||||
### Process Management
|
||||
```bash
|
||||
# Kill existing relay processes
|
||||
pkill -f "c_relay_"
|
||||
pkill -f "c_relay_pg_"
|
||||
|
||||
# Check running processes
|
||||
ps aux | grep c_relay_
|
||||
ps aux | grep c_relay_pg_
|
||||
|
||||
# Force kill port binding
|
||||
fuser -k 8888/tcp
|
||||
@@ -95,7 +95,7 @@ fuser -k 8888/tcp
|
||||
- Event configuration tests: `tests/event_config_tests.sh`
|
||||
|
||||
### SystemD Integration Considerations
|
||||
- Service runs as `c-relay` user in `/opt/c-relay`
|
||||
- Service runs as `c-relay-pg` user in `/opt/c-relay-pg`
|
||||
- Database files created in WorkingDirectory automatically
|
||||
- No environment variables needed (event-based config)
|
||||
- Resource limits: 65536 file descriptors, 4096 processes
|
||||
@@ -137,7 +137,7 @@ fuser -k 8888/tcp
|
||||
## Quick Debugging Commands
|
||||
```bash
|
||||
# Check relay status
|
||||
ps aux | grep c_relay_ && netstat -tln | grep 8888
|
||||
ps aux | grep c_relay_pg_ && netstat -tln | grep 8888
|
||||
|
||||
# View logs
|
||||
tail -f relay.log
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# C-Relay API Documentation
|
||||
# C-Relay-PG API Documentation
|
||||
|
||||
Complete API reference for the C-Relay event-based administration system and advanced features.
|
||||
Complete API reference for the C-Relay-PG event-based administration system and advanced features.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -21,7 +21,7 @@ Complete API reference for the C-Relay event-based administration system and adv
|
||||
|
||||
## Overview
|
||||
|
||||
C-Relay uses an innovative **event-based administration system** where all configuration and management commands are sent as cryptographically signed Nostr events. This provides:
|
||||
C-Relay-PG uses an innovative **event-based administration system** where all configuration and management commands are sent as cryptographically signed Nostr events. This provides:
|
||||
|
||||
- **Cryptographic security**: All commands must be signed with the admin private key
|
||||
- **Audit trail**: Complete history of all administrative actions
|
||||
@@ -63,8 +63,8 @@ Store the admin private key securely:
|
||||
export C_RELAY_ADMIN_KEY="nsec1abc123..."
|
||||
|
||||
# Secure file
|
||||
echo "nsec1abc123..." > ~/.c-relay-admin
|
||||
chmod 600 ~/.c-relay-admin
|
||||
echo "nsec1abc123..." > ~/.c-relay-pg-admin
|
||||
chmod 600 ~/.c-relay-pg-admin
|
||||
|
||||
# Password manager (recommended)
|
||||
# Store in 1Password, Bitwarden, etc.
|
||||
@@ -167,7 +167,7 @@ Examples:
|
||||
"data": [
|
||||
{
|
||||
"key": "relay_name",
|
||||
"value": "C-Relay",
|
||||
"value": "C-Relay-PG",
|
||||
"data_type": "string",
|
||||
"category": "relay",
|
||||
"description": "Relay name displayed in NIP-11"
|
||||
@@ -506,10 +506,10 @@ ORDER BY count DESC
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `relay_name` | string | "C-Relay" | Relay name (NIP-11) |
|
||||
| `relay_name` | string | "C-Relay-PG" | Relay name (NIP-11) |
|
||||
| `relay_description` | string | "C Nostr Relay" | Relay description |
|
||||
| `relay_contact` | string | "" | Admin contact info |
|
||||
| `relay_software` | string | "c-relay" | Software identifier |
|
||||
| `relay_software` | string | "c-relay-pg" | Software identifier |
|
||||
| `relay_version` | string | auto | Software version |
|
||||
| `supported_nips` | string | "1,9,11,13,15,20,33,40,42,45,50,70" | Supported NIPs |
|
||||
| `language_tags` | string | "*" | Supported languages |
|
||||
@@ -576,7 +576,7 @@ ORDER BY count DESC
|
||||
|
||||
## Real-time Monitoring
|
||||
|
||||
C-Relay provides subscription-based real-time monitoring using ephemeral events (kind 24567).
|
||||
C-Relay-PG provides subscription-based real-time monitoring using ephemeral events (kind 24567).
|
||||
|
||||
### Activation
|
||||
|
||||
|
||||
+41
-30
@@ -1,12 +1,14 @@
|
||||
# Alpine-based MUSL static binary builder for C-Relay
|
||||
# Alpine-based MUSL static binary builder for C-Relay-PG
|
||||
# Produces truly portable binaries with zero runtime dependencies
|
||||
|
||||
ARG DEBUG_BUILD=false
|
||||
ARG DB_BACKEND=sqlite
|
||||
|
||||
FROM alpine:3.19 AS builder
|
||||
|
||||
# Re-declare build argument in this stage
|
||||
# Re-declare build arguments in this stage
|
||||
ARG DEBUG_BUILD=false
|
||||
ARG DB_BACKEND=sqlite
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache \
|
||||
@@ -26,9 +28,11 @@ RUN apk add --no-cache \
|
||||
curl-static \
|
||||
sqlite-dev \
|
||||
sqlite-static \
|
||||
postgresql-dev \
|
||||
linux-headers \
|
||||
wget \
|
||||
bash
|
||||
bash \
|
||||
openssh-client
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /build
|
||||
@@ -68,15 +72,8 @@ RUN cd /tmp && \
|
||||
make install && \
|
||||
rm -rf /tmp/libwebsockets
|
||||
|
||||
# Copy only submodule configuration and git directory
|
||||
COPY .gitmodules /build/.gitmodules
|
||||
COPY .git /build/.git
|
||||
|
||||
# Clean up any stale submodule references (nips directory is not a submodule)
|
||||
RUN git rm --cached nips 2>/dev/null || true
|
||||
|
||||
# Initialize submodules (cached unless .gitmodules changes)
|
||||
RUN git submodule update --init --recursive
|
||||
# Submodules are provided in the local build context and copied explicitly below.
|
||||
# Avoid network/SSH dependency inside Docker image build.
|
||||
|
||||
# Copy nostr_core_lib source files (cached unless nostr_core_lib changes)
|
||||
COPY nostr_core_lib /build/nostr_core_lib/
|
||||
@@ -84,59 +81,73 @@ COPY nostr_core_lib /build/nostr_core_lib/
|
||||
# Copy c_utils_lib source files (cached unless c_utils_lib changes)
|
||||
COPY c_utils_lib /build/c_utils_lib/
|
||||
|
||||
# Build c_utils_lib with MUSL-compatible flags (cached unless c_utils_lib changes)
|
||||
# Build c_utils_lib static library for relay logging utilities
|
||||
# (build only debug.c to avoid broken/missing version header surface in submodule revision)
|
||||
RUN cd c_utils_lib && \
|
||||
sed -i 's/CFLAGS = -Wall -Wextra -std=c99 -O2 -g/CFLAGS = -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -O2 -g/' Makefile && \
|
||||
make clean && \
|
||||
make
|
||||
mkdir -p build && \
|
||||
gcc -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -O2 -g \
|
||||
-Isrc -Iinclude -c src/debug.c -o build/debug.o && \
|
||||
ar rcs libc_utils.a build/debug.o
|
||||
|
||||
# Build nostr_core_lib with required NIPs (cached unless nostr_core_lib changes)
|
||||
# Disable fortification in build.sh to prevent __*_chk symbol issues
|
||||
# NIPs: 001(Basic), 006(Keys), 013(PoW), 017(DMs), 019(Bech32), 044(Encryption), 059(Gift Wrap - required by NIP-17)
|
||||
# NIPs: 001(Basic), 004(Encryption), 006(Keys), 013(PoW), 017(DMs), 019(Bech32), 044(Encryption), 059(Gift Wrap - required by NIP-17)
|
||||
# NIP-04 is required by nostr_signer.c (NIP-46 bunker signer uses NIP-04 encryption)
|
||||
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
|
||||
./build.sh --nips=1,4,6,13,17,19,44,59
|
||||
|
||||
# Copy c-relay source files LAST (only this layer rebuilds on source changes)
|
||||
# Copy c-relay-pg source files LAST (only this layer rebuilds on source changes)
|
||||
COPY src/ /build/src/
|
||||
COPY Makefile /build/Makefile
|
||||
|
||||
# Build c-relay with full static linking (only rebuilds when src/ changes)
|
||||
# Build c-relay-pg with full static linking (only rebuilds when src/ changes)
|
||||
# Disable fortification to avoid __*_chk symbols that don't exist in MUSL
|
||||
# Use conditional compilation flags based on DEBUG_BUILD argument
|
||||
# Use conditional compilation flags based on DEBUG_BUILD and DB_BACKEND build args
|
||||
RUN if [ "$DEBUG_BUILD" = "true" ]; then \
|
||||
CFLAGS="-g -O2 -DDEBUG"; \
|
||||
STRIP_CMD="echo 'Keeping debug symbols'"; \
|
||||
echo "Building with DEBUG symbols enabled (optimized with -O2)"; \
|
||||
else \
|
||||
CFLAGS="-O2"; \
|
||||
STRIP_CMD="strip /build/c_relay_static"; \
|
||||
STRIP_CMD="strip /build/c_relay_pg_static"; \
|
||||
echo "Building optimized production binary (symbols stripped)"; \
|
||||
fi && \
|
||||
gcc -static $CFLAGS -Wall -Wextra -std=c99 \
|
||||
if [ "$DB_BACKEND" = "postgres" ]; then \
|
||||
DB_FLAGS="-DDB_BACKEND_POSTGRES -DHAVE_LIBPQ"; \
|
||||
DB_LIBS="-lpq -lpgcommon -lpgport"; \
|
||||
echo "Compiling with PostgreSQL backend"; \
|
||||
else \
|
||||
DB_FLAGS=""; \
|
||||
DB_LIBS=""; \
|
||||
echo "Compiling with SQLite backend"; \
|
||||
fi && \
|
||||
gcc -static $CFLAGS $DB_FLAGS -Wall -Wextra -std=c99 \
|
||||
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
|
||||
-I. -Ic_utils_lib/src -Inostr_core_lib -Inostr_core_lib/nostr_core \
|
||||
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
|
||||
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket -I/usr/include/postgresql \
|
||||
src/main.c src/config.c src/dm_admin.c src/request_validator.c \
|
||||
src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c \
|
||||
src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c src/ip_ban.c \
|
||||
-o /build/c_relay_static \
|
||||
src/caching_inbox_poller.c src/caching_service_launcher.c \
|
||||
src/db_ops.c src/db_ops_sqlite.c src/db_ops_postgres.c src/thread_pool.c \
|
||||
-o /build/c_relay_pg_static \
|
||||
c_utils_lib/libc_utils.a \
|
||||
nostr_core_lib/libnostr_core_x64.a \
|
||||
-lwebsockets -lssl -lcrypto -lsqlite3 -lsecp256k1 \
|
||||
-lcurl -lz -lpthread -lm -ldl && \
|
||||
-lcurl -lz -lpthread -lm -ldl $DB_LIBS && \
|
||||
eval "$STRIP_CMD"
|
||||
|
||||
# Verify it's truly static
|
||||
RUN echo "=== Binary Information ===" && \
|
||||
file /build/c_relay_static && \
|
||||
ls -lh /build/c_relay_static && \
|
||||
file /build/c_relay_pg_static && \
|
||||
ls -lh /build/c_relay_pg_static && \
|
||||
echo "=== Checking for dynamic dependencies ===" && \
|
||||
(ldd /build/c_relay_static 2>&1 || echo "Binary is static") && \
|
||||
(ldd /build/c_relay_pg_static 2>&1 || echo "Binary is static") && \
|
||||
echo "=== Build complete ==="
|
||||
|
||||
# Output stage - just the binary
|
||||
FROM scratch AS output
|
||||
COPY --from=builder /build/c_relay_static /c_relay_static
|
||||
COPY --from=builder /build/c_relay_pg_static /c_relay_pg_static
|
||||
@@ -1,28 +1,40 @@
|
||||
# C-Relay Makefile
|
||||
# C-Relay-PG Makefile
|
||||
|
||||
CC = gcc
|
||||
CFLAGS = -Wall -Wextra -std=c99 -g -O2
|
||||
INCLUDES = -I. -Ic_utils_lib/src -Inostr_core_lib -Inostr_core_lib/nostr_core -Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket
|
||||
INCLUDES = -I. -Ic_utils_lib/src -Inostr_core_lib -Inostr_core_lib/nostr_core -Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket -I/usr/include/postgresql
|
||||
LIBS = -lsqlite3 -lwebsockets -lz -ldl -lpthread -lm -L/usr/local/lib -lsecp256k1 -lssl -lcrypto -L/usr/local/lib -lcurl -Lc_utils_lib -lc_utils
|
||||
|
||||
DB_BACKEND ?= sqlite
|
||||
|
||||
# Build directory
|
||||
BUILD_DIR = build
|
||||
|
||||
# Source files
|
||||
MAIN_SRC = src/main.c src/config.c src/dm_admin.c src/request_validator.c src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c src/ip_ban.c
|
||||
MAIN_SRC = src/main.c src/config.c src/dm_admin.c src/request_validator.c src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c src/ip_ban.c src/thread_pool.c src/caching_inbox_poller.c src/caching_service_launcher.c
|
||||
DB_OPS_SRC = src/db_ops.c
|
||||
|
||||
ifeq ($(DB_BACKEND),postgres)
|
||||
CFLAGS += -DDB_BACKEND_POSTGRES -DHAVE_LIBPQ
|
||||
DB_OPS_SRC += src/db_ops_postgres.c
|
||||
LIBS += -lpq
|
||||
else
|
||||
DB_OPS_SRC += src/db_ops_sqlite.c
|
||||
endif
|
||||
|
||||
NOSTR_CORE_LIB = nostr_core_lib/libnostr_core_x64.a
|
||||
C_UTILS_LIB = c_utils_lib/libc_utils.a
|
||||
|
||||
# Architecture detection
|
||||
ARCH = $(shell uname -m)
|
||||
ifeq ($(ARCH),x86_64)
|
||||
TARGET = $(BUILD_DIR)/c_relay_x86
|
||||
TARGET = $(BUILD_DIR)/c_relay_pg_x86
|
||||
else ifeq ($(ARCH),aarch64)
|
||||
TARGET = $(BUILD_DIR)/c_relay_arm64
|
||||
TARGET = $(BUILD_DIR)/c_relay_pg_arm64
|
||||
else ifeq ($(ARCH),arm64)
|
||||
TARGET = $(BUILD_DIR)/c_relay_arm64
|
||||
TARGET = $(BUILD_DIR)/c_relay_pg_arm64
|
||||
else
|
||||
TARGET = $(BUILD_DIR)/c_relay_$(ARCH)
|
||||
TARGET = $(BUILD_DIR)/c_relay_pg_$(ARCH)
|
||||
endif
|
||||
|
||||
# Default target
|
||||
@@ -81,19 +93,19 @@ force-version:
|
||||
@$(MAKE) src/main.h
|
||||
|
||||
# Build the relay
|
||||
$(TARGET): $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(NOSTR_CORE_LIB) $(C_UTILS_LIB)
|
||||
@echo "Compiling C-Relay for architecture: $(ARCH)"
|
||||
$(CC) $(CFLAGS) $(INCLUDES) $(MAIN_SRC) -o $(TARGET) $(NOSTR_CORE_LIB) $(C_UTILS_LIB) $(LIBS)
|
||||
$(TARGET): $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(DB_OPS_SRC) $(NOSTR_CORE_LIB) $(C_UTILS_LIB)
|
||||
@echo "Compiling C-Relay-PG for architecture: $(ARCH) (backend: $(DB_BACKEND))"
|
||||
$(CC) $(CFLAGS) $(INCLUDES) $(MAIN_SRC) $(DB_OPS_SRC) -o $(TARGET) $(NOSTR_CORE_LIB) $(C_UTILS_LIB) $(LIBS)
|
||||
@echo "Build complete: $(TARGET)"
|
||||
|
||||
# Build for specific architectures
|
||||
x86: $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(NOSTR_CORE_LIB) $(C_UTILS_LIB)
|
||||
@echo "Building C-Relay for x86_64..."
|
||||
$(CC) $(CFLAGS) $(INCLUDES) $(MAIN_SRC) -o $(BUILD_DIR)/c_relay_x86 $(NOSTR_CORE_LIB) $(C_UTILS_LIB) $(LIBS)
|
||||
@echo "Build complete: $(BUILD_DIR)/c_relay_x86"
|
||||
x86: $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(DB_OPS_SRC) $(NOSTR_CORE_LIB) $(C_UTILS_LIB)
|
||||
@echo "Building C-Relay-PG for x86_64 (backend: $(DB_BACKEND))..."
|
||||
$(CC) $(CFLAGS) $(INCLUDES) $(MAIN_SRC) $(DB_OPS_SRC) -o $(BUILD_DIR)/c_relay_pg_x86 $(NOSTR_CORE_LIB) $(C_UTILS_LIB) $(LIBS)
|
||||
@echo "Build complete: $(BUILD_DIR)/c_relay_pg_x86"
|
||||
|
||||
arm64: $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(NOSTR_CORE_LIB) $(C_UTILS_LIB)
|
||||
@echo "Cross-compiling C-Relay for ARM64..."
|
||||
arm64: $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(DB_OPS_SRC) $(NOSTR_CORE_LIB) $(C_UTILS_LIB)
|
||||
@echo "Cross-compiling C-Relay-PG for ARM64 (backend: $(DB_BACKEND))..."
|
||||
@if ! command -v aarch64-linux-gnu-gcc >/dev/null 2>&1; then \
|
||||
echo "ERROR: ARM64 cross-compiler not found."; \
|
||||
echo "Install with: make install-cross-tools"; \
|
||||
@@ -116,9 +128,9 @@ arm64: $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(NOSTR_CORE_LIB) $(
|
||||
fi
|
||||
@echo "Using aarch64-linux-gnu-gcc with ARM64 libraries..."
|
||||
PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig \
|
||||
aarch64-linux-gnu-gcc $(CFLAGS) $(INCLUDES) $(MAIN_SRC) -o $(BUILD_DIR)/c_relay_arm64 $(NOSTR_CORE_LIB) $(C_UTILS_LIB) \
|
||||
aarch64-linux-gnu-gcc $(CFLAGS) $(INCLUDES) $(MAIN_SRC) $(DB_OPS_SRC) -o $(BUILD_DIR)/c_relay_pg_arm64 $(NOSTR_CORE_LIB) $(C_UTILS_LIB) \
|
||||
-L/usr/lib/aarch64-linux-gnu $(LIBS)
|
||||
@echo "Build complete: $(BUILD_DIR)/c_relay_arm64"
|
||||
@echo "Build complete: $(BUILD_DIR)/c_relay_pg_arm64"
|
||||
|
||||
# Install ARM64 cross-compilation dependencies
|
||||
install-arm64-deps:
|
||||
@@ -160,7 +172,7 @@ test: $(TARGET)
|
||||
init-db:
|
||||
@echo "Database initialization is now handled automatically when the server starts."
|
||||
@echo "The schema is embedded in the binary - no external files needed."
|
||||
@echo "To manually recreate database: rm -f db/c_nostr_relay.db && ./build/c_relay_x86"
|
||||
@echo "To manually recreate database: rm -f db/c_nostr_relay.db && ./build/c_relay_pg_x86"
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@@ -180,7 +192,7 @@ install-deps:
|
||||
|
||||
# Help
|
||||
help:
|
||||
@echo "C-Relay Build System"
|
||||
@echo "C-Relay-PG Build System"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@echo " all Build the relay for current architecture (default)"
|
||||
@@ -209,15 +221,15 @@ help:
|
||||
# Build fully static MUSL binaries using Docker
|
||||
static-musl-x86_64:
|
||||
@echo "Building fully static MUSL binary for x86_64..."
|
||||
docker buildx build --platform linux/amd64 -f examples/deployment/static-builder.Dockerfile -t c-relay-static-builder-x86_64 --load .
|
||||
docker run --rm -v $(PWD)/build:/output c-relay-static-builder-x86_64 sh -c "cp /c_relay_static_musl_x86_64 /output/"
|
||||
@echo "Static binary created: build/c_relay_static_musl_x86_64"
|
||||
docker buildx build --platform linux/amd64 -f examples/deployment/static-builder.Dockerfile -t c-relay-pg-static-builder-x86_64 --load .
|
||||
docker run --rm -v $(PWD)/build:/output c-relay-pg-static-builder-x86_64 sh -c "cp /c_relay_pg_static_musl_x86_64 /output/"
|
||||
@echo "Static binary created: build/c_relay_pg_static_musl_x86_64"
|
||||
|
||||
static-musl-arm64:
|
||||
@echo "Building fully static MUSL binary for ARM64..."
|
||||
docker buildx build --platform linux/arm64 -f examples/deployment/static-builder.Dockerfile -t c-relay-static-builder-arm64 --load .
|
||||
docker run --rm -v $(PWD)/build:/output c-relay-static-builder-arm64 sh -c "cp /c_relay_static_musl_x86_64 /output/c_relay_static_musl_arm64"
|
||||
@echo "Static binary created: build/c_relay_static_musl_arm64"
|
||||
docker buildx build --platform linux/arm64 -f examples/deployment/static-builder.Dockerfile -t c-relay-pg-static-builder-arm64 --load .
|
||||
docker run --rm -v $(PWD)/build:/output c-relay-pg-static-builder-arm64 sh -c "cp /c_relay_pg_static_musl_x86_64 /output/c_relay_pg_static_musl_arm64"
|
||||
@echo "Static binary created: build/c_relay_pg_static_musl_arm64"
|
||||
|
||||
static-musl: static-musl-x86_64 static-musl-arm64
|
||||
@echo "Built static MUSL binaries for both architectures"
|
||||
|
||||
+6
-6
@@ -45,21 +45,21 @@ Also included is a more standard administrative web front end. This front end co
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||
Main page with real time updates.
|
||||
|
||||

|
||||

|
||||
Set your configuration preferences.
|
||||
|
||||

|
||||

|
||||
View current subscriptions
|
||||
|
||||

|
||||

|
||||
Add npubs to white or black lists.
|
||||
|
||||

|
||||

|
||||
Run sql queries on the database.
|
||||
|
||||

|
||||

|
||||
Light mode.
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# C-Relay: High-Performance Nostr Relay
|
||||
# C-Relay-PG: High-Performance Nostr Relay
|
||||
|
||||
A blazingly fast, production-ready Nostr relay implemented in C with an innovative event-based configuration system. Built for performance, security, and ease of deployment.
|
||||
|
||||
## 🚀 Why C-Relay?
|
||||
## 🚀 Why C-Relay-PG?
|
||||
|
||||
### Event-Based Configuration
|
||||
Unlike traditional relays that require config files, C-Relay uses **cryptographically signed Nostr events** for all configuration. This means:
|
||||
Unlike traditional relays that require config files, C-Relay-PG uses **cryptographically signed Nostr events** for all configuration. This means:
|
||||
- **Zero config files** - Everything stored in the database
|
||||
- **Real-time updates** - Changes applied instantly without restart
|
||||
- **Cryptographic security** - All changes must be signed by admin
|
||||
@@ -36,7 +36,7 @@ Control your relay by sending direct messages from any Nostr client:
|
||||
|
||||
## 📋 Supported NIPs
|
||||
|
||||
C-Relay implements a comprehensive set of Nostr Improvement Proposals:
|
||||
C-Relay-PG implements a comprehensive set of Nostr Improvement Proposals:
|
||||
|
||||
- ✅ **NIP-01**: Basic protocol flow implementation
|
||||
- ✅ **NIP-09**: Event deletion
|
||||
@@ -89,12 +89,12 @@ Download and run - no dependencies required:
|
||||
|
||||
```bash
|
||||
# Download the latest static release
|
||||
wget https://git.laantungir.net/laantungir/c-relay/releases/download/v0.6.0/c-relay-v0.6.0-linux-x86_64-static
|
||||
chmod +x c-relay-v0.6.0-linux-x86_64-static
|
||||
mv c-relay-v0.6.0-linux-x86_64-static c-relay
|
||||
wget https://git.laantungir.net/laantungir/c-relay-pg/releases/download/v0.6.0/c-relay-pg-v0.6.0-linux-x86_64-static
|
||||
chmod +x c-relay-pg-v0.6.0-linux-x86_64-static
|
||||
mv c-relay-pg-v0.6.0-linux-x86_64-static c-relay-pg
|
||||
|
||||
# Run the relay
|
||||
./c-relay
|
||||
./c-relay-pg
|
||||
```
|
||||
|
||||
**Important**: On first startup, save the **Admin Private Key** displayed in the console. You'll need it for all administrative operations.
|
||||
@@ -107,8 +107,8 @@ sudo apt install -y build-essential git sqlite3 libsqlite3-dev \
|
||||
libwebsockets-dev libssl-dev libsecp256k1-dev libcurl4-openssl-dev zlib1g-dev
|
||||
|
||||
# Clone and build
|
||||
git clone https://github.com/your-org/c-relay.git
|
||||
cd c-relay
|
||||
git clone https://github.com/your-org/c-relay-pg.git
|
||||
cd c-relay-pg
|
||||
git submodule update --init --recursive
|
||||
./make_and_restart_relay.sh
|
||||
```
|
||||
@@ -136,8 +136,8 @@ The web interface provides:
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/your-org/c-relay.git
|
||||
cd c-relay
|
||||
git clone https://github.com/your-org/c-relay-pg.git
|
||||
cd c-relay-pg
|
||||
git submodule update --init --recursive
|
||||
|
||||
# Build
|
||||
@@ -147,25 +147,25 @@ make clean && make
|
||||
sudo systemd/install-service.sh
|
||||
|
||||
# Start and enable
|
||||
sudo systemctl start c-relay
|
||||
sudo systemctl enable c-relay
|
||||
sudo systemctl start c-relay-pg
|
||||
sudo systemctl enable c-relay-pg
|
||||
|
||||
# Capture admin keys from logs
|
||||
sudo journalctl -u c-relay | grep "Admin Private Key"
|
||||
sudo journalctl -u c-relay-pg | grep "Admin Private Key"
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
```bash
|
||||
# Build Docker image
|
||||
docker build -f Dockerfile.alpine-musl -t c-relay .
|
||||
docker build -f Dockerfile.alpine-musl -t c-relay-pg .
|
||||
|
||||
# Run container
|
||||
docker run -d \
|
||||
--name c-relay \
|
||||
--name c-relay-pg \
|
||||
-p 8888:8888 \
|
||||
-v /path/to/data:/data \
|
||||
c-relay
|
||||
c-relay-pg
|
||||
```
|
||||
|
||||
### Cloud Deployment
|
||||
@@ -181,7 +181,7 @@ See [`docs/deployment_guide.md`](docs/deployment_guide.md) for detailed deployme
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
C-Relay uses an innovative event-based configuration system. All settings are managed through signed Nostr events.
|
||||
C-Relay-PG uses an innovative event-based configuration system. All settings are managed through signed Nostr events.
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
@@ -239,8 +239,8 @@ The admin private key is displayed **only once** during first startup. Store it
|
||||
|
||||
```bash
|
||||
# Save to secure location
|
||||
echo "ADMIN_PRIVKEY=your_admin_private_key" > ~/.c-relay-admin
|
||||
chmod 600 ~/.c-relay-admin
|
||||
echo "ADMIN_PRIVKEY=your_admin_private_key" > ~/.c-relay-pg-admin
|
||||
chmod 600 ~/.c-relay-pg-admin
|
||||
```
|
||||
|
||||
### Production Security
|
||||
@@ -269,9 +269,9 @@ Contributions are welcome! Please:
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- **Repository**: [https://github.com/your-org/c-relay](https://github.com/your-org/c-relay)
|
||||
- **Releases**: [https://git.laantungir.net/laantungir/c-relay/releases](https://git.laantungir.net/laantungir/c-relay/releases)
|
||||
- **Issues**: [https://github.com/your-org/c-relay/issues](https://github.com/your-org/c-relay/issues)
|
||||
- **Repository**: [https://github.com/your-org/c-relay-pg](https://github.com/your-org/c-relay-pg)
|
||||
- **Releases**: [https://git.laantungir.net/laantungir/c-relay-pg/releases](https://git.laantungir.net/laantungir/c-relay-pg/releases)
|
||||
- **Issues**: [https://github.com/your-org/c-relay-pg/issues](https://github.com/your-org/c-relay-pg/issues)
|
||||
- **Nostr Protocol**: [https://github.com/nostr-protocol/nostr](https://github.com/nostr-protocol/nostr)
|
||||
|
||||
## 💬 Support
|
||||
|
||||
@@ -41,7 +41,7 @@ This guide is specifically tailored for C programs that use:
|
||||
|
||||
```bash
|
||||
# 1. Copy the Dockerfile template (see below)
|
||||
cp /path/to/c-relay/Dockerfile.alpine-musl ./Dockerfile.static
|
||||
cp /path/to/c-relay-pg/Dockerfile.alpine-musl ./Dockerfile.static
|
||||
|
||||
# 2. Customize for your project (see Customization section)
|
||||
vim Dockerfile.static
|
||||
@@ -466,13 +466,13 @@ docker build -t my-app:latest .
|
||||
docker run --rm my-app:latest --help
|
||||
```
|
||||
|
||||
## Reusing c-relay Files
|
||||
## Reusing c-relay-pg Files
|
||||
|
||||
You can directly copy these files from c-relay:
|
||||
You can directly copy these files from c-relay-pg:
|
||||
|
||||
### 1. Dockerfile.alpine-musl
|
||||
```bash
|
||||
cp /path/to/c-relay/Dockerfile.alpine-musl ./Dockerfile.static
|
||||
cp /path/to/c-relay-pg/Dockerfile.alpine-musl ./Dockerfile.static
|
||||
```
|
||||
|
||||
Then customize:
|
||||
@@ -482,7 +482,7 @@ Then customize:
|
||||
|
||||
### 2. build_static.sh
|
||||
```bash
|
||||
cp /path/to/c-relay/build_static.sh ./
|
||||
cp /path/to/c-relay-pg/build_static.sh ./
|
||||
```
|
||||
|
||||
Then customize:
|
||||
@@ -492,7 +492,7 @@ Then customize:
|
||||
|
||||
### 3. .dockerignore (Optional)
|
||||
```bash
|
||||
cp /path/to/c-relay/.dockerignore ./
|
||||
cp /path/to/c-relay-pg/.dockerignore ./
|
||||
```
|
||||
|
||||
Helps speed up Docker builds by excluding unnecessary files.
|
||||
@@ -522,7 +522,7 @@ Helps speed up Docker builds by excluding unnecessary files.
|
||||
- [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)
|
||||
- [c-relay-pg Implementation](./docs/musl_static_build.md)
|
||||
|
||||
## Example: Minimal Nostr Client
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
/** admin2/api/auth.php — Auth rules + WoT status. */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $input['action'] ?? '';
|
||||
try {
|
||||
if ($action === 'add') {
|
||||
$pdo->prepare("INSERT INTO auth_rules (rule_type, pattern_type, pattern_value) VALUES (?, 'pubkey', ?)")->execute([$input['rule_type'], $input['pattern_value']]);
|
||||
json_response(['message' => 'Rule added']);
|
||||
} elseif ($action === 'remove') {
|
||||
$pdo->prepare("DELETE FROM auth_rules WHERE id = ?")->execute([intval($input['id'])]);
|
||||
json_response(['message' => 'Rule removed']);
|
||||
} elseif ($action === 'set_wot_level') {
|
||||
$pdo->prepare("UPDATE config SET value = ? WHERE key = 'wot_level'")->execute([strval($input['level'])]);
|
||||
json_response(['message' => 'WoT level set']);
|
||||
} elseif ($action === 'sync_wot') {
|
||||
json_response(['message' => 'WoT sync not available via PHP (requires relay)']);
|
||||
}
|
||||
} catch (PDOException $e) { json_response(['error' => $e->getMessage()]); }
|
||||
}
|
||||
|
||||
// GET: WoT status
|
||||
if (isset($_GET['action']) && $_GET['action'] === 'wot') {
|
||||
$level = $pdo->query("SELECT value FROM config WHERE key = 'wot_level'")->fetchColumn() ?: '0';
|
||||
$wl_count = 0;
|
||||
try { $wl_count = intval($pdo->query("SELECT count(*) FROM auth_rules WHERE rule_type = 'whitelist'")->fetchColumn()); } catch (PDOException $e) {}
|
||||
$descriptions = ['0' => 'Level 0: Open relay — anyone can read and write', '1' => 'Level 1: Write only — WoT users can write', '2' => 'Level 2: Full — WoT users only'];
|
||||
json_response(['kind3_status' => 'N/A (PHP admin)', 'whitelist_count' => $wl_count, 'level_description' => $descriptions[$level] ?? $descriptions['0']]);
|
||||
}
|
||||
|
||||
// GET: auth rules
|
||||
$rules = [];
|
||||
try { $rules = $pdo->query("SELECT id, rule_type, pattern_type, pattern_value FROM auth_rules ORDER BY id")->fetchAll(); } catch (PDOException $e) {}
|
||||
json_response(['rules' => $rules]);
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
/** admin2/api/caching.php — Caching service status + follows + reset actions. */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
// --- POST actions: reset backfill (all or per-user) ---
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$action = $input['action'] ?? '';
|
||||
$pubkey = $input['pubkey'] ?? '';
|
||||
|
||||
if ($action === 'reset_all') {
|
||||
// Reset backfill progress for ALL followed authors, then bump config
|
||||
// generation so the running caching service hot-reloads and re-drains.
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$pdo->exec("UPDATE caching_followed_pubkeys
|
||||
SET until_cursor = 0, backfill_complete = FALSE,
|
||||
events_fetched = 0,
|
||||
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT");
|
||||
// Also reset per-relay progress rows
|
||||
$pdo->exec("UPDATE caching_backfill_relay_progress
|
||||
SET complete = FALSE, events_fetched = 0,
|
||||
until_cursor = 0, last_status = 'reset',
|
||||
consecutive_errors = 0,
|
||||
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT");
|
||||
// Bump config generation to trigger hot-reload
|
||||
$pdo->exec("UPDATE config SET value = (COALESCE(value::int, 0) + 1)::text
|
||||
WHERE key = 'caching_config_generation'");
|
||||
$pdo->commit();
|
||||
json_response(['ok' => true, 'message' => 'All backfill progress reset. Caching service will re-drain.']);
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
json_response(['ok' => false, 'error' => $e->getMessage()], 500);
|
||||
}
|
||||
} elseif ($action === 'reset_user' && $pubkey) {
|
||||
// Reset backfill progress for a single followed author.
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$stmt = $pdo->prepare("UPDATE caching_followed_pubkeys
|
||||
SET until_cursor = 0, backfill_complete = FALSE,
|
||||
events_fetched = 0,
|
||||
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
WHERE pubkey = ?");
|
||||
$stmt->execute([$pubkey]);
|
||||
// Also reset per-relay progress for this author
|
||||
$stmt2 = $pdo->prepare("UPDATE caching_backfill_relay_progress
|
||||
SET complete = FALSE, events_fetched = 0,
|
||||
until_cursor = 0, last_status = 'reset',
|
||||
consecutive_errors = 0,
|
||||
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT
|
||||
WHERE author_pubkey = ?");
|
||||
$stmt2->execute([$pubkey]);
|
||||
// Bump config generation to trigger hot-reload
|
||||
$pdo->exec("UPDATE config SET value = (COALESCE(value::int, 0) + 1)::text
|
||||
WHERE key = 'caching_config_generation'");
|
||||
$pdo->commit();
|
||||
json_response(['ok' => true, 'message' => "Backfill reset for pubkey. Caching service will re-fetch."]);
|
||||
} catch (PDOException $e) {
|
||||
$pdo->rollBack();
|
||||
json_response(['ok' => false, 'error' => $e->getMessage()], 500);
|
||||
}
|
||||
} else {
|
||||
json_response(['ok' => false, 'error' => 'Unknown action'], 400);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// Caching config values (for toggle controls)
|
||||
$config = [];
|
||||
try {
|
||||
$cfg_rows = $pdo->query("SELECT key, value FROM config WHERE key IN ('caching_enabled','caching_inbox_enabled','caching_live_enabled','caching_backfill_enabled')")->fetchAll();
|
||||
foreach ($cfg_rows as $r) { $config[$r['key']] = $r['value']; }
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Service state
|
||||
$state = [];
|
||||
try { $state = $pdo->query("SELECT * FROM caching_service_state WHERE id = 1")->fetch() ?: []; } catch (PDOException $e) {}
|
||||
|
||||
// Active target
|
||||
$active = ['pubkey' => '', 'relay' => ''];
|
||||
try { $active = $pdo->query("SELECT active_pubkey AS pubkey, active_relay AS relay FROM caching_backfill_active WHERE id = 1")->fetch() ?: $active; } catch (PDOException $e) {}
|
||||
|
||||
// Inbox
|
||||
$inbox = ['pending' => 0, 'live' => 0, 'backfill' => 0];
|
||||
try {
|
||||
$inbox = $pdo->query("SELECT COUNT(*) AS pending, COUNT(*) FILTER (WHERE source_class='live') AS live, COUNT(*) FILTER (WHERE source_class='backfill') AS backfill FROM caching_event_inbox")->fetch() ?: $inbox;
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Follows (paginated, with names from profiles cache + per-relay progress)
|
||||
$follows = [];
|
||||
try {
|
||||
$follows = $pdo->query("
|
||||
SELECT fp.pubkey, fp.is_root, fp.backfill_complete, fp.events_fetched,
|
||||
fp.last_event_at,
|
||||
p.name, p.display_name,
|
||||
(SELECT count(*) FROM events WHERE pubkey = fp.pubkey) AS total_events,
|
||||
(SELECT count(*) FROM caching_backfill_relay_progress WHERE author_pubkey = fp.pubkey) AS relay_count,
|
||||
(SELECT count(*) FROM caching_backfill_relay_progress WHERE author_pubkey = fp.pubkey AND complete = false) AS relay_incomplete
|
||||
FROM caching_followed_pubkeys fp
|
||||
LEFT JOIN profiles p ON p.pubkey = fp.pubkey
|
||||
ORDER BY fp.is_root DESC, fp.events_fetched DESC LIMIT 1000
|
||||
")->fetchAll();
|
||||
// Fetch per-relay progress for all followed pubkeys in one query
|
||||
$relayProgress = [];
|
||||
try {
|
||||
$rpRows = $pdo->query("
|
||||
SELECT author_pubkey, relay_url, complete, events_fetched, until_cursor, last_status, updated_at
|
||||
FROM caching_backfill_relay_progress
|
||||
ORDER BY author_pubkey, events_fetched DESC
|
||||
")->fetchAll();
|
||||
foreach ($rpRows as $rp) {
|
||||
$relayProgress[$rp['author_pubkey']][] = $rp;
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
foreach ($follows as &$f) {
|
||||
$f['name'] = profile_display_name($f);
|
||||
$f['npub'] = function_exists('hex_to_npub') ? hex_to_npub($f['pubkey']) : substr($f['pubkey'], 0, 20);
|
||||
$f['relays'] = $relayProgress[$f['pubkey']] ?? [];
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
json_response(['state' => $state, 'active' => $active, 'inbox' => $inbox, 'follows' => $follows, 'config' => $config]);
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
/**
|
||||
* admin2/api/chart.php — Standalone ASCII chart endpoint.
|
||||
*
|
||||
* Returns a plain-text ASCII X-bar chart of event counts over time.
|
||||
* Works in both the browser (injected into a <div>) and the terminal:
|
||||
*
|
||||
* curl http://localhost:8088/api/chart.php?range=hour
|
||||
* curl http://localhost:8088/api/chart.php?range=day
|
||||
* curl http://localhost:8088/api/chart.php?range=month
|
||||
* curl http://localhost:8088/api/chart.php?range=year
|
||||
*
|
||||
* Caching: the hour chart is never cached (live). Day/month/year are
|
||||
* cached to file with TTLs to avoid expensive queries on every request.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
require_once __DIR__ . '/../lib/ascii_chart.php';
|
||||
|
||||
// --- Range configuration ---
|
||||
$ranges = [
|
||||
'hour' => ['span' => 3600, 'bin' => 45, 'bins' => 80, 'ttl' => 0, 'title' => 'New Events — Last Hour'],
|
||||
'day' => ['span' => 86400, 'bin' => 1080, 'bins' => 80, 'ttl' => 3600, 'title' => 'New Events — Last 24 Hours'],
|
||||
'month' => ['span' => 2592000, 'bin' => 32400, 'bins' => 80, 'ttl' => 86400, 'title' => 'New Events — Last 30 Days'],
|
||||
'year' => ['span' => 31536000, 'bin' => 394200, 'bins' => 80, 'ttl' => 2592000, 'title' => 'New Events — Last Year'],
|
||||
];
|
||||
|
||||
$range = $_GET['range'] ?? 'hour';
|
||||
if (!isset($ranges[$range])) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "Invalid range. Use: hour, day, month, or year.\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$cfg = $ranges[$range];
|
||||
|
||||
// --- Set content type ---
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
|
||||
// --- Check cache ---
|
||||
if ($cfg['ttl'] > 0) {
|
||||
$cached = get_cached_chart($range, $cfg['ttl']);
|
||||
if ($cached !== null) {
|
||||
echo $cached;
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Run binning query ---
|
||||
$pdo = db();
|
||||
$now = time();
|
||||
$epoch = $now - $cfg['span'];
|
||||
$bin_size = $cfg['bin'];
|
||||
$num_bins = $cfg['bins'];
|
||||
|
||||
try {
|
||||
// Calculate the bin offset so the rightmost bin aligns with "now"
|
||||
// bin index 0 = oldest, bin index (num_bins-1) = newest
|
||||
$base_bin = (int)floor($epoch / $bin_size);
|
||||
|
||||
$sql = "SELECT FLOOR(created_at / {$bin_size})::BIGINT - {$base_bin} AS bin, COUNT(*) AS cnt
|
||||
FROM events
|
||||
WHERE created_at >= {$epoch}
|
||||
GROUP BY bin
|
||||
ORDER BY bin";
|
||||
$rows = $pdo->query($sql)->fetchAll();
|
||||
} catch (PDOException $e) {
|
||||
echo "Chart query failed: " . $e->getMessage() . "\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- Build bin array (zero-filled, fixed length) ---
|
||||
// Index 0 = oldest, index N-1 = newest (natural time order from SQL)
|
||||
$bins = build_bin_array($rows, $num_bins);
|
||||
|
||||
// Reverse so newest bin is at index 0 (leftmost) and oldest at right.
|
||||
// This matches the original text_graph.js display: new data enters
|
||||
// from the left and proceeds rightward as it ages.
|
||||
$bins = array_reverse($bins);
|
||||
|
||||
// --- Render ASCII chart ---
|
||||
$ascii = render_ascii_chart($bins, [
|
||||
'title' => $cfg['title'],
|
||||
'max_height' => 11,
|
||||
'bin_duration' => $bin_size,
|
||||
'label_interval' => max(1, (int)floor($num_bins / 15)), // ~15 labels across
|
||||
]);
|
||||
|
||||
// --- Write to cache (if TTL > 0) ---
|
||||
if ($cfg['ttl'] > 0) {
|
||||
set_cached_chart($range, $ascii);
|
||||
}
|
||||
|
||||
echo $ascii;
|
||||
|
||||
|
||||
// ================================
|
||||
// CACHE HELPERS
|
||||
// ================================
|
||||
|
||||
function get_cache_dir(): string {
|
||||
return __DIR__ . '/../cache';
|
||||
}
|
||||
|
||||
function get_cached_chart(string $range, int $ttl): ?string {
|
||||
$file = get_cache_dir() . "/chart_{$range}.txt";
|
||||
if (!file_exists($file)) return null;
|
||||
if (filemtime($file) < (time() - $ttl)) return null;
|
||||
$content = @file_get_contents($file);
|
||||
return ($content !== false) ? $content : null;
|
||||
}
|
||||
|
||||
function set_cached_chart(string $range, string $ascii): void {
|
||||
$dir = get_cache_dir();
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
@file_put_contents($dir . "/chart_{$range}.txt", $ascii);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
/** admin2/api/config.php — Config table read/edit (all keys). */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$key = $input['key'] ?? '';
|
||||
$value = $input['value'] ?? '';
|
||||
if (!$key) json_response(['error' => 'Missing key']);
|
||||
try {
|
||||
$pdo->prepare("UPDATE config SET value = ? WHERE key = ?")->execute([$value, $key]);
|
||||
json_response(['message' => "Updated $key"]);
|
||||
} catch (PDOException $e) {
|
||||
json_response(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
$rows = $pdo->query("SELECT key, value FROM config ORDER BY key")->fetchAll();
|
||||
json_response(['config' => $rows]);
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
/** admin2/api/dm.php — Direct messages (kind 4/14/15). */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
$limit = min(200, intval($_GET['limit'] ?? 50));
|
||||
$rows = $pdo->prepare("SELECT id, pubkey, kind, created_at, content FROM events WHERE kind IN (4, 14, 15) ORDER BY created_at DESC LIMIT $limit");
|
||||
$rows->execute();
|
||||
$messages = $rows->fetchAll();
|
||||
|
||||
foreach ($messages as &$m) {
|
||||
$m['created_at'] = date('Y-m-d H:i:s', intval($m['created_at']));
|
||||
$m['content'] = substr($m['content'] ?? '', 0, 300);
|
||||
}
|
||||
|
||||
json_response(['messages' => $messages]);
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/** admin2/api/events.php — Recent relay events (paginated). */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
$limit = min(200, intval($_GET['limit'] ?? 50));
|
||||
$offset = intval($_GET['offset'] ?? 0);
|
||||
$kind = $_GET['kind'] ?? '';
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($kind !== '') { $where[] = 'kind = ?'; $params[] = intval($kind); }
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$rows = $pdo->prepare("SELECT e.id, e.pubkey, e.kind, e.created_at, e.content,
|
||||
p.name, p.display_name
|
||||
FROM events e
|
||||
LEFT JOIN profiles p ON p.pubkey = e.pubkey
|
||||
$where_sql
|
||||
ORDER BY e.created_at DESC LIMIT $limit OFFSET $offset");
|
||||
$rows->execute($params);
|
||||
$events = $rows->fetchAll();
|
||||
|
||||
foreach ($events as &$e) {
|
||||
$e['created_at'] = date('Y-m-d H:i:s', intval($e['created_at']));
|
||||
$e['content'] = substr($e['content'] ?? '', 0, 200);
|
||||
// Resolve display name from profiles cache; fall back to truncated pubkey.
|
||||
$best = profile_display_name($e);
|
||||
$e['display_name'] = $best !== '' ? $best : substr($e['pubkey'] ?? '', 0, 16) . '…';
|
||||
}
|
||||
|
||||
json_response(['events' => $events]);
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
/** admin2/api/ipbans.php — IP ban management. */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$action = $input['action'] ?? '';
|
||||
try {
|
||||
if ($action === 'add') {
|
||||
$until = time() + intval($input['duration'] ?? 86400);
|
||||
$pdo->prepare("INSERT INTO ip_bans (ip, banned_until, ban_count) VALUES (?, ?, 1) ON CONFLICT (ip) DO UPDATE SET banned_until = ?, ban_count = ip_bans.ban_count + 1")->execute([$input['ip'], $until, $until]);
|
||||
json_response(['message' => 'IP banned']);
|
||||
} elseif ($action === 'remove') {
|
||||
$pdo->prepare("DELETE FROM ip_bans WHERE ip = ?")->execute([$input['ip']]);
|
||||
json_response(['message' => 'Ban removed']);
|
||||
}
|
||||
} catch (PDOException $e) { json_response(['error' => $e->getMessage()]); }
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$total = 0; $active = 0; $issued = 0;
|
||||
$bans = [];
|
||||
try {
|
||||
$total = intval($pdo->query("SELECT count(*) FROM ip_bans")->fetchColumn());
|
||||
$active = intval($pdo->query("SELECT count(*) FROM ip_bans WHERE banned_until > $now")->fetchColumn());
|
||||
$issued = intval($pdo->query("SELECT COALESCE(sum(ban_count),0) FROM ip_bans")->fetchColumn());
|
||||
$bans = $pdo->query("SELECT ip, banned_until, ban_count, failures, authed_successfully, connection_attempts FROM ip_bans ORDER BY banned_until DESC LIMIT 100")->fetchAll();
|
||||
foreach ($bans as &$b) {
|
||||
$b['status'] = intval($b['banned_until']) > $now ? 'banned' : 'expired';
|
||||
$b['banned_until'] = date('Y-m-d H:i:s', intval($b['banned_until']));
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
json_response(['total' => $total, 'active' => $active, 'issued' => $issued, 'bans' => $bans]);
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/api/profile.php — Single-profile lookup from the profiles cache.
|
||||
* Returns cached kind-0 metadata for a given pubkey.
|
||||
*
|
||||
* Usage: profile.php?pubkey=<64-char-hex>
|
||||
* Returns: { "pubkey": "...", "name": "...", "display_name": "...",
|
||||
* "best_name": "...", "picture": "...", "nip05": "...",
|
||||
* "about": "...", "website": "...", "lud16": "..." }
|
||||
* or { "error": "not found" } with HTTP 404 if no profile is cached.
|
||||
*/
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
|
||||
$pubkey = trim($_GET['pubkey'] ?? '');
|
||||
if (!preg_match('/^[0-9a-fA-F]{64}$/', $pubkey)) {
|
||||
http_response_code(400);
|
||||
json_response(['error' => 'Invalid pubkey']);
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
try {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT pubkey, name, display_name, about, picture, banner,
|
||||
nip05, website, lud16, lud06
|
||||
FROM profiles WHERE pubkey = ?"
|
||||
);
|
||||
$stmt->execute([$pubkey]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
http_response_code(404);
|
||||
json_response(['error' => 'not found']);
|
||||
}
|
||||
$row['best_name'] = profile_display_name($row);
|
||||
json_response($row);
|
||||
} catch (PDOException $e) {
|
||||
http_response_code(500);
|
||||
json_response(['error' => 'Database error']);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
/** admin2/api/query.php — Direct SQL query (SELECT only, admin only). */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
json_response(['error' => 'POST only']);
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$sql = trim($input['sql'] ?? '');
|
||||
|
||||
if (!$sql) json_response(['error' => 'No query provided']);
|
||||
if (!preg_match('/^\s*SELECT/i', $sql)) json_response(['error' => 'Only SELECT queries are allowed']);
|
||||
|
||||
$start = microtime(true);
|
||||
try {
|
||||
$stmt = $pdo->query($sql);
|
||||
$rows = $stmt->fetchAll();
|
||||
$time_ms = round((microtime(true) - $start) * 1000, 1);
|
||||
json_response(['rows' => $rows, 'row_count' => count($rows), 'time_ms' => $time_ms]);
|
||||
} catch (PDOException $e) {
|
||||
json_response(['error' => $e->getMessage()]);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
/**
|
||||
* admin2/api/stats.php — Statistics page data.
|
||||
* Returns DB size, event counts, kind distribution, top pubkeys,
|
||||
* time-based stats, and event-rate delta (for the chart).
|
||||
*/
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
|
||||
$pdo = db();
|
||||
|
||||
// Database size
|
||||
$db_size = '-';
|
||||
try {
|
||||
$bytes = $pdo->query("SELECT pg_database_size(current_database())")->fetchColumn();
|
||||
$db_size = format_bytes(intval($bytes));
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Total events
|
||||
$total_events = intval($pdo->query("SELECT COUNT(*) FROM events")->fetchColumn());
|
||||
|
||||
// Event rate: events with first_seen in last 10 seconds
|
||||
$events_delta = 0;
|
||||
try {
|
||||
$events_delta = intval($pdo->query("SELECT COUNT(*) FROM events WHERE first_seen >= EXTRACT(EPOCH FROM NOW())::BIGINT - 10")->fetchColumn());
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Process info (from pg_stat_activity)
|
||||
$process_id = '-';
|
||||
$ws_connections = '-';
|
||||
try {
|
||||
$pid = $pdo->query("SELECT pg_backend_pid()")->fetchColumn();
|
||||
$process_id = strval($pid);
|
||||
$ws_connections = intval($pdo->query("SELECT count(*) FROM pg_stat_activity WHERE state = 'active' AND pid != pg_backend_pid()")->fetchColumn());
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Active subscriptions
|
||||
$active_subscriptions = 0;
|
||||
try {
|
||||
$active_subscriptions = intval($pdo->query("SELECT count(*) FROM subscriptions WHERE active = true")->fetchColumn());
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Memory/CPU (from /proc on Linux)
|
||||
$memory_usage = '-';
|
||||
$cpu_usage = '-';
|
||||
$cpu_core = '-';
|
||||
if (is_readable('/proc/meminfo')) {
|
||||
$mem = parse_ini_file('/proc/meminfo');
|
||||
$total = intval($mem['MemTotal'] ?? 0) * 1024;
|
||||
$avail = intval($mem['MemAvailable'] ?? 0) * 1024;
|
||||
if ($total > 0) $memory_usage = format_bytes($total - $avail) . ' / ' . format_bytes($total);
|
||||
}
|
||||
if (is_readable('/proc/cpuinfo')) {
|
||||
$cores = intval(shell_exec('nproc 2>/dev/null') ?: 1);
|
||||
$cpu_core = strval($cores) . ' cores';
|
||||
}
|
||||
$cpu_usage = @file_get_contents('/proc/loadavg');
|
||||
if ($cpu_usage !== false) $cpu_usage = trim(explode(' ', $cpu_usage)[0] ?? '-');
|
||||
|
||||
// Oldest / newest event
|
||||
$oldest_event = '-';
|
||||
$newest_event = '-';
|
||||
try {
|
||||
$oldest = $pdo->query("SELECT to_timestamp(MIN(created_at)) FROM events")->fetchColumn();
|
||||
$newest = $pdo->query("SELECT to_timestamp(MAX(created_at)) FROM events")->fetchColumn();
|
||||
if ($oldest) $oldest_event = substr($oldest, 0, 19);
|
||||
if ($newest) $newest_event = substr($newest, 0, 19);
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Time-based stats
|
||||
$now = time();
|
||||
$events_24h = 0; $events_7d = 0; $events_30d = 0;
|
||||
try {
|
||||
$events_24h = intval($pdo->query("SELECT COUNT(*) FROM events WHERE created_at >= $now - 86400")->fetchColumn());
|
||||
$events_7d = intval($pdo->query("SELECT COUNT(*) FROM events WHERE created_at >= $now - 604800")->fetchColumn());
|
||||
$events_30d = intval($pdo->query("SELECT COUNT(*) FROM events WHERE created_at >= $now - 2592000")->fetchColumn());
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Kind distribution
|
||||
$kinds = [];
|
||||
try {
|
||||
$rows = $pdo->query("SELECT kind, COUNT(*) AS cnt FROM events GROUP BY kind ORDER BY cnt DESC LIMIT 20")->fetchAll();
|
||||
foreach ($rows as $r) {
|
||||
$kinds[] = ['kind' => intval($r['kind']), 'count' => intval($r['cnt']), 'pct' => $total_events > 0 ? round(intval($r['cnt']) / $total_events * 100, 1) : 0];
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Top pubkeys (with names from profiles cache)
|
||||
$top_pubkeys = [];
|
||||
try {
|
||||
$rows = $pdo->query("
|
||||
SELECT pubkey, COUNT(*) AS cnt
|
||||
FROM events
|
||||
GROUP BY pubkey
|
||||
ORDER BY cnt DESC LIMIT 20
|
||||
")->fetchAll();
|
||||
// Batch-resolve profile names from the profiles cache table.
|
||||
$pubkeys = array_column($rows, 'pubkey');
|
||||
$pmap = profile_map($pubkeys);
|
||||
foreach ($rows as $r) {
|
||||
$pk = $r['pubkey'];
|
||||
$prof = $pmap[$pk] ?? null;
|
||||
$top_pubkeys[] = [
|
||||
'pubkey' => $pk,
|
||||
'name' => $prof ? $prof['best_name'] : '',
|
||||
'count' => intval($r['cnt']),
|
||||
'pct' => $total_events > 0 ? round(intval($r['cnt']) / $total_events * 100, 1) : 0,
|
||||
];
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Name-field usage stats (from profiles cache)
|
||||
$name_field_usage = ['both' => 0, 'name_only' => 0, 'display_only' => 0, 'neither' => 0, 'both_differ' => 0, 'total' => 0];
|
||||
try {
|
||||
$row = $pdo->query("
|
||||
SELECT count(*) FILTER (WHERE name <> '' AND display_name <> '') AS both,
|
||||
count(*) FILTER (WHERE name <> '' AND display_name = '') AS name_only,
|
||||
count(*) FILTER (WHERE name = '' AND display_name <> '') AS display_only,
|
||||
count(*) FILTER (WHERE name = '' AND display_name = '') AS neither,
|
||||
count(*) FILTER (WHERE name <> '' AND display_name <> '' AND name <> display_name) AS both_differ,
|
||||
count(*) AS total
|
||||
FROM profiles
|
||||
")->fetch();
|
||||
if ($row) {
|
||||
$name_field_usage = array_map('intval', $row);
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
json_response([
|
||||
'db_size' => $db_size,
|
||||
'total_events' => $total_events,
|
||||
'events_delta' => $events_delta,
|
||||
'process_id' => $process_id,
|
||||
'ws_connections' => $ws_connections,
|
||||
'active_subscriptions' => $active_subscriptions,
|
||||
'memory_usage' => $memory_usage,
|
||||
'cpu_usage' => $cpu_usage,
|
||||
'cpu_core' => $cpu_core,
|
||||
'oldest_event' => $oldest_event,
|
||||
'newest_event' => $newest_event,
|
||||
'events_24h' => $events_24h,
|
||||
'events_7d' => $events_7d,
|
||||
'events_30d' => $events_30d,
|
||||
'kinds' => $kinds,
|
||||
'top_pubkeys' => $top_pubkeys,
|
||||
'name_field_usage' => $name_field_usage,
|
||||
]);
|
||||
|
||||
/** Format bytes as human-readable. */
|
||||
function format_bytes(int $bytes): string {
|
||||
if ($bytes < 1024) return $bytes . ' B';
|
||||
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
|
||||
if ($bytes < 1073741824) return round($bytes / 1048576, 1) . ' MB';
|
||||
return round($bytes / 1073741824, 2) . ' GB';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
/** admin2/api/subscriptions.php — Active subscription details. */
|
||||
require_once __DIR__ . '/../lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
$subs = [];
|
||||
try {
|
||||
$subs = $pdo->query("SELECT id, sub_id, pubkey, filters, created_at, events_sent FROM subscriptions ORDER BY created_at DESC LIMIT 100")->fetchAll();
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
json_response(['subscriptions' => $subs]);
|
||||
@@ -0,0 +1,956 @@
|
||||
/*
|
||||
* admin2 — Full PHP admin for C-Relay-PG.
|
||||
* Replaces the 7457-line api/index.js with a lightweight AJAX-based
|
||||
* controller that fetches data from PHP API endpoints (api/*.php)
|
||||
* instead of Nostr WebSocket admin commands.
|
||||
*/
|
||||
|
||||
const REFRESH_MS = 10000;
|
||||
let currentPage = 'statistics';
|
||||
let statsInterval = null;
|
||||
|
||||
// HTML-escape helper for safe interpolation into innerHTML.
|
||||
// Prevents stored XSS from user-controlled data (profile names, event
|
||||
// content, config values, etc.) being parsed as HTML by the browser.
|
||||
const esc = (s) => {
|
||||
const str = String(s ?? '');
|
||||
return str.replace(/[&<>"']/g, (ch) => {
|
||||
return '&#' + ch.charCodeAt(0) + ';';
|
||||
});
|
||||
};
|
||||
|
||||
// Auth state
|
||||
let nlLite = null;
|
||||
let userPubkey = null;
|
||||
let isLoggedIn = false;
|
||||
let relayAnimationTimer = null;
|
||||
|
||||
// Server-rendered ASCII chart state
|
||||
let currentChartRange = 'hour';
|
||||
let chartLoadTimer = null;
|
||||
|
||||
// ================================
|
||||
// NAVIGATION
|
||||
// ================================
|
||||
|
||||
function toggleNav() {
|
||||
document.getElementById('side-nav').classList.toggle('open');
|
||||
document.getElementById('side-nav-overlay').classList.toggle('show');
|
||||
}
|
||||
|
||||
document.getElementById('side-nav-overlay')?.addEventListener('click', toggleNav);
|
||||
|
||||
document.querySelectorAll('.nav-item').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const page = btn.getAttribute('data-page');
|
||||
switchPage(page);
|
||||
document.getElementById('side-nav').classList.remove('open');
|
||||
document.getElementById('side-nav-overlay').classList.remove('show');
|
||||
});
|
||||
});
|
||||
|
||||
function switchPage(pageName) {
|
||||
currentPage = pageName;
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
if (item.getAttribute('data-page') === pageName) item.classList.add('active');
|
||||
});
|
||||
|
||||
const sections = [
|
||||
'databaseStatisticsSection', 'subscriptionDetailsSection', 'div_config',
|
||||
'authRulesSection', 'wotSection', 'ipBansSection', 'relayEventsSection',
|
||||
'cachingSection', 'nip17DMSection', 'sqlQuerySection'
|
||||
];
|
||||
sections.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; });
|
||||
|
||||
const pageMap = {
|
||||
'statistics': 'databaseStatisticsSection',
|
||||
'subscriptions': 'subscriptionDetailsSection',
|
||||
'configuration': 'div_config',
|
||||
'ip-bans': 'ipBansSection',
|
||||
'relay-events': 'relayEventsSection',
|
||||
'caching': 'cachingSection',
|
||||
'dm': 'nip17DMSection',
|
||||
'database': 'sqlQuerySection'
|
||||
};
|
||||
|
||||
if (pageName === 'authorization') {
|
||||
document.getElementById('authRulesSection').style.display = 'block';
|
||||
document.getElementById('wotSection').style.display = 'block';
|
||||
loadAuthRules();
|
||||
loadWotStatus();
|
||||
} else {
|
||||
const target = pageMap[pageName];
|
||||
if (target) document.getElementById(target).style.display = 'block';
|
||||
}
|
||||
|
||||
// Load data for the page
|
||||
const loaders = {
|
||||
'statistics': loadStats,
|
||||
'subscriptions': loadSubscriptions,
|
||||
'configuration': loadConfig,
|
||||
'ip-bans': loadIpBans,
|
||||
'relay-events': loadEvents,
|
||||
'caching': loadCaching,
|
||||
'dm': loadDMs,
|
||||
};
|
||||
if (loaders[pageName]) loaders[pageName]();
|
||||
|
||||
// Start/stop auto-refresh for statistics
|
||||
if (pageName === 'statistics') {
|
||||
if (!statsInterval) {
|
||||
loadStats();
|
||||
statsInterval = setInterval(loadStats, REFRESH_MS);
|
||||
console.log('[admin2] auto-refresh started, interval:', REFRESH_MS, 'ms');
|
||||
}
|
||||
} else {
|
||||
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
|
||||
}
|
||||
}
|
||||
|
||||
// ================================
|
||||
// STATISTICS
|
||||
// ================================
|
||||
|
||||
async function loadStats() {
|
||||
console.log('[admin2] loading stats at', new Date().toLocaleTimeString());
|
||||
// Fire the RELAY letter animation on every refresh — visual indicator the page is updating
|
||||
startRelayAnimation();
|
||||
try {
|
||||
const res = await fetch('api/stats.php');
|
||||
if (!res.ok) { console.warn('[admin2] stats.php returned', res.status); return; }
|
||||
const d = await res.json();
|
||||
console.log('[admin2] stats loaded — events:', d.total_events, 'rate:', d.events_delta, '/10s');
|
||||
|
||||
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
||||
set('db-size', d.db_size || '-');
|
||||
set('total-events', (d.total_events ?? 0).toLocaleString());
|
||||
set('process-id', d.process_id || '-');
|
||||
set('websocket-connections', d.ws_connections ?? '-');
|
||||
set('active-subscriptions', d.active_subscriptions ?? '-');
|
||||
set('memory-usage', d.memory_usage || '-');
|
||||
set('cpu-usage', d.cpu_usage || '-');
|
||||
set('cpu-core', d.cpu_core || '-');
|
||||
set('oldest-event', d.oldest_event || '-');
|
||||
set('newest-event', d.newest_event || '-');
|
||||
set('events-24h', (d.events_24h ?? 0).toLocaleString());
|
||||
set('events-7d', (d.events_7d ?? 0).toLocaleString());
|
||||
set('events-30d', (d.events_30d ?? 0).toLocaleString());
|
||||
|
||||
// Kind distribution
|
||||
if (d.kinds) {
|
||||
const tbody = document.getElementById('stats-kinds-table-body');
|
||||
tbody.innerHTML = d.kinds.map(k =>
|
||||
`<tr><td>${k.kind}</td><td>${k.count.toLocaleString()}</td><td>${k.pct}%</td></tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Top pubkeys
|
||||
if (d.top_pubkeys) {
|
||||
const tbody = document.getElementById('stats-pubkeys-table-body');
|
||||
tbody.innerHTML = d.top_pubkeys.map((p, i) =>
|
||||
`<tr><td>${i+1}</td><td><bdi>${esc(p.name) || '<i>unknown</i>'}</bdi></td><td class="pubkey">${esc(p.pubkey.substring(0,16))}…</td><td>${p.count.toLocaleString()}</td><td>${p.pct}%</td></tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Name-field usage stats (from profiles cache)
|
||||
if (d.name_field_usage && d.name_field_usage.total > 0) {
|
||||
const u = d.name_field_usage;
|
||||
const el = document.getElementById('name-field-usage');
|
||||
if (el) {
|
||||
el.innerHTML = `
|
||||
<div class="name-usage-stats">
|
||||
<span class="name-usage-label">Profile name fields (${u.total} profiles):</span>
|
||||
<span class="name-usage-item">Both: ${u.both}</span>
|
||||
<span class="name-usage-item">name only: ${u.name_only}</span>
|
||||
<span class="name-usage-item">display_name only: ${u.display_only}</span>
|
||||
<span class="name-usage-item name-usage-differ">Differ: ${u.both_differ}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the chart on every stats poll (server handles caching for non-hour ranges)
|
||||
loadChart(currentChartRange);
|
||||
} catch (e) { console.error('[admin2] stats error:', e); }
|
||||
}
|
||||
|
||||
// ================================
|
||||
// EVENT RATE CHART (server-rendered ASCII via chart.php)
|
||||
// ================================
|
||||
|
||||
// Fetch the ASCII chart from the server and inject it into the div.
|
||||
// The chart is rendered server-side as plain text — works in both
|
||||
// browser and terminal (curl http://localhost:8088/api/chart.php?range=hour)
|
||||
async function loadChart(range) {
|
||||
const el = document.getElementById('event-rate-chart');
|
||||
if (!el) return;
|
||||
try {
|
||||
const res = await fetch('api/chart.php?range=' + encodeURIComponent(range));
|
||||
if (!res.ok) { el.textContent = 'Chart load failed (HTTP ' + res.status + ')'; return; }
|
||||
const text = await res.text();
|
||||
el.textContent = text;
|
||||
// Newest data is at the left (index 0) — scroll to start so it's visible
|
||||
el.scrollLeft = 0;
|
||||
} catch (e) {
|
||||
console.error('[admin2] chart load error:', e);
|
||||
el.textContent = 'Chart load error: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Chart range tab click handlers
|
||||
document.querySelectorAll('.chart-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const range = tab.getAttribute('data-range');
|
||||
if (!range || range === currentChartRange) return;
|
||||
currentChartRange = range;
|
||||
document.querySelectorAll('.chart-tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
loadChart(range);
|
||||
});
|
||||
});
|
||||
|
||||
// ================================
|
||||
// SUBSCRIPTIONS
|
||||
// ================================
|
||||
|
||||
async function loadSubscriptions() {
|
||||
try {
|
||||
const res = await fetch('api/subscriptions.php');
|
||||
const d = await res.json();
|
||||
const tbody = document.getElementById('subscription-details-table-body');
|
||||
if (!d.subscriptions || d.subscriptions.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;font-style:italic">No subscriptions active</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = d.subscriptions.map(s =>
|
||||
`<tr><td>${esc(s.sub_id) || '-'}</td><td class="pubkey">${esc((s.pubkey||'-').substring(0,16))}</td><td>${esc(s.filters) || '-'}</td><td>${esc(s.created) || '-'}</td><td>${s.events_sent ?? 0}</td></tr>`
|
||||
).join('');
|
||||
} catch (e) { console.error('[admin2] subscriptions error:', e); }
|
||||
}
|
||||
|
||||
// ================================
|
||||
// CONFIGURATION
|
||||
// ================================
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const res = await fetch('api/config.php');
|
||||
const d = await res.json();
|
||||
const tbody = document.getElementById('config-table-body');
|
||||
if (!d.config || d.config.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;font-style:italic">No config entries</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = d.config.map(c =>
|
||||
`<tr><td>${esc(c.key)}</td><td>${esc(c.value)}</td><td><button onclick="editConfig('${esc(c.key)}')">EDIT</button></td></tr>`
|
||||
).join('');
|
||||
} catch (e) { console.error('[admin2] config error:', e); }
|
||||
}
|
||||
|
||||
function editConfig(key) {
|
||||
const newVal = prompt('Enter new value for ' + key + ':');
|
||||
if (newVal === null) return;
|
||||
fetch('api/config.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({key, value: newVal})
|
||||
}).then(r => r.json()).then(d => {
|
||||
alert(d.message || 'Saved');
|
||||
loadConfig();
|
||||
}).catch(e => alert('Error: ' + e));
|
||||
}
|
||||
|
||||
// ================================
|
||||
// AUTH RULES
|
||||
// ================================
|
||||
|
||||
async function loadAuthRules() {
|
||||
try {
|
||||
const res = await fetch('api/auth.php');
|
||||
const d = await res.json();
|
||||
const tbody = document.getElementById('authRulesTableBody');
|
||||
if (!d.rules || d.rules.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;font-style:italic">No auth rules</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = d.rules.map(r =>
|
||||
`<tr><td>${esc(r.rule_type)}</td><td>${esc(r.pattern_type)}</td><td>${esc(r.pattern_value)}</td><td>${esc(r.status) || 'active'}</td><td><button onclick="removeAuthRule(${r.id})">REMOVE</button></td></tr>`
|
||||
).join('');
|
||||
} catch (e) { console.error('[admin2] auth error:', e); }
|
||||
}
|
||||
|
||||
async function addAuthRule(type) {
|
||||
const pk = document.getElementById('authRulePubkey').value.trim();
|
||||
if (!pk) { alert('Enter a pubkey first'); return; }
|
||||
const res = await fetch('api/auth.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'add', rule_type: type, pattern_value: pk})
|
||||
});
|
||||
const d = await res.json();
|
||||
alert(d.message || 'Done');
|
||||
loadAuthRules();
|
||||
}
|
||||
|
||||
async function removeAuthRule(id) {
|
||||
const res = await fetch('api/auth.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'remove', id})
|
||||
});
|
||||
const d = await res.json();
|
||||
alert(d.message || 'Done');
|
||||
loadAuthRules();
|
||||
}
|
||||
|
||||
// ================================
|
||||
// WEB OF TRUST
|
||||
// ================================
|
||||
|
||||
async function loadWotStatus() {
|
||||
try {
|
||||
const res = await fetch('api/auth.php?action=wot');
|
||||
const d = await res.json();
|
||||
const ind = document.getElementById('wotKind3Indicator');
|
||||
if (ind) ind.textContent = d.kind3_status || 'Unknown';
|
||||
const cnt = document.getElementById('wotWhitelistCount');
|
||||
if (cnt) cnt.textContent = d.whitelist_count ?? '—';
|
||||
const desc = document.getElementById('wotLevelDescription');
|
||||
if (desc) desc.textContent = d.level_description || '';
|
||||
} catch (e) { console.error('[admin2] wot error:', e); }
|
||||
}
|
||||
|
||||
async function setWotLevel(level) {
|
||||
const res = await fetch('api/auth.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'set_wot_level', level})
|
||||
});
|
||||
const d = await res.json();
|
||||
alert(d.message || 'Done');
|
||||
loadWotStatus();
|
||||
}
|
||||
|
||||
async function syncWot() {
|
||||
const res = await fetch('api/auth.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'sync_wot'})
|
||||
});
|
||||
const d = await res.json();
|
||||
alert(d.message || 'Done');
|
||||
loadWotStatus();
|
||||
}
|
||||
|
||||
// ================================
|
||||
// IP BANS
|
||||
// ================================
|
||||
|
||||
async function loadIpBans() {
|
||||
try {
|
||||
const res = await fetch('api/ipbans.php');
|
||||
const d = await res.json();
|
||||
document.getElementById('ip-bans-total').textContent = d.total ?? '-';
|
||||
document.getElementById('ip-bans-active').textContent = d.active ?? '-';
|
||||
document.getElementById('ip-bans-issued').textContent = d.issued ?? '-';
|
||||
const tbody = document.getElementById('ip-bans-tbody');
|
||||
if (!d.bans || d.bans.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center">No IP bans</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = d.bans.map(b =>
|
||||
`<tr><td>${esc(b.ip)}</td><td>${esc(b.status)}</td><td>${esc(b.banned_until)}</td><td>${b.failures ?? 0}</td><td><button onclick="removeBan('${esc(b.ip)}')">REMOVE</button></td></tr>`
|
||||
).join('');
|
||||
} catch (e) { console.error('[admin2] ipbans error:', e); }
|
||||
}
|
||||
|
||||
async function addBan() {
|
||||
const ip = document.getElementById('ban-ip-input').value.trim();
|
||||
const duration = document.getElementById('ban-duration-select').value;
|
||||
if (!ip) { alert('Enter an IP address'); return; }
|
||||
const res = await fetch('api/ipbans.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'add', ip, duration: parseInt(duration)})
|
||||
});
|
||||
const d = await res.json();
|
||||
document.getElementById('add-ban-status').textContent = d.message || 'Done';
|
||||
loadIpBans();
|
||||
}
|
||||
|
||||
async function removeBan(ip) {
|
||||
const res = await fetch('api/ipbans.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'remove', ip})
|
||||
});
|
||||
const d = await res.json();
|
||||
alert(d.message || 'Done');
|
||||
loadIpBans();
|
||||
}
|
||||
|
||||
// ================================
|
||||
// RELAY EVENTS
|
||||
// ================================
|
||||
|
||||
async function loadEvents() {
|
||||
try {
|
||||
const res = await fetch('api/events.php?limit=50');
|
||||
const d = await res.json();
|
||||
const tbody = document.getElementById('live-relay-events-table-body');
|
||||
if (!d.events || d.events.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center">No events</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = d.events.map(e =>
|
||||
`<tr><td>${esc(e.created_at)}</td><td>${e.kind}</td><td><bdi>${esc(e.display_name || (e.pubkey||'').substring(0,16) + '…')}</bdi></td><td class="pubkey">${esc((e.id||'').substring(0,16))}…</td><td>${esc((e.content||'').substring(0,80))}</td></tr>`
|
||||
).join('');
|
||||
} catch (err) { console.error('[admin2] events error:', err); }
|
||||
}
|
||||
|
||||
// ================================
|
||||
// CACHING
|
||||
// ================================
|
||||
|
||||
async function loadCaching() {
|
||||
try {
|
||||
const res = await fetch('api/caching.php');
|
||||
const d = await res.json();
|
||||
// Config toggle state
|
||||
if (d.config) {
|
||||
const enabled = d.config.caching_enabled === 'true';
|
||||
const inboxEnabled = d.config.caching_inbox_enabled === 'true';
|
||||
const enLabel = document.getElementById('caching-enabled-label');
|
||||
const enBtn = document.getElementById('caching-toggle-btn');
|
||||
const inLabel = document.getElementById('caching-inbox-enabled-label');
|
||||
const inBtn = document.getElementById('caching-inbox-toggle-btn');
|
||||
if (enLabel) enLabel.textContent = 'Caching: ' + (enabled ? 'ON' : 'OFF');
|
||||
if (enBtn) enBtn.textContent = enabled ? 'Turn OFF' : 'Turn ON';
|
||||
if (inLabel) inLabel.textContent = 'Inbox: ' + (inboxEnabled ? 'ON' : 'OFF');
|
||||
if (inBtn) inBtn.textContent = inboxEnabled ? 'Turn OFF' : 'Turn ON';
|
||||
}
|
||||
// Service status
|
||||
const ssEl = document.getElementById('caching-service-status');
|
||||
if (ssEl && d.state) {
|
||||
const s = d.state;
|
||||
const hb = s.heartbeat_at ? new Date(s.heartbeat_at * 1000).toLocaleTimeString() : '—';
|
||||
ssEl.innerHTML = `<p>State: <strong>${esc(s.service_state)}</strong> | Follows: ${s.followed_author_count ?? 0} | Connected relays: ${s.connected_relay_count ?? 0}/${s.selected_relay_count ?? 0} | Backfill: ${s.backfill_authors_complete ?? 0}/${s.backfill_authors_total ?? 0} | Events fetched: ${s.events_fetched ?? 0} | Inbox inserts: ${s.inbox_inserts ?? 0} | Heartbeat: ${hb}</p>`;
|
||||
}
|
||||
// Inbox status
|
||||
const isEl = document.getElementById('caching-inbox-status');
|
||||
if (isEl && d.inbox) {
|
||||
isEl.innerHTML = `<p>Pending: ${d.inbox.pending} | Live: ${d.inbox.live} | Backfill: ${d.inbox.backfill}</p>`;
|
||||
}
|
||||
// Follows table
|
||||
const tbody = document.getElementById('caching-follows-table-body');
|
||||
if (tbody && d.follows) {
|
||||
if (d.follows.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;font-style:italic">No followed pubkeys</td></tr>';
|
||||
} else {
|
||||
const activePk = d.active && d.active.pubkey ? d.active.pubkey : '';
|
||||
tbody.innerHTML = d.follows.map((f, i) => {
|
||||
const npub = f.npub || f.pubkey.substring(0, 20);
|
||||
const isActive = activePk && f.pubkey === activePk;
|
||||
const relaySummary = f.relay_incomplete > 0
|
||||
? `<span class="status-working">${f.relay_count - f.relay_incomplete}/${f.relay_count} done</span>`
|
||||
: `${f.relay_count ?? 0} relays`;
|
||||
// Per-relay detail row (hidden by default, toggle via click)
|
||||
let relayDetail = '';
|
||||
if (f.relays && f.relays.length > 0) {
|
||||
relayDetail = '<div class="relay-progress-list">' +
|
||||
f.relays.map(r => {
|
||||
const statusIcon = r.complete ? '✓' : '⏳';
|
||||
const statusBadge = r.complete
|
||||
? `<span class="relay-status relay-done">${esc(r.last_status || 'eose')}</span>`
|
||||
: `<span class="relay-status relay-pending">${esc(r.last_status || 'pending')}</span>`;
|
||||
const relayHost = r.relay_url.replace(/^wss?:\/\//, '').replace(/\/relay$/, '');
|
||||
return `<div class="relay-progress-row"><span class="relay-icon">${statusIcon}</span><span class="relay-url" title="${esc(r.relay_url)}">${esc(relayHost)}</span><span class="relay-events">${r.events_fetched} evts</span>${statusBadge}</div>`;
|
||||
}).join('') +
|
||||
'</div>';
|
||||
}
|
||||
const refreshBtn = `<button type="button" class="refresh-user-btn" onclick="event.stopPropagation(); refreshCachingUser('${esc(f.pubkey)}', '${esc(f.name || '')}')">↻ Refresh this user</button>`;
|
||||
const rowClass = isActive ? 'follows-row follows-row-active' : 'follows-row';
|
||||
const activeIcon = isActive ? '⚡ ' : '';
|
||||
return `<tr class="${rowClass}" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? '' : 'none'"><td><bdi>${activeIcon}${esc(f.name) || '<i>unknown</i>'}</bdi></td><td class="npub-link">${esc(npub)}…</td><td>${f.is_root ? '✓' : ''}</td><td>${f.total_events ?? 0}</td><td>${f.backfill_complete ? '✓' : '…'}</td><td>${relaySummary}</td></tr><tr class="follows-detail" style="display:none"><td colspan="6"><div class="follows-detail-content">${relayDetail || '<i>No relay progress data</i>'}<div class="follows-detail-actions">${refreshBtn}</div></div></td></tr>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
// Active target
|
||||
const fsEl = document.getElementById('caching-follows-status');
|
||||
if (fsEl) {
|
||||
if (d.active && d.active.pubkey && d.active.relay) {
|
||||
fsEl.innerHTML = `<span class="status-working">⚡ Backfilling: ${d.active.pubkey.substring(0,16)}… @ ${esc(d.active.relay)}</span>`;
|
||||
} else if (d.state && d.state.service_state === 'running') {
|
||||
const incomplete = d.state.backfill_authors_complete < d.state.backfill_authors_total;
|
||||
fsEl.innerHTML = incomplete
|
||||
? `<span class="status-working">⚡ Backfill in progress…</span>`
|
||||
: `<span class="status-complete">✓ Backfill complete — listening for live events</span>`;
|
||||
} else {
|
||||
fsEl.innerHTML = `<span class="status-complete">✓ Caching complete</span>`;
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('[admin2] caching error:', e); }
|
||||
}
|
||||
|
||||
// Toggle caching_enabled config via the config API.
|
||||
async function toggleCachingEnabled() {
|
||||
try {
|
||||
const res = await fetch('api/caching.php');
|
||||
const d = await res.json();
|
||||
const current = d.config?.caching_enabled === 'true';
|
||||
const newVal = current ? 'false' : 'true';
|
||||
await fetch('api/config.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({key: 'caching_enabled', value: newVal})
|
||||
});
|
||||
loadCaching();
|
||||
} catch (e) { console.error('[admin2] toggle caching error:', e); }
|
||||
}
|
||||
|
||||
// Toggle caching_inbox_enabled config via the config API.
|
||||
async function toggleCachingInboxEnabled() {
|
||||
try {
|
||||
const res = await fetch('api/caching.php');
|
||||
const d = await res.json();
|
||||
const current = d.config?.caching_inbox_enabled === 'true';
|
||||
const newVal = current ? 'false' : 'true';
|
||||
await fetch('api/config.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({key: 'caching_inbox_enabled', value: newVal})
|
||||
});
|
||||
loadCaching();
|
||||
} catch (e) { console.error('[admin2] toggle inbox error:', e); }
|
||||
}
|
||||
|
||||
// Re-run all caching: resets backfill progress for all followed authors and
|
||||
// bumps caching_config_generation so the running service hot-reloads and
|
||||
// re-drains from the beginning.
|
||||
async function rerunAllCaching() {
|
||||
if (!confirm('Reset backfill progress for ALL followed authors? The caching service will re-download everything from scratch.')) return;
|
||||
const btn = document.getElementById('caching-rerun-all-btn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Resetting…'; }
|
||||
try {
|
||||
const res = await fetch('api/caching.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'reset_all'})
|
||||
});
|
||||
const d = await res.json();
|
||||
if (d.ok) {
|
||||
if (btn) { btn.textContent = '✓ Reset — re-draining'; }
|
||||
setTimeout(() => { if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; } loadCaching(); }, 2000);
|
||||
} else {
|
||||
alert('Reset failed: ' + (d.error || 'unknown error'));
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[admin2] rerunAllCaching error:', e);
|
||||
alert('Reset failed: ' + e.message);
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; }
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh a single user: resets backfill progress for one followed author
|
||||
// and bumps caching_config_generation so the service re-fetches that author.
|
||||
async function refreshCachingUser(pubkey, name) {
|
||||
if (!confirm('Reset backfill progress for ' + (name || pubkey.substring(0, 16) + '…') + '? The caching service will re-download this user\'s events from scratch.')) return;
|
||||
try {
|
||||
const res = await fetch('api/caching.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({action: 'reset_user', pubkey: pubkey})
|
||||
});
|
||||
const d = await res.json();
|
||||
if (d.ok) {
|
||||
loadCaching();
|
||||
} else {
|
||||
alert('Refresh failed: ' + (d.error || 'unknown error'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[admin2] refreshCachingUser error:', e);
|
||||
alert('Refresh failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================
|
||||
// DMs
|
||||
// ================================
|
||||
|
||||
async function loadDMs() {
|
||||
try {
|
||||
const res = await fetch('api/dm.php?limit=50');
|
||||
const d = await res.json();
|
||||
const el = document.getElementById('dm-inbox');
|
||||
if (!d.messages || d.messages.length === 0) {
|
||||
el.innerHTML = '<div class="log-entry">No messages found.</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = d.messages.map(m =>
|
||||
`<div class="log-entry"><strong>kind ${m.kind}</strong> from <span class="pubkey">${esc((m.pubkey||'').substring(0,16))}…</span> at ${esc(m.created_at)}:<br>${esc((m.content||'').substring(0,200))}</div>`
|
||||
).join('');
|
||||
} catch (e) { console.error('[admin2] dm error:', e); }
|
||||
}
|
||||
|
||||
// ================================
|
||||
// SQL QUERY
|
||||
// ================================
|
||||
|
||||
async function executeQuery() {
|
||||
const sql = document.getElementById('sql-input').value.trim();
|
||||
if (!sql) { alert('Enter a SQL query'); return; }
|
||||
if (!sql.toUpperCase().startsWith('SELECT')) { alert('Only SELECT queries are allowed'); return; }
|
||||
try {
|
||||
const res = await fetch('api/query.php', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({sql})
|
||||
});
|
||||
const d = await res.json();
|
||||
const info = document.getElementById('query-info');
|
||||
const tableDiv = document.getElementById('query-table');
|
||||
if (d.error) {
|
||||
info.textContent = 'Error: ' + d.error;
|
||||
tableDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
info.textContent = `${d.row_count} rows in ${d.time_ms}ms`;
|
||||
if (d.rows && d.rows.length > 0) {
|
||||
const cols = Object.keys(d.rows[0]);
|
||||
let html = '<table class="config-table"><thead><tr>';
|
||||
cols.forEach(c => html += `<th>${c}</th>`);
|
||||
html += '</tr></thead><tbody>';
|
||||
d.rows.forEach(r => {
|
||||
html += '<tr>';
|
||||
cols.forEach(c => html += `<td>${r[c] ?? ''}</td>`);
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
tableDiv.innerHTML = html;
|
||||
} else {
|
||||
tableDiv.innerHTML = '<p>No rows returned.</p>';
|
||||
}
|
||||
} catch (e) { console.error('[admin2] query error:', e); }
|
||||
}
|
||||
|
||||
// ================================
|
||||
// DARK MODE
|
||||
// ================================
|
||||
|
||||
document.getElementById('nav-dark-mode-btn')?.addEventListener('click', () => {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('admin2-dark-mode', document.body.classList.contains('dark-mode'));
|
||||
const btn = document.getElementById('nav-dark-mode-btn');
|
||||
if (btn) btn.textContent = document.body.classList.contains('dark-mode') ? 'LIGHT MODE' : 'DARK MODE';
|
||||
});
|
||||
|
||||
if (localStorage.getItem('admin2-dark-mode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
const btn = document.getElementById('nav-dark-mode-btn');
|
||||
if (btn) btn.textContent = 'LIGHT MODE';
|
||||
}
|
||||
|
||||
// ================================
|
||||
// RELAY LETTER ANIMATION
|
||||
// ================================
|
||||
|
||||
// Animate the RELAY letters with an underline sweep. Fires on every stats
|
||||
// refresh so the user gets a visual cue that the page is updating.
|
||||
function startRelayAnimation() {
|
||||
const letters = document.querySelectorAll('.relay-letter');
|
||||
if (letters.length === 0) return;
|
||||
|
||||
// Cancel any in-flight animation so rapid refreshes don't overlap
|
||||
if (relayAnimationTimer) { clearTimeout(relayAnimationTimer); relayAnimationTimer = null; }
|
||||
|
||||
let currentIndex = 0;
|
||||
letters.forEach(l => l.classList.remove('underlined'));
|
||||
|
||||
function animateLetter() {
|
||||
letters.forEach(letter => letter.classList.remove('underlined'));
|
||||
if (letters[currentIndex]) {
|
||||
letters[currentIndex].classList.add('underlined');
|
||||
}
|
||||
currentIndex++;
|
||||
if (currentIndex > letters.length) {
|
||||
// Sweep complete — clear underlines and pause before next refresh
|
||||
letters.forEach(letter => letter.classList.remove('underlined'));
|
||||
relayAnimationTimer = null;
|
||||
return;
|
||||
}
|
||||
relayAnimationTimer = setTimeout(animateLetter, 100);
|
||||
}
|
||||
animateLetter();
|
||||
}
|
||||
|
||||
// ================================
|
||||
// RELAY PUBKEY COPY-TO-CLIPBOARD
|
||||
// ================================
|
||||
|
||||
document.getElementById('relay-pubkey-container')?.addEventListener('click', async () => {
|
||||
const el = document.getElementById('relay-pubkey');
|
||||
if (!el || !el.textContent.trim()) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(el.textContent.replace(/\s+/g, ''));
|
||||
const container = document.getElementById('relay-pubkey-container');
|
||||
container.classList.add('copied');
|
||||
setTimeout(() => container.classList.remove('copied'), 500);
|
||||
} catch (e) { console.warn('[admin2] clipboard copy failed:', e); }
|
||||
});
|
||||
|
||||
// ================================
|
||||
// AUTH — nostr_login_lite modal
|
||||
// ================================
|
||||
|
||||
const loginModal = document.getElementById('login-modal');
|
||||
const loginModalContainer = document.getElementById('login-modal-container');
|
||||
const profileArea = document.getElementById('profile-area');
|
||||
const headerUserName = document.getElementById('header-user-name');
|
||||
const headerUserImage = document.getElementById('header-user-image');
|
||||
const logoutDropdown = document.getElementById('logout-dropdown');
|
||||
|
||||
function showLoginModal() {
|
||||
if (loginModal && loginModalContainer) {
|
||||
if (window.NOSTR_LOGIN_LITE && typeof window.NOSTR_LOGIN_LITE.embed === 'function') {
|
||||
// Clear previous embed before re-embedding
|
||||
loginModalContainer.innerHTML = '';
|
||||
window.NOSTR_LOGIN_LITE.embed('#login-modal-container', { seamless: true });
|
||||
}
|
||||
loginModal.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function hideLoginModal() {
|
||||
if (loginModal) loginModal.style.display = 'none';
|
||||
}
|
||||
|
||||
function showProfileInHeader() {
|
||||
if (profileArea) profileArea.style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideProfileFromHeader() {
|
||||
if (profileArea) profileArea.style.display = 'none';
|
||||
}
|
||||
|
||||
// Toggle logout dropdown when clicking the profile area
|
||||
profileArea?.addEventListener('click', (e) => {
|
||||
// Only toggle if the click wasn't on the logout button itself
|
||||
if (e.target.closest('.logout-btn')) return;
|
||||
if (logoutDropdown) {
|
||||
logoutDropdown.style.display = (logoutDropdown.style.display === 'none' || !logoutDropdown.style.display) ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Hide logout dropdown when clicking elsewhere
|
||||
document.addEventListener('click', (e) => {
|
||||
if (logoutDropdown && logoutDropdown.style.display === 'block' && !e.target.closest('#profile-area')) {
|
||||
logoutDropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Update header profile display from logged-in user's pubkey.
|
||||
// Sets a placeholder from the npub immediately, then fetches the kind 0
|
||||
// profile event from public relays to populate the name + picture.
|
||||
function updateProfileDisplay(pubkey) {
|
||||
if (!pubkey) return;
|
||||
let npub = '';
|
||||
try {
|
||||
if (pubkey.length === 64 && /^[0-9a-fA-F]+$/.test(pubkey)) {
|
||||
npub = window.NostrTools.nip19.npubEncode(pubkey);
|
||||
}
|
||||
} catch (err) { console.warn('[admin2] npub encode failed:', err); }
|
||||
|
||||
// Placeholder until the profile fetch resolves
|
||||
if (headerUserName) {
|
||||
headerUserName.textContent = npub ? npub.substring(0, 16) + '…' : pubkey.substring(0, 16) + '…';
|
||||
}
|
||||
if (headerUserImage) headerUserImage.style.display = 'none';
|
||||
|
||||
// Fetch kind 0 profile from public relays (same approach as original api page)
|
||||
loadUserProfile(pubkey, npub);
|
||||
}
|
||||
|
||||
// Apply profile data to the header name + profile picture.
|
||||
// Uses best_name from the server (resolved per profile_name_preference).
|
||||
function applyProfileToHeader(name, picture) {
|
||||
if (headerUserName) headerUserName.textContent = name || 'Anonymous User';
|
||||
if (headerUserImage && picture && typeof picture === 'string' &&
|
||||
(picture.startsWith('http://') || picture.startsWith('https://'))) {
|
||||
headerUserImage.src = picture;
|
||||
headerUserImage.style.display = 'block';
|
||||
headerUserImage.onerror = function() { this.style.display = 'none'; };
|
||||
} else if (headerUserImage) {
|
||||
headerUserImage.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the user's profile. Tries the local profiles cache first
|
||||
// (admin/api/profile.php), then falls back to public relays if the
|
||||
// relay has no cached kind-0 for this pubkey.
|
||||
async function loadUserProfile(pubkey, npub) {
|
||||
if (!pubkey) return;
|
||||
|
||||
// Try local profiles cache first.
|
||||
try {
|
||||
const res = await fetch('api/profile.php?pubkey=' + encodeURIComponent(pubkey));
|
||||
if (res.ok) {
|
||||
const profile = await res.json();
|
||||
if (profile && profile.best_name !== undefined) {
|
||||
applyProfileToHeader(profile.best_name, profile.picture);
|
||||
console.log('[admin2] profile loaded from local cache for', profile.best_name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Local endpoint not available — fall through to public relays.
|
||||
}
|
||||
|
||||
// Fall back to public relays.
|
||||
if (!window.NostrTools || !window.NostrTools.SimplePool) {
|
||||
if (headerUserName) headerUserName.textContent = npub ? npub.substring(0, 16) + '…' : 'Anonymous User';
|
||||
return;
|
||||
}
|
||||
const relays = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.snort.social'
|
||||
];
|
||||
try {
|
||||
const pool = new window.NostrTools.SimplePool();
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Profile query timeout')), 5000)
|
||||
);
|
||||
const queryPromise = pool.querySync(relays, {
|
||||
kinds: [0],
|
||||
authors: [pubkey],
|
||||
limit: 1
|
||||
});
|
||||
const events = await Promise.race([queryPromise, timeoutPromise]);
|
||||
try { await pool.close(relays); } catch (e) {}
|
||||
|
||||
if (events && events.length > 0) {
|
||||
const profile = JSON.parse(events[0].content);
|
||||
// Use best_name resolution: display_name first, then name.
|
||||
const name = profile.display_name || profile.name || profile.displayName || 'Anonymous User';
|
||||
const picture = profile.picture || profile.image || null;
|
||||
applyProfileToHeader(name, picture);
|
||||
console.log('[admin2] profile loaded from public relays for', name);
|
||||
} else {
|
||||
if (headerUserName) headerUserName.textContent = 'Anonymous User';
|
||||
console.log('[admin2] no profile event found for', pubkey);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[admin2] profile load failed:', err.message);
|
||||
if (headerUserName) headerUserName.textContent = npub ? npub.substring(0, 16) + '…' : 'Error loading profile';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize nostr_login_lite and show modal if not already authenticated
|
||||
async function initializeAuth() {
|
||||
if (!window.NOSTR_LOGIN_LITE) {
|
||||
console.warn('[admin2] NOSTR_LOGIN_LITE not loaded — skipping auth modal');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await window.NOSTR_LOGIN_LITE.init({
|
||||
theme: 'default',
|
||||
methods: {
|
||||
extension: true,
|
||||
local: true,
|
||||
seedphrase: true,
|
||||
readonly: true,
|
||||
connect: true,
|
||||
remote: true,
|
||||
otp: false
|
||||
},
|
||||
floatingTab: { enabled: false }
|
||||
});
|
||||
nlLite = window.NOSTR_LOGIN_LITE;
|
||||
console.log('[admin2] nostr_login_lite initialized');
|
||||
|
||||
// Check for existing auth state
|
||||
let alreadyLoggedIn = false;
|
||||
try {
|
||||
const stored = localStorage.getItem('nostr_login_lite_auth');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed && parsed.pubkey) {
|
||||
userPubkey = parsed.pubkey;
|
||||
isLoggedIn = true;
|
||||
alreadyLoggedIn = true;
|
||||
showProfileInHeader();
|
||||
updateProfileDisplay(userPubkey);
|
||||
hideLoginModal();
|
||||
console.log('[admin2] existing auth restored for', userPubkey);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* no stored auth */ }
|
||||
|
||||
if (!alreadyLoggedIn) {
|
||||
console.log('[admin2] no existing auth — showing login modal');
|
||||
showLoginModal();
|
||||
}
|
||||
|
||||
// Listen for auth events
|
||||
window.addEventListener('nlMethodSelected', (event) => {
|
||||
const { pubkey, method, error } = event.detail || {};
|
||||
if (method && pubkey) {
|
||||
userPubkey = pubkey;
|
||||
isLoggedIn = true;
|
||||
console.log('[admin2] login success via', method, pubkey);
|
||||
hideLoginModal();
|
||||
showProfileInHeader();
|
||||
updateProfileDisplay(pubkey);
|
||||
} else if (error) {
|
||||
console.warn('[admin2] auth error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('nlLogout', () => {
|
||||
console.log('[admin2] logout event received');
|
||||
userPubkey = null;
|
||||
isLoggedIn = false;
|
||||
hideProfileFromHeader();
|
||||
if (logoutDropdown) logoutDropdown.style.display = 'none';
|
||||
showLoginModal();
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('[admin2] nostr_login_lite init failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Logout function — clears auth state and re-shows login modal
|
||||
async function logout() {
|
||||
console.log('[admin2] logging out...');
|
||||
try {
|
||||
if (nlLite && typeof nlLite.logout === 'function') {
|
||||
await nlLite.logout();
|
||||
}
|
||||
} catch (e) { console.warn('[admin2] nlLite.logout error:', e); }
|
||||
userPubkey = null;
|
||||
isLoggedIn = false;
|
||||
hideProfileFromHeader();
|
||||
if (logoutDropdown) logoutDropdown.style.display = 'none';
|
||||
showLoginModal();
|
||||
console.log('[admin2] logged out');
|
||||
}
|
||||
|
||||
// ================================
|
||||
// INIT
|
||||
// ================================
|
||||
|
||||
switchPage('statistics');
|
||||
|
||||
// Start the RELAY animation immediately on page load
|
||||
startRelayAnimation();
|
||||
|
||||
// Initialize auth + load initial chart on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(initializeAuth, 100);
|
||||
// Load the default (hour) chart immediately
|
||||
loadChart(currentChartRange);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
New Events — Last 24 Hours
|
||||
|
||||
11 | X
|
||||
10 | XX
|
||||
9 | XX
|
||||
8 | XX
|
||||
7 | XX X X
|
||||
6 | XX X X
|
||||
5 | XX X X
|
||||
4 | X XX X XX
|
||||
3 | X XXX X XX
|
||||
2 | X X X XXX X XX
|
||||
1 | X XX X X X X X X X X X X X X XX X X XXXX X X X X X XX
|
||||
+--------------------------------------------------------------------------------
|
||||
0s 1h 3h 4h 6h 7h 9h 10h 12h 13h 15h 16h 18h 19h 21h 22h
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
New Events — Last 30 Days
|
||||
|
||||
81 | X
|
||||
73 | XX X
|
||||
65 | XX X
|
||||
57 | XX X X X
|
||||
49 | X X XX XX X X X
|
||||
41 | X X X XX X X X XXX XXX XXX X X X X
|
||||
33 | X X X XXXX XX X X X XX XXX XXXXXXXX XX X X X X X XX
|
||||
25 |X XX XX XX XXXX XX XXX X XX XXXXXXXXXXXXXXXXXXXX XX XXX X XX XX XXXXX
|
||||
17 |X XXXXX XX XXXXXXXXXXXXXXX X XX XXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXX XX XXXXXXXX
|
||||
9 |XXX XXXXXXXX XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXX
|
||||
1 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
+--------------------------------------------------------------------------------
|
||||
0s 1d 3d 5d 7d 9d 11d 13d 15d 16d 18d 20d 22d 24d 26d 28d
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
New Events — Last Year
|
||||
|
||||
531 | X XX
|
||||
478 | X X X XXX X
|
||||
425 | X X XX X XX XXX X
|
||||
372 | XXX XX X XX XXX X
|
||||
319 |XXXXXXXXXXX X X X XXXXXXX X
|
||||
266 |XXXXXXXXXXXX X XX XXX XXXXXXXXX X
|
||||
213 |XXXXXXXXXXXXXX XXX XXXXXXXXXXXXX XX X
|
||||
160 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX X X XXXX X
|
||||
107 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XX XX XX X X X XX XXX X XXXXXXX X
|
||||
54 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXX
|
||||
1 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
+--------------------------------------------------------------------------------
|
||||
0s 22d 45d 68d 91d 114d 136d 159d 182d 205d 228d 250d 273d 296d 319d 342d
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
/**
|
||||
* admin2 — Full PHP admin for C-Relay-PG.
|
||||
* Recreates the original api/ admin interface with a PHP/PDO backend
|
||||
* instead of Nostr WebSocket admin commands.
|
||||
*/
|
||||
require_once __DIR__ . '/lib/helpers.php';
|
||||
$pdo = db();
|
||||
|
||||
// Fetch relay info for the header (from config table)
|
||||
$relay_name = '';
|
||||
$relay_desc = '';
|
||||
$relay_pubkey = '';
|
||||
$relay_version = '';
|
||||
try {
|
||||
$relay_name = $pdo->query("SELECT value FROM config WHERE key = 'relay_name'")->fetchColumn() ?: 'C-Relay-PG';
|
||||
$relay_desc = $pdo->query("SELECT value FROM config WHERE key = 'relay_description'")->fetchColumn() ?: '';
|
||||
$relay_pubkey = $pdo->query("SELECT value FROM config WHERE key = 'relay_pubkey'")->fetchColumn() ?: '';
|
||||
$relay_version = $pdo->query("SELECT value FROM config WHERE key = 'relay_version'")->fetchColumn() ?: '';
|
||||
} catch (PDOException $e) {}
|
||||
|
||||
// Convert relay pubkey (hex) to npub and format into 3 lines of 3 groups of 7 chars
|
||||
$relay_npub = $relay_pubkey;
|
||||
if ($relay_pubkey && preg_match('/^[0-9a-fA-F]{64}$/', $relay_pubkey)) {
|
||||
$relay_npub = hex_to_npub($relay_pubkey);
|
||||
}
|
||||
// Format npub (63 chars) into 3 lines of "xxxxxxx xxxxxxx xxxxxxx"
|
||||
$formatted_npub = $relay_npub;
|
||||
if (strlen($relay_npub) === 63) {
|
||||
$line1 = substr($relay_npub, 0, 7) . ' ' . substr($relay_npub, 7, 7) . ' ' . substr($relay_npub, 14, 7);
|
||||
$line2 = substr($relay_npub, 21, 7) . ' ' . substr($relay_npub, 28, 7) . ' ' . substr($relay_npub, 35, 7);
|
||||
$line3 = substr($relay_npub, 42, 7) . ' ' . substr($relay_npub, 49, 7) . ' ' . substr($relay_npub, 56, 7);
|
||||
$formatted_npub = $line1 . "\n" . $line2 . "\n" . $line3;
|
||||
}
|
||||
$display_name = $relay_version ? ($relay_name . ' ' . $relay_version) : $relay_name;
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>C-Relay-PG Admin</title>
|
||||
<link rel="stylesheet" href="assets/index.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Side Navigation Menu -->
|
||||
<nav class="side-nav" id="side-nav">
|
||||
<ul class="nav-menu">
|
||||
<li><button class="nav-item" data-page="statistics">Statistics</button></li>
|
||||
<li><button class="nav-item" data-page="subscriptions">Subscriptions</button></li>
|
||||
<li><button class="nav-item" data-page="configuration">Configuration</button></li>
|
||||
<li><button class="nav-item" data-page="authorization">Authorization</button></li>
|
||||
<li><button class="nav-item" data-page="ip-bans">IP BAN</button></li>
|
||||
<li><button class="nav-item" data-page="relay-events">Relay Events</button></li>
|
||||
<li><button class="nav-item" data-page="caching">Caching</button></li>
|
||||
<li><button class="nav-item" data-page="dm">DM</button></li>
|
||||
<li><button class="nav-item" data-page="database">Database Query</button></li>
|
||||
</ul>
|
||||
<div class="nav-footer">
|
||||
<button class="nav-footer-btn" id="nav-dark-mode-btn">DARK MODE</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Side Navigation Overlay -->
|
||||
<div class="side-nav-overlay" id="side-nav-overlay"></div>
|
||||
|
||||
<!-- Header with title and profile display -->
|
||||
<div class="section">
|
||||
<div class="header-content">
|
||||
<div class="header-title clickable" id="header-title" onclick="toggleNav()">
|
||||
<span class="relay-letter" data-letter="R">R</span>
|
||||
<span class="relay-letter" data-letter="E">E</span>
|
||||
<span class="relay-letter" data-letter="L">L</span>
|
||||
<span class="relay-letter" data-letter="A">A</span>
|
||||
<span class="relay-letter" data-letter="Y">Y</span>
|
||||
</div>
|
||||
<div class="relay-info">
|
||||
<div id="relay-name" class="relay-name"><?= e($display_name) ?></div>
|
||||
<div id="relay-description" class="relay-description"><?= e($relay_desc) ?></div>
|
||||
<div id="relay-pubkey-container" class="relay-pubkey-container" title="Click to copy npub">
|
||||
<div id="relay-pubkey" class="relay-pubkey"><?= e($formatted_npub) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-area" id="profile-area" style="display: none;">
|
||||
<div class="admin-label">admin</div>
|
||||
<div class="profile-container">
|
||||
<img id="header-user-image" class="header-user-image" alt="Profile" style="display: none;">
|
||||
<span id="header-user-name" class="header-user-name">Loading...</span>
|
||||
</div>
|
||||
<!-- Logout dropdown (under admin picture) -->
|
||||
<div class="logout-dropdown" id="logout-dropdown" style="display: none;">
|
||||
<button type="button" class="logout-btn" onclick="logout()">LOGOUT</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Modal Overlay (nostr_login_lite) -->
|
||||
<div id="login-modal" class="login-modal-overlay" style="display: none;">
|
||||
<div class="login-modal-content">
|
||||
<div id="login-modal-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Denied Overlay (shown when non-admin user logs in) -->
|
||||
<div id="access-denied-overlay" class="access-denied-overlay" style="display: none;">
|
||||
<div class="access-denied-content">
|
||||
<div class="access-denied-icon">⛔</div>
|
||||
<h2>ACCESS DENIED</h2>
|
||||
<p class="access-denied-message">This interface is restricted to the relay administrator.</p>
|
||||
<p class="access-denied-submessage">The logged-in account does not have admin privileges.</p>
|
||||
<button type="button" class="access-denied-logout-btn" onclick="logout()">LOGOUT</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DATABASE STATISTICS Section -->
|
||||
<div class="section flex-section" id="databaseStatisticsSection">
|
||||
<div class="section-header">DATABASE STATISTICS</div>
|
||||
|
||||
<!-- Event Rate Graph — range selector tabs + ASCII chart (server-rendered) -->
|
||||
<div class="chart-range-tabs">
|
||||
<button class="chart-tab active" data-range="hour">1H</button>
|
||||
<button class="chart-tab" data-range="day">1D</button>
|
||||
<button class="chart-tab" data-range="month">1M</button>
|
||||
<button class="chart-tab" data-range="year">1Y</button>
|
||||
</div>
|
||||
<div id="event-rate-chart" class="event-rate-chart-container">Loading chart...</div>
|
||||
|
||||
<!-- Database Overview Table -->
|
||||
<div class="input-group">
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="stats-overview-table">
|
||||
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
|
||||
<tbody id="stats-overview-table-body">
|
||||
<tr><td>Database Size</td><td id="db-size">-</td></tr>
|
||||
<tr><td>Total Events</td><td id="total-events">-</td></tr>
|
||||
<tr><td>Process ID</td><td id="process-id">-</td></tr>
|
||||
<tr><td>WebSocket Connections</td><td id="websocket-connections">-</td></tr>
|
||||
<tr><td>Active Subscriptions</td><td id="active-subscriptions">-</td></tr>
|
||||
<tr><td>Memory Usage</td><td id="memory-usage">-</td></tr>
|
||||
<tr><td>CPU Usage</td><td id="cpu-usage">-</td></tr>
|
||||
<tr><td>CPU Core</td><td id="cpu-core">-</td></tr>
|
||||
<tr><td>Oldest Event</td><td id="oldest-event">-</td></tr>
|
||||
<tr><td>Newest Event</td><td id="newest-event">-</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Kind Distribution Table -->
|
||||
<div class="input-group">
|
||||
<label>Event Kind Distribution:</label>
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="stats-kinds-table">
|
||||
<thead><tr><th>Event Kind</th><th>Count</th><th>Percentage</th></tr></thead>
|
||||
<tbody id="stats-kinds-table-body">
|
||||
<tr><td colspan="3" style="text-align: center; font-style: italic;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time-based Statistics Table -->
|
||||
<div class="input-group">
|
||||
<label>Time-based Statistics:</label>
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="stats-time-table">
|
||||
<thead><tr><th>Period</th><th>Events</th></tr></thead>
|
||||
<tbody id="stats-time-table-body">
|
||||
<tr><td>Last 24 Hours</td><td id="events-24h">-</td></tr>
|
||||
<tr><td>Last 7 Days</td><td id="events-7d">-</td></tr>
|
||||
<tr><td>Last 30 Days</td><td id="events-30d">-</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Pubkeys Table -->
|
||||
<div class="input-group">
|
||||
<label>Top Pubkeys by Event Count:</label>
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="stats-pubkeys-table">
|
||||
<thead><tr><th>Rank</th><th>Name</th><th>Pubkey</th><th>Event Count</th><th>Percentage</th></tr></thead>
|
||||
<tbody id="stats-pubkeys-table-body">
|
||||
<tr><td colspan="5" style="text-align: center; font-style: italic;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name-field usage stats (from profiles cache) -->
|
||||
<div class="input-group" id="name-field-usage" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- SUBSCRIPTION DETAILS Section -->
|
||||
<div class="section flex-section" id="subscriptionDetailsSection" style="display: none;">
|
||||
<div class="section-header">ACTIVE SUBSCRIPTION DETAILS</div>
|
||||
<div class="input-group">
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="subscription-details-table">
|
||||
<thead><tr><th>Sub ID</th><th>Pubkey</th><th>Filters</th><th>Created</th><th>Events Sent</th></tr></thead>
|
||||
<tbody id="subscription-details-table-body">
|
||||
<tr><td colspan="5" style="text-align: center; font-style: italic;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONFIGURATION Section -->
|
||||
<div id="div_config" class="section flex-section" style="display: none;">
|
||||
<div class="section-header">RELAY CONFIGURATION</div>
|
||||
<div id="config-display">
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="config-table">
|
||||
<thead><tr><th>Parameter</th><th>Value</th><th>Actions</th></tr></thead>
|
||||
<tbody id="config-table-body">
|
||||
<tr><td colspan="3" style="text-align: center; font-style: italic;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" id="fetch-config-btn" onclick="loadConfig()">REFRESH</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AUTH RULES Section -->
|
||||
<div class="section flex-section" id="authRulesSection" style="display: none;">
|
||||
<div class="section-header">AUTH RULES MANAGEMENT</div>
|
||||
<div id="authRulesTableContainer">
|
||||
<table class="config-table" id="authRulesTable">
|
||||
<thead><tr><th>Rule Type</th><th>Pattern Type</th><th>Pattern Value</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody id="authRulesTableBody">
|
||||
<tr><td colspan="5" style="text-align: center; font-style: italic;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="authRuleInputSections" style="display: block;">
|
||||
<div class="input-group">
|
||||
<label for="authRulePubkey">Public Key (npub or hex):</label>
|
||||
<input type="text" id="authRulePubkey" placeholder="npub1... or 64-character hex pubkey">
|
||||
</div>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" onclick="addAuthRule('whitelist')">ADD TO WHITELIST</button>
|
||||
<button type="button" onclick="addAuthRule('blacklist')">ADD TO BLACKLIST</button>
|
||||
<button type="button" onclick="loadAuthRules()">REFRESH</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WEB OF TRUST Section -->
|
||||
<div class="section flex-section" id="wotSection" style="display: none;">
|
||||
<div class="section-header">WEB OF TRUST</div>
|
||||
<div id="wotKind3Status" class="wot-status-row">
|
||||
<span>Admin Contact List (kind 3):</span>
|
||||
<span id="wotKind3Indicator" class="wot-indicator wot-unknown">Loading...</span>
|
||||
</div>
|
||||
<div class="wot-level-selector">
|
||||
<label>WoT Level:</label>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" class="wot-level-btn" onclick="setWotLevel(0)">OFF</button>
|
||||
<button type="button" class="wot-level-btn" onclick="setWotLevel(1)">WRITE ONLY</button>
|
||||
<button type="button" class="wot-level-btn" onclick="setWotLevel(2)">FULL</button>
|
||||
</div>
|
||||
<div class="wot-level-description" id="wotLevelDescription">Loading...</div>
|
||||
</div>
|
||||
<div class="wot-stats-row">
|
||||
<span>Whitelisted Pubkeys:</span>
|
||||
<span id="wotWhitelistCount">—</span>
|
||||
</div>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" onclick="syncWot()">SYNC FROM KIND 3</button>
|
||||
<button type="button" onclick="loadWotStatus()">REFRESH STATUS</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NIP-17 DIRECT MESSAGES Section -->
|
||||
<div class="section" id="nip17DMSection" style="display: none;">
|
||||
<div class="section-header"><h2>NIP-17 DIRECT MESSAGES</h2></div>
|
||||
<div class="input-group">
|
||||
<label>Received Messages (kind 4/14/15):</label>
|
||||
<div id="dm-inbox" class="log-panel" style="height: 300px; overflow-y: auto;">
|
||||
<div class="log-entry">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP BANS Section -->
|
||||
<div class="section" id="ipBansSection" style="display: none;">
|
||||
<div class="section-header">IP BAN MANAGEMENT</div>
|
||||
<div class="input-group">
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="ip-bans-stats-table">
|
||||
<thead><tr><th>Total IPs Tracked</th><th>Currently Banned</th><th>Total Bans Issued</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td id="ip-bans-total">-</td><td id="ip-bans-active">-</td><td id="ip-bans-issued">-</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<h3>Manually Ban IP Address</h3>
|
||||
<div class="form-group">
|
||||
<label for="ban-ip-input">IP Address:</label>
|
||||
<input type="text" id="ban-ip-input" placeholder="192.168.1.100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ban-duration-select">Ban Duration:</label>
|
||||
<select id="ban-duration-select">
|
||||
<option value="3600">1 Hour</option>
|
||||
<option value="86400" selected>24 Hours</option>
|
||||
<option value="604800">7 Days</option>
|
||||
<option value="2592000">30 Days</option>
|
||||
<option value="31536000">1 Year</option>
|
||||
<option value="999999999">Permanent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" onclick="addBan()">BAN IP</button>
|
||||
<button type="button" onclick="loadIpBans()">REFRESH</button>
|
||||
</div>
|
||||
<div id="add-ban-status" class="status-message"></div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>Banned IP Addresses:</label>
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="ip-bans-table">
|
||||
<thead><tr><th>IP Address</th><th>Status</th><th>Banned Until</th><th>Failures</th><th>Actions</th></tr></thead>
|
||||
<tbody id="ip-bans-tbody">
|
||||
<tr><td colspan="5" style="text-align: center;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RELAY EVENTS Section -->
|
||||
<div class="section" id="relayEventsSection" style="display: none;">
|
||||
<div class="section-header">RELAY EVENTS MANAGEMENT</div>
|
||||
<div class="input-group">
|
||||
<h3>Recent Events</h3>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" onclick="loadEvents()">REFRESH</button>
|
||||
</div>
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="live-relay-events-table">
|
||||
<thead><tr><th>Time</th><th>Kind</th><th>Name</th><th>ID</th><th>Content Preview</th></tr></thead>
|
||||
<tbody id="live-relay-events-table-body">
|
||||
<tr><td colspan="5" style="text-align: center;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CACHING Section -->
|
||||
<div class="section" id="cachingSection" style="display: none;">
|
||||
<div class="section-header">CACHING</div>
|
||||
<div class="input-group">
|
||||
<h3>Caching Controls</h3>
|
||||
<div id="caching-controls" class="inline-buttons">
|
||||
<span id="caching-enabled-label">Caching: ?</span>
|
||||
<button type="button" id="caching-toggle-btn" onclick="toggleCachingEnabled()">Toggle Caching</button>
|
||||
<span id="caching-inbox-enabled-label">Inbox: ?</span>
|
||||
<button type="button" id="caching-inbox-toggle-btn" onclick="toggleCachingInboxEnabled()">Toggle Inbox</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<h3>Caching Service Status</h3>
|
||||
<div id="caching-service-status" class="status-display">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div class="inline-buttons" style="margin-top:8px">
|
||||
<button type="button" id="caching-rerun-all-btn" onclick="rerunAllCaching()">Re-run All Caching</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<h3>Relay Inbox Status</h3>
|
||||
<div id="caching-inbox-status" class="status-display">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<h3>Followed Pubkeys <span style="font-size:11px;font-weight:normal;color:var(--text-muted,#888)">(click a row for per-relay details)</span></h3>
|
||||
<div id="caching-follows-status" class="status-message"></div>
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="caching-follows-table">
|
||||
<thead><tr><th>Name</th><th>npub</th><th>Root?</th><th>Events in DB</th><th>Backfill</th><th>Relays</th></tr></thead>
|
||||
<tbody id="caching-follows-table-body">
|
||||
<tr><td colspan="6" style="text-align: center; font-style: italic;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" onclick="loadCaching()">REFRESH</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SQL QUERY Section -->
|
||||
<div class="section" id="sqlQuerySection" style="display: none;">
|
||||
<div class="section-header"><h2>SQL QUERY CONSOLE</h2></div>
|
||||
<div class="input-group">
|
||||
<label for="sql-input">SQL Query (SELECT only):</label>
|
||||
<textarea id="sql-input" rows="5" placeholder="SELECT * FROM events LIMIT 10"></textarea>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="inline-buttons">
|
||||
<button type="button" onclick="executeQuery()">EXECUTE QUERY</button>
|
||||
<button type="button" onclick="document.getElementById('sql-input').value=''">CLEAR</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>Query Results:</label>
|
||||
<div id="query-info" class="info-box"></div>
|
||||
<div id="query-table" class="config-table-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nostr libraries for nostr_login_lite auth modal -->
|
||||
<script src="assets/nostr.bundle.js"></script>
|
||||
<script src="assets/nostr-lite.js"></script>
|
||||
<script src="assets/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
/**
|
||||
* admin2/lib/ascii_chart.php — Server-side ASCII X-bar chart renderer.
|
||||
*
|
||||
* Produces a monospaced ASCII bar chart (using 'X' characters) from an
|
||||
* array of bin counts. Mirrors the layout of the original text_graph.js
|
||||
* ASCIIBarChart: Y-axis count labels, X-bar columns, X-axis with
|
||||
* elapsed-time labels.
|
||||
*
|
||||
* The output is a plain text string suitable for both browser <div>
|
||||
* injection (white-space: pre) and terminal display via curl.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Render an ASCII bar chart from an array of bin counts.
|
||||
*
|
||||
* @param array $bins Array of integer counts (one per time bin, left=oldest)
|
||||
* @param array $options {
|
||||
* @var string $title Chart title (centered at top)
|
||||
* @var int $max_height Chart height in rows (default 11)
|
||||
* @var string $x_axis_label Label below the X-axis (default '')
|
||||
* @var int $bin_duration Seconds per bin — for X-axis elapsed labels
|
||||
* @var int $label_interval Label every N bins (default 5)
|
||||
* }
|
||||
* @return string The ASCII chart as a multi-line string.
|
||||
*/
|
||||
function render_ascii_chart(array $bins, array $options = []): string {
|
||||
$title = $options['title'] ?? 'New Events';
|
||||
$max_height = $options['max_height'] ?? 11;
|
||||
$x_axis_label = $options['x_axis_label'] ?? '';
|
||||
$bin_duration = $options['bin_duration'] ?? 10;
|
||||
$label_interval = $options['label_interval'] ?? 5;
|
||||
|
||||
$num_bins = count($bins);
|
||||
if ($num_bins === 0) {
|
||||
return "No data available.\n";
|
||||
}
|
||||
|
||||
$max_count = max($bins);
|
||||
if ($max_count < 1) $max_count = 1; // Avoid division by zero for all-empty bins
|
||||
|
||||
// Scaling: each X represents scale_factor counts
|
||||
$scale_factor = max(1, (int)ceil($max_count / $max_height));
|
||||
$scaled_max = (int)ceil($max_count / $scale_factor) * $scale_factor;
|
||||
|
||||
$output = '';
|
||||
|
||||
// --- Title (centered) ---
|
||||
$chart_width = 4 + $num_bins; // 4 = Y-axis number width (3) + separator (1)
|
||||
if ($title !== '') {
|
||||
$title_padding = (int)floor(($chart_width - strlen($title)) / 2);
|
||||
if ($title_padding < 0) $title_padding = 0;
|
||||
$output .= str_repeat(' ', $title_padding) . $title . "\n\n";
|
||||
}
|
||||
|
||||
// --- Bar rows (top to bottom) ---
|
||||
for ($row = $max_height; $row > 0; $row--) {
|
||||
$row_count = ($row - 1) * $scale_factor + 1;
|
||||
$line = str_pad((string)$row_count, 3, ' ', STR_PAD_LEFT) . ' |';
|
||||
|
||||
for ($i = 0; $i < $num_bins; $i++) {
|
||||
$count = $bins[$i];
|
||||
$scaled_height = ($count > 0) ? (int)ceil($count / $scale_factor) : 0;
|
||||
$line .= ($scaled_height >= $row) ? 'X' : ' ';
|
||||
}
|
||||
$output .= $line . "\n";
|
||||
}
|
||||
|
||||
// --- X-axis line ---
|
||||
$output .= ' +' . str_repeat('-', $num_bins) . "\n";
|
||||
|
||||
// --- X-axis labels (elapsed time every label_interval bins) ---
|
||||
$label_line = ' ';
|
||||
$labels = [];
|
||||
for ($i = 0; $i < $num_bins; $i++) {
|
||||
if ($i % $label_interval === 0) {
|
||||
$elapsed_sec = $i * $bin_duration;
|
||||
$labels[] = format_elapsed_time($elapsed_sec);
|
||||
}
|
||||
}
|
||||
// Build label line with spacing
|
||||
for ($i = 0; $i < count($labels); $i++) {
|
||||
$label_line .= $labels[$i];
|
||||
if ($i < count($labels) - 1) {
|
||||
$spacing = $label_interval - strlen($labels[$i]);
|
||||
if ($spacing < 1) $spacing = 1;
|
||||
$label_line .= str_repeat(' ', $spacing);
|
||||
}
|
||||
}
|
||||
// Pad to match X-axis dash line length
|
||||
$min_label_len = 4 + $num_bins;
|
||||
if (strlen($label_line) < $min_label_len) {
|
||||
$label_line .= str_repeat(' ', $min_label_len - strlen($label_line));
|
||||
}
|
||||
$output .= $label_line . "\n";
|
||||
|
||||
// --- X-axis label (if provided) ---
|
||||
if ($x_axis_label !== '') {
|
||||
$label_pad = (int)floor(($num_bins - strlen($x_axis_label)) / 2);
|
||||
if ($label_pad < 0) $label_pad = 0;
|
||||
$output .= "\n" . ' ' . str_repeat(' ', $label_pad) . $x_axis_label . "\n";
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an elapsed time in seconds as a compact label.
|
||||
* < 60s → "Ns"
|
||||
* < 1h → "Nm"
|
||||
* < 1d → "Nh"
|
||||
* else → "Nd"
|
||||
*/
|
||||
function format_elapsed_time(int $seconds): string {
|
||||
if ($seconds < 60) {
|
||||
return $seconds . 's';
|
||||
} elseif ($seconds < 3600) {
|
||||
return (int)floor($seconds / 60) . 'm';
|
||||
} elseif ($seconds < 86400) {
|
||||
return (int)floor($seconds / 3600) . 'h';
|
||||
} else {
|
||||
return (int)floor($seconds / 86400) . 'd';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fixed-length bin array from SQL query rows.
|
||||
*
|
||||
* The query returns only non-empty bins. This function creates a
|
||||
* zero-filled array of $num_bins length and overlays the counts
|
||||
* at the correct positions, so empty time slots show as blank
|
||||
* columns — the chart always advances in time.
|
||||
*
|
||||
* @param array $rows Query rows with 'bin' (int) and 'cnt' (int)
|
||||
* @param int $num_bins Total number of bins (fixed length)
|
||||
* @return array Zero-filled array of counts
|
||||
*/
|
||||
function build_bin_array(array $rows, int $num_bins): array {
|
||||
$bins = array_fill(0, $num_bins, 0);
|
||||
foreach ($rows as $r) {
|
||||
$idx = (int)$r['bin'];
|
||||
if ($idx >= 0 && $idx < $num_bins) {
|
||||
$bins[$idx] = (int)$r['cnt'];
|
||||
}
|
||||
}
|
||||
return $bins;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
/**
|
||||
* C-Relay-PG Admin — Database connection configuration.
|
||||
*
|
||||
* This file is NOT web-accessible (denied by nginx location block).
|
||||
* It contains the PostgreSQL connection string used by all admin pages.
|
||||
*
|
||||
* Edit these values to match your relay's PostgreSQL credentials.
|
||||
*/
|
||||
|
||||
// PostgreSQL connection parameters.
|
||||
// These should match the --db-* flags passed to the c-relay-pg binary
|
||||
// or the connection string passed to caching_relay -p "...".
|
||||
return [
|
||||
'db_host' => 'localhost',
|
||||
'db_port' => '5432',
|
||||
'db_name' => 'crelay',
|
||||
'db_user' => 'crelay',
|
||||
'db_password' => 'crelay',
|
||||
|
||||
// Page sizes for paginated tables.
|
||||
'follows_per_page' => 50,
|
||||
'relays_per_page' => 50,
|
||||
|
||||
// Auto-refresh interval for dashboard polling (milliseconds).
|
||||
'refresh_ms' => 10000,
|
||||
];
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/**
|
||||
* C-Relay-PG Admin — PDO database connection helper.
|
||||
*
|
||||
* Provides a singleton PDO connection to the relay's PostgreSQL database.
|
||||
* All admin pages use db() to get the connection.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
function db(): PDO {
|
||||
static $pdo = null;
|
||||
if ($pdo === null) {
|
||||
$cfg = require __DIR__ . '/config.php';
|
||||
// Build DSN — supports both TCP (host=localhost) and Unix socket
|
||||
// (host=/var/run/postgresql). Omit password if empty (peer auth).
|
||||
$dsn = sprintf(
|
||||
'pgsql:host=%s;port=%d;dbname=%s;user=%s',
|
||||
$cfg['db_host'],
|
||||
$cfg['db_port'],
|
||||
$cfg['db_name'],
|
||||
$cfg['db_user']
|
||||
);
|
||||
if (!empty($cfg['db_password'])) {
|
||||
$dsn .= ';password=' . $cfg['db_password'];
|
||||
}
|
||||
try {
|
||||
$pdo = new PDO($dsn, null, null, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
http_response_code(500);
|
||||
die('Database connection failed: ' . htmlspecialchars($e->getMessage()));
|
||||
}
|
||||
}
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
/** Get a config value from the admin config file. */
|
||||
function cfg(string $key, $default = null) {
|
||||
static $cfg = null;
|
||||
if ($cfg === null) {
|
||||
$cfg = require __DIR__ . '/config.php';
|
||||
}
|
||||
return $cfg[$key] ?? $default;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
/**
|
||||
* C-Relay-PG Admin — Shared helper functions.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
/** HTML-escape a string. */
|
||||
function e(?string $s): string {
|
||||
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/** Format a Unix timestamp as a human-readable relative time. */
|
||||
function time_ago(int $ts): string {
|
||||
if ($ts <= 0) return 'never';
|
||||
$diff = time() - $ts;
|
||||
if ($diff < 60) return $diff . 's ago';
|
||||
if ($diff < 3600) return floor($diff / 60) . 'm ago';
|
||||
if ($diff < 86400) return floor($diff / 3600) . 'h ago';
|
||||
return floor($diff / 86400) . 'd ago';
|
||||
}
|
||||
|
||||
/** Format a Unix timestamp as a date/time string. */
|
||||
function fmt_date(int $ts): string {
|
||||
if ($ts <= 0) return '-';
|
||||
return date('Y-m-d H:i:s', $ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a 64-char hex pubkey to npub (bech32).
|
||||
* Uses the standard bech32 encoding (NIP-19).
|
||||
*/
|
||||
function hex_to_npub(string $hex): string {
|
||||
if (!preg_match('/^[0-9a-fA-F]{64}$/', $hex)) return $hex;
|
||||
$hex = strtolower($hex);
|
||||
|
||||
// Bech32 constants
|
||||
$charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||
$data = [];
|
||||
for ($i = 0; $i < strlen($hex); $i += 2) {
|
||||
$data[] = intval(substr($hex, $i, 2), 16);
|
||||
}
|
||||
// Convert 8-bit groups to 5-bit groups
|
||||
$conv = [];
|
||||
$buffer = 0;
|
||||
$bits = 0;
|
||||
foreach ($data as $byte) {
|
||||
$buffer = ($buffer << 8) | $byte;
|
||||
$bits += 8;
|
||||
while ($bits >= 5) {
|
||||
$conv[] = ($buffer >> ($bits - 5)) & 31;
|
||||
$bits -= 5;
|
||||
}
|
||||
}
|
||||
if ($bits > 0) {
|
||||
$conv[] = ($buffer << (5 - $bits)) & 31;
|
||||
}
|
||||
|
||||
// Compute checksum — HRP values are positions of each char in the charset.
|
||||
$values = array_merge(
|
||||
array_map(function($c) {
|
||||
return strpos('qpzry9x8gf2tvdw0s3jn54khce6mua7l', $c);
|
||||
}, str_split('npub')),
|
||||
$conv
|
||||
);
|
||||
$gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
||||
$chk = 1;
|
||||
foreach ($values as $v) {
|
||||
$top = $chk >> 25;
|
||||
$chk = (($chk & 0x1ffffff) << 5) ^ $v;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
if (($top >> $i) & 1) $chk ^= $gen[$i];
|
||||
}
|
||||
}
|
||||
$chk ^= 1;
|
||||
$checksum = [];
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$checksum[] = ($chk >> (5 * (5 - $i))) & 31;
|
||||
}
|
||||
|
||||
$result = 'npub1';
|
||||
foreach (array_merge($conv, $checksum) as $v) {
|
||||
$result .= $charset[$v];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Truncate a string to $len chars with ellipsis. */
|
||||
function trunc(string $s, int $len = 16): string {
|
||||
if (strlen($s) <= $len) return $s;
|
||||
return substr($s, 0, $len) . '…';
|
||||
}
|
||||
|
||||
/** Build pagination links HTML. */
|
||||
function pagination(int $page, int $per_page, int $total, string $base_url): string {
|
||||
$total_pages = max(1, ceil($total / $per_page));
|
||||
if ($total_pages <= 1) return '';
|
||||
|
||||
$html = '<div class="pagination">';
|
||||
if ($page > 1) {
|
||||
$html .= '<a href="' . e($base_url) . '?page=' . ($page - 1) . '">← Prev</a>';
|
||||
}
|
||||
$html .= '<span>Page ' . $page . ' of ' . $total_pages . ' (' . $total . ' total)</span>';
|
||||
if ($page < $total_pages) {
|
||||
$html .= '<a href="' . e($base_url) . '?page=' . ($page + 1) . '">Next →</a>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
/** Get the current page number from the query string. */
|
||||
function current_page(): int {
|
||||
$p = intval($_GET['page'] ?? 1);
|
||||
return max(1, $p);
|
||||
}
|
||||
|
||||
/** JSON response helper for AJAX endpoints. */
|
||||
function json_response($data): void {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** Get a query parameter with a default. */
|
||||
function query_param(string $key, $default = null) {
|
||||
return $_GET[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-resolve profiles from the profiles cache table.
|
||||
* Returns [pubkey_hex => ['name'=>..., 'display_name'=>...,
|
||||
* 'best_name'=>..., 'picture'=>..., 'nip05'=>...]].
|
||||
* Pubkeys with no cached profile are absent from the result.
|
||||
*/
|
||||
function profile_map(array $pubkeys): array {
|
||||
if (empty($pubkeys)) return [];
|
||||
$pdo = db();
|
||||
$map = [];
|
||||
try {
|
||||
// Build parameterized IN clause: WHERE pubkey = ANY(?)
|
||||
// PDO PostgreSQL supports passing an array as a string literal.
|
||||
$placeholders = implode(',', array_fill(0, count($pubkeys), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT pubkey, name, display_name, picture, nip05 FROM profiles "
|
||||
. "WHERE pubkey IN ($placeholders)"
|
||||
);
|
||||
$stmt->execute(array_values($pubkeys));
|
||||
$rows = $stmt->fetchAll();
|
||||
foreach ($rows as $r) {
|
||||
$map[$r['pubkey']] = [
|
||||
'name' => $r['name'] ?? '',
|
||||
'display_name' => $r['display_name'] ?? '',
|
||||
'best_name' => profile_display_name($r),
|
||||
'picture' => $r['picture'] ?? '',
|
||||
'nip05' => $r['nip05'] ?? '',
|
||||
];
|
||||
}
|
||||
} catch (PDOException $e) {}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the profile_name_preference config key to resolve the display name.
|
||||
* Never returns null. Returns '' if neither field is set.
|
||||
*/
|
||||
function profile_display_name(array $profile): string {
|
||||
$name = $profile['name'] ?? '';
|
||||
$display_name = $profile['display_name'] ?? '';
|
||||
static $pref = null;
|
||||
if ($pref === null) {
|
||||
try {
|
||||
$pdo = db();
|
||||
$pref = $pdo->query("SELECT value FROM config WHERE key = 'profile_name_preference'")->fetchColumn() ?: 'display_name';
|
||||
} catch (PDOException $e) {
|
||||
$pref = 'display_name';
|
||||
}
|
||||
}
|
||||
if ($pref === 'name') {
|
||||
return $name !== '' ? $name : $display_name;
|
||||
}
|
||||
// Default: prefer display_name, fall back to name.
|
||||
return $display_name !== '' ? $display_name : $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the shared HTML header with side navigation.
|
||||
* Pass the active page name to highlight the current nav item.
|
||||
*/
|
||||
function admin_header(string $active = '', string $title = 'C-Relay-PG Admin'): void {
|
||||
$pages = [
|
||||
'dashboard' => ['index.php', 'Dashboard'],
|
||||
'follows' => ['follows.php', 'Follows'],
|
||||
'relays' => ['relays.php', 'Relays'],
|
||||
'config' => ['config-edit.php', 'Config'],
|
||||
'inbox' => ['inbox.php', 'Inbox'],
|
||||
];
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= e($title) ?></title>
|
||||
<link rel="stylesheet" href="assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Side Navigation -->
|
||||
<nav class="side-nav" id="side-nav">
|
||||
<ul class="nav-menu">
|
||||
<?php foreach ($pages as $key => [$url, $label]): ?>
|
||||
<li><a class="nav-item<?= $active === $key ? ' active' : '' ?>" href="<?= e($url) ?>"><?= e($label) ?></a></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="side-nav-overlay" id="side-nav-overlay" onclick="closeNav()"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="main-header">
|
||||
<div class="header-content">
|
||||
<button class="menu-btn" onclick="openNav()">☰</button>
|
||||
<div class="header-title" onclick="location.href='index.php'">
|
||||
<span class="relay-letter">R</span><span class="relay-letter">E</span><span class="relay-letter">L</span><span class="relay-letter">A</span><span class="relay-letter">Y</span>
|
||||
</div>
|
||||
<div style="width:40px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openNav() {
|
||||
document.getElementById('side-nav').classList.add('open');
|
||||
document.getElementById('side-nav-overlay').classList.add('show');
|
||||
}
|
||||
function closeNav() {
|
||||
document.getElementById('side-nav').classList.remove('open');
|
||||
document.getElementById('side-nav-overlay').classList.remove('show');
|
||||
}
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
/** Render the shared HTML footer. */
|
||||
function admin_footer(): void {
|
||||
?>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# Launch the admin2 PHP built-in server, fully detached from the calling shell.
|
||||
# Usage: ./admin2/serve.sh [port] (default port 8088)
|
||||
PORT="${1:-8088}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Kill any existing instance on this port.
|
||||
pkill -f "php -S 127.0.0.1:$PORT" 2>/dev/null
|
||||
sleep 1
|
||||
|
||||
# Start fully detached in its own session so SIGTERM to the launcher
|
||||
# does not propagate to the PHP server.
|
||||
setsid php -S 127.0.0.1:$PORT -t "$SCRIPT_DIR" > "$SCRIPT_DIR/php_server.log" 2>&1 < /dev/null &
|
||||
PHP_PID=$!
|
||||
disown $PHP_PID 2>/dev/null
|
||||
|
||||
# Give it a moment to bind, then report.
|
||||
sleep 1
|
||||
if kill -0 "$PHP_PID" 2>/dev/null; then
|
||||
echo "admin PHP server started on http://127.0.0.1:$PORT (PID $PHP_PID)"
|
||||
echo "Serving from: $SCRIPT_DIR"
|
||||
echo "Log: $SCRIPT_DIR/php_server.log"
|
||||
else
|
||||
echo "ERROR: PHP server failed to start. Log:"
|
||||
cat "$SCRIPT_DIR/php_server.log" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
@@ -1486,3 +1486,16 @@ body.dark-mode .admin-verification-content {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
/* ================================
|
||||
IP BANS TABLE - compact rows
|
||||
================================ */
|
||||
#ip-bans-table td, #ip-bans-table th {
|
||||
padding: 4px 8px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
}
|
||||
#ip-bans-table button {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
+209
-7
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>C-Relay Admin</title>
|
||||
<title>C-Relay-PG Admin</title>
|
||||
<link rel="stylesheet" href="/api/index.css">
|
||||
</head>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<li><button class="nav-item" data-page="authorization">Authorization</button></li>
|
||||
<li><button class="nav-item" data-page="ip-bans">IP BANS</button></li>
|
||||
<li><button class="nav-item" data-page="relay-events">Relay Events</button></li>
|
||||
<li><button class="nav-item" data-page="caching">Caching</button></li>
|
||||
<li><button class="nav-item" data-page="dm">DM</button></li>
|
||||
<li><button class="nav-item" data-page="database">Database Query</button></li>
|
||||
</ul>
|
||||
@@ -42,7 +43,7 @@
|
||||
<span class="relay-letter" data-letter="Y">Y</span>
|
||||
</div>
|
||||
<div class="relay-info">
|
||||
<div id="relay-name" class="relay-name">C-Relay</div>
|
||||
<div id="relay-name" class="relay-name">C-Relay-PG</div>
|
||||
<div id="relay-description" class="relay-description">Loading...</div>
|
||||
<div id="relay-pubkey-container" class="relay-pubkey-container">
|
||||
<div id="relay-pubkey" class="relay-pubkey">Loading...</div>
|
||||
@@ -112,6 +113,10 @@
|
||||
<td>Process ID</td>
|
||||
<td id="process-id">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>WebSocket Connections</td>
|
||||
<td id="websocket-connections">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Active Subscriptions</td>
|
||||
<td id="active-subscriptions">-</td>
|
||||
@@ -120,14 +125,14 @@
|
||||
<td>Memory Usage</td>
|
||||
<td id="memory-usage">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CPU Core</td>
|
||||
<td id="cpu-core">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CPU Usage</td>
|
||||
<td id="cpu-usage">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CPU Core</td>
|
||||
<td id="cpu-core">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Oldest Event</td>
|
||||
<td id="oldest-event">-</td>
|
||||
@@ -199,6 +204,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rank</th>
|
||||
<th>Name</th>
|
||||
<th>Pubkey</th>
|
||||
<th>Event Count</th>
|
||||
<th>Percentage</th>
|
||||
@@ -206,7 +212,7 @@
|
||||
</thead>
|
||||
<tbody id="stats-pubkeys-table-body">
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: center; font-style: italic;">No data loaded</td>
|
||||
<td colspan="5" style="text-align: center; font-style: italic;">No data loaded</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -435,6 +441,21 @@ WEB OF TRUST
|
||||
<div id="add-ban-status" class="status-message"></div>
|
||||
</div>
|
||||
|
||||
<!-- Whitelist Management -->
|
||||
<div class="input-group">
|
||||
<h3>IP Whitelist (Never Banned)</h3>
|
||||
<p style="font-size:13px;opacity:0.8;">IPs in this list are never idle-banned. Comma-separated.</p>
|
||||
<div class="form-group">
|
||||
<label for="whitelist-ip-input">Add IP to Whitelist:</label>
|
||||
<input type="text" id="whitelist-ip-input" placeholder="103.81.231.220">
|
||||
</div>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" id="add-whitelist-btn">ADD TO WHITELIST</button>
|
||||
</div>
|
||||
<div id="whitelist-status" class="status-message"></div>
|
||||
<div id="whitelist-current" style="margin-top:8px;font-size:13px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Controls -->
|
||||
<div class="input-group">
|
||||
<div class="inline-buttons">
|
||||
@@ -477,6 +498,29 @@ WEB OF TRUST
|
||||
RELAY EVENTS MANAGEMENT
|
||||
</div>
|
||||
|
||||
<!-- Live Relay Event Feed -->
|
||||
<div class="input-group">
|
||||
<h3>Live Relay Event Feed (Normal Nostr Subscription)</h3>
|
||||
<div class="config-table-container">
|
||||
<table class="config-table" id="live-relay-events-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Kind</th>
|
||||
<th>Pubkey</th>
|
||||
<th>ID</th>
|
||||
<th>Content Preview</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="live-relay-events-table-body">
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center;">Waiting for live events...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kind 0: User Metadata -->
|
||||
<div class="input-group">
|
||||
<h3>Kind 0: User Metadata</h3>
|
||||
@@ -537,6 +581,164 @@ WEB OF TRUST
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CACHING Section -->
|
||||
<div class="section" id="cachingSection" style="display: none;">
|
||||
<div class="section-header">CACHING</div>
|
||||
|
||||
<!-- Caching Service Status (read-only) -->
|
||||
<div class="input-group">
|
||||
<h3>Caching Service Status</h3>
|
||||
<div id="caching-service-status" class="status-display">
|
||||
<p>Service status will appear here after connecting.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Relay Inbox Status (read-only) -->
|
||||
<div class="input-group">
|
||||
<h3>Relay Inbox Status</h3>
|
||||
<div id="caching-inbox-status" class="status-display">
|
||||
<p>Inbox status will appear here after connecting.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Caching Configuration (editable) -->
|
||||
<div class="input-group">
|
||||
<h3>Caching Configuration</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-enabled">Enable Caching:</label>
|
||||
<select id="caching-enabled">
|
||||
<option value="false">Disabled</option>
|
||||
<option value="true">Enabled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-inbox-enabled">Enable Inbox Consumer:</label>
|
||||
<select id="caching-inbox-enabled">
|
||||
<option value="false">Disabled</option>
|
||||
<option value="true">Enabled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-root-npubs">Root Npubs (one per line):</label>
|
||||
<textarea id="caching-root-npubs" rows="4" placeholder="npub1...">npub13lm5wf8dvsdnc2894pkhch9uf8phvw9varrv8zf4sc885hhdmc8q6lx7ks</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-bootstrap-relays">Bootstrap Relays (one per line):</label>
|
||||
<textarea id="caching-bootstrap-relays" rows="4" placeholder="wss://relay.damus.io">wss://relay.damus.io
|
||||
wss://nos.lol
|
||||
wss://relay.primal.net
|
||||
wss://laantungir.net/relay</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-kinds">Kinds (comma-separated):</label>
|
||||
<input type="text" id="caching-kinds" placeholder="1,3,6,10000,30023" value="0,1,3,6,10000,10002,30023">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-admin-kinds">Admin Kinds (comma-separated, * for all):</label>
|
||||
<input type="text" id="caching-admin-kinds" placeholder="*">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-live-enabled">Live Subscriptions:</label>
|
||||
<select id="caching-live-enabled">
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-backfill-enabled">Backfill:</label>
|
||||
<select id="caching-backfill-enabled">
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-backfill-windows">Backfill Windows (seconds, comma-separated):</label>
|
||||
<input type="text" id="caching-backfill-windows" placeholder="86400,604800,2592000,7776000,31536000">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-backfill-page-size">Backfill Page Size:</label>
|
||||
<input type="number" id="caching-backfill-page-size" placeholder="50">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-backfill-tick-interval">Backfill Tick Interval (ms):</label>
|
||||
<input type="number" id="caching-backfill-tick-interval" placeholder="5000">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-inbox-batch-size">Inbox Batch Size:</label>
|
||||
<input type="number" id="caching-inbox-batch-size" placeholder="150">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-inbox-active-poll">Inbox Active Poll (ms):</label>
|
||||
<input type="number" id="caching-inbox-active-poll" placeholder="200">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-inbox-idle-poll">Inbox Idle Poll (ms):</label>
|
||||
<input type="number" id="caching-inbox-idle-poll" placeholder="5000">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-service-binary-path">Caching Service Binary Path:</label>
|
||||
<input type="text" id="caching-service-binary-path" placeholder="./caching_relay" value="./caching_relay">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caching-service-pg-conn">Caching Service PG Connection:</label>
|
||||
<input type="text" id="caching-service-pg-conn" placeholder="host=localhost port=5432 dbname=crelay user=crelay password=crelay" value="host=localhost port=5432 dbname=crelay user=crelay password=crelay">
|
||||
</div>
|
||||
|
||||
<div class="inline-buttons">
|
||||
<button type="button" id="caching-apply-btn">APPLY CONFIGURATION</button>
|
||||
<button type="button" id="caching-reset-progress-btn">RESET BACKFILL PROGRESS</button>
|
||||
</div>
|
||||
<div id="caching-config-status" class="status-message"></div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<h3>Caching Service Control</h3>
|
||||
<p>Start or stop the external caching service process. Set <code>caching_service_binary_path</code> and <code>caching_service_pg_conn</code> above and click APPLY CONFIGURATION before starting.</p>
|
||||
<div class="inline-buttons">
|
||||
<button type="button" id="caching-start-service-btn">START CACHING SERVICE</button>
|
||||
<button type="button" id="caching-stop-service-btn">STOP CACHING SERVICE</button>
|
||||
</div>
|
||||
<div id="caching-service-control-status" class="status-message"></div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<h3>Followed Pubkeys</h3>
|
||||
<p>Pubkeys being cached with their profile names, event counts, outbox relays, and backfill status.</p>
|
||||
<div id="caching-follows-status" class="status-message"></div>
|
||||
<table id="caching-follows-table" style="width: 100%; border-collapse: collapse; font-size: 13px;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 2px solid var(--border-color, #444); text-align: left;">
|
||||
<th style="padding: 6px 8px;">Name</th>
|
||||
<th style="padding: 6px 8px;">npub</th>
|
||||
<th style="padding: 6px 8px;">Root?</th>
|
||||
<th style="padding: 6px 8px;">Events in DB</th>
|
||||
<th style="padding: 6px 8px;">Backfill</th>
|
||||
<th style="padding: 6px 8px; width: 40%;">Relays (status, events fetched)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="caching-follows-table-body">
|
||||
<tr><td colspan="6" style="text-align: center; font-style: italic; padding: 12px;">Loading follows status...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SQL QUERY Section -->
|
||||
<div class="section" id="sqlQuerySection" style="display: none;">
|
||||
<div class="section-header">
|
||||
|
||||
+1214
-168
File diff suppressed because it is too large
Load Diff
+95
-1
@@ -3297,8 +3297,18 @@ var NostrTools = (() => {
|
||||
this.enablePing = opts.enablePing;
|
||||
}
|
||||
async ensureRelay(url, params) {
|
||||
const rawUrl = url;
|
||||
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
|
||||
url = normalizeURL(url);
|
||||
let relay = this.relays.get(url);
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL ensureRelay START", {
|
||||
rawUrl,
|
||||
normalizedUrl: url,
|
||||
hadRelay: !!relay,
|
||||
relayMapKeysBefore: Array.from(this.relays.keys())
|
||||
});
|
||||
}
|
||||
if (!relay) {
|
||||
relay = new AbstractRelay(url, {
|
||||
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
|
||||
@@ -3306,13 +3316,38 @@ var NostrTools = (() => {
|
||||
enablePing: this.enablePing
|
||||
});
|
||||
relay.onclose = () => {
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL ensureRelay onclose", {
|
||||
normalizedUrl: url,
|
||||
relayMapKeysBeforeDelete: Array.from(this.relays.keys())
|
||||
});
|
||||
}
|
||||
this.relays.delete(url);
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL ensureRelay onclose complete", {
|
||||
normalizedUrl: url,
|
||||
relayMapKeysAfterDelete: Array.from(this.relays.keys())
|
||||
});
|
||||
}
|
||||
};
|
||||
if (params?.connectionTimeout)
|
||||
relay.connectionTimeout = params.connectionTimeout;
|
||||
this.relays.set(url, relay);
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL ensureRelay created relay", {
|
||||
normalizedUrl: url,
|
||||
relayMapKeysAfterCreate: Array.from(this.relays.keys())
|
||||
});
|
||||
}
|
||||
}
|
||||
await relay.connect();
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL ensureRelay connected", {
|
||||
normalizedUrl: url,
|
||||
relayConnected: relay.connected,
|
||||
relayMapKeysAfterConnect: Array.from(this.relays.keys())
|
||||
});
|
||||
}
|
||||
return relay;
|
||||
}
|
||||
close(relays) {
|
||||
@@ -3334,16 +3369,26 @@ var NostrTools = (() => {
|
||||
}
|
||||
subscribeMany(relays, filters, params) {
|
||||
params.onauth = params.onauth || params.doauth;
|
||||
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
|
||||
const request = [];
|
||||
const uniqUrls = [];
|
||||
for (let i2 = 0; i2 < relays.length; i2++) {
|
||||
const url = normalizeURL(relays[i2]);
|
||||
if (uniqUrls.indexOf(url) === -1) {
|
||||
uniqUrls.push(url);
|
||||
for (let f2 = 0; f2 < filters.length; f2++) {
|
||||
request.push({ url, filter: filters[f2] });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL subscribeMany", {
|
||||
relaysInput: relays,
|
||||
uniqUrls,
|
||||
filtersCount: filters.length,
|
||||
requestsCount: request.length
|
||||
});
|
||||
}
|
||||
return this.subscribeMap(request, params);
|
||||
}
|
||||
subscribeMap(requests, params) {
|
||||
@@ -3391,14 +3436,31 @@ var NostrTools = (() => {
|
||||
_knownIds.add(id);
|
||||
return have;
|
||||
};
|
||||
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
|
||||
const allOpened = Promise.all(
|
||||
requests.map(async ({ url, filter }, i2) => {
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL subscribeMap request", {
|
||||
index: i2,
|
||||
url,
|
||||
filterKinds: filter?.kinds,
|
||||
hasAuthorFilter: !!filter?.authors,
|
||||
hasPTagFilter: !!filter?.["#p"]
|
||||
});
|
||||
}
|
||||
let relay;
|
||||
try {
|
||||
relay = await this.ensureRelay(url, {
|
||||
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1e3) : void 0
|
||||
});
|
||||
} catch (err) {
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL subscribeMap ensureRelay FAILED", {
|
||||
index: i2,
|
||||
url,
|
||||
error: err?.message || String(err)
|
||||
});
|
||||
}
|
||||
handleClose(i2, err?.message || String(err));
|
||||
return;
|
||||
}
|
||||
@@ -3480,18 +3542,50 @@ var NostrTools = (() => {
|
||||
return events[0] || null;
|
||||
}
|
||||
publish(relays, event, options) {
|
||||
return relays.map(normalizeURL).map(async (url, i2, arr) => {
|
||||
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
|
||||
const normalizedRelays = relays.map(normalizeURL);
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL publish START", {
|
||||
relaysInput: relays,
|
||||
normalizedRelays,
|
||||
eventKind: event?.kind,
|
||||
eventId: event?.id
|
||||
});
|
||||
}
|
||||
return normalizedRelays.map(async (url, i2, arr) => {
|
||||
if (arr.indexOf(url) !== i2) {
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL publish duplicate URL", { url, index: i2 });
|
||||
}
|
||||
return Promise.reject("duplicate url");
|
||||
}
|
||||
let r = await this.ensureRelay(url);
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL publish ensured relay", {
|
||||
url,
|
||||
relayConnected: r?.connected,
|
||||
relayMapKeys: Array.from(this.relays.keys())
|
||||
});
|
||||
}
|
||||
return r.publish(event).catch(async (err) => {
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL publish ERROR", {
|
||||
url,
|
||||
error: err?.message || String(err)
|
||||
});
|
||||
}
|
||||
if (err instanceof Error && err.message.startsWith("auth-required: ") && options?.onauth) {
|
||||
await r.auth(options.onauth);
|
||||
return r.publish(event);
|
||||
}
|
||||
throw err;
|
||||
}).then((reason) => {
|
||||
if (debugEnabled) {
|
||||
console.log("🔎 SIMPLE_POOL publish SUCCESS", {
|
||||
url,
|
||||
reason
|
||||
});
|
||||
}
|
||||
if (this.trackRelays) {
|
||||
let set = this.seenOn.get(event.id);
|
||||
if (!set) {
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
<!DOCTYPE html>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html lang="en" dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>TEMPLATE</title>
|
||||
|
||||
<link rel="stylesheet" href="./css/client.css" />
|
||||
|
||||
<!-- Initialize theme BEFORE any components load -->
|
||||
<script>
|
||||
(function () {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark-mode');
|
||||
if (document.body) {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
|
||||
|
||||
<!-- SVG.js library (required by HamburgerMorphing) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- ================================================================
|
||||
HAMBURGER BUTTON (Fixed, separate from header)
|
||||
================================================================
|
||||
The hamburger button is a fixed element outside the header
|
||||
to ensure it stays visible above the sidenav (z-index: 10 > 3).
|
||||
================================================================ -->
|
||||
<div id="divSvgHam" class="divHeaderButtons">
|
||||
<!-- HamburgerMorphing will be injected here -->
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
HEADER
|
||||
================================================================
|
||||
Standard header with title (center).
|
||||
================================================================ -->
|
||||
<div id="divHeader">
|
||||
<div id="divHeaderFlexLeft">
|
||||
<!-- Hamburger is now separate fixed element -->
|
||||
</div>
|
||||
|
||||
<div id="divHeaderFlexCenter">
|
||||
<div class="divHeaderText"></div>
|
||||
</div>
|
||||
|
||||
<div id="divHeaderFlexRight">
|
||||
<!-- No button in header right - logout is in sidenav footer -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
BODY
|
||||
================================================================
|
||||
Main content area. Add your page-specific content here.
|
||||
================================================================ -->
|
||||
<div id="divBody">
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
FOOTER
|
||||
================================================================
|
||||
Three-section footer layout:
|
||||
- Left: Relay status animations (HamburgerMorphing instances)
|
||||
- Center: General status information
|
||||
- Right: Additional information
|
||||
================================================================ -->
|
||||
<div id="divFooter">
|
||||
<div id="divFooterLeft" class="divFooterBox"></div>
|
||||
<div id="divFooterCenter" class="divFooterBox"></div>
|
||||
<div id="divFooterRight" class="divFooterBox"></div>
|
||||
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
SIDENAV
|
||||
================================================================
|
||||
Slide-out navigation panel. Opens from left when hamburger clicked.
|
||||
Uses flexbox layout to pin version bar to bottom.
|
||||
Includes a version bar footer with theme toggle and logout buttons.
|
||||
================================================================ -->
|
||||
<div id="divSideNav">
|
||||
<div id="divSideNavHeader">
|
||||
<!-- No close button - use main hamburger to close -->
|
||||
</div>
|
||||
|
||||
<div id="divSideNavBody">
|
||||
<div id="divFiles"></div>
|
||||
</div>
|
||||
|
||||
<div id="divAiSection" class="sidenavSection">
|
||||
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
|
||||
<div id="divAiList" class="sidenavSectionList">
|
||||
<div id="divAiProvidersList">No saved providers yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="divRelaySection">
|
||||
<div id="divRelaySectionTitle">
|
||||
リレー
|
||||
</div>
|
||||
<div id="divRelayList">
|
||||
Loading relays...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="divBlossomSection">
|
||||
|
||||
<div id="divBlossomSectionTitle">ブロッサム</div>
|
||||
|
||||
<div id="divBlossomList">Loading blossom servers...</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div id="divVersionBar">
|
||||
<span id="versionDisplay">v0.0.1</span>
|
||||
<div id="divVersionBarButtons">
|
||||
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
|
||||
<div id="themeToggleHamburgerContainer"></div>
|
||||
</button>
|
||||
<button id="logoutButton" title="Logout">
|
||||
<div id="logoutHamburgerContainer"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
REQUIRED SCRIPTS
|
||||
================================================================
|
||||
These scripts must be loaded in this order:
|
||||
1. nostr.bundle.js - Nostr tools library
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
IMPORTS
|
||||
================================================================
|
||||
Import shared NDK functionality from init-ndk.mjs:
|
||||
- initNDKPage() - Initialize authentication and worker
|
||||
- getPubkey() - Get current user's pubkey
|
||||
- subscribe() - Create NDK subscriptions
|
||||
- publishEvent() - Publish events via NDK
|
||||
- disconnect() - Disconnect from worker
|
||||
- getRelayData() - Get relay connection data
|
||||
- getRelayStats() - Get relay activity statistics
|
||||
|
||||
Import HamburgerMorphing for animated icons
|
||||
================================================================ */
|
||||
import {
|
||||
initNDKPage,
|
||||
getPubkey, injectHeaderAvatar,
|
||||
subscribe,
|
||||
publishEvent,
|
||||
disconnect,
|
||||
getVersion,
|
||||
updateVersionDisplay,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
onUserSettings
|
||||
} from './js/init-ndk.mjs';
|
||||
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
|
||||
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
||||
|
||||
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
||||
|
||||
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
||||
// Version will be loaded asynchronously
|
||||
const versionInfo = await getVersion();
|
||||
const VERSION = versionInfo.VERSION;
|
||||
console.log(`[template.html ${VERSION}] Loading...`);
|
||||
|
||||
/* ================================================================
|
||||
GLOBAL VARIABLES
|
||||
================================================================
|
||||
Track state for hamburger menu, relay status, and theme.
|
||||
================================================================ */
|
||||
let updateIntervalId = null;
|
||||
let currentPubkey = null;
|
||||
|
||||
/*
|
||||
AUTH STATE MODEL (Template reference)
|
||||
------------------------------------------------------------------
|
||||
This template now demonstrates three auth modes for standalone pages:
|
||||
|
||||
- required (default):
|
||||
Behaves like existing pages: login is required immediately.
|
||||
|
||||
- optional:
|
||||
Page can render public/read-only data without login, but can still
|
||||
prompt login later for user actions (publish, settings, etc).
|
||||
|
||||
- none:
|
||||
Never auto-login on load (pure public page).
|
||||
|
||||
URL behavior in this template:
|
||||
- If ?auth=required|optional|none is present, it wins.
|
||||
- Otherwise, if URL includes ?npub=... or ?pubkey=..., mode defaults
|
||||
to optional because pages with explicit profile targets are commonly
|
||||
public-readable.
|
||||
- Otherwise, mode defaults to required.
|
||||
*/
|
||||
let isAuthenticated = false;
|
||||
let authMode = 'required';
|
||||
let authedPageInitialized = false;
|
||||
let relayActivityListenersBound = false;
|
||||
|
||||
// Hamburger menu
|
||||
let hamburgerInstance = null;
|
||||
let isNavOpen = false;
|
||||
|
||||
// Version bar buttons
|
||||
let logoutHamburger = null;
|
||||
let themeToggleHamburger = null;
|
||||
let isDarkMode = false;
|
||||
|
||||
// App-wide user settings (NIP-78 kind 30078, d:user-settings)
|
||||
let pageSettings = {};
|
||||
let unsubscribeUserSettings = null;
|
||||
|
||||
/* ================================================================
|
||||
DOM VARIABLES
|
||||
================================================================
|
||||
Cache DOM element references for better performance.
|
||||
================================================================ */
|
||||
const divBody = document.getElementById("divBody");
|
||||
const divSideNav = document.getElementById("divSideNav");
|
||||
const divSideNavBody = document.getElementById("divSideNavBody");
|
||||
const divFooterCenter = document.getElementById("divFooterCenter");
|
||||
const divFooterRight = document.getElementById("divFooterRight");
|
||||
|
||||
/* ================================================================
|
||||
HAMBURGER MENU
|
||||
================================================================
|
||||
Initialize and control the animated hamburger menu.
|
||||
================================================================ */
|
||||
function initHamburgerMenu() {
|
||||
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
|
||||
foreground: 'var(--primary-color)',
|
||||
background: 'var(--secondary-color)',
|
||||
hover: 'var(--accent-color)'
|
||||
});
|
||||
hamburgerInstance.animateTo('burger');
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
SIDENAV FUNCTIONS
|
||||
================================================================
|
||||
Open/close sidenav with hamburger morphing animation.
|
||||
================================================================ */
|
||||
function openNav() {
|
||||
divSideNav.style.zIndex = 3;
|
||||
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
|
||||
isNavOpen = true;
|
||||
if (hamburgerInstance) {
|
||||
hamburgerInstance.animateTo('arrow_left');
|
||||
}
|
||||
|
||||
|
||||
// Initialize version bar buttons when sidenav opens (lazy load)
|
||||
if (!logoutHamburger) {
|
||||
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
|
||||
size: 24,
|
||||
foreground: 'var(--primary-color)',
|
||||
background: 'var(--secondary-color)',
|
||||
hover: 'var(--accent-color)'
|
||||
});
|
||||
logoutHamburger.animateTo('x');
|
||||
}
|
||||
|
||||
if (!themeToggleHamburger) {
|
||||
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
|
||||
size: 24,
|
||||
foreground: 'var(--primary-color)',
|
||||
background: 'var(--secondary-color)',
|
||||
hover: 'var(--accent-color)'
|
||||
});
|
||||
|
||||
// Determine current theme
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
|
||||
const initialShape = isDarkMode ? 'moon' : 'circle';
|
||||
themeToggleHamburger.animateTo(initialShape);
|
||||
}
|
||||
}
|
||||
|
||||
function closeNav() {
|
||||
divSideNav.style.width = "0vw";
|
||||
divSideNav.style.zIndex = -1;
|
||||
isNavOpen = false;
|
||||
if (hamburgerInstance) {
|
||||
hamburgerInstance.animateTo('burger');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNav() {
|
||||
if (isNavOpen) {
|
||||
closeNav();
|
||||
} else {
|
||||
openNav();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ================================================================
|
||||
AUTH MODE HELPERS
|
||||
================================================================
|
||||
These functions are meant as reusable guidance for future pages.
|
||||
================================================================ */
|
||||
function hasTargetPubkeyInUrl() {
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
const npub = String(params.get('npub') || '').trim();
|
||||
const pubkey = String(params.get('pubkey') || '').trim();
|
||||
return Boolean(npub || pubkey);
|
||||
}
|
||||
|
||||
function resolveAuthModeFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search || '');
|
||||
const explicitAuth = String(params.get('auth') || '').trim().toLowerCase();
|
||||
if (explicitAuth === 'required' || explicitAuth === 'optional' || explicitAuth === 'none') {
|
||||
return explicitAuth;
|
||||
}
|
||||
|
||||
// Convention: explicit target profiles are public-readable by default.
|
||||
if (hasTargetPubkeyInUrl()) {
|
||||
return 'optional';
|
||||
}
|
||||
|
||||
return 'required';
|
||||
}
|
||||
|
||||
function isAuthRequiredError(error) {
|
||||
const message = String(error?.message || error || '').toLowerCase();
|
||||
return message.includes('authentication required');
|
||||
}
|
||||
|
||||
async function initializeAuthentication(mode) {
|
||||
// required: existing behavior, throw if auth fails.
|
||||
if (mode === 'required') {
|
||||
await initNDKPage();
|
||||
currentPubkey = await getPubkey();
|
||||
isAuthenticated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// none: public page, no login attempt on load.
|
||||
if (mode === 'none') {
|
||||
isAuthenticated = false;
|
||||
currentPubkey = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// optional: try silent/normal init; if auth required, continue public.
|
||||
try {
|
||||
await initNDKPage();
|
||||
currentPubkey = await getPubkey();
|
||||
isAuthenticated = true;
|
||||
} catch (error) {
|
||||
if (isAuthRequiredError(error)) {
|
||||
console.log('[template.html] Optional auth mode: continuing unauthenticated');
|
||||
isAuthenticated = false;
|
||||
currentPubkey = null;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeAuthenticatedPageFeatures() {
|
||||
if (!isAuthenticated || authedPageInitialized) return;
|
||||
|
||||
await injectHeaderAvatar(currentPubkey);
|
||||
console.log('[template.html] Authenticated as:', currentPubkey);
|
||||
|
||||
// Hydrate app-wide user settings for this page
|
||||
try {
|
||||
pageSettings = await getUserSettings();
|
||||
} catch (error) {
|
||||
console.warn('[template.html] getUserSettings failed:', error);
|
||||
pageSettings = {};
|
||||
}
|
||||
|
||||
// Subscribe to live user settings updates (cross-tab + publish echoes)
|
||||
if (!unsubscribeUserSettings) {
|
||||
unsubscribeUserSettings = onUserSettings((settings) => {
|
||||
pageSettings = settings || {};
|
||||
// TODO: Re-render page-specific UI from pageSettings here.
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize relay-dependent UI only once authenticated.
|
||||
initFooterRelayStatus();
|
||||
initSidenavRelaySection();
|
||||
await initBlossomSection();
|
||||
initAiSectionWithLocalConfig();
|
||||
await UpdateFooter();
|
||||
|
||||
if (!updateIntervalId) {
|
||||
updateIntervalId = setInterval(UpdateFooter, 1000);
|
||||
}
|
||||
|
||||
// Relay activity listeners only matter after worker init/auth.
|
||||
if (!relayActivityListenersBound) {
|
||||
window.addEventListener('ndkRelayActivity', (event) => {
|
||||
const { relayUrl, activity, stats } = event.detail;
|
||||
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`, stats);
|
||||
setRelayActivityState(relayUrl, activity);
|
||||
});
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'relayActivity') {
|
||||
const { relayUrl, activity } = event.data;
|
||||
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`);
|
||||
setRelayActivityState(relayUrl, activity);
|
||||
}
|
||||
});
|
||||
|
||||
relayActivityListenersBound = true;
|
||||
}
|
||||
|
||||
authedPageInitialized = true;
|
||||
}
|
||||
|
||||
async function promptLoginIfNeeded() {
|
||||
if (isAuthenticated) return true;
|
||||
|
||||
await initNDKPage();
|
||||
currentPubkey = await getPubkey();
|
||||
isAuthenticated = true;
|
||||
await initializeAuthenticatedPageFeatures();
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
UPDATE FOOTER
|
||||
================================================================
|
||||
Update footer sections with relay status, pubkey, and other info.
|
||||
Called periodically by update loop.
|
||||
================================================================ */
|
||||
const UpdateFooter = async () => {
|
||||
|
||||
try {
|
||||
// Update relay status visuals in footer and sidenav
|
||||
await updateFooterRelayStatus();
|
||||
await updateSidenavRelaySection();
|
||||
|
||||
await updateBlossomSection();
|
||||
// Clear center and right sections
|
||||
divFooterCenter.innerHTML = '';
|
||||
divFooterRight.innerHTML = '';
|
||||
} catch (error) {
|
||||
console.error('[template.html] Error updating footer:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
LOGOUT
|
||||
================================================================
|
||||
Complete logout process:
|
||||
1. Stop update loop
|
||||
2. Disconnect from NDK worker
|
||||
3. Logout from nostr-login-lite
|
||||
4. Clear all storage (localStorage, sessionStorage, IndexedDB)
|
||||
5. Reload page
|
||||
================================================================ */
|
||||
const Logout = async () => {
|
||||
console.log("[template.html] Starting logout process...");
|
||||
|
||||
// Stop the update loop
|
||||
if (updateIntervalId) {
|
||||
clearInterval(updateIntervalId);
|
||||
updateIntervalId = null;
|
||||
}
|
||||
|
||||
// Disconnect from worker
|
||||
disconnect();
|
||||
|
||||
// Logout from nostr-login-lite
|
||||
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
|
||||
await window.NOSTR_LOGIN_LITE.logout();
|
||||
}
|
||||
|
||||
// Clear all storage
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
// Clear IndexedDB
|
||||
if (window.indexedDB) {
|
||||
const databases = await window.indexedDB.databases();
|
||||
for (const db of databases) {
|
||||
if (db.name) {
|
||||
window.indexedDB.deleteDatabase(db.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[template.html] Logged out, reloading page");
|
||||
location.reload(true);
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
EVENT LISTENERS
|
||||
================================================================
|
||||
Wire up UI interactions.
|
||||
Main hamburger button click handler is set up in main() after initialization.
|
||||
================================================================ */
|
||||
|
||||
/* ================================================================
|
||||
SUBSCRIPTION EXAMPLE
|
||||
================================================================
|
||||
Example of how to subscribe to Nostr events:
|
||||
|
||||
const sub = subscribe(
|
||||
{ kinds: [1], authors: [pubkey], limit: 10 },
|
||||
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
||||
);
|
||||
|
||||
Cache usage options:
|
||||
- 'CACHE_FIRST' - Check cache first, then relays
|
||||
- 'ONLY_RELAY' - Only query relays
|
||||
- 'ONLY_CACHE' - Only query cache
|
||||
- 'PARALLEL' - Query cache and relays simultaneously
|
||||
|
||||
Listen for events via window events:
|
||||
window.addEventListener('ndkEvent', (event) => {
|
||||
const evt = event.detail;
|
||||
console.log('Received event:', evt);
|
||||
});
|
||||
================================================================ */
|
||||
|
||||
/* ================================================================
|
||||
PUBLISH EXAMPLE
|
||||
================================================================
|
||||
Example of how to publish a Nostr event:
|
||||
|
||||
const event = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: "Hello, Nostr!"
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await publishEvent(event);
|
||||
console.log("✅ Published to:", result.relayResults.successful);
|
||||
console.log("❌ Failed:", result.relayResults.failed);
|
||||
console.log("Total relays:", result.totalRelays);
|
||||
} catch (error) {
|
||||
console.error("Publish error:", error);
|
||||
}
|
||||
|
||||
Note: Events are automatically signed by the NDK worker using
|
||||
the message-based signer (which calls window.nostr.signEvent).
|
||||
================================================================ */
|
||||
|
||||
/* ================================================================
|
||||
USER SETTINGS EXAMPLE (NIP-78)
|
||||
================================================================
|
||||
Read/subscribe/write helper pattern for all pages:
|
||||
|
||||
// Read latest merged settings (cache + relay hydrated by worker)
|
||||
const settings = await getUserSettings();
|
||||
|
||||
// Subscribe to cross-tab updates
|
||||
const unsubscribe = onUserSettings((nextSettings) => {
|
||||
// Re-render page from nextSettings
|
||||
});
|
||||
|
||||
// Patch only your feature namespace
|
||||
await patchUserSettings({
|
||||
myFeature: {
|
||||
someFlag: true
|
||||
}
|
||||
});
|
||||
|
||||
// On page teardown (if applicable)
|
||||
// unsubscribe();
|
||||
================================================================ */
|
||||
|
||||
/* ================================================================
|
||||
INITIALIZATION
|
||||
================================================================
|
||||
Main initialization sequence:
|
||||
1. Initialize hamburger menu
|
||||
2. Set up hamburger click handler
|
||||
3. Resolve auth mode from URL/query policy
|
||||
4. Initialize authentication based on mode
|
||||
5. Initialize authenticated-only features (if signed in)
|
||||
6. Set up version bar button listeners
|
||||
7. Restore sidenav state
|
||||
8. Update version display
|
||||
|
||||
Notes:
|
||||
- required mode = existing behavior (prompt login on load)
|
||||
- optional mode = allow public load, login later on demand
|
||||
- none mode = no auto-login on load
|
||||
================================================================ */
|
||||
(async function main() {
|
||||
console.log("[template.html] Starting initialization...");
|
||||
|
||||
try {
|
||||
// Initialize hamburger menu first
|
||||
initHamburgerMenu();
|
||||
|
||||
// Add click handler to hamburger
|
||||
const divSvgHam = document.getElementById('divSvgHam');
|
||||
if (divSvgHam) {
|
||||
divSvgHam.addEventListener('click', toggleNav);
|
||||
}
|
||||
|
||||
// Initialize version bar buttons
|
||||
const themeToggleButton = document.getElementById('themeToggleButton');
|
||||
const logoutButton = document.getElementById('logoutButton');
|
||||
|
||||
if (themeToggleButton) {
|
||||
themeToggleButton.addEventListener('click', () => {
|
||||
isDarkMode = !isDarkMode;
|
||||
if (isDarkMode) {
|
||||
localStorage.setItem('theme', 'dark');
|
||||
} else {
|
||||
localStorage.setItem('theme', 'light');
|
||||
}
|
||||
// Save sidenav state before reload
|
||||
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
if (logoutButton) {
|
||||
logoutButton.addEventListener('click', async () => {
|
||||
try {
|
||||
// In optional/none modes this doubles as a "Sign in" entry point.
|
||||
if (!isAuthenticated) {
|
||||
await promptLoginIfNeeded();
|
||||
return;
|
||||
}
|
||||
await Logout();
|
||||
} catch (error) {
|
||||
console.error('Logout/login action failed:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve and initialize page auth policy.
|
||||
authMode = resolveAuthModeFromUrl();
|
||||
console.log('[template.html] Resolved auth mode:', authMode);
|
||||
await initializeAuthentication(authMode);
|
||||
|
||||
// Initialize authenticated features only when signed in.
|
||||
await initializeAuthenticatedPageFeatures();
|
||||
|
||||
/* ============================================================
|
||||
EXAMPLE: Subscribe to events
|
||||
============================================================
|
||||
Uncomment to subscribe to user's notes:
|
||||
|
||||
const notesSub = subscribe(
|
||||
{ kinds: [1], authors: [currentPubkey], limit: 10 },
|
||||
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
||||
);
|
||||
console.log("[template.html] Subscribed to kind 1");
|
||||
============================================================ */
|
||||
|
||||
/* ============================================================
|
||||
EXAMPLE: Listen for events from worker
|
||||
============================================================
|
||||
Uncomment to handle incoming events:
|
||||
|
||||
window.addEventListener('ndkEvent', (event) => {
|
||||
const evt = event.detail;
|
||||
console.log("[template.html] Received event:", evt.kind, evt.pubkey);
|
||||
|
||||
if (evt.pubkey === currentPubkey) {
|
||||
if (evt.kind === 1) {
|
||||
// Handle note event
|
||||
console.log("Note:", evt.content);
|
||||
}
|
||||
}
|
||||
});
|
||||
============================================================ */
|
||||
|
||||
/* ============================================================
|
||||
EXAMPLE: Listen for cached profile
|
||||
============================================================
|
||||
Uncomment to handle cached profile data:
|
||||
|
||||
window.addEventListener('ndkProfile', (event) => {
|
||||
console.log("[template.html] Cached profile:", event.detail);
|
||||
// event.detail contains profile object (name, about, etc.)
|
||||
});
|
||||
============================================================ */
|
||||
|
||||
// Restore sidenav state if it was open before theme toggle
|
||||
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
|
||||
if (sidenavWasOpen === 'true') {
|
||||
localStorage.removeItem('sidenavWasOpen');
|
||||
openNav();
|
||||
}
|
||||
|
||||
// Optional UX note for public mode pages.
|
||||
if (!isAuthenticated && (authMode === 'optional' || authMode === 'none')) {
|
||||
divFooterCenter.textContent = 'Public mode';
|
||||
divFooterRight.textContent = 'Sign in from side menu for private features';
|
||||
}
|
||||
|
||||
// Update version display
|
||||
await updateVersionDisplay();
|
||||
|
||||
console.log('[template.html] Initialization complete');
|
||||
} catch (error) {
|
||||
console.error('[template.html] Initialization failed:', error);
|
||||
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
|
||||
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Authentication Error</div>
|
||||
<div style="font-size: 16px; color: #666;">${error.message}</div>
|
||||
<div style="margin-top: 20px;">
|
||||
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
})();
|
||||
|
||||
/* ================================================================
|
||||
WORKER MESSAGE TYPES
|
||||
================================================================
|
||||
The NDK worker can send these message types:
|
||||
|
||||
1. 'response' - Response to init/subscribe/publish requests
|
||||
- data.profile - User profile (from init)
|
||||
- data.relays - User relays (from init)
|
||||
- data.success - Publish success status
|
||||
- data.relayResults - Relay publish results
|
||||
|
||||
2. 'event' - Nostr event from subscription
|
||||
- Dispatched as 'ndkEvent' window event
|
||||
- event.detail contains the Nostr event
|
||||
|
||||
3. 'eose' - End of stored events for subscription
|
||||
- Dispatched as 'ndkEose' window event
|
||||
- event.detail.subId contains subscription ID
|
||||
|
||||
4. 'signRequest' - Request to sign event/encrypt/decrypt
|
||||
- Handled automatically by init-ndk.mjs
|
||||
- Calls window.nostr methods and sends response
|
||||
|
||||
5. 'error' - Error from worker
|
||||
- Logged to console automatically
|
||||
|
||||
6. 'relayActivity' - Relay read/write activity notification
|
||||
- Dispatched as 'ndkRelayActivity' window event
|
||||
- Used to animate relay status icons in footer
|
||||
================================================================ */
|
||||
|
||||
/* ================================================================
|
||||
DISTRIBUTED ARCHITECTURE NOTES
|
||||
================================================================
|
||||
Each page is independently accessible and self-contained:
|
||||
|
||||
1. Authentication persists via nostr-login-lite localStorage
|
||||
- Login once on any page
|
||||
- All other pages automatically authenticated
|
||||
|
||||
2. NDK SharedWorker is shared across all tabs/pages
|
||||
- Single NDK instance manages all connections
|
||||
- Subscriptions from all pages handled by one worker
|
||||
- Events broadcast to all connected pages
|
||||
|
||||
3. Dexie cache is shared across all pages
|
||||
- IndexedDB persists across sessions
|
||||
- Cache-first queries are fast
|
||||
- Reduces relay load
|
||||
|
||||
4. User settings are centralized and shared
|
||||
- Worker hydrates kind 30078 (`d:user-settings`) on init
|
||||
- Pages read via getUserSettings()
|
||||
- Pages patch via patchUserSettings({ featureNamespace: ... })
|
||||
- Pages subscribe via onUserSettings() for live updates
|
||||
|
||||
5. Each page can be distributed independently
|
||||
- Copy template.html and customize
|
||||
- No dependencies on other pages
|
||||
- Works standalone or as part of suite
|
||||
|
||||
6. Message-based signer bridges worker and page
|
||||
- Worker's NDK uses MessageBasedSigner
|
||||
- Signer sends sign requests to page
|
||||
- Page calls window.nostr.signEvent()
|
||||
- Response sent back to worker
|
||||
- NDK completes signing and publishing
|
||||
|
||||
7. Relay status visualization
|
||||
- Footer left section shows connected relays
|
||||
- Each relay has animated icon (HamburgerMorphing)
|
||||
- Icons morph based on activity (read/write)
|
||||
- Temporary animations show real-time activity
|
||||
================================================================ */
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+49
-13
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build fully static MUSL binaries for C-Relay using Alpine Docker
|
||||
# Build fully static MUSL binaries for C-Relay-PG using Alpine Docker
|
||||
# Produces truly portable binaries with zero runtime dependencies
|
||||
|
||||
set -e
|
||||
@@ -11,19 +11,52 @@ DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
|
||||
|
||||
# Parse command line arguments
|
||||
DEBUG_BUILD=false
|
||||
if [[ "$1" == "--debug" ]]; then
|
||||
DEBUG_BUILD=true
|
||||
DB_BACKEND="${DB_BACKEND:-postgres}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--debug)
|
||||
DEBUG_BUILD=true
|
||||
shift
|
||||
;;
|
||||
--db-backend)
|
||||
if [[ -z "$2" ]]; then
|
||||
echo "ERROR: --db-backend requires a value (sqlite|postgres)"
|
||||
exit 1
|
||||
fi
|
||||
DB_BACKEND="$2"
|
||||
shift 2
|
||||
;;
|
||||
--db-backend=*)
|
||||
DB_BACKEND="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown argument: $1"
|
||||
echo "Usage: $0 [--debug] [--db-backend postgres|sqlite]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$DB_BACKEND" != "sqlite" && "$DB_BACKEND" != "postgres" ]]; then
|
||||
echo "ERROR: Invalid DB backend '$DB_BACKEND'. Use sqlite or postgres."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$DEBUG_BUILD" == "true" ]]; then
|
||||
echo "=========================================="
|
||||
echo "C-Relay MUSL Static Binary Builder (DEBUG MODE)"
|
||||
echo "C-Relay-PG MUSL Static Binary Builder (DEBUG MODE)"
|
||||
echo "=========================================="
|
||||
else
|
||||
echo "=========================================="
|
||||
echo "C-Relay MUSL Static Binary Builder (PRODUCTION MODE)"
|
||||
echo "C-Relay-PG MUSL Static Binary Builder (PRODUCTION MODE)"
|
||||
echo "=========================================="
|
||||
fi
|
||||
echo "Project directory: $SCRIPT_DIR"
|
||||
echo "Build directory: $BUILD_DIR"
|
||||
echo "Debug build: $DEBUG_BUILD"
|
||||
echo "DB backend: $DB_BACKEND"
|
||||
echo ""
|
||||
|
||||
# Create build directory
|
||||
@@ -63,17 +96,17 @@ ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64)
|
||||
PLATFORM="linux/amd64"
|
||||
OUTPUT_NAME="c_relay_static_x86_64"
|
||||
OUTPUT_NAME="c_relay_pg_static_x86_64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
PLATFORM="linux/arm64"
|
||||
OUTPUT_NAME="c_relay_static_arm64"
|
||||
OUTPUT_NAME="c_relay_pg_static_arm64"
|
||||
;;
|
||||
*)
|
||||
echo "WARNING: Unknown architecture: $ARCH"
|
||||
echo "Defaulting to linux/amd64"
|
||||
PLATFORM="linux/amd64"
|
||||
OUTPUT_NAME="c_relay_static_${ARCH}"
|
||||
OUTPUT_NAME="c_relay_pg_static_${ARCH}"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -116,14 +149,15 @@ echo "=========================================="
|
||||
echo "This will:"
|
||||
echo " - Use Alpine Linux (native MUSL)"
|
||||
echo " - Build all dependencies statically"
|
||||
echo " - Compile c-relay with full static linking"
|
||||
echo " - Compile c-relay-pg with full static linking"
|
||||
echo ""
|
||||
|
||||
$DOCKER_CMD build \
|
||||
--platform "$PLATFORM" \
|
||||
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
|
||||
--build-arg DB_BACKEND=$DB_BACKEND \
|
||||
-f "$DOCKERFILE" \
|
||||
-t c-relay-musl-builder:latest \
|
||||
-t c-relay-pg-musl-builder:latest \
|
||||
--progress=plain \
|
||||
. || {
|
||||
echo ""
|
||||
@@ -145,16 +179,17 @@ echo "=========================================="
|
||||
$DOCKER_CMD build \
|
||||
--platform "$PLATFORM" \
|
||||
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
|
||||
--build-arg DB_BACKEND=$DB_BACKEND \
|
||||
--target builder \
|
||||
-f "$DOCKERFILE" \
|
||||
-t c-relay-static-builder-stage:latest \
|
||||
-t c-relay-pg-static-builder-stage:latest \
|
||||
. > /dev/null 2>&1
|
||||
|
||||
# Create a temporary container to copy the binary
|
||||
CONTAINER_ID=$($DOCKER_CMD create c-relay-static-builder-stage:latest)
|
||||
CONTAINER_ID=$($DOCKER_CMD create c-relay-pg-static-builder-stage:latest)
|
||||
|
||||
# Copy binary from container
|
||||
$DOCKER_CMD cp "$CONTAINER_ID:/build/c_relay_static" "$BUILD_DIR/$OUTPUT_NAME" || {
|
||||
$DOCKER_CMD cp "$CONTAINER_ID:/build/c_relay_pg_static" "$BUILD_DIR/$OUTPUT_NAME" || {
|
||||
echo "ERROR: Failed to extract binary from container"
|
||||
$DOCKER_CMD rm "$CONTAINER_ID" 2>/dev/null
|
||||
exit 1
|
||||
@@ -224,6 +259,7 @@ if [ "$DEBUG_BUILD" = true ]; then
|
||||
else
|
||||
echo "Build Type: PRODUCTION (optimized, stripped)"
|
||||
fi
|
||||
echo "DB Backend: $DB_BACKEND"
|
||||
if [ "$TRULY_STATIC" = true ]; then
|
||||
echo "Linkage: Fully static binary (Alpine MUSL-based)"
|
||||
echo "Portability: Works on ANY Linux distribution"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Database files (from c-relay running in this directory)
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Build artifacts
|
||||
*.o
|
||||
*.a
|
||||
|
||||
# Binary (built into root, not version-controlled)
|
||||
caching_relay
|
||||
caching_relay_static_*
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
crelay.log
|
||||
|
||||
# Test configs (not for version control)
|
||||
test_config*.jsonc
|
||||
|
||||
# Note: caching_relay_config.jsonc IS version-controlled (it's the default
|
||||
# config + state store). Don't ignore it.
|
||||
|
||||
# Docker build context temp (copied nostr_core_lib during static build)
|
||||
nostr_core_lib/
|
||||
|
||||
# Editor
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.vscode/
|
||||
@@ -0,0 +1,128 @@
|
||||
# Alpine-based MUSL static binary builder for caching_relay
|
||||
# Produces a truly portable binary with zero runtime dependencies.
|
||||
# Adapted from c-relay's Dockerfile.alpine-musl.
|
||||
|
||||
ARG DEBUG_BUILD=false
|
||||
|
||||
FROM alpine:3.19 AS builder
|
||||
|
||||
ARG DEBUG_BUILD=false
|
||||
|
||||
# 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 \
|
||||
postgresql-dev \
|
||||
linux-headers \
|
||||
wget \
|
||||
bash
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Build libsecp256k1 static
|
||||
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 with minimal features
|
||||
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 nostr_core_lib source (sibling project)
|
||||
COPY nostr_core_lib /build/nostr_core_lib/
|
||||
|
||||
# Build nostr_core_lib with the NIPs needed by the relay pool + signer:
|
||||
# 1 (basic), 6 (keys), 19 (npub bech32), 4 (legacy encryption, signer dep),
|
||||
# 42 (auth, relay pool dep), 44 (modern encryption, signer dep)
|
||||
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,4,6,19,42,44 && \
|
||||
if [ -f libnostr_core_arm64.a ]; then \
|
||||
cp libnostr_core_arm64.a libnostr_core.a; \
|
||||
elif [ -f libnostr_core_x64.a ]; then \
|
||||
cp libnostr_core_x64.a libnostr_core.a; \
|
||||
else \
|
||||
echo "ERROR: No supported nostr_core static library produced"; \
|
||||
ls -la *.a 2>/dev/null || true; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Copy caching_relay source LAST (only this layer rebuilds on source changes)
|
||||
COPY src/ /build/src/
|
||||
|
||||
# Build caching_relay with full static linking.
|
||||
# No sqlite (the daemon doesn't use it), no c_utils (not needed).
|
||||
RUN if [ "$DEBUG_BUILD" = "true" ]; then \
|
||||
CFLAGS="-g -O2 -DDEBUG -fno-omit-frame-pointer"; \
|
||||
STRIP_CMD="echo 'Keeping debug symbols'"; \
|
||||
else \
|
||||
CFLAGS="-O2"; \
|
||||
STRIP_CMD="strip /build/caching_relay_static"; \
|
||||
fi && \
|
||||
gcc -static $CFLAGS -Wall -Wextra -std=c99 \
|
||||
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
|
||||
-I. -Isrc -Inostr_core_lib -Inostr_core_lib/nostr_core \
|
||||
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
|
||||
-I/usr/include/postgresql \
|
||||
src/main.c src/debug.c src/jsonc_strip.c src/config.c src/state.c \
|
||||
src/follow_graph.c src/relay_sink.c src/live_subscriber.c \
|
||||
src/backfill.c src/relay_discovery.c \
|
||||
src/pg_inbox.c src/pg_config.c src/forward_catchup.c \
|
||||
-o /build/caching_relay_static \
|
||||
nostr_core_lib/libnostr_core.a \
|
||||
-lwebsockets -lssl -lcrypto -lsecp256k1 \
|
||||
-lcurl -lz -lpthread -lm -ldl -lpq -lpgcommon -lpgport && \
|
||||
eval "$STRIP_CMD"
|
||||
|
||||
# Verify it's truly static
|
||||
RUN echo "=== Binary Information ===" && \
|
||||
file /build/caching_relay_static && \
|
||||
ls -lh /build/caching_relay_static && \
|
||||
echo "=== Checking for dynamic dependencies ===" && \
|
||||
(ldd /build/caching_relay_static 2>&1 || echo "Binary is static") && \
|
||||
echo "=== Build complete ==="
|
||||
|
||||
# Output stage - just the binary
|
||||
FROM scratch AS output
|
||||
COPY --from=builder /build/caching_relay_static /caching_relay_static
|
||||
@@ -0,0 +1,67 @@
|
||||
# caching_relay Makefile - statically linked C99 binary, c-relay style
|
||||
|
||||
# Use bash for reliable glob expansion in recipes (dash handles globs differently).
|
||||
SHELL := /bin/bash
|
||||
|
||||
CC = gcc
|
||||
CFLAGS = -Wall -Wextra -std=c99 -g -O2
|
||||
|
||||
# nostr_core_lib is inside the c-relay-pg project root (one dir up from caching/).
|
||||
NOSTR_CORE_DIR = ../nostr_core_lib
|
||||
|
||||
INCLUDES = -I. -Isrc -I$(NOSTR_CORE_DIR) -I$(NOSTR_CORE_DIR)/nostr_core \
|
||||
-I$(NOSTR_CORE_DIR)/cjson -I$(NOSTR_CORE_DIR)/nostr_websocket \
|
||||
-I/usr/include/postgresql
|
||||
|
||||
# -lsqlite3: nostr_core_lib's request_validator.x64.o references SQLite symbols;
|
||||
# we use --whole-archive so the linker pulls in all objects (needed for NIP-42
|
||||
# cross-references within the archive), which means SQLite must be satisfied.
|
||||
# No c_utils_lib: nostr_core_lib does not depend on it for the NIPs we use.
|
||||
# -lpq: PostgreSQL client library for the caching_event_inbox integration.
|
||||
LIBS = -lwebsockets -lssl -lcrypto -lsecp256k1 -lcurl -lz -ldl -lpthread -lm -lpq -lsqlite3
|
||||
|
||||
MAIN_SRC = src/main.c src/debug.c src/jsonc_strip.c src/config.c src/state.c \
|
||||
src/follow_graph.c src/relay_sink.c src/live_subscriber.c \
|
||||
src/backfill.c src/forward_catchup.c src/relay_discovery.c \
|
||||
src/pg_inbox.c src/pg_config.c
|
||||
|
||||
# Architecture detection
|
||||
ARCH = $(shell uname -m)
|
||||
ifeq ($(ARCH),x86_64)
|
||||
NOSTR_CORE_LIB = $(NOSTR_CORE_DIR)/libnostr_core_x64.a
|
||||
else ifeq ($(ARCH),aarch64)
|
||||
NOSTR_CORE_LIB = $(NOSTR_CORE_DIR)/libnostr_core_arm64.a
|
||||
else ifeq ($(ARCH),arm64)
|
||||
NOSTR_CORE_LIB = $(NOSTR_CORE_DIR)/libnostr_core_arm64.a
|
||||
else
|
||||
NOSTR_CORE_LIB = $(NOSTR_CORE_DIR)/libnostr_core_x64.a
|
||||
endif
|
||||
|
||||
# Binary goes in the c-relay-pg build/ directory so the launcher can find it.
|
||||
TARGET = ../build/caching_relay
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
# Build nostr_core_lib with the NIPs needed by the relay pool + signer:
|
||||
# 1 (basic), 6 (keys), 19 (npub bech32), 4 (legacy encryption, signer dep),
|
||||
# 42 (auth, relay pool dep), 44 (modern encryption, signer dep)
|
||||
$(NOSTR_CORE_LIB):
|
||||
@echo "Building nostr_core_lib with required NIPs..."
|
||||
cd $(NOSTR_CORE_DIR) && ./build.sh --nips=1,4,6,19,42,44
|
||||
|
||||
$(TARGET): $(MAIN_SRC) $(NOSTR_CORE_LIB)
|
||||
@echo "Compiling caching_relay for architecture: $(ARCH)"
|
||||
@# Extract all objects from the static library and link them directly.
|
||||
@# This avoids archive symbol resolution ordering issues (NIP-42 cross-refs).
|
||||
@# Use a fixed temp dir and chain all commands with && so failures stop the build.
|
||||
@rm -rf /tmp/cr_lib && mkdir -p /tmp/cr_lib && \
|
||||
ar x $(NOSTR_CORE_LIB) --output=/tmp/cr_lib && \
|
||||
echo "Extracted $$(ls /tmp/cr_lib/*.o | wc -l) objects" && \
|
||||
$(CC) $(CFLAGS) $(INCLUDES) $(MAIN_SRC) /tmp/cr_lib/*.o -o $(TARGET) $(LIBS) && \
|
||||
rm -rf /tmp/cr_lib && \
|
||||
echo "Build complete: $(TARGET)"
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET)
|
||||
|
||||
.PHONY: all clean
|
||||
@@ -0,0 +1,297 @@
|
||||
# caching_relay
|
||||
|
||||
A C99 daemon that caches Nostr events from people you follow into a local relay,
|
||||
so your Nostr client can point at a single fast local relay instead of fanning
|
||||
out to dozens of upstream relays.
|
||||
|
||||
## What it does
|
||||
|
||||
1. Reads a `.jsonc` config file listing your **root npub(s)**, upstream relays,
|
||||
a local relay URL, and the event kinds to cache.
|
||||
2. For each root npub, fetches its kind-3 contact list to discover **followed
|
||||
pubkeys** (your follows + their follows).
|
||||
3. **Live-subscribes** to new events of the configured kinds from the union of
|
||||
followed pubkeys.
|
||||
4. **Backfills** historical events using a progressive window-expansion strategy
|
||||
(24h → 7d → 30d → 90d → 365d), round-robin per pubkey, throttled to be polite
|
||||
to upstream relays.
|
||||
5. **Re-publishes** every fetched event to the local relay via a plain WebSocket
|
||||
`EVENT` client connection (relay-agnostic - works with c-relay, c-relay-pg,
|
||||
or any Nostr relay).
|
||||
6. Persists its backfill progress **in the config file itself** so a restart
|
||||
resumes where it left off.
|
||||
|
||||
## Build
|
||||
|
||||
### Static binary (recommended, c-relay style)
|
||||
|
||||
Produces a truly portable statically-linked MUSL binary with zero runtime
|
||||
dependencies:
|
||||
|
||||
```bash
|
||||
./build_static.sh
|
||||
# or with debug symbols:
|
||||
./build_static.sh --debug
|
||||
# or cross-compile for arm64:
|
||||
./build_static.sh --arch arm64
|
||||
```
|
||||
|
||||
Output: `caching_relay` (in the project root).
|
||||
|
||||
Requires Docker. The build runs in an Alpine container that compiles
|
||||
libsecp256k1, libwebsockets, and nostr_core_lib from source, then statically
|
||||
links everything.
|
||||
|
||||
### Local build (if you have the shared libs installed)
|
||||
|
||||
```bash
|
||||
make
|
||||
```
|
||||
|
||||
Output: `caching_relay` (in the project root).
|
||||
|
||||
Requires: `libwebsockets`, `openssl`, `libsecp256k1`, `libcurl`, `zlib` shared
|
||||
libraries, and a pre-built `../nostr_core_lib/libnostr_core_x64.a`.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
./caching_relay -d 3
|
||||
```
|
||||
|
||||
The daemon looks for `caching_relay_config.jsonc` in the current directory by default.
|
||||
You can override with `-c`:
|
||||
|
||||
```bash
|
||||
./caching_relay -c /path/to/my_config.jsonc -d 3
|
||||
```
|
||||
|
||||
Options:
|
||||
- `-c, --config <file>` - Path to `.jsonc` config file (default: `./caching_relay_config.jsonc`)
|
||||
- `-d, --debug <level>` - Log level 1-4 (1=error, 2=warn, 3=info, 4=debug). Default 3.
|
||||
- `-h, --help` - Show help
|
||||
|
||||
Signals:
|
||||
- `SIGINT` / `SIGTERM` - Graceful shutdown (saves state, closes connections)
|
||||
- `SIGHUP` - Reload config (preserves backfill state)
|
||||
|
||||
## Config file
|
||||
|
||||
The config file [`caching_relay_config.jsonc`](caching_relay_config.jsonc) lives in the
|
||||
project root. It is JSONC (JSON with `//` and `/* */` comments). The daemon
|
||||
rewrites it as plain JSON when saving state (comments are not preserved on
|
||||
rewrite).
|
||||
|
||||
Key fields:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `root_npubs` | npubs whose kind-3 follows list we crawl |
|
||||
| `upstream_relays` | bootstrap relays for initial discovery (kind-3, kind-10002). Outbox relays are discovered dynamically via NIP-65. |
|
||||
| `local_relay` | relay to publish cached events into (never queried) |
|
||||
| `kinds` | event kinds to cache for followed people (e.g. `[1, 3, 6, 10000, 30023]`) |
|
||||
| `admin_kinds` | kinds to follow specifically for root (admin) npubs. Use `["*"]` for all kinds. If omitted, admin uses same `kinds` as everyone else. |
|
||||
| `backfill.window_schedule_seconds` | progressive window sizes in seconds |
|
||||
| `backfill.events_per_tick` | max events per pubkey per backfill tick |
|
||||
| `backfill.tick_interval_seconds` | delay between pubkey backfills (throttle) |
|
||||
| `live.enabled` | enable live subscription |
|
||||
| `follow_graph_refresh_seconds` | how often to re-resolve the follow graph |
|
||||
| `state.*` | managed by the daemon - do not hand-edit |
|
||||
|
||||
## Architecture
|
||||
|
||||
See [`plans/plan.md`](plans/plan.md) for the full architecture document with
|
||||
Mermaid diagrams.
|
||||
|
||||
The daemon uses a **NIP-65 outbox model**: instead of querying a fixed set of
|
||||
bootstrap relays for everything, it discovers which relays each followed pubkey
|
||||
actually posts to (via kind 10002 relay lists) and connects to those relays
|
||||
dynamically. The `upstream_relays` in the config serve only as **bootstrap
|
||||
relays** -- the initial set used to discover kind-3 and kind-10002 events before
|
||||
the outbox relay map is built.
|
||||
|
||||
### Relay pool selection: minimum covering set
|
||||
|
||||
After discovering each followed pubkey's outbox relays (from their kind 10002),
|
||||
the daemon computes the **minimum set of relays that covers all followed
|
||||
pubkeys**. This is the classic set cover problem, solved with a greedy
|
||||
approximation:
|
||||
|
||||
1. Build a map: `{relay_url -> set of pubkeys that list it in their 10002}`
|
||||
2. Greedily pick the relay that covers the most uncovered pubkeys
|
||||
3. Repeat until all pubkeys are covered (or no more relays to pick)
|
||||
4. Always include the bootstrap relays in the final set (they may have events
|
||||
from pubkeys that don't publish a kind 10002)
|
||||
|
||||
This minimizes the number of WebSocket connections while ensuring every
|
||||
followed pubkey is reachable. The daemon logs the selected relay set and which
|
||||
pubkeys each relay covers.
|
||||
|
||||
Pubkeys that have no kind 10002 (or whose 10002 lists no relays) are covered by
|
||||
the bootstrap relays as a fallback.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ caching_relay │
|
||||
│ │
|
||||
upstream relays │ upstream_pool sink_pool │ local relay
|
||||
(damus, nos.lol) │ (query + subscribe) (publish only) │ (c-relay)
|
||||
│ │ │ │ │ │
|
||||
│ │ follow_graph relay_sink │ │
|
||||
└──────────►│ live_subscriber ──────►│ ├─────►│
|
||||
│ backfill ──────────────►│ │ │
|
||||
│ │ │ │ │
|
||||
│ state + seen ring │ │ │
|
||||
└──────────────────────────────────────────┘ │
|
||||
```
|
||||
|
||||
Two `nostr_relay_pool_t` instances:
|
||||
- **upstream_pool** - holds bootstrap relays initially, then dynamically adds
|
||||
outbox relays discovered from kind 10002. Used for `query_sync` (kind-3 fetch,
|
||||
kind-10002 fetch, backfill) and the long-lived live subscription.
|
||||
- **sink_pool** - holds only the local relay; used exclusively for
|
||||
`publish_async`. Never queried.
|
||||
|
||||
## Startup Flow
|
||||
|
||||
### First-time startup (empty local relay)
|
||||
|
||||
The daemon has never run before. The local relay has no cached events. The
|
||||
daemon must bootstrap from the config's `upstream_relays` to discover the
|
||||
outbox relays for each followed pubkey.
|
||||
|
||||
```
|
||||
START
|
||||
|
|
||||
v
|
||||
Load config (caching_relay_config.jsonc)
|
||||
| - root_npubs, upstream_relays (bootstrap), kinds, admin_kinds
|
||||
| - state.backfilled_until == 0 => first-time startup
|
||||
|
|
||||
v
|
||||
Create upstream_pool with bootstrap relays only
|
||||
Create sink_pool with local_relay
|
||||
|
|
||||
v
|
||||
Phase 1: Resolve follow graph (from bootstrap relays)
|
||||
| - For each root npub: query_sync kind=3 from bootstrap relays
|
||||
| - Parse "p" tags => followed pubkey set
|
||||
| - Log: "follow: resolved N followed pubkeys"
|
||||
|
|
||||
v
|
||||
Phase 2: Discover outbox relays (NIP-65, kind 10002) from bootstrap relays
|
||||
| - For each followed pubkey: query_sync kind=10002 from bootstrap relays
|
||||
| - Parse "r" tags => per-pubkey relay list
|
||||
| - Publish all kind-10002 events to local relay (cache them)
|
||||
| - Dynamically add discovered relays to upstream_pool
|
||||
| - Log: "relay_discovery: found N relays from M pubkeys"
|
||||
| - Log: "upstream_pool: now connected to N relays" + list them
|
||||
|
|
||||
v
|
||||
Phase 3: Open live subscriptions on upstream_pool
|
||||
| - follows_sub: non-admin pubkeys + regular kinds
|
||||
| - admin_sub: admin npubs + admin_kinds (or all kinds)
|
||||
|
|
||||
v
|
||||
Phase 4: Begin progressive backfill
|
||||
| - Window 0: 24h -> query each pubkey from their outbox relays
|
||||
| - Window 1: 7d -> ...
|
||||
| - Window 2: 30d -> ...
|
||||
| - Publish all fetched events to local relay
|
||||
| - Save state to config file as each window completes
|
||||
|
|
||||
v
|
||||
Steady-state: live sub + periodic follow-graph refresh + periodic
|
||||
kind-10002 refresh + backfill re-cycle
|
||||
|
|
||||
v
|
||||
SHUTDOWN (SIGINT/SIGTERM) -> save state -> clean exit
|
||||
```
|
||||
|
||||
### Subsequent startup (local relay already has cached events)
|
||||
|
||||
The daemon has run before. The local relay already has kind-3 and kind-10002
|
||||
events cached from the previous run. The daemon can read these from the local
|
||||
relay directly (fast, no network round-trip to bootstrap relays) and only falls
|
||||
back to bootstrap relays for pubkeys it cannot find locally.
|
||||
|
||||
```
|
||||
START
|
||||
|
|
||||
v
|
||||
Load config (caching_relay_config.jsonc)
|
||||
| - state.backfilled_until > 0 => subsequent startup
|
||||
| - state.current_window_index, backfill_cursor preserved
|
||||
|
|
||||
v
|
||||
Create upstream_pool with bootstrap relays
|
||||
Create sink_pool with local_relay
|
||||
|
|
||||
v
|
||||
Phase 1: Resolve follow graph (from LOCAL relay first, bootstrap fallback)
|
||||
| - Query local relay for kind=3 per root npub
|
||||
| - If found locally: use it (fast, no upstream query)
|
||||
| - If not found: fall back to bootstrap relays
|
||||
| - Parse "p" tags => followed pubkey set
|
||||
| - Log: "follow: resolved N followed pubkeys (L local, B bootstrap)"
|
||||
|
|
||||
v
|
||||
Phase 2: Discover outbox relays (from LOCAL relay first, bootstrap fallback)
|
||||
| - Query local relay for kind=10002 per followed pubkey
|
||||
| - If found locally: use it (fast)
|
||||
| - If not found: fall back to bootstrap relays, cache result to local
|
||||
| - Parse "r" tags => per-pubkey relay list
|
||||
| - Dynamically add discovered relays to upstream_pool
|
||||
| - Log: "relay_discovery: found N relays (L local, B bootstrap)"
|
||||
| - Log: "upstream_pool: now connected to N relays" + list them
|
||||
|
|
||||
v
|
||||
Phase 3: Open live subscriptions on upstream_pool
|
||||
| - (same as first-time)
|
||||
|
|
||||
v
|
||||
Phase 4: Resume backfill from saved state
|
||||
| - Resume at state.current_window_index, state.backfill_cursor
|
||||
| - No re-pull of already-backfilled windows
|
||||
| - Continue progressive window expansion from where it left off
|
||||
|
|
||||
v
|
||||
Steady-state (same as first-time)
|
||||
|
|
||||
v
|
||||
SHUTDOWN -> save state -> clean exit
|
||||
```
|
||||
|
||||
### Relay logging
|
||||
|
||||
The daemon logs all relay activity so you can see exactly which relays are
|
||||
being used:
|
||||
|
||||
- `upstream: added wss://relay.damus.io` -- each relay added to the pool
|
||||
- `relay_discovery: found 47 relays from 195 pubkeys` -- outbox discovery summary
|
||||
- `upstream_pool: 50 relays connected:` -- full relay list at startup
|
||||
- `relay_discovery: pubkey X -> wss://relay.example.com (local)` -- per-pubkey
|
||||
relay source (local cache vs bootstrap)
|
||||
- `backfill: pubkey[3/195] abc123 -> wss://relay.example.com (12 events)` --
|
||||
which relay served each backfill query
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [nostr_core_lib](../nostr_core_lib) - built with NIPs 1, 4, 6, 19, 42, 44
|
||||
- libwebsockets, openssl, libsecp256k1, libcurl, zlib (all statically linked
|
||||
in the Docker build)
|
||||
|
||||
## Testing
|
||||
|
||||
Start a local c-relay, then run the daemon:
|
||||
|
||||
```bash
|
||||
# Start local relay (from c-relay project)
|
||||
cd ../c-relay/build && ./c_relay_static_x86_64 -p 8888 &
|
||||
|
||||
# Run the caching daemon (uses ./caching_relay_config.jsonc by default)
|
||||
./caching_relay -d 3
|
||||
|
||||
# Verify events are cached (from another terminal)
|
||||
nak req -k 1 -l 10 ws://127.0.0.1:8888
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
0.0.2
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build fully static MUSL binary for caching_relay using Alpine Docker.
|
||||
# Produces a truly portable binary with zero runtime dependencies.
|
||||
# Adapted from c-relay's build_static.sh.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
|
||||
|
||||
# Parse command line arguments
|
||||
DEBUG_BUILD=false
|
||||
TARGET_ARCH=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--debug)
|
||||
DEBUG_BUILD=true
|
||||
shift
|
||||
;;
|
||||
--arch)
|
||||
if [[ -z "$2" ]]; then
|
||||
echo "ERROR: --arch requires a value"
|
||||
echo "Usage: $0 [--debug] [--arch <arm64|x86_64>]"
|
||||
exit 1
|
||||
fi
|
||||
case "$2" in
|
||||
arm64|x86_64)
|
||||
TARGET_ARCH="$2"
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unsupported architecture '$2'"
|
||||
echo "Supported values: arm64, x86_64"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown argument '$1'"
|
||||
echo "Usage: $0 [--debug] [--arch <arm64|x86_64>]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$DEBUG_BUILD" = true ]; then
|
||||
echo "=========================================="
|
||||
echo "caching_relay MUSL Static Binary Builder (DEBUG MODE)"
|
||||
echo "=========================================="
|
||||
else
|
||||
echo "=========================================="
|
||||
echo "caching_relay MUSL Static Binary Builder (PRODUCTION MODE)"
|
||||
echo "=========================================="
|
||||
fi
|
||||
echo "Project directory: $SCRIPT_DIR"
|
||||
echo ""
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "ERROR: Docker is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
if ! docker info &> /dev/null; then
|
||||
echo "ERROR: Docker daemon is not running or user not in docker group"
|
||||
exit 1
|
||||
fi
|
||||
echo "Docker is available and running"
|
||||
echo ""
|
||||
|
||||
# Detect host architecture
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="x86_64";;
|
||||
aarch64) ARCH="arm64";;
|
||||
arm64) ARCH="arm64";;
|
||||
esac
|
||||
if [[ -n "$TARGET_ARCH" ]]; then
|
||||
ARCH="$TARGET_ARCH"
|
||||
echo "Using target architecture: $ARCH"
|
||||
else
|
||||
echo "Detected host architecture: $ARCH"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# nostr_core_lib is inside the c-relay-pg project root (one dir up from caching/).
|
||||
# Copy it into the Docker build context (excluding .git and build artifacts).
|
||||
NOSTR_CORE_DIR="$SCRIPT_DIR/../nostr_core_lib"
|
||||
if [ ! -d "$NOSTR_CORE_DIR" ]; then
|
||||
echo "ERROR: nostr_core_lib not found at $NOSTR_CORE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEEDS_COPY=1
|
||||
if [ -d "$SCRIPT_DIR/nostr_core_lib" ] && [ ! -L "$SCRIPT_DIR/nostr_core_lib" ]; then
|
||||
# Already a real directory (e.g. from a previous run); assume it's good.
|
||||
NEEDS_COPY=0
|
||||
fi
|
||||
if [ "$NEEDS_COPY" = "1" ]; then
|
||||
echo "Copying nostr_core_lib into build context..."
|
||||
rm -rf "$SCRIPT_DIR/nostr_core_lib"
|
||||
mkdir -p "$SCRIPT_DIR/nostr_core_lib"
|
||||
rsync -a --exclude='.git' --exclude='*.a' --exclude='*.o' \
|
||||
--exclude='.venv*' --exclude='backups' --exclude='verify_*' \
|
||||
--exclude='rewrite_mirror' --exclude='websocket_debug' \
|
||||
"$NOSTR_CORE_DIR/" "$SCRIPT_DIR/nostr_core_lib/"
|
||||
COPIED_NOSTR_CORE=1
|
||||
fi
|
||||
|
||||
# Build args
|
||||
BUILD_ARGS="--build-arg DEBUG_BUILD=$DEBUG_BUILD"
|
||||
if [ "$ARCH" = "arm64" ] && [ "$(uname -m)" != "aarch64" ] && [ "$(uname -m)" != "arm64" ]; then
|
||||
BUILD_ARGS="$BUILD_ARGS --platform linux/arm64"
|
||||
fi
|
||||
|
||||
IMAGE_TAG="caching_relay_builder:latest"
|
||||
|
||||
echo "Building Docker image..."
|
||||
docker build $BUILD_ARGS \
|
||||
-f "$DOCKERFILE" \
|
||||
-t "$IMAGE_TAG" \
|
||||
"$SCRIPT_DIR" 2>&1
|
||||
|
||||
BUILD_RC=$?
|
||||
|
||||
# Clean up the copied nostr_core_lib to keep the project dir tidy.
|
||||
if [ "${COPIED_NOSTR_CORE:-0}" = "1" ]; then
|
||||
echo "Cleaning up copied nostr_core_lib from build context..."
|
||||
rm -rf "$SCRIPT_DIR/nostr_core_lib"
|
||||
fi
|
||||
|
||||
if [ $BUILD_RC -ne 0 ]; then
|
||||
exit $BUILD_RC
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Extracting binary from image..."
|
||||
# The output stage is FROM scratch with no CMD, so pass an empty command.
|
||||
CONTAINER_ID=$(docker create "$IMAGE_TAG" "")
|
||||
docker cp "$CONTAINER_ID:/caching_relay_static" "$SCRIPT_DIR/caching_relay"
|
||||
docker rm "$CONTAINER_ID" >/dev/null
|
||||
chmod +x "$SCRIPT_DIR/caching_relay"
|
||||
|
||||
echo ""
|
||||
echo "=== Build complete ==="
|
||||
ls -lh "$SCRIPT_DIR/caching_relay"
|
||||
file "$SCRIPT_DIR/caching_relay"
|
||||
@@ -0,0 +1,456 @@
|
||||
/*
|
||||
* caching_relay - relay-by-relay per-author until-cursor drain backfill.
|
||||
*
|
||||
* Each (author, relay) pair has its own persistent until_cursor stored in
|
||||
* the caching_backfill_relay_progress table. Each backfill tick picks the
|
||||
* next author that still has at least one incomplete relay (round-robin),
|
||||
* then queries each incomplete relay one at a time with
|
||||
* since=0 & until=cursor & limit=page_size
|
||||
* publishes the page, and advances that relay's cursor to
|
||||
* oldest_event_created_at - 1. A relay is marked complete when a page
|
||||
* returns fewer than page_size events (the relay is drained for this
|
||||
* author). An author is marked complete in caching_followed_pubkeys when
|
||||
* all their relay progress rows are complete.
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include "backfill.h"
|
||||
#include "follow_graph.h"
|
||||
#include "pg_inbox.h"
|
||||
#include "debug.h"
|
||||
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Global access for status reporting */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Singleton pointer set in cr_backfill_init, used by the accessor
|
||||
* functions below so the main loop / status reporter can see what
|
||||
* the backfill is currently working on. */
|
||||
static cr_backfill_t *g_bf = NULL;
|
||||
|
||||
const char *cr_backfill_active_pubkey(void) {
|
||||
return g_bf ? g_bf->active_pubkey : "";
|
||||
}
|
||||
const char *cr_backfill_active_relay(void) {
|
||||
return g_bf ? g_bf->active_relay : "";
|
||||
}
|
||||
int cr_backfill_active_got_eose(void) {
|
||||
return g_bf ? g_bf->active_relay_got_eose : 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* EOSE callback for synchronous_query_relays_with_progress */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Context passed to the query callback. Records whether the relay sent
|
||||
* EOSE (we got everything) or timed out (partial result). */
|
||||
typedef struct {
|
||||
int got_eose; /* 1 if EOSE received, 0 if timed out/errored */
|
||||
int events_count; /* events reported by callback */
|
||||
char last_status[256]; /* last status string from callback (may include message) */
|
||||
} backfill_query_ctx_t;
|
||||
|
||||
static void backfill_query_callback(const char *relay_url, const char *status,
|
||||
const char *event_id, int events_received,
|
||||
int total_relays, int completed_relays,
|
||||
void *user_data) {
|
||||
(void)relay_url; (void)total_relays; (void)completed_relays;
|
||||
backfill_query_ctx_t *ctx = (backfill_query_ctx_t *)user_data;
|
||||
if (!ctx || !status) return;
|
||||
|
||||
/* Ignore the synthetic "all_complete" status from core_relays.c — it
|
||||
* is sent AFTER the real EOSE/timeout and would overwrite the real
|
||||
* status with a misleading "all_complete" string, and clobber
|
||||
* events_count with total_unique_events. The real per-relay status
|
||||
* (eose/timeout/error) is what we need for completion decisions. */
|
||||
if (strcmp(status, "all_complete") == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* For NOTICE/CLOSED/error, event_id carries the descriptive message
|
||||
* content (relay's own text for NOTICE/CLOSED, or our transport-level
|
||||
* diagnosis for error). Include it in the status for display.
|
||||
* Priority: relay's own response (NOTICE/CLOSED) > our error > bare
|
||||
* status string. */
|
||||
if ((strcmp(status, "NOTICE") == 0 || strcmp(status, "CLOSED") == 0) &&
|
||||
event_id && event_id[0] != '\0') {
|
||||
snprintf(ctx->last_status, sizeof(ctx->last_status), "%s: %s", status, event_id);
|
||||
} else if (strcmp(status, "error") == 0) {
|
||||
if (event_id && event_id[0] != '\0') {
|
||||
snprintf(ctx->last_status, sizeof(ctx->last_status), "error: %s", event_id);
|
||||
} else {
|
||||
snprintf(ctx->last_status, sizeof(ctx->last_status), "error");
|
||||
}
|
||||
} else {
|
||||
snprintf(ctx->last_status, sizeof(ctx->last_status), "%s", status);
|
||||
}
|
||||
|
||||
if (strcmp(status, "eose") == 0) {
|
||||
ctx->got_eose = 1;
|
||||
} else if (strcmp(status, "timeout") == 0) {
|
||||
ctx->got_eose = 0;
|
||||
} else if (strcmp(status, "error") == 0) {
|
||||
ctx->got_eose = 0;
|
||||
}
|
||||
/* NOTICE/CLOSED don't change got_eose — the relay might still send
|
||||
* events or EOSE after a NOTICE. */
|
||||
ctx->events_count = events_received;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Returns 1 if the PostgreSQL inbox is initialized (PG mode active).
|
||||
* Probes the config table; a non-NULL result means the connection is up. */
|
||||
static int pg_mode_active(void) {
|
||||
char *v = pg_inbox_get_config_value("caching_root_npubs");
|
||||
if (v) { free(v); return 1; }
|
||||
char *v2 = pg_inbox_get_config_value("caching_kinds");
|
||||
if (v2) { free(v2); return 1; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Find the oldest (minimum) created_at among an array of event JSON objects.
|
||||
* Returns 1 and sets *out_oldest, or 0 if no events / no valid created_at. */
|
||||
static int find_oldest_created_at(cJSON **events, int count, long *out_oldest) {
|
||||
long oldest = 0;
|
||||
int found = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
cJSON *ca = cJSON_GetObjectItem(events[i], "created_at");
|
||||
if (!ca || !cJSON_IsNumber(ca)) continue;
|
||||
long ts = (long)ca->valuedouble;
|
||||
if (!found || ts < oldest) {
|
||||
oldest = ts;
|
||||
found = 1;
|
||||
}
|
||||
}
|
||||
if (found && out_oldest) *out_oldest = oldest;
|
||||
return found;
|
||||
}
|
||||
|
||||
/* Find the newest (maximum) created_at among an array of event JSON objects.
|
||||
* Returns 1 and sets *out_newest, or 0 if no events / no valid created_at. */
|
||||
static int find_newest_created_at(cJSON **events, int count, long *out_newest) {
|
||||
long newest = 0;
|
||||
int found = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
cJSON *ca = cJSON_GetObjectItem(events[i], "created_at");
|
||||
if (!ca || !cJSON_IsNumber(ca)) continue;
|
||||
long ts = (long)ca->valuedouble;
|
||||
if (!found || ts > newest) {
|
||||
newest = ts;
|
||||
found = 1;
|
||||
}
|
||||
}
|
||||
if (found && out_newest) *out_newest = newest;
|
||||
return found;
|
||||
}
|
||||
|
||||
/* Build the kinds array for a given pubkey (admin vs regular).
|
||||
* Returns NULL if no kinds filter should be applied (admin_all_kinds). */
|
||||
static cJSON *build_kinds(cr_config_t *cfg, const char *pk) {
|
||||
int is_admin = cr_follow_is_root(cfg, pk);
|
||||
if (is_admin && cfg->admin_all_kinds) {
|
||||
return NULL;
|
||||
}
|
||||
cJSON *kinds = cJSON_CreateArray();
|
||||
if (is_admin && cfg->admin_kind_count > 0) {
|
||||
for (int i = 0; i < cfg->admin_kind_count; i++)
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->admin_kinds[i]));
|
||||
} else {
|
||||
for (int i = 0; i < cfg->kind_count; i++)
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->kinds[i]));
|
||||
}
|
||||
return kinds;
|
||||
}
|
||||
|
||||
/* Resolve the relay URL list to query for a given pubkey. Sets *out_urls and
|
||||
* *out_n. Caller frees *out_urls (but not the strings, which alias internal
|
||||
* storage). Returns 0 on success, -1 if no relays available. */
|
||||
static int resolve_relays(nostr_relay_pool_t *upstream,
|
||||
const cr_relay_map_t *relay_map,
|
||||
const char *pk,
|
||||
const char ***out_urls,
|
||||
int *out_n) {
|
||||
*out_urls = NULL;
|
||||
*out_n = 0;
|
||||
|
||||
/* Collect outbox relays for this author (from kind-10002). */
|
||||
int outbox_count = 0;
|
||||
const cr_outbox_entry_t *oe = NULL;
|
||||
if (relay_map) {
|
||||
oe = cr_relay_map_get_outbox(relay_map, pk);
|
||||
if (oe) outbox_count = oe->relay_count;
|
||||
}
|
||||
|
||||
/* Also collect all upstream (bootstrap) relays. We query BOTH the
|
||||
* author's outbox relays AND the bootstrap relays to maximize coverage.
|
||||
* Different relays may have different subsets of the author's events. */
|
||||
char **listed = NULL;
|
||||
nostr_pool_relay_status_t *statuses = NULL;
|
||||
int upstream_count = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
|
||||
|
||||
/* Merge outbox + upstream, deduplicating by URL. */
|
||||
int max_total = outbox_count + upstream_count;
|
||||
if (max_total <= 0) {
|
||||
free(listed);
|
||||
free(statuses);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char **urls = malloc(max_total * sizeof(char *));
|
||||
if (!urls) { free(listed); free(statuses); return -1; }
|
||||
|
||||
int n = 0;
|
||||
|
||||
/* Add outbox relays first. */
|
||||
if (oe) {
|
||||
for (int j = 0; j < oe->relay_count && n < max_total; j++) {
|
||||
urls[n++] = oe->relays[j];
|
||||
}
|
||||
}
|
||||
|
||||
/* Add upstream relays, skipping duplicates. */
|
||||
for (int j = 0; j < upstream_count && n < max_total; j++) {
|
||||
int dup = 0;
|
||||
for (int k = 0; k < n; k++) {
|
||||
if (strcmp(urls[k], listed[j]) == 0) { dup = 1; break; }
|
||||
}
|
||||
if (!dup) {
|
||||
urls[n++] = listed[j];
|
||||
}
|
||||
}
|
||||
|
||||
free(listed);
|
||||
free(statuses);
|
||||
|
||||
*out_urls = urls;
|
||||
*out_n = n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Init */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
void cr_backfill_init(cr_backfill_t *bf, cr_config_t *cfg) {
|
||||
(void)cfg;
|
||||
memset(bf, 0, sizeof(*bf));
|
||||
g_bf = bf; /* Set singleton for status accessors */
|
||||
|
||||
/* In PG mode, check whether any incomplete authors remain in the DB.
|
||||
* If so, backfill is in progress; otherwise we're in steady state.
|
||||
* Outside PG mode there is no per-author table, so we just start. */
|
||||
if (pg_mode_active()) {
|
||||
int complete = 0, total = 0;
|
||||
if (pg_inbox_count_backfill_progress(&complete, &total) == 0) {
|
||||
bf->in_progress = (total > 0 && complete < total) ? 1 : 0;
|
||||
DEBUG_INFO("backfill: init complete=%d/%d -> in_progress=%d",
|
||||
complete, total, bf->in_progress);
|
||||
return;
|
||||
}
|
||||
/* Count query failed - assume in progress so we keep trying. */
|
||||
bf->in_progress = 1;
|
||||
DEBUG_WARN("backfill: init could not read progress counts, "
|
||||
"defaulting to in_progress=1");
|
||||
return;
|
||||
}
|
||||
|
||||
/* Legacy (non-PG) mode: no per-author table; just start. */
|
||||
bf->in_progress = 1;
|
||||
DEBUG_INFO("backfill: init (legacy mode) in_progress=1");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tick */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int cr_backfill_tick(cr_backfill_t *bf, cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream,
|
||||
cr_pubkey_set_t *followed,
|
||||
cr_sink_t *sink, const cr_relay_map_t *relay_map) {
|
||||
(void)followed; /* followed set is tracked in the DB now */
|
||||
(void)relay_map; /* relays are tracked per-author in the DB */
|
||||
|
||||
if (!cfg->backfill.enabled) return -2;
|
||||
if (!bf->in_progress) return -2;
|
||||
|
||||
/* Throttle: one query per tick_interval. */
|
||||
time_t now = time(NULL);
|
||||
if (bf->last_tick && (now - bf->last_tick) < cfg->backfill.tick_interval_seconds)
|
||||
return 0;
|
||||
|
||||
/* Pick the next author that has at least one incomplete relay progress
|
||||
* row (round-robin). */
|
||||
char pk[CR_HEX_LEN];
|
||||
if (pg_inbox_pick_next_author_with_incomplete_relays(pk, sizeof(pk),
|
||||
&bf->author_round_cursor) != 0) {
|
||||
/* No incomplete authors - steady state. */
|
||||
bf->in_progress = 0;
|
||||
DEBUG_INFO("backfill: no authors with incomplete relays, steady-state");
|
||||
return -2;
|
||||
}
|
||||
|
||||
int page_size = cfg->backfill.events_per_tick;
|
||||
if (page_size < 1) page_size = 500;
|
||||
|
||||
bf->last_tick = now;
|
||||
|
||||
/* Get the incomplete relays for this author. */
|
||||
cJSON *relays = pg_inbox_get_incomplete_relays(pk);
|
||||
if (!relays) {
|
||||
DEBUG_WARN("backfill: no incomplete relays returned for %s", pk);
|
||||
/* Author has no relay rows - mark complete so we don't loop. */
|
||||
pg_inbox_check_and_mark_author_complete(pk);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int relay_count = cJSON_GetArraySize(relays);
|
||||
DEBUG_TRACE("backfill: author %s has %d incomplete relay(s)", pk, relay_count);
|
||||
|
||||
cr_sink_set_source_class(sink, CR_SINK_CLASS_BACKFILL);
|
||||
|
||||
/* Query each incomplete relay one at a time. */
|
||||
for (int r = 0; r < relay_count; r++) {
|
||||
cJSON *entry = cJSON_GetArrayItem(relays, r);
|
||||
if (!entry) continue;
|
||||
cJSON *url_node = cJSON_GetObjectItem(entry, "relay_url");
|
||||
cJSON *cur_node = cJSON_GetObjectItem(entry, "until_cursor");
|
||||
if (!url_node || !cJSON_IsString(url_node)) continue;
|
||||
const char *relay_url = cJSON_GetStringValue(url_node);
|
||||
long until_cursor = (cur_node && cJSON_IsNumber(cur_node))
|
||||
? (long)cur_node->valuedouble : 0;
|
||||
if (until_cursor == 0) until_cursor = (long)now;
|
||||
|
||||
/* Build the filter for this single relay. */
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *authors = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(pk));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON *kinds = build_kinds(cfg, pk);
|
||||
if (kinds) cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber(0.0));
|
||||
cJSON_AddItemToObject(filter, "until", cJSON_CreateNumber((double)until_cursor));
|
||||
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber((double)page_size));
|
||||
|
||||
DEBUG_TRACE("backfill: %s @ %s (until=%ld, limit=%d)",
|
||||
pk, relay_url, until_cursor, page_size);
|
||||
|
||||
/* Record the active target for UI status display (both
|
||||
* in-memory for local accessors and in PG for the relay). */
|
||||
strncpy(bf->active_pubkey, pk, sizeof(bf->active_pubkey) - 1);
|
||||
bf->active_pubkey[sizeof(bf->active_pubkey) - 1] = '\0';
|
||||
strncpy(bf->active_relay, relay_url, sizeof(bf->active_relay) - 1);
|
||||
bf->active_relay[sizeof(bf->active_relay) - 1] = '\0';
|
||||
bf->active_relay_got_eose = 0;
|
||||
pg_inbox_set_active_target(pk, relay_url);
|
||||
|
||||
/* Query this single relay using synchronous_query_relays_with_progress
|
||||
* which gives us an EOSE/timeout callback — the relay's own signal
|
||||
* of whether it sent everything or we timed out. */
|
||||
backfill_query_ctx_t qctx = {0, 0};
|
||||
const char *one_url = relay_url;
|
||||
int ev_count = 0;
|
||||
cJSON **events = synchronous_query_relays_with_progress(
|
||||
&one_url, 1, filter, RELAY_QUERY_ALL_RESULTS,
|
||||
&ev_count, 30, /* timeout in seconds */
|
||||
backfill_query_callback, &qctx,
|
||||
0, NULL /* nip42 disabled, no private key */);
|
||||
cJSON_Delete(filter);
|
||||
|
||||
bf->active_relay_got_eose = qctx.got_eose;
|
||||
|
||||
if (!events || ev_count == 0) {
|
||||
/* No events from this relay - it is drained for this author.
|
||||
* (ev_count == 0 with EOSE means truly empty; without EOSE it
|
||||
* means a connection failure — keep incomplete to retry.
|
||||
* The pg_inbox_update_relay_progress SQL will auto-mark
|
||||
* complete once consecutive_errors reaches 3.) */
|
||||
int mark_complete = qctx.got_eose ? 1 : 0;
|
||||
pg_inbox_update_relay_progress(pk, relay_url, until_cursor,
|
||||
mark_complete, 0, qctx.last_status);
|
||||
free(events);
|
||||
if (!qctx.got_eose) {
|
||||
/* Error or timeout with no events — surface the
|
||||
* descriptive status at WARN so operators can see why. */
|
||||
DEBUG_WARN("backfill: %s @ %s failed: %s (will auto-complete after 3 consecutive errors)",
|
||||
pk, relay_url, qctx.last_status);
|
||||
} else {
|
||||
DEBUG_LOG("backfill: %s @ %s complete (no events, eose)",
|
||||
pk, relay_url);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Find oldest and newest timestamps, then publish every event. */
|
||||
long oldest_ts = 0;
|
||||
long newest_ts = 0;
|
||||
find_oldest_created_at(events, ev_count, &oldest_ts);
|
||||
find_newest_created_at(events, ev_count, &newest_ts);
|
||||
|
||||
for (int k = 0; k < ev_count; k++) {
|
||||
cr_sink_publish(sink, events[k]);
|
||||
cJSON_Delete(events[k]);
|
||||
}
|
||||
free(events);
|
||||
bf->events_total += ev_count;
|
||||
|
||||
/* Update last_event_at for forward catch-up gap tracking. */
|
||||
if (newest_ts > 0) {
|
||||
pg_inbox_update_last_event_at(pk, newest_ts);
|
||||
}
|
||||
|
||||
DEBUG_LOG("backfill: %s @ %s -> %d events (until=%ld, oldest=%ld, eose=%d)",
|
||||
pk, relay_url, ev_count, until_cursor, oldest_ts, qctx.got_eose);
|
||||
|
||||
/* Completion decision based on EOSE status:
|
||||
* - ev_count >= page_size: saturated, advance cursor, keep incomplete
|
||||
* - ev_count < page_size AND got EOSE: relay is truly drained
|
||||
* - ev_count < page_size AND no EOSE (timeout): partial result,
|
||||
* advance cursor but keep incomplete to retry next tick
|
||||
* - Guard: if cursor didn't advance (oldest_ts >= until_cursor),
|
||||
* mark complete to avoid infinite loop on identical timestamps. */
|
||||
long next_cursor = (oldest_ts > 0) ? oldest_ts - 1 : 0;
|
||||
int cursor_advanced = (oldest_ts > 0 && oldest_ts < until_cursor);
|
||||
|
||||
if (ev_count >= page_size) {
|
||||
pg_inbox_update_relay_progress(pk, relay_url, next_cursor, 0, ev_count, qctx.last_status);
|
||||
} else if (qctx.got_eose) {
|
||||
/* Relay sent EOSE with < page_size events — truly drained. */
|
||||
pg_inbox_update_relay_progress(pk, relay_url, next_cursor, 1, ev_count, qctx.last_status);
|
||||
} else {
|
||||
/* Timed out before EOSE — partial result. Keep incomplete
|
||||
* to retry, unless the cursor didn't advance (stall guard).
|
||||
* Timeouts DO count toward the consecutive_errors limit
|
||||
* (a relay that consistently times out is effectively dead
|
||||
* for backfill purposes), so the SQL will auto-mark
|
||||
* complete after 3 consecutive timeouts/errors. */
|
||||
int mark_complete = cursor_advanced ? 0 : 1;
|
||||
pg_inbox_update_relay_progress(pk, relay_url, next_cursor,
|
||||
mark_complete, ev_count, qctx.last_status);
|
||||
DEBUG_WARN("backfill: %s @ %s partial: %s (%d events, will auto-complete after 3 consecutive timeouts/errors)",
|
||||
pk, relay_url, qctx.last_status, ev_count);
|
||||
}
|
||||
}
|
||||
|
||||
/* Clear active target — done with this author. */
|
||||
bf->active_relay[0] = '\0';
|
||||
pg_inbox_set_active_target(pk, "");
|
||||
|
||||
cJSON_Delete(relays);
|
||||
|
||||
/* After querying all incomplete relays for this author, check whether
|
||||
* the author is now fully complete. */
|
||||
int crc = pg_inbox_check_and_mark_author_complete(pk);
|
||||
if (crc == 1) {
|
||||
DEBUG_LOG("backfill: author %s fully complete (all relays drained)", pk);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* caching_relay - per-author until-cursor drain backfill.
|
||||
*
|
||||
* Each followed author has a single persistent cursor (starting at `now`)
|
||||
* stored in the caching_followed_pubkeys table. Each backfill tick picks
|
||||
* the next incomplete author (round-robin), queries
|
||||
* since=0 & until=cursor & limit=page_size
|
||||
* publishes the page, and sets cursor = oldest_event_created_at - 1.
|
||||
* Repeat until a page returns fewer than page_size events (beginning of
|
||||
* the author's history reached), then mark the author complete.
|
||||
*/
|
||||
#ifndef CACHING_RELAY_BACKFILL_H
|
||||
#define CACHING_RELAY_BACKFILL_H
|
||||
|
||||
#include "config.h"
|
||||
#include "state.h"
|
||||
#include "relay_sink.h"
|
||||
#include "relay_discovery.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
typedef struct {
|
||||
int in_progress; /* 1 if any incomplete authors remain */
|
||||
long events_total; /* events pulled across all ticks (diagnostic) */
|
||||
time_t last_tick; /* last time an author was queried (throttle) */
|
||||
int author_round_cursor;/* round-robin cursor for author selection */
|
||||
/* Current backfill target (for UI status display). */
|
||||
char active_pubkey[65]; /* pubkey currently being backfilled (or "") */
|
||||
char active_relay[256]; /* relay URL currently being queried (or "") */
|
||||
int active_relay_got_eose; /* 1 if last query got EOSE, 0 if timed out */
|
||||
} cr_backfill_t;
|
||||
|
||||
/* Global access to the current backfill target for status reporting.
|
||||
* Returns pointers to static storage in the singleton cr_backfill_t.
|
||||
* Safe to call from the main loop; not thread-safe but backfill is
|
||||
* single-threaded. */
|
||||
const char *cr_backfill_active_pubkey(void);
|
||||
const char *cr_backfill_active_relay(void);
|
||||
int cr_backfill_active_got_eose(void);
|
||||
|
||||
/* Initialize backfill state. Checks the caching_followed_pubkeys table for
|
||||
* any incomplete authors; sets in_progress accordingly. */
|
||||
void cr_backfill_init(cr_backfill_t *bf, cr_config_t *cfg);
|
||||
|
||||
/* Perform one backfill tick. Picks the next incomplete author from the DB
|
||||
* (round-robin), queries one page of history, publishes it to the sink, and
|
||||
* updates the per-author cursor in the DB.
|
||||
*
|
||||
* Returns:
|
||||
* 1 if a tick was performed (an author was queried)
|
||||
* 0 if throttled (tick_interval not elapsed) - caller should pump pools
|
||||
* -2 if backfill is complete (no incomplete authors) - caller goes steady-state
|
||||
*/
|
||||
int cr_backfill_tick(cr_backfill_t *bf, cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream,
|
||||
cr_pubkey_set_t *followed,
|
||||
cr_sink_t *sink, const cr_relay_map_t *relay_map);
|
||||
|
||||
#endif /* CACHING_RELAY_BACKFILL_H */
|
||||
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
* caching_relay - config parsing + persistent state (in the .jsonc file itself)
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include "config.h"
|
||||
#include "jsonc_strip.h"
|
||||
#include "debug.h"
|
||||
|
||||
#include "../nostr_core_lib/cjson/cJSON.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
|
||||
static int read_file(const char *path, char **out, size_t *out_len) {
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) {
|
||||
DEBUG_ERROR("cannot open config '%s': %s", path, strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return -1; }
|
||||
long sz = ftell(f);
|
||||
if (sz < 0) { fclose(f); return -1; }
|
||||
rewind(f);
|
||||
char *buf = malloc(sz + 1);
|
||||
if (!buf) { fclose(f); return -1; }
|
||||
size_t n = fread(buf, 1, sz, f);
|
||||
fclose(f);
|
||||
buf[n] = '\0';
|
||||
*out = buf;
|
||||
if (out_len) *out_len = n;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void copy_string_array(cJSON *arr, char *dst, int max, int *count_out,
|
||||
int elem_len) {
|
||||
int count = 0;
|
||||
if (arr && cJSON_IsArray(arr)) {
|
||||
cJSON *item;
|
||||
cJSON_ArrayForEach(item, arr) {
|
||||
if (count >= max) break;
|
||||
if (!cJSON_IsString(item)) continue;
|
||||
const char *s = cJSON_GetStringValue(item);
|
||||
if (!s) continue;
|
||||
strncpy(dst + count * elem_len, s, elem_len - 1);
|
||||
dst[count * elem_len + (elem_len - 1)] = '\0';
|
||||
count++;
|
||||
}
|
||||
}
|
||||
*count_out = count;
|
||||
}
|
||||
|
||||
static void copy_int_array(cJSON *arr, int *dst, int max, int *count_out) {
|
||||
int count = 0;
|
||||
if (arr && cJSON_IsArray(arr)) {
|
||||
cJSON *item;
|
||||
cJSON_ArrayForEach(item, arr) {
|
||||
if (count >= max) break;
|
||||
if (cJSON_IsNumber(item)) {
|
||||
dst[count++] = (int)cJSON_GetNumberValue(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
*count_out = count;
|
||||
}
|
||||
|
||||
static void copy_long_array(cJSON *arr, long *dst, int max, int *count_out) {
|
||||
int count = 0;
|
||||
if (arr && cJSON_IsArray(arr)) {
|
||||
cJSON *item;
|
||||
cJSON_ArrayForEach(item, arr) {
|
||||
if (count >= max) break;
|
||||
if (cJSON_IsNumber(item)) {
|
||||
dst[count++] = (long)cJSON_GetNumberValue(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
*count_out = count;
|
||||
}
|
||||
|
||||
int cr_config_load(cr_config_t *cfg, const char *path) {
|
||||
memset(cfg, 0, sizeof(*cfg));
|
||||
strncpy(cfg->path, path, sizeof(cfg->path) - 1);
|
||||
|
||||
char *raw = NULL;
|
||||
size_t raw_len = 0;
|
||||
if (read_file(path, &raw, &raw_len) != 0) return -1;
|
||||
|
||||
char *stripped = jsonc_strip_comments(raw, raw_len);
|
||||
free(raw);
|
||||
if (!stripped) {
|
||||
DEBUG_ERROR("failed to strip comments from config");
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *root = cJSON_Parse(stripped);
|
||||
free(stripped);
|
||||
if (!root) {
|
||||
DEBUG_ERROR("failed to parse config JSON");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* root_npubs */
|
||||
copy_string_array(cJSON_GetObjectItem(root, "root_npubs"),
|
||||
(char *)cfg->root_npubs, CR_MAX_ROOT_NPUBS,
|
||||
&cfg->root_npub_count, CR_NPUB_LEN);
|
||||
|
||||
/* upstream_relays */
|
||||
copy_string_array(cJSON_GetObjectItem(root, "upstream_relays"),
|
||||
(char *)cfg->upstream_relays, CR_MAX_UPSTREAM,
|
||||
&cfg->upstream_count, CR_URL_LEN);
|
||||
|
||||
/* local_relay */
|
||||
cJSON *local = cJSON_GetObjectItem(root, "local_relay");
|
||||
if (local && cJSON_IsString(local)) {
|
||||
strncpy(cfg->local_relay, cJSON_GetStringValue(local), CR_URL_LEN - 1);
|
||||
}
|
||||
|
||||
/* kinds */
|
||||
copy_int_array(cJSON_GetObjectItem(root, "kinds"),
|
||||
cfg->kinds, CR_MAX_KINDS, &cfg->kind_count);
|
||||
|
||||
/* admin_kinds - kinds to follow specifically for root (admin) npubs.
|
||||
* Supports [*] to mean "all kinds" (no kind filter for admin). */
|
||||
cJSON *ak = cJSON_GetObjectItem(root, "admin_kinds");
|
||||
if (ak && cJSON_IsArray(ak)) {
|
||||
cJSON *item;
|
||||
cJSON_ArrayForEach(item, ak) {
|
||||
if (cJSON_IsString(item)) {
|
||||
const char *s = cJSON_GetStringValue(item);
|
||||
if (s && strcmp(s, "*") == 0) {
|
||||
cfg->admin_all_kinds = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!cfg->admin_all_kinds) {
|
||||
copy_int_array(ak, cfg->admin_kinds, CR_MAX_KINDS, &cfg->admin_kind_count);
|
||||
}
|
||||
}
|
||||
|
||||
/* backfill */
|
||||
cJSON *bf = cJSON_GetObjectItem(root, "backfill");
|
||||
if (bf) {
|
||||
cfg->backfill.enabled = cJSON_IsTrue(cJSON_GetObjectItem(bf, "enabled"));
|
||||
cJSON *ept = cJSON_GetObjectItem(bf, "events_per_tick");
|
||||
if (ept) cfg->backfill.events_per_tick = (int)cJSON_GetNumberValue(ept);
|
||||
cJSON *tis = cJSON_GetObjectItem(bf, "tick_interval_seconds");
|
||||
if (tis) cfg->backfill.tick_interval_seconds = (int)cJSON_GetNumberValue(tis);
|
||||
}
|
||||
|
||||
/* live */
|
||||
cJSON *lv = cJSON_GetObjectItem(root, "live");
|
||||
if (lv) {
|
||||
cfg->live.enabled = cJSON_IsTrue(cJSON_GetObjectItem(lv, "enabled"));
|
||||
cJSON *rsi = cJSON_GetObjectItem(lv, "resubscribe_interval_seconds");
|
||||
if (rsi) cfg->live.resubscribe_interval_seconds = (int)cJSON_GetNumberValue(rsi);
|
||||
}
|
||||
|
||||
/* follow_graph_refresh_seconds */
|
||||
cJSON *fgr = cJSON_GetObjectItem(root, "follow_graph_refresh_seconds");
|
||||
if (fgr) cfg->follow_graph_refresh_seconds = (int)cJSON_GetNumberValue(fgr);
|
||||
|
||||
/* state */
|
||||
cJSON *st = cJSON_GetObjectItem(root, "state");
|
||||
if (st) {
|
||||
cJSON *bu = cJSON_GetObjectItem(st, "backfilled_until");
|
||||
if (bu) cfg->state.backfilled_until = (long)cJSON_GetNumberValue(bu);
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
|
||||
/* Defaults if missing. */
|
||||
if (cfg->backfill.events_per_tick == 0) cfg->backfill.events_per_tick = 500;
|
||||
if (cfg->backfill.tick_interval_seconds == 0) cfg->backfill.tick_interval_seconds = 5;
|
||||
if (cfg->live.resubscribe_interval_seconds == 0) cfg->live.resubscribe_interval_seconds = 300;
|
||||
if (cfg->follow_graph_refresh_seconds == 0) cfg->follow_graph_refresh_seconds = 600;
|
||||
|
||||
/* Validation. */
|
||||
if (cfg->root_npub_count == 0) {
|
||||
DEBUG_ERROR("config: at least one root_npub required");
|
||||
return -1;
|
||||
}
|
||||
if (cfg->upstream_count == 0) {
|
||||
DEBUG_ERROR("config: at least one upstream_relay required");
|
||||
return -1;
|
||||
}
|
||||
if (cfg->local_relay[0] == '\0') {
|
||||
DEBUG_ERROR("config: local_relay required");
|
||||
return -1;
|
||||
}
|
||||
if (cfg->kind_count == 0) {
|
||||
DEBUG_ERROR("config: at least one kind required");
|
||||
return -1;
|
||||
}
|
||||
|
||||
DEBUG_INFO("config loaded: %d root npubs, %d upstream relays, %d kinds",
|
||||
cfg->root_npub_count, cfg->upstream_count, cfg->kind_count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Build a fresh cJSON tree from the in-memory config and write it out.
|
||||
* We re-emit plain JSON (comments are not preserved on rewrite). */
|
||||
int cr_config_save_state(cr_config_t *cfg) {
|
||||
cJSON *root = cJSON_CreateObject();
|
||||
|
||||
cJSON *npubs = cJSON_CreateArray();
|
||||
for (int i = 0; i < cfg->root_npub_count; i++)
|
||||
cJSON_AddItemToArray(npubs, cJSON_CreateString(cfg->root_npubs[i]));
|
||||
cJSON_AddItemToObject(root, "root_npubs", npubs);
|
||||
|
||||
cJSON *upstream = cJSON_CreateArray();
|
||||
for (int i = 0; i < cfg->upstream_count; i++)
|
||||
cJSON_AddItemToArray(upstream, cJSON_CreateString(cfg->upstream_relays[i]));
|
||||
cJSON_AddItemToObject(root, "upstream_relays", upstream);
|
||||
|
||||
cJSON_AddItemToObject(root, "local_relay", cJSON_CreateString(cfg->local_relay));
|
||||
|
||||
cJSON *kinds = cJSON_CreateArray();
|
||||
for (int i = 0; i < cfg->kind_count; i++)
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->kinds[i]));
|
||||
cJSON_AddItemToObject(root, "kinds", kinds);
|
||||
|
||||
/* admin_kinds */
|
||||
cJSON *akinds = cJSON_CreateArray();
|
||||
if (cfg->admin_all_kinds) {
|
||||
cJSON_AddItemToArray(akinds, cJSON_CreateString("*"));
|
||||
} else {
|
||||
for (int i = 0; i < cfg->admin_kind_count; i++)
|
||||
cJSON_AddItemToArray(akinds, cJSON_CreateNumber(cfg->admin_kinds[i]));
|
||||
}
|
||||
cJSON_AddItemToObject(root, "admin_kinds", akinds);
|
||||
|
||||
cJSON *bf = cJSON_CreateObject();
|
||||
cJSON_AddItemToObject(bf, "enabled", cJSON_CreateBool(cfg->backfill.enabled));
|
||||
cJSON_AddItemToObject(bf, "events_per_tick", cJSON_CreateNumber(cfg->backfill.events_per_tick));
|
||||
cJSON_AddItemToObject(bf, "tick_interval_seconds", cJSON_CreateNumber(cfg->backfill.tick_interval_seconds));
|
||||
cJSON_AddItemToObject(root, "backfill", bf);
|
||||
|
||||
cJSON *lv = cJSON_CreateObject();
|
||||
cJSON_AddItemToObject(lv, "enabled", cJSON_CreateBool(cfg->live.enabled));
|
||||
cJSON_AddItemToObject(lv, "resubscribe_interval_seconds", cJSON_CreateNumber(cfg->live.resubscribe_interval_seconds));
|
||||
cJSON_AddItemToObject(root, "live", lv);
|
||||
|
||||
cJSON_AddItemToObject(root, "follow_graph_refresh_seconds",
|
||||
cJSON_CreateNumber(cfg->follow_graph_refresh_seconds));
|
||||
|
||||
cJSON *st = cJSON_CreateObject();
|
||||
cJSON_AddItemToObject(st, "backfilled_until", cJSON_CreateNumber(cfg->state.backfilled_until));
|
||||
cJSON_AddItemToObject(root, "state", st);
|
||||
|
||||
char *json = cJSON_Print(root);
|
||||
cJSON_Delete(root);
|
||||
if (!json) {
|
||||
DEBUG_ERROR("config save: failed to serialize");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Atomic write: temp file + rename. */
|
||||
char tmp[1100];
|
||||
snprintf(tmp, sizeof(tmp), "%s.tmp", cfg->path);
|
||||
int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (fd < 0) {
|
||||
DEBUG_ERROR("config save: cannot open tmp '%s': %s", tmp, strerror(errno));
|
||||
free(json);
|
||||
return -1;
|
||||
}
|
||||
size_t jlen = strlen(json);
|
||||
ssize_t w = write(fd, json, jlen);
|
||||
close(fd);
|
||||
free(json);
|
||||
if (w < 0 || (size_t)w != jlen) {
|
||||
DEBUG_ERROR("config save: short write");
|
||||
return -1;
|
||||
}
|
||||
if (rename(tmp, cfg->path) != 0) {
|
||||
DEBUG_ERROR("config save: rename failed: %s", strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
DEBUG_LOG("config state saved: backfilled_until=%ld",
|
||||
cfg->state.backfilled_until);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void cr_config_free(cr_config_t *cfg) {
|
||||
(void)cfg;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* caching_relay - config parsing + persistent state (in the .jsonc file itself)
|
||||
*/
|
||||
#ifndef CACHING_RELAY_CONFIG_H
|
||||
#define CACHING_RELAY_CONFIG_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <time.h>
|
||||
|
||||
/* Maximums to keep things statically sized and simple. */
|
||||
#define CR_MAX_ROOT_NPUBS 16
|
||||
#define CR_MAX_UPSTREAM 32
|
||||
#define CR_MAX_KINDS 32
|
||||
#define CR_NPUB_LEN 64 /* npub1... bech32, generous */
|
||||
#define CR_URL_LEN 256
|
||||
#define CR_HEX_PUBKEY_LEN 65 /* 64 hex chars + NUL */
|
||||
|
||||
typedef struct {
|
||||
int enabled;
|
||||
int events_per_tick; /* per-query page size (default 500) */
|
||||
int tick_interval_seconds;
|
||||
} cr_backfill_config_t;
|
||||
|
||||
typedef struct {
|
||||
int enabled;
|
||||
int resubscribe_interval_seconds;
|
||||
} cr_live_config_t;
|
||||
|
||||
/* Persistent state. The per-author until-cursor drain model stores all
|
||||
* backfill progress in the caching_followed_pubkeys table, so the only
|
||||
* state kept here is the legacy-mode first-time flag (used to drive relay
|
||||
* discovery caching behavior). */
|
||||
typedef struct {
|
||||
long backfilled_until; /* legacy: 0 = first-time startup */
|
||||
} cr_state_t;
|
||||
|
||||
typedef struct {
|
||||
char root_npubs[CR_MAX_ROOT_NPUBS][CR_NPUB_LEN];
|
||||
int root_npub_count;
|
||||
/* Decoded hex pubkeys for root npubs (filled by follow_graph). */
|
||||
char root_hex[CR_MAX_ROOT_NPUBS][CR_HEX_PUBKEY_LEN];
|
||||
int root_hex_ready;
|
||||
|
||||
char upstream_relays[CR_MAX_UPSTREAM][CR_URL_LEN];
|
||||
int upstream_count;
|
||||
|
||||
char local_relay[CR_URL_LEN];
|
||||
|
||||
int kinds[CR_MAX_KINDS];
|
||||
int kind_count;
|
||||
|
||||
/* Kinds to follow specifically for the root (admin) npubs.
|
||||
* If admin_all_kinds is 1, grab everything (no kind filter). */
|
||||
int admin_kinds[CR_MAX_KINDS];
|
||||
int admin_kind_count;
|
||||
int admin_all_kinds;
|
||||
|
||||
cr_backfill_config_t backfill;
|
||||
cr_live_config_t live;
|
||||
int follow_graph_refresh_seconds;
|
||||
|
||||
cr_state_t state;
|
||||
|
||||
/* Path the config was loaded from (for state write-back). */
|
||||
char path[1024];
|
||||
} cr_config_t;
|
||||
|
||||
/* Load + validate config from a .jsonc file. Returns 0 on success, -1 on error.
|
||||
* On success cfg->path is set and cfg->state is populated from the file. */
|
||||
int cr_config_load(cr_config_t *cfg, const char *path);
|
||||
|
||||
/* Persist the state sub-object back into the config file (temp + rename).
|
||||
* Preserves all other config fields. Returns 0 on success, -1 on error. */
|
||||
int cr_config_save_state(cr_config_t *cfg);
|
||||
|
||||
/* Free any heap resources held by cfg (currently none, but kept for future). */
|
||||
void cr_config_free(cr_config_t *cfg);
|
||||
|
||||
#endif /* CACHING_RELAY_CONFIG_H */
|
||||
@@ -0,0 +1,71 @@
|
||||
#include "debug.h"
|
||||
#include <stdarg.h>
|
||||
#include <string.h>
|
||||
|
||||
/**
|
||||
* @file debug.c
|
||||
* @brief Debug and logging system implementation
|
||||
*
|
||||
* Provides a configurable logging system with timestamp formatting,
|
||||
* level-based filtering, and optional file:line information.
|
||||
*/
|
||||
|
||||
// Global debug level (default: no debug output)
|
||||
debug_level_t g_debug_level = DEBUG_LEVEL_NONE;
|
||||
|
||||
/**
|
||||
* @brief Initialize the debug system with a specific level
|
||||
* @param level Debug level (0-5, clamped to valid range)
|
||||
*/
|
||||
void debug_init(int level) {
|
||||
if (level < 0) level = 0;
|
||||
if (level > 5) level = 5;
|
||||
g_debug_level = (debug_level_t)level;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Core logging function
|
||||
* @param level Debug level for this message
|
||||
* @param file Source file name (__FILE__)
|
||||
* @param line Source line number (__LINE__)
|
||||
* @param format printf-style format string
|
||||
* @param ... Variable arguments for format string
|
||||
*/
|
||||
void debug_log(debug_level_t level, const char* file, int line, const char* format, ...) {
|
||||
// Get timestamp
|
||||
time_t now = time(NULL);
|
||||
struct tm* tm_info = localtime(&now);
|
||||
char timestamp[32];
|
||||
strftime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S", tm_info);
|
||||
|
||||
// Get level string
|
||||
const char* level_str = "UNKNOWN";
|
||||
switch (level) {
|
||||
case DEBUG_LEVEL_ERROR: level_str = "ERROR"; break;
|
||||
case DEBUG_LEVEL_WARN: level_str = "WARN "; break;
|
||||
case DEBUG_LEVEL_INFO: level_str = "INFO "; break;
|
||||
case DEBUG_LEVEL_DEBUG: level_str = "DEBUG"; break;
|
||||
case DEBUG_LEVEL_TRACE: level_str = "TRACE"; break;
|
||||
default: break;
|
||||
}
|
||||
|
||||
// Print prefix with timestamp and level
|
||||
printf("[%s] [%s] ", timestamp, level_str);
|
||||
|
||||
// Print source location when debug level is TRACE (5) or higher
|
||||
if (file && g_debug_level >= DEBUG_LEVEL_TRACE) {
|
||||
// Extract just the filename (not full path)
|
||||
const char* filename = strrchr(file, '/');
|
||||
filename = filename ? filename + 1 : file;
|
||||
printf("[%s:%d] ", filename, line);
|
||||
}
|
||||
|
||||
// Print message
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
vprintf(format, args);
|
||||
va_end(args);
|
||||
|
||||
printf("\n");
|
||||
fflush(stdout);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#ifndef C_UTILS_DEBUG_H
|
||||
#define C_UTILS_DEBUG_H
|
||||
|
||||
/**
|
||||
* @file debug.h
|
||||
* @brief Debug and logging system with configurable verbosity levels
|
||||
*
|
||||
* Provides a simple, efficient logging system with 5 levels:
|
||||
* - ERROR: Critical errors
|
||||
* - WARN: Warnings
|
||||
* - INFO: Informational messages
|
||||
* - DEBUG: Debug messages
|
||||
* - TRACE: Detailed trace with file:line info
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
|
||||
// Debug levels
|
||||
typedef enum {
|
||||
DEBUG_LEVEL_NONE = 0, /**< No debug output */
|
||||
DEBUG_LEVEL_ERROR = 1, /**< Critical errors only */
|
||||
DEBUG_LEVEL_WARN = 2, /**< Warnings and above */
|
||||
DEBUG_LEVEL_INFO = 3, /**< Informational messages and above */
|
||||
DEBUG_LEVEL_DEBUG = 4, /**< Debug messages and above */
|
||||
DEBUG_LEVEL_TRACE = 5 /**< Detailed trace with file:line info */
|
||||
} debug_level_t;
|
||||
|
||||
// Global debug level (set at runtime via CLI)
|
||||
extern debug_level_t g_debug_level;
|
||||
|
||||
/**
|
||||
* @brief Initialize the debug system
|
||||
* @param level Debug level (0-5, clamped to valid range)
|
||||
*/
|
||||
void debug_init(int level);
|
||||
|
||||
/**
|
||||
* @brief Core logging function
|
||||
* @param level Debug level for this message
|
||||
* @param file Source file name (__FILE__)
|
||||
* @param line Source line number (__LINE__)
|
||||
* @param format printf-style format string
|
||||
* @param ... Variable arguments for format string
|
||||
*/
|
||||
void debug_log(debug_level_t level, const char* file, int line, const char* format, ...);
|
||||
|
||||
// Convenience macros that check level before calling
|
||||
// Note: TRACE level (5) and above include file:line information for ALL messages
|
||||
#define DEBUG_ERROR(...) \
|
||||
do { if (g_debug_level >= DEBUG_LEVEL_ERROR) debug_log(DEBUG_LEVEL_ERROR, __FILE__, __LINE__, __VA_ARGS__); } while(0)
|
||||
|
||||
#define DEBUG_WARN(...) \
|
||||
do { if (g_debug_level >= DEBUG_LEVEL_WARN) debug_log(DEBUG_LEVEL_WARN, __FILE__, __LINE__, __VA_ARGS__); } while(0)
|
||||
|
||||
#define DEBUG_INFO(...) \
|
||||
do { if (g_debug_level >= DEBUG_LEVEL_INFO) debug_log(DEBUG_LEVEL_INFO, __FILE__, __LINE__, __VA_ARGS__); } while(0)
|
||||
|
||||
#define DEBUG_LOG(...) \
|
||||
do { if (g_debug_level >= DEBUG_LEVEL_DEBUG) debug_log(DEBUG_LEVEL_DEBUG, __FILE__, __LINE__, __VA_ARGS__); } while(0)
|
||||
|
||||
#define DEBUG_TRACE(...) \
|
||||
do { if (g_debug_level >= DEBUG_LEVEL_TRACE) debug_log(DEBUG_LEVEL_TRACE, __FILE__, __LINE__, __VA_ARGS__); } while(0)
|
||||
|
||||
#endif /* C_UTILS_DEBUG_H */
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* caching_relay - follow graph resolution
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include "follow_graph.h"
|
||||
#include "debug.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <ctype.h>
|
||||
|
||||
int cr_follow_is_root(const cr_config_t *cfg, const char *hex) {
|
||||
if (!cfg->root_hex_ready) return 0;
|
||||
for (int i = 0; i < cfg->root_npub_count; i++) {
|
||||
if (strcmp(cfg->root_hex[i], hex) == 0) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Check if a string is a 64-character hex pubkey (no npub prefix). */
|
||||
static int is_hex_pubkey(const char *s) {
|
||||
if (!s || strlen(s) != 64) return 0;
|
||||
for (int i = 0; i < 64; i++) {
|
||||
if (!isxdigit((unsigned char)s[i])) return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
int cr_follow_decode_roots(cr_config_t *cfg) {
|
||||
for (int i = 0; i < cfg->root_npub_count; i++) {
|
||||
const char *input = cfg->root_npubs[i];
|
||||
if (is_hex_pubkey(input)) {
|
||||
/* Already a 64-char hex pubkey — copy directly. */
|
||||
strncpy(cfg->root_hex[i], input, 64);
|
||||
cfg->root_hex[i][64] = '\0';
|
||||
} else {
|
||||
/* Assume npub (bech32) format — decode to hex. */
|
||||
unsigned char pubkey[32];
|
||||
if (nostr_decode_npub(input, pubkey) != NOSTR_SUCCESS) {
|
||||
DEBUG_ERROR("follow: failed to decode root pubkey '%s' "
|
||||
"(not a valid npub or 64-char hex)", input);
|
||||
return -1;
|
||||
}
|
||||
nostr_bytes_to_hex(pubkey, 32, cfg->root_hex[i]);
|
||||
}
|
||||
}
|
||||
cfg->root_hex_ready = 1;
|
||||
DEBUG_INFO("follow: decoded %d root pubkeys", cfg->root_npub_count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Parse "p" tags from a kind-3 event and add pubkeys to the set. */
|
||||
static int parse_p_tags(cJSON *event, cr_pubkey_set_t *followed) {
|
||||
cJSON *tags = cJSON_GetObjectItem(event, "tags");
|
||||
if (!tags || !cJSON_IsArray(tags)) return 0;
|
||||
int added = 0;
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
cJSON *name = cJSON_GetArrayItem(tag, 0);
|
||||
if (!name || !cJSON_IsString(name)) continue;
|
||||
if (strcmp(cJSON_GetStringValue(name), "p") != 0) continue;
|
||||
cJSON *pk = cJSON_GetArrayItem(tag, 1);
|
||||
if (!pk || !cJSON_IsString(pk)) continue;
|
||||
const char *hex = cJSON_GetStringValue(pk);
|
||||
if (strlen(hex) != 64) continue;
|
||||
if (cr_pubkey_set_add(followed, hex) == 1) added++;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
int cr_follow_resolve(cr_config_t *cfg, nostr_relay_pool_t *upstream,
|
||||
cr_pubkey_set_t *followed) {
|
||||
if (!cfg->root_hex_ready) {
|
||||
if (cr_follow_decode_roots(cfg) != 0) return -1;
|
||||
}
|
||||
|
||||
/* Seed the followed set with the root pubkeys themselves. */
|
||||
for (int i = 0; i < cfg->root_npub_count; i++) {
|
||||
cr_pubkey_set_add(followed, cfg->root_hex[i]);
|
||||
}
|
||||
|
||||
int total_follows = 0;
|
||||
for (int i = 0; i < cfg->root_npub_count; i++) {
|
||||
/* Build filter: authors=[root], kinds=[3], limit=1 (most recent). */
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *authors = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(cfg->root_hex[i]));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON *kinds = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber(1));
|
||||
|
||||
const char **urls = NULL;
|
||||
int n = 0;
|
||||
char **listed = NULL;
|
||||
nostr_pool_relay_status_t *statuses = NULL;
|
||||
int listed_count = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
|
||||
if (listed_count > 0) {
|
||||
urls = malloc(listed_count * sizeof(char *));
|
||||
for (int j = 0; j < listed_count; j++) { urls[n++] = listed[j]; }
|
||||
}
|
||||
free(listed);
|
||||
free(statuses);
|
||||
|
||||
int ev_count = 0;
|
||||
cJSON **events = nostr_relay_pool_query_sync(upstream, urls, n,
|
||||
filter, &ev_count, 15000);
|
||||
free(urls);
|
||||
cJSON_Delete(filter);
|
||||
|
||||
if (events && ev_count > 0) {
|
||||
int added = parse_p_tags(events[0], followed);
|
||||
total_follows += added;
|
||||
DEBUG_INFO("follow: root %d: kind-3 found, +%d follows (total set %d)",
|
||||
i, added, followed->count);
|
||||
for (int k = 0; k < ev_count; k++) cJSON_Delete(events[k]);
|
||||
free(events);
|
||||
} else {
|
||||
DEBUG_WARN("follow: root %d: no kind-3 found", i);
|
||||
free(events);
|
||||
}
|
||||
}
|
||||
|
||||
DEBUG_INFO("follow: resolved %d total followed pubkeys (set size %d)",
|
||||
total_follows, followed->count);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* caching_relay - follow graph resolution.
|
||||
*
|
||||
* Decodes root npubs (NIP-19) to hex, queries their most recent kind-3
|
||||
* contact list from the upstream pool, parses the "p" tags, and populates
|
||||
* the in-memory followed-pubkey set (root pubkeys + their follows).
|
||||
*/
|
||||
#ifndef CACHING_RELAY_FOLLOW_GRAPH_H
|
||||
#define CACHING_RELAY_FOLLOW_GRAPH_H
|
||||
|
||||
#include "config.h"
|
||||
#include "state.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
/* Check if a hex pubkey is one of the root (admin) npubs. Returns 1 if yes. */
|
||||
int cr_follow_is_root(const cr_config_t *cfg, const char *hex);
|
||||
|
||||
/* Decode all root_npubs in cfg into cfg->root_hex[]. Returns 0 on success. */
|
||||
int cr_follow_decode_roots(cr_config_t *cfg);
|
||||
|
||||
/* Resolve the follow graph: for each root hex pubkey, query the most recent
|
||||
* kind-3 from the upstream pool, parse "p" tags, and add root + follows to
|
||||
* the followed set. Returns 0 on success, -1 on hard failure. */
|
||||
int cr_follow_resolve(cr_config_t *cfg, nostr_relay_pool_t *upstream,
|
||||
cr_pubkey_set_t *followed);
|
||||
|
||||
#endif /* CACHING_RELAY_FOLLOW_GRAPH_H */
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* caching_relay - forward catch-up implementation.
|
||||
*
|
||||
* See forward_catchup.h for design.
|
||||
*/
|
||||
#include "forward_catchup.h"
|
||||
#include "pg_inbox.h"
|
||||
#include "config.h"
|
||||
#include "follow_graph.h"
|
||||
#include "relay_sink.h"
|
||||
#include "debug.h"
|
||||
|
||||
#include <cjson/cJSON.h>
|
||||
#include <time.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* Find the newest (maximum) created_at among an array of event JSON objects.
|
||||
* Returns 1 and sets *out_newest, or 0 if no events / no valid created_at. */
|
||||
static int find_newest_created_at(cJSON **events, int count, long *out_newest) {
|
||||
long newest = 0;
|
||||
int found = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
cJSON *ca = cJSON_GetObjectItem(events[i], "created_at");
|
||||
if (!ca || !cJSON_IsNumber(ca)) continue;
|
||||
long ts = (long)ca->valuedouble;
|
||||
if (!found || ts > newest) {
|
||||
newest = ts;
|
||||
found = 1;
|
||||
}
|
||||
}
|
||||
if (found && out_newest) *out_newest = newest;
|
||||
return found;
|
||||
}
|
||||
|
||||
/* Build the kinds array for a given pubkey (admin vs regular).
|
||||
* Returns NULL if no kinds filter should be applied (admin_all_kinds). */
|
||||
static cJSON *build_kinds(cr_config_t *cfg, const char *pk) {
|
||||
int is_admin = cr_follow_is_root(cfg, pk);
|
||||
if (is_admin && cfg->admin_all_kinds) {
|
||||
return NULL;
|
||||
}
|
||||
cJSON *kinds = cJSON_CreateArray();
|
||||
if (is_admin && cfg->admin_kind_count > 0) {
|
||||
for (int i = 0; i < cfg->admin_kind_count; i++)
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->admin_kinds[i]));
|
||||
} else {
|
||||
for (int i = 0; i < cfg->kind_count; i++)
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->kinds[i]));
|
||||
}
|
||||
return kinds;
|
||||
}
|
||||
|
||||
/* Get relay URLs for an author from the relay progress table.
|
||||
* Returns a cJSON array of relay_url strings. Caller must cJSON_Delete().
|
||||
* Returns NULL on error or if no relays found. */
|
||||
static cJSON *get_author_relays(const char *pk) {
|
||||
/* Use pg_inbox_get_incomplete_relays for incomplete authors.
|
||||
* For completed authors, we need all relays — query directly. */
|
||||
cJSON *incomplete = pg_inbox_get_incomplete_relays(pk);
|
||||
if (incomplete && cJSON_GetArraySize(incomplete) > 0) {
|
||||
/* Extract just the relay_url strings. */
|
||||
cJSON *urls = cJSON_CreateArray();
|
||||
int n = cJSON_GetArraySize(incomplete);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON *entry = cJSON_GetArrayItem(incomplete, i);
|
||||
cJSON *url = cJSON_GetObjectItemCaseSensitive(entry, "relay_url");
|
||||
if (url && cJSON_IsString(url) && url->valuestring[0]) {
|
||||
cJSON_AddItemToArray(urls, cJSON_CreateString(url->valuestring));
|
||||
}
|
||||
}
|
||||
cJSON_Delete(incomplete);
|
||||
if (cJSON_GetArraySize(urls) > 0) return urls;
|
||||
cJSON_Delete(urls);
|
||||
}
|
||||
if (incomplete) cJSON_Delete(incomplete);
|
||||
|
||||
/* For completed authors, fall back to the upstream pool's connected
|
||||
* relays. The caller will use the pool directly. */
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int cr_forward_catchup(cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream,
|
||||
cr_sink_t *sink) {
|
||||
if (!cfg || !upstream || !sink) return -1;
|
||||
|
||||
cJSON *authors = pg_inbox_get_authors_for_catchup();
|
||||
if (!authors) {
|
||||
DEBUG_INFO("forward_catchup: no authors with last_event_at > 0");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int author_count = cJSON_GetArraySize(authors);
|
||||
if (author_count == 0) {
|
||||
cJSON_Delete(authors);
|
||||
DEBUG_INFO("forward_catchup: no authors need catch-up");
|
||||
return 0;
|
||||
}
|
||||
|
||||
DEBUG_INFO("forward_catchup: checking %d authors for missed events", author_count);
|
||||
|
||||
long now = (long)time(NULL);
|
||||
int page_size = cfg->backfill.events_per_tick;
|
||||
if (page_size < 1) page_size = 500;
|
||||
|
||||
cr_sink_set_source_class(sink, CR_SINK_CLASS_BACKFILL);
|
||||
|
||||
int total_events_published = 0;
|
||||
int authors_caught_up = 0;
|
||||
|
||||
for (int i = 0; i < author_count; i++) {
|
||||
cJSON *entry = cJSON_GetArrayItem(authors, i);
|
||||
if (!entry) continue;
|
||||
|
||||
cJSON *pk_node = cJSON_GetObjectItemCaseSensitive(entry, "pubkey");
|
||||
cJSON *lea_node = cJSON_GetObjectItemCaseSensitive(entry, "last_event_at");
|
||||
if (!pk_node || !cJSON_IsString(pk_node) || !lea_node || !cJSON_IsNumber(lea_node))
|
||||
continue;
|
||||
|
||||
const char *pk = pk_node->valuestring;
|
||||
long last_event_at = (long)lea_node->valuedouble;
|
||||
|
||||
if (last_event_at <= 0 || last_event_at >= now) continue;
|
||||
|
||||
/* Build filter: since = last_event_at + 1, until = now. */
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *authors_arr = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(authors_arr, cJSON_CreateString(pk));
|
||||
cJSON_AddItemToObject(filter, "authors", authors_arr);
|
||||
cJSON *kinds = build_kinds(cfg, pk);
|
||||
if (kinds) cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)(last_event_at + 1)));
|
||||
cJSON_AddItemToObject(filter, "until", cJSON_CreateNumber((double)now));
|
||||
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber((double)page_size));
|
||||
|
||||
/* Try to get author-specific relays first; fall back to upstream pool. */
|
||||
cJSON *author_relays = get_author_relays(pk);
|
||||
|
||||
int ev_count = 0;
|
||||
cJSON **events = NULL;
|
||||
|
||||
if (author_relays && cJSON_GetArraySize(author_relays) > 0) {
|
||||
/* Query using the author's outbox relays. */
|
||||
int n_relays = cJSON_GetArraySize(author_relays);
|
||||
const char **urls = malloc(sizeof(char *) * n_relays);
|
||||
for (int r = 0; r < n_relays; r++) {
|
||||
cJSON *u = cJSON_GetArrayItem(author_relays, r);
|
||||
urls[r] = cJSON_IsString(u) ? u->valuestring : "";
|
||||
}
|
||||
events = synchronous_query_relays_with_progress(
|
||||
urls, n_relays, filter, RELAY_QUERY_ALL_RESULTS,
|
||||
&ev_count, 30, NULL, NULL, 0, NULL);
|
||||
free(urls);
|
||||
} else {
|
||||
/* Fall back: query all connected upstream relays.
|
||||
* Use the pool's relay list. We pass NULL for urls to let
|
||||
* the pool use all connected relays. */
|
||||
events = synchronous_query_relays_with_progress(
|
||||
NULL, 0, filter, RELAY_QUERY_ALL_RESULTS,
|
||||
&ev_count, 30, NULL, NULL, 0, NULL);
|
||||
}
|
||||
cJSON_Delete(author_relays);
|
||||
cJSON_Delete(filter);
|
||||
|
||||
if (events && ev_count > 0) {
|
||||
/* Publish all events. */
|
||||
long newest = 0;
|
||||
find_newest_created_at(events, ev_count, &newest);
|
||||
|
||||
for (int k = 0; k < ev_count; k++) {
|
||||
cr_sink_publish(sink, events[k]);
|
||||
cJSON_Delete(events[k]);
|
||||
}
|
||||
free(events);
|
||||
total_events_published += ev_count;
|
||||
authors_caught_up++;
|
||||
|
||||
/* Update last_event_at to the newest event we saw. */
|
||||
if (newest > 0) {
|
||||
pg_inbox_update_last_event_at(pk, newest);
|
||||
}
|
||||
|
||||
DEBUG_LOG("forward_catchup: %s -> %d events (since=%ld, until=%ld)",
|
||||
pk, ev_count, last_event_at + 1, now);
|
||||
} else {
|
||||
/* No events in the gap — update last_event_at to now so we
|
||||
* don't re-query the same empty window next time. */
|
||||
pg_inbox_update_last_event_at(pk, now);
|
||||
if (events) free(events);
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(authors);
|
||||
|
||||
DEBUG_INFO("forward_catchup: %d authors checked, %d had events, %d total events published",
|
||||
author_count, authors_caught_up, total_events_published);
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* caching_relay - forward catch-up for gap bridging.
|
||||
*
|
||||
* When the caching service starts (or caching is re-enabled after being
|
||||
* off), events posted by followed authors during the downtime are missed
|
||||
* by the backward-drain backfill (which walks toward the beginning of
|
||||
* time) and by the live subscriber (which starts at `since = now`).
|
||||
*
|
||||
* The forward catch-up queries `since = last_event_at + 1, until = now`
|
||||
* for each followed author to bridge that gap. `last_event_at` is the
|
||||
* `created_at` of the most recent event we've ever seen for the author,
|
||||
* maintained by the backfill and live subscriber.
|
||||
*/
|
||||
#ifndef CACHING_RELAY_FORWARD_CATCHUP_H
|
||||
#define CACHING_RELAY_FORWARD_CATCHUP_H
|
||||
|
||||
#include "config.h"
|
||||
#include "relay_sink.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
/* Run forward catch-up for all followed authors with last_event_at > 0.
|
||||
* For each author, queries events from last_event_at + 1 to now on one
|
||||
* outbox relay and publishes them to the sink.
|
||||
*
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int cr_forward_catchup(cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream,
|
||||
cr_sink_t *sink);
|
||||
|
||||
#endif /* CACHING_RELAY_FORWARD_CATCHUP_H */
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* caching_relay - strip JSONC comments so cJSON can parse it.
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include "jsonc_strip.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
char *jsonc_strip_comments(const char *input, size_t len) {
|
||||
if (!input) return NULL;
|
||||
if (len == 0) len = strlen(input);
|
||||
|
||||
/* Worst case: output is same size as input (no comments). +1 for NUL. */
|
||||
char *out = malloc(len + 1);
|
||||
if (!out) return NULL;
|
||||
|
||||
size_t i = 0, o = 0;
|
||||
int in_string = 0;
|
||||
int escape = 0;
|
||||
|
||||
while (i < len) {
|
||||
char c = input[i];
|
||||
|
||||
if (in_string) {
|
||||
out[o++] = c;
|
||||
if (escape) {
|
||||
escape = 0;
|
||||
} else if (c == '\\') {
|
||||
escape = 1;
|
||||
} else if (c == '"') {
|
||||
in_string = 0;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Not in a string. */
|
||||
if (c == '"') {
|
||||
in_string = 1;
|
||||
out[o++] = c;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Line comment // */
|
||||
if (c == '/' && i + 1 < len && input[i + 1] == '/') {
|
||||
i += 2;
|
||||
while (i < len && input[i] != '\n') i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Block comment / * ... * / */
|
||||
if (c == '/' && i + 1 < len && input[i + 1] == '*') {
|
||||
i += 2;
|
||||
while (i < len && !(input[i] == '*' && i + 1 < len && input[i + 1] == '/')) i++;
|
||||
if (i < len) i += 2; /* skip closing */
|
||||
/* Preserve a space so tokens don't merge. */
|
||||
out[o++] = ' ';
|
||||
continue;
|
||||
}
|
||||
|
||||
out[o++] = c;
|
||||
i++;
|
||||
}
|
||||
|
||||
out[o] = '\0';
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* caching_relay - strip JSONC comments so cJSON can parse it.
|
||||
*
|
||||
* cJSON does not natively understand JSONC. This produces a heap buffer the
|
||||
* caller must free() containing the comment-stripped JSON. String literals are
|
||||
* respected so a double-slash inside a string is preserved.
|
||||
*/
|
||||
#ifndef CACHING_RELAY_JSONC_STRIP_H
|
||||
#define CACHING_RELAY_JSONC_STRIP_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/* Returns malloc'd buffer (null-terminated) or NULL on alloc failure. */
|
||||
char *jsonc_strip_comments(const char *input, size_t len);
|
||||
|
||||
#endif /* CACHING_RELAY_JSONC_STRIP_H */
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* caching_relay - live subscription on the upstream pool
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include "live_subscriber.h"
|
||||
#include "follow_graph.h"
|
||||
#include "pg_inbox.h"
|
||||
#include "debug.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
|
||||
/* Context passed as user_data to the subscription callbacks. */
|
||||
typedef struct {
|
||||
cr_sink_t *sink;
|
||||
cr_live_t *live;
|
||||
cr_config_t *cfg;
|
||||
} cr_live_ctx_t;
|
||||
|
||||
static cr_live_ctx_t g_live_ctx;
|
||||
|
||||
static void live_on_event(cJSON *event, const char *relay_url, void *user_data) {
|
||||
cr_live_ctx_t *ctx = (cr_live_ctx_t *)user_data;
|
||||
(void)relay_url;
|
||||
ctx->live->events_received++;
|
||||
cr_sink_publish(ctx->sink, event);
|
||||
|
||||
/* Update last_event_at for forward catch-up gap tracking. */
|
||||
cJSON *ev_pubkey = cJSON_GetObjectItem(event, "pubkey");
|
||||
cJSON *ev_created = cJSON_GetObjectItem(event, "created_at");
|
||||
if (ev_pubkey && cJSON_IsString(ev_pubkey) &&
|
||||
ev_created && cJSON_IsNumber(ev_created)) {
|
||||
pg_inbox_update_last_event_at(ev_pubkey->valuestring,
|
||||
(long)ev_created->valuedouble);
|
||||
}
|
||||
|
||||
/* Detect admin kind-3 (contact list) changes from a root pubkey and
|
||||
* signal the main loop to refresh the follow graph immediately. */
|
||||
if (ctx->cfg) {
|
||||
cJSON *kind = cJSON_GetObjectItem(event, "kind");
|
||||
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
|
||||
if (kind && cJSON_IsNumber(kind) && kind->valuedouble == 3.0 &&
|
||||
pubkey && cJSON_IsString(pubkey)) {
|
||||
if (cr_follow_is_root(ctx->cfg, cJSON_GetStringValue(pubkey))) {
|
||||
ctx->live->follow_graph_changed = 1;
|
||||
DEBUG_INFO("live: root kind-3 detected from %s, "
|
||||
"signaling follow graph refresh",
|
||||
cJSON_GetStringValue(pubkey));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void live_on_eose(cJSON **events, int event_count, void *user_data) {
|
||||
(void)events; (void)event_count; (void)user_data;
|
||||
DEBUG_LOG("live: EOSE (stored events flushed), staying open");
|
||||
}
|
||||
|
||||
/* Build a filter for non-admin followed pubkeys with regular kinds. */
|
||||
static cJSON *build_follows_filter(cr_config_t *cfg, cr_pubkey_set_t *followed) {
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *authors = cJSON_CreateArray();
|
||||
for (int i = 0; i < followed->count; i++) {
|
||||
/* Skip admin pubkeys - they get their own subscription. */
|
||||
if (cr_follow_is_root(cfg, followed->items[i])) continue;
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(followed->items[i]));
|
||||
}
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
|
||||
cJSON *kinds = cJSON_CreateArray();
|
||||
for (int i = 0; i < cfg->kind_count; i++)
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->kinds[i]));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
|
||||
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)time(NULL)));
|
||||
return filter;
|
||||
}
|
||||
|
||||
/* Build a filter for admin (root) pubkeys with admin_kinds (or all kinds). */
|
||||
static cJSON *build_admin_filter(cr_config_t *cfg) {
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *authors = cJSON_CreateArray();
|
||||
for (int i = 0; i < cfg->root_npub_count; i++)
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(cfg->root_hex[i]));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
|
||||
/* If admin_all_kinds, omit the kinds filter entirely (grab everything). */
|
||||
if (!cfg->admin_all_kinds && cfg->admin_kind_count > 0) {
|
||||
cJSON *kinds = cJSON_CreateArray();
|
||||
for (int i = 0; i < cfg->admin_kind_count; i++)
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->admin_kinds[i]));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
}
|
||||
|
||||
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)time(NULL)));
|
||||
return filter;
|
||||
}
|
||||
|
||||
static int get_relay_urls(nostr_relay_pool_t *upstream, const char ***urls_out) {
|
||||
char **listed = NULL;
|
||||
nostr_pool_relay_status_t *statuses = NULL;
|
||||
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
|
||||
const char **urls = NULL;
|
||||
if (n > 0) {
|
||||
urls = malloc(n * sizeof(char *));
|
||||
for (int j = 0; j < n; j++) urls[j] = listed[j];
|
||||
}
|
||||
free(listed);
|
||||
free(statuses);
|
||||
*urls_out = urls;
|
||||
return n;
|
||||
}
|
||||
|
||||
static nostr_pool_subscription_t *open_subscription(nostr_relay_pool_t *upstream,
|
||||
cJSON *filter) {
|
||||
const char **urls = NULL;
|
||||
int n = get_relay_urls(upstream, &urls);
|
||||
|
||||
nostr_pool_subscription_t *sub = nostr_relay_pool_subscribe(
|
||||
upstream, urls, n, filter,
|
||||
live_on_event, live_on_eose, &g_live_ctx,
|
||||
0, 1, NOSTR_POOL_EOSE_FULL_SET, 0, 0);
|
||||
free(urls);
|
||||
return sub;
|
||||
}
|
||||
|
||||
static int open_subs(cr_live_t *live, cr_config_t *cfg, nostr_relay_pool_t *upstream,
|
||||
cr_pubkey_set_t *followed, cr_sink_t *sink) {
|
||||
g_live_ctx.sink = sink;
|
||||
g_live_ctx.live = live;
|
||||
g_live_ctx.cfg = cfg;
|
||||
|
||||
int admin_count = 0;
|
||||
int follows_count = 0;
|
||||
for (int i = 0; i < followed->count; i++) {
|
||||
if (cr_follow_is_root(cfg, followed->items[i])) admin_count++;
|
||||
else follows_count++;
|
||||
}
|
||||
|
||||
/* Follows subscription (non-admin pubkeys, regular kinds). */
|
||||
if (follows_count > 0) {
|
||||
cJSON *filter = build_follows_filter(cfg, followed);
|
||||
live->follows_sub = open_subscription(upstream, filter);
|
||||
cJSON_Delete(filter);
|
||||
if (!live->follows_sub) {
|
||||
DEBUG_ERROR("live: follows subscribe failed");
|
||||
} else {
|
||||
DEBUG_INFO("live: follows sub - %d authors, %d kinds",
|
||||
follows_count, cfg->kind_count);
|
||||
}
|
||||
}
|
||||
|
||||
/* Admin subscription (root npubs, admin_kinds or all kinds). */
|
||||
if (admin_count > 0 || cfg->root_npub_count > 0) {
|
||||
cJSON *filter = build_admin_filter(cfg);
|
||||
live->admin_sub = open_subscription(upstream, filter);
|
||||
cJSON_Delete(filter);
|
||||
if (!live->admin_sub) {
|
||||
DEBUG_ERROR("live: admin subscribe failed");
|
||||
} else {
|
||||
if (cfg->admin_all_kinds) {
|
||||
DEBUG_INFO("live: admin sub - %d authors, ALL kinds",
|
||||
cfg->root_npub_count);
|
||||
} else {
|
||||
DEBUG_INFO("live: admin sub - %d authors, %d kinds",
|
||||
cfg->root_npub_count, cfg->admin_kind_count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
live->last_resubscribe = time(NULL);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cr_live_open(cr_live_t *live, cr_config_t *cfg, nostr_relay_pool_t *upstream,
|
||||
cr_pubkey_set_t *followed, cr_sink_t *sink) {
|
||||
memset(live, 0, sizeof(*live));
|
||||
return open_subs(live, cfg, upstream, followed, sink);
|
||||
}
|
||||
|
||||
int cr_live_resubscribe(cr_live_t *live, cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream, cr_pubkey_set_t *followed,
|
||||
cr_sink_t *sink) {
|
||||
cr_live_close(live);
|
||||
DEBUG_INFO("live: resubscribing (events so far: %ld)", live->events_received);
|
||||
return open_subs(live, cfg, upstream, followed, sink);
|
||||
}
|
||||
|
||||
void cr_live_close(cr_live_t *live) {
|
||||
if (live->follows_sub) {
|
||||
nostr_pool_subscription_close(live->follows_sub);
|
||||
live->follows_sub = NULL;
|
||||
}
|
||||
if (live->admin_sub) {
|
||||
nostr_pool_subscription_close(live->admin_sub);
|
||||
live->admin_sub = NULL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* caching_relay - live subscription on the upstream pool.
|
||||
*
|
||||
* Builds filters from the followed-pubkey set + configured kinds, opens
|
||||
* long-lived subscriptions, and routes on_event to the relay sink.
|
||||
*
|
||||
* Two subscriptions are opened:
|
||||
* 1. "follows" sub: non-admin followed pubkeys + regular kinds
|
||||
* 2. "admin" sub: root (admin) npubs + admin_kinds (or all kinds if [*])
|
||||
*/
|
||||
#ifndef CACHING_RELAY_LIVE_SUBSCRIBER_H
|
||||
#define CACHING_RELAY_LIVE_SUBSCRIBER_H
|
||||
|
||||
#include "config.h"
|
||||
#include "state.h"
|
||||
#include "relay_sink.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
typedef struct {
|
||||
nostr_pool_subscription_t *follows_sub; /* non-admin pubkeys, regular kinds */
|
||||
nostr_pool_subscription_t *admin_sub; /* admin pubkeys, admin_kinds */
|
||||
long events_received;
|
||||
time_t last_resubscribe;
|
||||
int follow_graph_changed; /* set when a root npub publishes a kind-3 */
|
||||
} cr_live_t;
|
||||
|
||||
/* Open the live subscription(s). Returns 0 on success. */
|
||||
int cr_live_open(cr_live_t *live, cr_config_t *cfg, nostr_relay_pool_t *upstream,
|
||||
cr_pubkey_set_t *followed, cr_sink_t *sink);
|
||||
|
||||
/* Close + reopen the subscription(s). Returns 0 on success. */
|
||||
int cr_live_resubscribe(cr_live_t *live, cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream, cr_pubkey_set_t *followed,
|
||||
cr_sink_t *sink);
|
||||
|
||||
void cr_live_close(cr_live_t *live);
|
||||
|
||||
#endif /* CACHING_RELAY_LIVE_SUBSCRIBER_H */
|
||||
@@ -0,0 +1,639 @@
|
||||
/*
|
||||
* caching_relay - a daemon that caches Nostr events from followed people
|
||||
* into a local relay.
|
||||
*
|
||||
* Architecture: see plans/plan.md. Two nostr_relay_pool_t instances:
|
||||
* - upstream_pool: query/subscribe to upstream relays
|
||||
* - sink_pool (in cr_sink_t): publish-only to the local relay
|
||||
*
|
||||
* Build: see Makefile. Statically linked C99 binary, c-relay style.
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include "main.h"
|
||||
#include "debug.h"
|
||||
#include "config.h"
|
||||
#include "state.h"
|
||||
#include "follow_graph.h"
|
||||
#include "relay_sink.h"
|
||||
#include "live_subscriber.h"
|
||||
#include "backfill.h"
|
||||
#include "forward_catchup.h"
|
||||
#include "relay_discovery.h"
|
||||
#include "pg_inbox.h"
|
||||
#include "pg_config.h"
|
||||
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_log.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <signal.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include <getopt.h>
|
||||
|
||||
/* Forward nostr_core_lib's internal logging to our debug system. */
|
||||
static void nostr_log_forwarder(int level, const char *component,
|
||||
const char *message, void *user_data) {
|
||||
(void)user_data;
|
||||
/* Map nostr log levels to our debug levels (they're the same 1-5). */
|
||||
if (level >= 5) {
|
||||
DEBUG_TRACE("[nostr:%s] %s", component ? component : "?", message ? message : "");
|
||||
} else if (level >= 4) {
|
||||
DEBUG_LOG("[nostr:%s] %s", component ? component : "?", message ? message : "");
|
||||
} else if (level >= 3) {
|
||||
DEBUG_INFO("[nostr:%s] %s", component ? component : "?", message ? message : "");
|
||||
} else if (level >= 2) {
|
||||
DEBUG_WARN("[nostr:%s] %s", component ? component : "?", message ? message : "");
|
||||
} else {
|
||||
DEBUG_ERROR("[nostr:%s] %s", component ? component : "?", message ? message : "");
|
||||
}
|
||||
}
|
||||
|
||||
/* Initialize per-relay backfill progress rows for every followed pubkey.
|
||||
* For each author, merges its discovered outbox relays (from relay_map)
|
||||
* with the bootstrap (upstream) relays from cfg, then inserts one
|
||||
* caching_backfill_relay_progress row per relay (ON CONFLICT DO NOTHING).
|
||||
* Safe to call repeatedly - existing rows are left untouched. */
|
||||
static void init_relay_progress_for_all(const cr_config_t *cfg,
|
||||
const cr_relay_map_t *relay_map,
|
||||
const cr_pubkey_set_t *followed) {
|
||||
if (!followed || followed->count <= 0) return;
|
||||
|
||||
/* Bootstrap relay URLs from cfg->upstream_relays. */
|
||||
const char **bootstrap = NULL;
|
||||
int bootstrap_n = cfg->upstream_count;
|
||||
if (bootstrap_n > 0) {
|
||||
bootstrap = malloc(bootstrap_n * sizeof(char *));
|
||||
if (!bootstrap) return;
|
||||
for (int i = 0; i < bootstrap_n; i++)
|
||||
bootstrap[i] = cfg->upstream_relays[i];
|
||||
}
|
||||
|
||||
for (int i = 0; i < followed->count; i++) {
|
||||
const char *pk = followed->items[i];
|
||||
if (!pk || !*pk) continue;
|
||||
|
||||
/* Collect this author's outbox relays. */
|
||||
const cr_outbox_entry_t *oe = NULL;
|
||||
int outbox_n = 0;
|
||||
if (relay_map) {
|
||||
oe = cr_relay_map_get_outbox(relay_map, pk);
|
||||
if (oe) outbox_n = oe->relay_count;
|
||||
}
|
||||
|
||||
int total = outbox_n + bootstrap_n;
|
||||
if (total <= 0) continue;
|
||||
|
||||
const char **urls = malloc(total * sizeof(char *));
|
||||
if (!urls) continue;
|
||||
int n = 0;
|
||||
if (oe) {
|
||||
for (int j = 0; j < oe->relay_count && n < total; j++)
|
||||
urls[n++] = oe->relays[j];
|
||||
}
|
||||
for (int j = 0; j < bootstrap_n && n < total; j++) {
|
||||
int dup = 0;
|
||||
for (int k = 0; k < n; k++) {
|
||||
if (strcmp(urls[k], bootstrap[j]) == 0) { dup = 1; break; }
|
||||
}
|
||||
if (!dup) urls[n++] = bootstrap[j];
|
||||
}
|
||||
|
||||
if (n > 0) {
|
||||
pg_inbox_init_relay_progress_for_author(pk, urls, n);
|
||||
}
|
||||
free(urls);
|
||||
}
|
||||
|
||||
free(bootstrap);
|
||||
}
|
||||
|
||||
volatile sig_atomic_t g_shutdown = 0;
|
||||
static volatile sig_atomic_t g_reload = 0;
|
||||
|
||||
static void on_signal(int sig) {
|
||||
if (sig == SIGINT || sig == SIGTERM) g_shutdown = 1;
|
||||
else if (sig == SIGHUP) g_reload = 1;
|
||||
}
|
||||
|
||||
static void usage(const char *prog) {
|
||||
fprintf(stderr,
|
||||
"caching_relay %s - cache Nostr events from followed people into a local relay\n"
|
||||
"\n"
|
||||
"Usage: %s [-c <config.jsonc>] [-p <pg-conn>] [options]\n"
|
||||
"\n"
|
||||
"Options:\n"
|
||||
" -c, --config <file> Path to .jsonc config file (default: ./caching_relay_config.jsonc)\n"
|
||||
" -p, --pg-conn <str> PostgreSQL connection string (libpq format). When provided,\n"
|
||||
" config is read from the c-relay-pg config table and fetched\n"
|
||||
" events are inserted into the caching_event_inbox table instead\n"
|
||||
" of being published via WebSocket to a local relay.\n"
|
||||
" -d, --debug <level> Log level 0-5 (0=none, 1=error, 2=warn, 3=info, 4=debug, 5=trace). Default 3.\n"
|
||||
" -r, --restart Reset state to first-time startup (ignore local relay cache,\n"
|
||||
" re-discover all kind-10002 from bootstrap relays, reset backfill)\n"
|
||||
" -h, --help Show this help\n"
|
||||
"\n"
|
||||
"Without -p, the daemon uses the .jsonc config file and publishes to a local relay\n"
|
||||
"via WebSocket (legacy mode). With -p, it uses PostgreSQL for config and inbox.\n"
|
||||
"The config file is also the persistent state store in legacy mode; the daemon\n"
|
||||
"rewrites it as backfill progresses. See plans/plan.md and caching_relay_config.jsonc.\n",
|
||||
CR_VERSION, prog);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const char *config_path = NULL;
|
||||
const char *pg_conn = NULL;
|
||||
int log_level = DEBUG_LEVEL_INFO;
|
||||
int restart = 0;
|
||||
|
||||
static struct option longopts[] = {
|
||||
{"config", required_argument, 0, 'c'},
|
||||
{"pg-conn", required_argument, 0, 'p'},
|
||||
{"debug", required_argument, 0, 'd'},
|
||||
{"restart", no_argument, 0, 'r'},
|
||||
{"help", no_argument, 0, 'h'},
|
||||
{0, 0, 0, 0}
|
||||
};
|
||||
int opt;
|
||||
while ((opt = getopt_long(argc, argv, "c:p:d:rh", longopts, NULL)) != -1) {
|
||||
switch (opt) {
|
||||
case 'c': config_path = optarg; break;
|
||||
case 'p': pg_conn = optarg; break;
|
||||
case 'd': log_level = atoi(optarg); break;
|
||||
case 'r': restart = 1; break;
|
||||
case 'h': usage(argv[0]); return 0;
|
||||
default: usage(argv[0]); return 1;
|
||||
}
|
||||
}
|
||||
/* In PostgreSQL mode, the .jsonc config file is optional. */
|
||||
if (!config_path && !pg_conn) {
|
||||
/* Default: look for caching_relay.jsonc in the current directory. */
|
||||
config_path = "caching_relay_config.jsonc";
|
||||
if (access(config_path, F_OK) != 0) {
|
||||
fprintf(stderr, "ERROR: no config specified and default '%s' not found\n\n", config_path);
|
||||
usage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (log_level < 0) log_level = 0;
|
||||
if (log_level > 5) log_level = 5;
|
||||
|
||||
debug_init(log_level);
|
||||
|
||||
/* Enable nostr_core_lib internal logging and forward to our debug system.
|
||||
* This shows the actual WebSocket messages (REQ filters, EVENT responses)
|
||||
* at debug level 5 (trace). */
|
||||
nostr_set_log_callback(nostr_log_forwarder, NULL);
|
||||
nostr_set_log_level((nostr_log_level_t)log_level);
|
||||
|
||||
DEBUG_INFO("caching_relay %s starting (config=%s, pg-conn=%s, loglevel=%d%s)",
|
||||
CR_VERSION,
|
||||
config_path ? config_path : "(none)",
|
||||
pg_conn ? "yes" : "no",
|
||||
log_level, restart ? ", RESTART" : "");
|
||||
|
||||
/* Signals. */
|
||||
struct sigaction sa;
|
||||
memset(&sa, 0, sizeof(sa));
|
||||
sa.sa_handler = on_signal;
|
||||
sigaction(SIGINT, &sa, NULL);
|
||||
sigaction(SIGTERM, &sa, NULL);
|
||||
sigaction(SIGHUP, &sa, NULL);
|
||||
/* Ignore SIGPIPE - relay pool handles its own socket errors. */
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
|
||||
/* PostgreSQL inbox mode: connect and load config from the config table. */
|
||||
long config_generation = -1;
|
||||
if (pg_conn) {
|
||||
if (pg_inbox_init(pg_conn) != 0) {
|
||||
DEBUG_ERROR("failed to connect to PostgreSQL");
|
||||
return 1;
|
||||
}
|
||||
config_generation = pg_inbox_get_config_generation();
|
||||
if (config_generation < 0) {
|
||||
DEBUG_WARN("could not read caching_config_generation (defaulting to 0)");
|
||||
config_generation = 0;
|
||||
}
|
||||
DEBUG_INFO("config generation: %ld", config_generation);
|
||||
}
|
||||
|
||||
/* Load config. */
|
||||
cr_config_t cfg;
|
||||
if (pg_conn) {
|
||||
if (pg_config_load(&cfg) != 0) {
|
||||
DEBUG_ERROR("failed to load config from PostgreSQL");
|
||||
pg_inbox_shutdown();
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
if (cr_config_load(&cfg, config_path) != 0) return 1;
|
||||
}
|
||||
|
||||
/* --restart: reset state to first-time startup. */
|
||||
if (restart) {
|
||||
DEBUG_INFO("RESTART: resetting state to first-time startup");
|
||||
cfg.state.backfilled_until = 0;
|
||||
if (pg_conn) {
|
||||
/* Reset per-author backfill progress (caching_followed_pubkeys)
|
||||
* so the next run starts fresh. The followed set itself is
|
||||
* preserved; only cursor/completion state is reset. */
|
||||
pg_inbox_reset_backfill_progress();
|
||||
} else {
|
||||
cr_config_save_state(&cfg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Init crypto. */
|
||||
if (nostr_crypto_init() != NOSTR_SUCCESS) {
|
||||
DEBUG_ERROR("nostr_crypto_init failed");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* In-memory state. */
|
||||
cr_seen_ring_t seen;
|
||||
cr_seen_ring_init(&seen);
|
||||
cr_pubkey_set_t followed;
|
||||
cr_pubkey_set_init(&followed);
|
||||
|
||||
/* Upstream pool. */
|
||||
nostr_relay_pool_t *upstream = nostr_relay_pool_create(nostr_pool_reconnect_config_default());
|
||||
if (!upstream) {
|
||||
DEBUG_ERROR("failed to create upstream pool");
|
||||
return 1;
|
||||
}
|
||||
for (int i = 0; i < cfg.upstream_count; i++) {
|
||||
if (nostr_relay_pool_add_relay(upstream, cfg.upstream_relays[i]) != NOSTR_SUCCESS) {
|
||||
DEBUG_WARN("failed to add upstream relay %s (continuing)", cfg.upstream_relays[i]);
|
||||
} else {
|
||||
DEBUG_INFO("upstream: added %s", cfg.upstream_relays[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sink: WebSocket pool (legacy) or PostgreSQL inbox. */
|
||||
cr_sink_t sink;
|
||||
if (pg_conn) {
|
||||
if (cr_sink_init_pg(&sink, &seen) != 0) {
|
||||
DEBUG_ERROR("failed to init sink (pg)");
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
if (cr_sink_init(&sink, cfg.local_relay, &seen) != 0) {
|
||||
DEBUG_ERROR("failed to init sink");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Give the sink pool a moment to connect to the local relay before we
|
||||
* start publishing (relay discovery caches kind-10002 events to local).
|
||||
* No-op in PostgreSQL inbox mode (cr_sink_pump does nothing). */
|
||||
if (!pg_conn) {
|
||||
DEBUG_INFO("waiting for sink connection to establish...");
|
||||
for (int i = 0; i < 30 && !g_shutdown; i++) {
|
||||
cr_sink_pump(&sink, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/* Resolve follow graph. */
|
||||
if (g_shutdown) goto shutdown;
|
||||
if (cr_follow_resolve(&cfg, upstream, &followed) != 0) {
|
||||
DEBUG_ERROR("follow graph resolution failed");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Sync the followed set to the DB so the backfill tick can iterate
|
||||
* over it (per-author until-cursor drain). Also run the one-time
|
||||
* migration from the old caching_backfill_progress table. */
|
||||
if (pg_conn) {
|
||||
pg_inbox_migrate_backfill_progress();
|
||||
const char **root_pks = malloc(cfg.root_npub_count * sizeof(char *));
|
||||
for (int i = 0; i < cfg.root_npub_count; i++)
|
||||
root_pks[i] = cfg.root_hex[i];
|
||||
pg_inbox_sync_followed_pubkeys((const char **)followed.items,
|
||||
followed.count, root_pks,
|
||||
cfg.root_npub_count);
|
||||
free(root_pks);
|
||||
}
|
||||
|
||||
if (g_shutdown) goto shutdown;
|
||||
|
||||
/* Discover outbox relays (NIP-65) and compute minimum covering set. */
|
||||
int is_first_time = (cfg.state.backfilled_until == 0);
|
||||
cr_relay_map_t relay_map;
|
||||
if (cr_relay_discovery_run(&relay_map, &cfg, upstream, &sink, &followed,
|
||||
is_first_time) != 0) {
|
||||
DEBUG_WARN("relay discovery failed, continuing with bootstrap relays only");
|
||||
memset(&relay_map, 0, sizeof(relay_map));
|
||||
}
|
||||
|
||||
/* Add discovered outbox relays to the upstream pool. */
|
||||
for (int i = 0; i < relay_map.selected_count; i++) {
|
||||
/* Check if already in the pool (bootstrap relays may already be there). */
|
||||
char **listed = NULL;
|
||||
nostr_pool_relay_status_t *statuses = NULL;
|
||||
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
|
||||
int already = 0;
|
||||
for (int j = 0; j < n; j++) {
|
||||
if (strcmp(listed[j], relay_map.selected_relays[i]) == 0) {
|
||||
already = 1; break;
|
||||
}
|
||||
}
|
||||
free(listed);
|
||||
free(statuses);
|
||||
if (!already) {
|
||||
if (nostr_relay_pool_add_relay(upstream, relay_map.selected_relays[i]) == NOSTR_SUCCESS) {
|
||||
DEBUG_INFO("upstream: added outbox relay %s", relay_map.selected_relays[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Log final upstream pool. */
|
||||
{
|
||||
char **listed = NULL;
|
||||
nostr_pool_relay_status_t *statuses = NULL;
|
||||
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
|
||||
DEBUG_INFO("upstream_pool: %d relays connected:", n);
|
||||
for (int j = 0; j < n; j++) {
|
||||
DEBUG_INFO(" %s", listed[j]);
|
||||
}
|
||||
free(listed);
|
||||
free(statuses);
|
||||
}
|
||||
|
||||
/* Create per-relay backfill progress rows for every followed pubkey
|
||||
* (outbox relays + bootstrap relays). Existing rows are left untouched
|
||||
* so cursor/completion state is preserved across restarts. */
|
||||
if (pg_conn) {
|
||||
init_relay_progress_for_all(&cfg, &relay_map, &followed);
|
||||
}
|
||||
|
||||
/* Forward catch-up: bridge the gap for events posted while caching
|
||||
* was off. Runs once at startup (not on --restart, which does a full
|
||||
* re-drain from now). For each followed author with last_event_at > 0,
|
||||
* queries since = last_event_at + 1, until = now. */
|
||||
if (pg_conn && !restart && cfg.backfill.enabled) {
|
||||
DEBUG_INFO("forward catch-up: bridging gap for followed authors");
|
||||
cr_forward_catchup(&cfg, upstream, &sink);
|
||||
}
|
||||
|
||||
/* Open live subscription. */
|
||||
cr_live_t live;
|
||||
if (cfg.live.enabled) {
|
||||
if (cr_live_open(&live, &cfg, upstream, &followed, &sink) != 0) {
|
||||
DEBUG_WARN("live subscription failed to open (will retry on resubscribe)");
|
||||
}
|
||||
} else {
|
||||
memset(&live, 0, sizeof(live));
|
||||
}
|
||||
|
||||
/* Init backfill. */
|
||||
cr_backfill_t bf;
|
||||
cr_backfill_init(&bf, &cfg);
|
||||
|
||||
time_t last_follow_refresh = time(NULL);
|
||||
time_t last_state_save = time(NULL);
|
||||
time_t last_status_heartbeat = 0;
|
||||
|
||||
/* Initial status heartbeat in PostgreSQL mode. */
|
||||
if (pg_conn) {
|
||||
int bf_complete = 0, bf_total = 0;
|
||||
pg_inbox_count_backfill_progress(&bf_complete, &bf_total);
|
||||
pg_inbox_update_status("starting", config_generation, (long)time(NULL),
|
||||
followed.count, relay_map.selected_count, 0,
|
||||
bf_complete, bf_total, 0, 0, NULL, 0);
|
||||
}
|
||||
|
||||
DEBUG_INFO("entering main loop");
|
||||
|
||||
while (!g_shutdown) {
|
||||
if (g_reload) {
|
||||
g_reload = 0;
|
||||
DEBUG_INFO("SIGHUP: reloading config (state preserved)");
|
||||
cr_config_t newcfg;
|
||||
int reload_ok = 0;
|
||||
if (pg_conn) {
|
||||
if (pg_config_load(&newcfg) == 0 &&
|
||||
cr_follow_decode_roots(&newcfg) == 0) reload_ok = 1;
|
||||
} else if (config_path) {
|
||||
if (cr_config_load(&newcfg, config_path) == 0) reload_ok = 1;
|
||||
}
|
||||
if (reload_ok) {
|
||||
/* Preserve runtime state across reload. */
|
||||
newcfg.state = cfg.state;
|
||||
cr_config_free(&cfg);
|
||||
cfg = newcfg;
|
||||
DEBUG_INFO("config reloaded");
|
||||
/* Force an immediate follow-graph refresh so any new
|
||||
* root npubs are picked up right away. */
|
||||
last_follow_refresh = 0;
|
||||
} else {
|
||||
DEBUG_ERROR("config reload failed, keeping old config");
|
||||
}
|
||||
}
|
||||
|
||||
/* PostgreSQL: check for config generation change and reload. */
|
||||
if (pg_conn) {
|
||||
int changed = pg_config_generation_changed(config_generation);
|
||||
if (changed == 1) {
|
||||
long new_gen = pg_inbox_get_config_generation();
|
||||
DEBUG_INFO("config generation changed (%ld -> %ld), reloading",
|
||||
config_generation, new_gen);
|
||||
cr_config_t newcfg;
|
||||
if (pg_config_load(&newcfg) == 0) {
|
||||
/* Decode root npubs to hex — pg_config_load fills
|
||||
* root_npubs[] but NOT root_hex[] / root_hex_ready.
|
||||
* Without this, cr_follow_is_root() returns 0 for
|
||||
* everything and the new root's follows are never
|
||||
* resolved. */
|
||||
if (cr_follow_decode_roots(&newcfg) != 0) {
|
||||
DEBUG_ERROR("config reload: failed to decode root npubs, keeping old config");
|
||||
cr_config_free(&newcfg);
|
||||
} else {
|
||||
newcfg.state = cfg.state;
|
||||
cr_config_free(&cfg);
|
||||
cfg = newcfg;
|
||||
config_generation = new_gen;
|
||||
DEBUG_INFO("config reloaded from PostgreSQL");
|
||||
/* Force an immediate follow-graph refresh so the
|
||||
* new root npub's follows are picked up right
|
||||
* away (instead of waiting up to
|
||||
* follow_graph_refresh_seconds). */
|
||||
last_follow_refresh = 0;
|
||||
/* Resubscribe live with new config. */
|
||||
if (cfg.live.enabled) {
|
||||
cr_live_resubscribe(&live, &cfg, upstream, &followed, &sink);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DEBUG_ERROR("PostgreSQL config reload failed, keeping old config");
|
||||
}
|
||||
} else if (changed < 0) {
|
||||
DEBUG_WARN("config generation check failed");
|
||||
}
|
||||
}
|
||||
|
||||
/* Pump upstream pool (drives live subscription callbacks). */
|
||||
nostr_relay_pool_run(upstream, 100);
|
||||
|
||||
/* Pump sink pool (flush publish callbacks). */
|
||||
cr_sink_pump(&sink, 50);
|
||||
|
||||
/* Backfill tick. */
|
||||
int brc = cr_backfill_tick(&bf, &cfg, upstream, &followed, &sink, &relay_map);
|
||||
(void)brc;
|
||||
|
||||
/* Immediate follow-graph refresh when a root npub publishes a new
|
||||
* kind-3 contact list (detected by the live subscriber). */
|
||||
if (cfg.live.enabled && live.follow_graph_changed) {
|
||||
live.follow_graph_changed = 0;
|
||||
DEBUG_INFO("live: admin kind-3 detected, refreshing follow graph immediately");
|
||||
cr_pubkey_set_t new_followed;
|
||||
cr_pubkey_set_init(&new_followed);
|
||||
if (cr_follow_resolve(&cfg, upstream, &new_followed) == 0) {
|
||||
int changed = (new_followed.count != followed.count);
|
||||
if (!changed) {
|
||||
for (int i = 0; i < followed.count; i++) {
|
||||
if (!cr_pubkey_set_contains(&new_followed, followed.items[i])) {
|
||||
changed = 1; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
cr_pubkey_set_free(&followed);
|
||||
followed = new_followed;
|
||||
if (pg_conn) {
|
||||
const char **root_pks = malloc(cfg.root_npub_count * sizeof(char *));
|
||||
for (int i = 0; i < cfg.root_npub_count; i++)
|
||||
root_pks[i] = cfg.root_hex[i];
|
||||
pg_inbox_sync_followed_pubkeys((const char **)followed.items,
|
||||
followed.count, root_pks,
|
||||
cfg.root_npub_count);
|
||||
free(root_pks);
|
||||
/* Create relay progress rows for newly added follows.
|
||||
* Existing rows are preserved (ON CONFLICT DO NOTHING). */
|
||||
init_relay_progress_for_all(&cfg, &relay_map, &followed);
|
||||
}
|
||||
if (changed && cfg.live.enabled) {
|
||||
cr_live_resubscribe(&live, &cfg, upstream, &followed, &sink);
|
||||
}
|
||||
/* Reset the backfill in_progress flag in case we had entered
|
||||
* steady-state; new follows need to be drained. */
|
||||
bf.in_progress = 1;
|
||||
last_follow_refresh = time(NULL);
|
||||
} else {
|
||||
DEBUG_WARN("immediate follow graph refresh failed");
|
||||
cr_pubkey_set_free(&new_followed);
|
||||
}
|
||||
}
|
||||
|
||||
/* Periodic follow-graph refresh. */
|
||||
time_t now = time(NULL);
|
||||
if (cfg.follow_graph_refresh_seconds > 0 &&
|
||||
(now - last_follow_refresh) >= cfg.follow_graph_refresh_seconds) {
|
||||
DEBUG_INFO("refreshing follow graph");
|
||||
cr_pubkey_set_t new_followed;
|
||||
cr_pubkey_set_init(&new_followed);
|
||||
if (cr_follow_resolve(&cfg, upstream, &new_followed) == 0) {
|
||||
/* If the set changed, resubscribe live. */
|
||||
int changed = (new_followed.count != followed.count);
|
||||
if (!changed) {
|
||||
for (int i = 0; i < followed.count; i++) {
|
||||
if (!cr_pubkey_set_contains(&new_followed, followed.items[i])) {
|
||||
changed = 1; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
cr_pubkey_set_free(&followed);
|
||||
followed = new_followed;
|
||||
/* Sync the refreshed followed set to the DB so newly added
|
||||
* follows get backfilled and dropped follows stop. Existing
|
||||
* cursor/completion state is preserved (upsert only touches
|
||||
* is_root and last_seen). */
|
||||
if (pg_conn) {
|
||||
const char **root_pks = malloc(cfg.root_npub_count * sizeof(char *));
|
||||
for (int i = 0; i < cfg.root_npub_count; i++)
|
||||
root_pks[i] = cfg.root_hex[i];
|
||||
pg_inbox_sync_followed_pubkeys((const char **)followed.items,
|
||||
followed.count, root_pks,
|
||||
cfg.root_npub_count);
|
||||
free(root_pks);
|
||||
/* Create relay progress rows for newly added follows.
|
||||
* Existing rows are preserved (ON CONFLICT DO NOTHING). */
|
||||
init_relay_progress_for_all(&cfg, &relay_map, &followed);
|
||||
}
|
||||
if (changed && cfg.live.enabled) {
|
||||
cr_live_resubscribe(&live, &cfg, upstream, &followed, &sink);
|
||||
}
|
||||
/* Reset the backfill in_progress flag in case we had entered
|
||||
* steady-state; new follows need to be drained. The backfill
|
||||
* tick will quickly re-enter steady-state if there are no
|
||||
* incomplete authors, so this is cheap. */
|
||||
bf.in_progress = 1;
|
||||
} else {
|
||||
DEBUG_WARN("follow graph refresh failed");
|
||||
cr_pubkey_set_free(&new_followed);
|
||||
}
|
||||
last_follow_refresh = now;
|
||||
}
|
||||
|
||||
/* Periodic live resubscribe. */
|
||||
if (cfg.live.enabled && cfg.live.resubscribe_interval_seconds > 0 &&
|
||||
(now - live.last_resubscribe) >= cfg.live.resubscribe_interval_seconds) {
|
||||
cr_live_resubscribe(&live, &cfg, upstream, &followed, &sink);
|
||||
}
|
||||
|
||||
/* Periodic state save (in case backfill didn't just save). */
|
||||
if ((now - last_state_save) >= 60) {
|
||||
if (!pg_conn) cr_config_save_state(&cfg);
|
||||
last_state_save = now;
|
||||
}
|
||||
|
||||
/* PostgreSQL: periodic status heartbeat. */
|
||||
if (pg_conn && (now - last_status_heartbeat) >= 15) {
|
||||
long events_fetched = live.events_received + bf.events_total;
|
||||
long inbox_inserts = sink.published_ok;
|
||||
int connected = 0;
|
||||
{
|
||||
char **listed = NULL;
|
||||
nostr_pool_relay_status_t *statuses = NULL;
|
||||
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
|
||||
for (int j = 0; j < n; j++) {
|
||||
if (statuses[j] == NOSTR_POOL_RELAY_CONNECTED) connected++;
|
||||
}
|
||||
free(listed);
|
||||
free(statuses);
|
||||
}
|
||||
int bf_complete = 0, bf_total = 0;
|
||||
pg_inbox_count_backfill_progress(&bf_complete, &bf_total);
|
||||
pg_inbox_update_status("running", config_generation, (long)now,
|
||||
followed.count, relay_map.selected_count,
|
||||
connected, bf_complete, bf_total,
|
||||
events_fetched, inbox_inserts, NULL, 0);
|
||||
last_status_heartbeat = now;
|
||||
}
|
||||
}
|
||||
|
||||
/* Graceful shutdown. */
|
||||
shutdown:
|
||||
DEBUG_INFO("shutting down...");
|
||||
cr_live_close(&live);
|
||||
if (!pg_conn) cr_config_save_state(&cfg);
|
||||
if (pg_conn) {
|
||||
int bf_complete = 0, bf_total = 0;
|
||||
pg_inbox_count_backfill_progress(&bf_complete, &bf_total);
|
||||
pg_inbox_update_status("stopped", config_generation, (long)time(NULL),
|
||||
followed.count, relay_map.selected_count, 0,
|
||||
bf_complete, bf_total,
|
||||
live.events_received + bf.events_total,
|
||||
sink.published_ok, NULL, 0);
|
||||
}
|
||||
cr_sink_destroy(&sink);
|
||||
nostr_relay_pool_destroy(upstream);
|
||||
cr_pubkey_set_free(&followed);
|
||||
cr_relay_map_free(&relay_map);
|
||||
cr_config_free(&cfg);
|
||||
if (pg_conn) pg_inbox_shutdown();
|
||||
nostr_crypto_cleanup();
|
||||
DEBUG_INFO("clean exit");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* caching_relay - main header
|
||||
*
|
||||
* Version information is auto-updated by increment_and_push.sh.
|
||||
* Git tags are the source of truth for versioning.
|
||||
*/
|
||||
#ifndef CACHING_RELAY_MAIN_H
|
||||
#define CACHING_RELAY_MAIN_H
|
||||
|
||||
// Version information (auto-updated by increment_and_push.sh)
|
||||
#define CR_VERSION_MAJOR 0
|
||||
#define CR_VERSION_MINOR 0
|
||||
#define CR_VERSION_PATCH 2
|
||||
#define CR_VERSION "v0.0.2"
|
||||
|
||||
#endif /* CACHING_RELAY_MAIN_H */
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* caching_relay - read caching configuration from the c-relay-pg PostgreSQL
|
||||
* config table and populate the existing cr_config_t structure.
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include "pg_config.h"
|
||||
#include "pg_inbox.h"
|
||||
#include "debug.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Split a comma-separated string into a string array (fixed-size rows).
|
||||
* Trims leading/trailing whitespace from each token. */
|
||||
static void split_csv_str(const char *s, char *dst, int max, int elem_len,
|
||||
int *count_out) {
|
||||
int count = 0;
|
||||
if (!s) { *count_out = 0; return; }
|
||||
|
||||
/* Work on a mutable copy because strtok_r modifies its input. */
|
||||
char *copy = strdup(s);
|
||||
if (!copy) { *count_out = 0; return; }
|
||||
|
||||
char *saveptr = NULL;
|
||||
char *tok = strtok_r(copy, ",", &saveptr);
|
||||
while (tok && count < max) {
|
||||
/* Trim leading spaces. */
|
||||
while (*tok == ' ' || *tok == '\t') tok++;
|
||||
/* Trim trailing spaces. */
|
||||
char *end = tok + strlen(tok) - 1;
|
||||
while (end > tok && (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n')) {
|
||||
*end-- = '\0';
|
||||
}
|
||||
if (*tok != '\0') {
|
||||
strncpy(dst + count * elem_len, tok, elem_len - 1);
|
||||
dst[count * elem_len + (elem_len - 1)] = '\0';
|
||||
count++;
|
||||
}
|
||||
tok = strtok_r(NULL, ",", &saveptr);
|
||||
}
|
||||
free(copy);
|
||||
*count_out = count;
|
||||
}
|
||||
|
||||
/* Split a comma-separated string of integers into an int array. */
|
||||
static void split_csv_int(const char *s, int *dst, int max, int *count_out) {
|
||||
int count = 0;
|
||||
if (!s) { *count_out = 0; return; }
|
||||
|
||||
char *copy = strdup(s);
|
||||
if (!copy) { *count_out = 0; return; }
|
||||
|
||||
char *saveptr = NULL;
|
||||
char *tok = strtok_r(copy, ",", &saveptr);
|
||||
while (tok && count < max) {
|
||||
while (*tok == ' ' || *tok == '\t') tok++;
|
||||
if (*tok != '\0') {
|
||||
dst[count++] = atoi(tok);
|
||||
}
|
||||
tok = strtok_r(NULL, ",", &saveptr);
|
||||
}
|
||||
free(copy);
|
||||
*count_out = count;
|
||||
}
|
||||
|
||||
/* Split a comma-separated string of longs into a long array. */
|
||||
static void split_csv_long(const char *s, long *dst, int max, int *count_out) {
|
||||
int count = 0;
|
||||
if (!s) { *count_out = 0; return; }
|
||||
|
||||
char *copy = strdup(s);
|
||||
if (!copy) { *count_out = 0; return; }
|
||||
|
||||
char *saveptr = NULL;
|
||||
char *tok = strtok_r(copy, ",", &saveptr);
|
||||
while (tok && count < max) {
|
||||
while (*tok == ' ' || *tok == '\t') tok++;
|
||||
if (*tok != '\0') {
|
||||
dst[count++] = strtol(tok, NULL, 10);
|
||||
}
|
||||
tok = strtok_r(NULL, ",", &saveptr);
|
||||
}
|
||||
free(copy);
|
||||
*count_out = count;
|
||||
}
|
||||
|
||||
/* Read a config key as a freshly-allocated string. Returns NULL if missing. */
|
||||
static char *get_key(const char *key) {
|
||||
return pg_inbox_get_config_value(key);
|
||||
}
|
||||
|
||||
/* Read a config key as a boolean ("true"/"1"/"yes" -> 1). */
|
||||
static int get_bool(const char *key, int default_val) {
|
||||
char *v = get_key(key);
|
||||
if (!v) return default_val;
|
||||
int r = (strcmp(v, "true") == 0 || strcmp(v, "1") == 0 ||
|
||||
strcmp(v, "yes") == 0 || strcmp(v, "on") == 0);
|
||||
free(v);
|
||||
return r;
|
||||
}
|
||||
|
||||
/* Read a config key as a long. */
|
||||
static long get_long(const char *key, long default_val) {
|
||||
char *v = get_key(key);
|
||||
if (!v) return default_val;
|
||||
long r = strtol(v, NULL, 10);
|
||||
free(v);
|
||||
return r;
|
||||
}
|
||||
|
||||
/* Read a config key as an int. */
|
||||
static int get_int(const char *key, int default_val) {
|
||||
return (int)get_long(key, (long)default_val);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Public API */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int pg_config_load(cr_config_t *cfg) {
|
||||
if (!cfg) return -1;
|
||||
memset(cfg, 0, sizeof(*cfg));
|
||||
|
||||
/* root_npubs */
|
||||
char *npubs = get_key("caching_root_npubs");
|
||||
split_csv_str(npubs, (char *)cfg->root_npubs, CR_MAX_ROOT_NPUBS,
|
||||
CR_NPUB_LEN, &cfg->root_npub_count);
|
||||
free(npubs);
|
||||
|
||||
/* bootstrap_relays -> upstream_relays */
|
||||
char *relays = get_key("caching_bootstrap_relays");
|
||||
split_csv_str(relays, (char *)cfg->upstream_relays, CR_MAX_UPSTREAM,
|
||||
CR_URL_LEN, &cfg->upstream_count);
|
||||
free(relays);
|
||||
|
||||
/* local_relay - no longer required for PostgreSQL mode, but keep a
|
||||
* placeholder for compatibility. The caller may override it. */
|
||||
cfg->local_relay[0] = '\0';
|
||||
|
||||
/* kinds */
|
||||
char *kinds = get_key("caching_kinds");
|
||||
split_csv_int(kinds, cfg->kinds, CR_MAX_KINDS, &cfg->kind_count);
|
||||
free(kinds);
|
||||
|
||||
/* admin_kinds - supports "*" to mean all kinds */
|
||||
char *akinds = get_key("caching_admin_kinds");
|
||||
if (akinds) {
|
||||
/* Check for "*" anywhere in the list. */
|
||||
if (strstr(akinds, "*") != NULL) {
|
||||
cfg->admin_all_kinds = 1;
|
||||
} else {
|
||||
split_csv_int(akinds, cfg->admin_kinds, CR_MAX_KINDS,
|
||||
&cfg->admin_kind_count);
|
||||
}
|
||||
free(akinds);
|
||||
}
|
||||
|
||||
/* live */
|
||||
cfg->live.enabled = get_bool("caching_live_enabled", 1);
|
||||
cfg->live.resubscribe_interval_seconds =
|
||||
get_int("caching_live_resubscribe_seconds", 300);
|
||||
|
||||
/* backfill */
|
||||
cfg->backfill.enabled = get_bool("caching_backfill_enabled", 1);
|
||||
cfg->backfill.events_per_tick =
|
||||
get_int("caching_backfill_page_size", 500);
|
||||
/* tick interval is stored in ms in the config table; convert to seconds. */
|
||||
int tick_ms = get_int("caching_backfill_tick_interval_ms", 5000);
|
||||
cfg->backfill.tick_interval_seconds = (tick_ms + 999) / 1000;
|
||||
if (cfg->backfill.tick_interval_seconds < 1)
|
||||
cfg->backfill.tick_interval_seconds = 1;
|
||||
|
||||
/* follow graph refresh */
|
||||
cfg->follow_graph_refresh_seconds =
|
||||
get_int("caching_follow_graph_refresh_seconds", 600);
|
||||
|
||||
/* Defaults if missing. */
|
||||
if (cfg->backfill.events_per_tick == 0)
|
||||
cfg->backfill.events_per_tick = 500;
|
||||
if (cfg->backfill.tick_interval_seconds == 0)
|
||||
cfg->backfill.tick_interval_seconds = 5;
|
||||
if (cfg->live.resubscribe_interval_seconds == 0)
|
||||
cfg->live.resubscribe_interval_seconds = 300;
|
||||
if (cfg->follow_graph_refresh_seconds == 0)
|
||||
cfg->follow_graph_refresh_seconds = 600;
|
||||
|
||||
/* State: per-author backfill progress lives in caching_followed_pubkeys.
|
||||
* backfilled_until is only used as a first-time flag in legacy mode. */
|
||||
cfg->state.backfilled_until = 0;
|
||||
|
||||
/* Validation. */
|
||||
if (cfg->root_npub_count == 0) {
|
||||
DEBUG_ERROR("pg_config: at least one caching_root_npubs required");
|
||||
return -1;
|
||||
}
|
||||
if (cfg->upstream_count == 0) {
|
||||
DEBUG_ERROR("pg_config: at least one caching_bootstrap_relays required");
|
||||
return -1;
|
||||
}
|
||||
if (cfg->kind_count == 0) {
|
||||
DEBUG_ERROR("pg_config: at least one caching_kinds required");
|
||||
return -1;
|
||||
}
|
||||
|
||||
DEBUG_INFO("pg_config loaded: %d root npubs, %d upstream relays, %d kinds",
|
||||
cfg->root_npub_count, cfg->upstream_count, cfg->kind_count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int pg_config_generation_changed(long last_generation) {
|
||||
long cur = pg_inbox_get_config_generation();
|
||||
if (cur < 0) return -1;
|
||||
return (cur != last_generation) ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* caching_relay - read caching configuration from the c-relay-pg PostgreSQL
|
||||
* config table and populate the existing cr_config_t structure.
|
||||
*/
|
||||
#ifndef CACHING_RELAY_PG_CONFIG_H
|
||||
#define CACHING_RELAY_PG_CONFIG_H
|
||||
|
||||
#include "config.h" /* cr_config_t */
|
||||
|
||||
/* Load caching configuration from the c-relay-pg PostgreSQL config table.
|
||||
* Populates the cr_config_t fields from the config table.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int pg_config_load(cr_config_t *cfg);
|
||||
|
||||
/* Check if the config generation has changed since the last load.
|
||||
* Returns 1 if changed, 0 if not, -1 on error. */
|
||||
int pg_config_generation_changed(long last_generation);
|
||||
|
||||
#endif /* CACHING_RELAY_PG_CONFIG_H */
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* caching_relay - PostgreSQL inbox connection and operations.
|
||||
*
|
||||
* Manages the libpq connection to the c-relay-pg PostgreSQL database and
|
||||
* provides functions to insert fetched events into the caching_event_inbox
|
||||
* table, update the caching_service_state singleton, and read configuration
|
||||
* from the shared config table.
|
||||
*/
|
||||
#ifndef CACHING_RELAY_PG_INBOX_H
|
||||
#define CACHING_RELAY_PG_INBOX_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/* Forward declaration so the header does not pull in cJSON everywhere. */
|
||||
typedef struct cJSON cJSON;
|
||||
|
||||
/* Initialize PostgreSQL connection using the given connection string.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_init(const char *connection_string);
|
||||
|
||||
/* Close the PostgreSQL connection. */
|
||||
void pg_inbox_shutdown(void);
|
||||
|
||||
/* Insert an event into the caching_event_inbox table.
|
||||
* event_json is the raw Nostr event JSON string.
|
||||
* source_relay may be NULL. source_class is "live", "discovery", or "backfill".
|
||||
* priority is 0 (live/discovery) or 1 (backfill).
|
||||
* Returns 0 on success (including conflict-skip), -1 on error. */
|
||||
int pg_inbox_insert_event(const char *event_json, const char *source_relay,
|
||||
const char *source_class, int priority);
|
||||
|
||||
/* Update the caching_service_state singleton row.
|
||||
* All string parameters may be NULL (left unchanged).
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_update_status(const char *service_state,
|
||||
long config_generation,
|
||||
long heartbeat_at,
|
||||
int followed_author_count,
|
||||
int selected_relay_count,
|
||||
int connected_relay_count,
|
||||
int backfill_authors_complete,
|
||||
int backfill_authors_total,
|
||||
long events_fetched,
|
||||
long inbox_inserts,
|
||||
const char *last_error,
|
||||
long last_error_at);
|
||||
|
||||
/* Read a config value from the c-relay-pg config table.
|
||||
* Returns a malloc'd string (caller must free) or NULL if not found/error. */
|
||||
char *pg_inbox_get_config_value(const char *key);
|
||||
|
||||
/* Read the current config generation.
|
||||
* Returns the generation number, or -1 on error. */
|
||||
long pg_inbox_get_config_generation(void);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Per-author until-cursor drain backfill model */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Reset all backfill progress: set until_cursor=0 and backfill_complete=FALSE
|
||||
* for all rows in caching_followed_pubkeys (the followed set itself is
|
||||
* preserved). Used by the "Reset Backfill" API button and --restart.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_reset_backfill_progress(void);
|
||||
|
||||
/* Upsert all followed pubkeys into caching_followed_pubkeys.
|
||||
* For each pubkey: INSERT if new (until_cursor=0, backfill_complete=FALSE),
|
||||
* or UPDATE is_root and last_seen if existing (until_cursor and
|
||||
* backfill_complete are NOT touched). root_pubkeys marks which entries are
|
||||
* root follows. Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_sync_followed_pubkeys(const char **pubkeys, int count,
|
||||
const char **root_pubkeys, int root_count);
|
||||
|
||||
/* Pick the next incomplete author for backfill, round-robin.
|
||||
* On success fills out_pk, out_until_cursor and out_is_root, advances
|
||||
* *round_cursor, and returns 0. Returns -1 if no incomplete authors or
|
||||
* on error. */
|
||||
int pg_inbox_pick_next_backfill_author(char *out_pk, int pk_len,
|
||||
long *out_until_cursor,
|
||||
int *out_is_root,
|
||||
int *round_cursor);
|
||||
|
||||
/* Update the cursor and completion state for an author.
|
||||
* events_this_page is added to the running events_fetched counter.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_update_backfill_progress(const char *pk, long until_cursor,
|
||||
int complete, int events_this_page);
|
||||
|
||||
/* One-time migration from the old caching_backfill_progress table to the
|
||||
* new caching_followed_pubkeys table. Only runs if the new table is empty
|
||||
* and the old table has rows. Returns 0 on success (including no-op),
|
||||
* -1 on error. */
|
||||
int pg_inbox_migrate_backfill_progress(void);
|
||||
|
||||
/* Count complete/total authors for status reporting.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_count_backfill_progress(int *out_complete, int *out_total);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Per-relay backfill progress (relay-by-relay drain model) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Initialize relay progress rows for an author. Creates one row per relay
|
||||
* URL with until_cursor=0, complete=FALSE. Skips rows that already exist
|
||||
* (ON CONFLICT DO NOTHING). Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_init_relay_progress_for_author(const char *pk,
|
||||
const char **relay_urls,
|
||||
int relay_count);
|
||||
|
||||
/* Pick the next author that has at least one incomplete relay progress row.
|
||||
* Round-robin via *round_cursor. On success fills out_pk and returns 0.
|
||||
* Returns -1 if no incomplete authors or on error. */
|
||||
int pg_inbox_pick_next_author_with_incomplete_relays(char *out_pk, int pk_len,
|
||||
int *round_cursor);
|
||||
|
||||
/* Get incomplete relays for an author. Returns a cJSON array of
|
||||
* {relay_url, until_cursor} objects (caller frees with cJSON_Delete).
|
||||
* Returns NULL on error or if no incomplete relays. */
|
||||
cJSON *pg_inbox_get_incomplete_relays(const char *pk);
|
||||
|
||||
/* Update relay progress: advance cursor and/or mark complete.
|
||||
* events_this_page is added to the running events_fetched counter.
|
||||
* last_status is a short string like "eose", "timeout", "error", or NULL
|
||||
* to leave unchanged. Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_update_relay_progress(const char *pk, const char *relay_url,
|
||||
long until_cursor, int complete,
|
||||
int events_this_page,
|
||||
const char *last_status);
|
||||
|
||||
/* Check if all relays for an author are complete. If so, mark the author
|
||||
* as backfill_complete=TRUE in caching_followed_pubkeys.
|
||||
* Returns 1 if marked complete, 0 if still incomplete, -1 on error. */
|
||||
int pg_inbox_check_and_mark_author_complete(const char *pk);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Active backfill target (for UI status display) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Set the currently-active backfill target (pubkey + relay being queried).
|
||||
* Either parameter may be NULL/empty to clear. Creates the
|
||||
* caching_backfill_active table if needed (single-row singleton).
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_set_active_target(const char *pubkey, const char *relay_url);
|
||||
|
||||
/* Read the active backfill target. Fills out_pubkey/out_relay with
|
||||
* the current values (or empty strings if none). Buffers must be
|
||||
* at least 65 and 256 bytes respectively.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_get_active_target(char *out_pubkey, int pk_len,
|
||||
char *out_relay, int relay_len);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Forward catch-up support (last_event_at tracking) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* Returns a cJSON array of {pubkey, last_event_at} objects for all
|
||||
* followed authors where last_event_at > 0. Caller must cJSON_Delete().
|
||||
* Returns NULL on error. */
|
||||
cJSON* pg_inbox_get_authors_for_catchup(void);
|
||||
|
||||
/* Update last_event_at for a pubkey to the GREATEST of current and
|
||||
* the given value. Returns 0 on success, -1 on error. */
|
||||
int pg_inbox_update_last_event_at(const char *pk, long event_created_at);
|
||||
|
||||
#endif /* CACHING_RELAY_PG_INBOX_H */
|
||||
@@ -0,0 +1,471 @@
|
||||
/*
|
||||
* caching_relay - NIP-65 outbox relay discovery
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include "relay_discovery.h"
|
||||
#include "debug.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <signal.h>
|
||||
|
||||
/* Access the shutdown flag from main.c so we can abort discovery early. */
|
||||
extern volatile sig_atomic_t g_shutdown;
|
||||
|
||||
/* Normalize a relay URL: strip trailing slash for consistency. */
|
||||
static void normalize_url(char *url, size_t maxlen) {
|
||||
size_t len = strlen(url);
|
||||
while (len > 0 && url[len - 1] == '/') {
|
||||
url[--len] = '\0';
|
||||
}
|
||||
(void)maxlen;
|
||||
}
|
||||
|
||||
/* ---- helpers ---- */
|
||||
|
||||
static int get_pool_urls(nostr_relay_pool_t *pool, const char ***urls_out) {
|
||||
char **listed = NULL;
|
||||
nostr_pool_relay_status_t *statuses = NULL;
|
||||
int n = nostr_relay_pool_list_relays(pool, &listed, &statuses);
|
||||
const char **urls = NULL;
|
||||
if (n > 0) {
|
||||
urls = malloc(n * sizeof(char *));
|
||||
for (int j = 0; j < n; j++) urls[j] = listed[j];
|
||||
}
|
||||
free(listed);
|
||||
free(statuses);
|
||||
*urls_out = urls;
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Query the most recent kind 10002 for a single pubkey from a pool.
|
||||
* Returns the event (caller must cJSON_Delete) or NULL. */
|
||||
static cJSON *query_kind10002(nostr_relay_pool_t *pool, const char *hex) {
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *authors = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(hex));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON *kinds = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber(1));
|
||||
|
||||
const char **urls = NULL;
|
||||
int n = get_pool_urls(pool, &urls);
|
||||
int ev_count = 0;
|
||||
cJSON **events = nostr_relay_pool_query_sync(pool, urls, n, filter, &ev_count, 10000);
|
||||
free(urls);
|
||||
cJSON_Delete(filter);
|
||||
|
||||
cJSON *result = NULL;
|
||||
if (events && ev_count > 0) {
|
||||
result = events[0]; /* take first (most recent) */
|
||||
for (int k = 1; k < ev_count; k++) cJSON_Delete(events[k]);
|
||||
}
|
||||
free(events);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* Parse "r" tags from a kind 10002 event into an outbox entry.
|
||||
* Only includes relays with "read" marker or no marker (assume both).
|
||||
* Skips "write" only relays. */
|
||||
static int parse_r_tags(cJSON *event, cr_outbox_entry_t *entry) {
|
||||
memset(entry, 0, sizeof(*entry));
|
||||
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
|
||||
if (pubkey && cJSON_IsString(pubkey)) {
|
||||
strncpy(entry->pubkey, cJSON_GetStringValue(pubkey), CR_HEX_LEN - 1);
|
||||
}
|
||||
|
||||
cJSON *tags = cJSON_GetObjectItem(event, "tags");
|
||||
if (!tags || !cJSON_IsArray(tags)) return 0;
|
||||
|
||||
int added = 0;
|
||||
cJSON *tag;
|
||||
cJSON_ArrayForEach(tag, tags) {
|
||||
if (!cJSON_IsArray(tag)) continue;
|
||||
cJSON *name = cJSON_GetArrayItem(tag, 0);
|
||||
if (!name || !cJSON_IsString(name)) continue;
|
||||
if (strcmp(cJSON_GetStringValue(name), "r") != 0) continue;
|
||||
cJSON *url = cJSON_GetArrayItem(tag, 1);
|
||||
if (!url || !cJSON_IsString(url)) continue;
|
||||
const char *relay_url = cJSON_GetStringValue(url);
|
||||
if (!relay_url || !relay_url[0]) continue;
|
||||
|
||||
/* Check marker (3rd element): "read", "write", or absent. */
|
||||
cJSON *marker = cJSON_GetArrayItem(tag, 2);
|
||||
if (marker && cJSON_IsString(marker)) {
|
||||
const char *m = cJSON_GetStringValue(marker);
|
||||
if (strcmp(m, "write") == 0) continue; /* skip write-only relays */
|
||||
}
|
||||
/* No marker or "read" => include. */
|
||||
|
||||
if (entry->relay_count >= CR_MAX_RELAYS_PER_PUBKEY) break;
|
||||
strncpy(entry->relays[entry->relay_count], relay_url, CR_URL_LEN - 1);
|
||||
entry->relays[entry->relay_count][CR_URL_LEN - 1] = '\0';
|
||||
normalize_url(entry->relays[entry->relay_count], CR_URL_LEN);
|
||||
entry->relay_count++;
|
||||
added++;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/* ---- greedy set cover ---- */
|
||||
|
||||
/* Build relay -> pubkey coverage map and compute minimum covering set. */
|
||||
static void compute_covering_set(cr_relay_map_t *map, cr_config_t *cfg,
|
||||
const cr_pubkey_set_t *followed) {
|
||||
/* Build a temporary relay -> pubkeys map. */
|
||||
typedef struct {
|
||||
char url[CR_URL_LEN];
|
||||
char pubkeys[CR_MAX_PUBKEYS_PER_RELAY][CR_HEX_LEN];
|
||||
int pubkey_count;
|
||||
} relay_cov_t;
|
||||
|
||||
relay_cov_t *relays = calloc(CR_MAX_DISCOVERED_RELAYS, sizeof(relay_cov_t));
|
||||
int relay_count = 0;
|
||||
|
||||
/* Always include bootstrap relays in the coverage map. */
|
||||
for (int i = 0; i < cfg->upstream_count && relay_count < CR_MAX_DISCOVERED_RELAYS; i++) {
|
||||
strncpy(relays[relay_count].url, cfg->upstream_relays[i], CR_URL_LEN - 1);
|
||||
relays[relay_count].url[CR_URL_LEN - 1] = '\0';
|
||||
normalize_url(relays[relay_count].url, CR_URL_LEN);
|
||||
relay_count++;
|
||||
}
|
||||
|
||||
/* Add outbox relays and build coverage. */
|
||||
for (int i = 0; i < map->outbox_count; i++) {
|
||||
cr_outbox_entry_t *oe = &map->outboxes[i];
|
||||
for (int r = 0; r < oe->relay_count; r++) {
|
||||
/* Find or create relay entry. */
|
||||
int found = -1;
|
||||
for (int j = 0; j < relay_count; j++) {
|
||||
if (strcmp(relays[j].url, oe->relays[r]) == 0) { found = j; break; }
|
||||
}
|
||||
if (found < 0) {
|
||||
if (relay_count >= CR_MAX_DISCOVERED_RELAYS) break;
|
||||
strncpy(relays[relay_count].url, oe->relays[r], CR_URL_LEN - 1);
|
||||
found = relay_count++;
|
||||
}
|
||||
/* Add this pubkey to the relay's coverage. */
|
||||
if (relays[found].pubkey_count < CR_MAX_PUBKEYS_PER_RELAY) {
|
||||
strncpy(relays[found].pubkeys[relays[found].pubkey_count],
|
||||
oe->pubkey, CR_HEX_LEN - 1);
|
||||
relays[found].pubkey_count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Greedy set cover. */
|
||||
char *covered = calloc(followed->count, 1); /* 1 if pubkey is covered */
|
||||
map->selected_count = 0;
|
||||
map->coverage = calloc(map->outbox_count, sizeof(int));
|
||||
for (int i = 0; i < map->outbox_count; i++) map->coverage[i] = -1;
|
||||
|
||||
int uncovered_count = followed->count;
|
||||
|
||||
/* Helper: find index of a pubkey in the followed set. */
|
||||
/* (linear search, fine for our scale) */
|
||||
|
||||
while (uncovered_count > 0 && map->selected_count < CR_MAX_DISCOVERED_RELAYS) {
|
||||
int best_relay = -1;
|
||||
int best_count = 0;
|
||||
|
||||
for (int r = 0; r < relay_count; r++) {
|
||||
/* Count how many uncovered pubkeys this relay covers. */
|
||||
int count = 0;
|
||||
for (int p = 0; p < relays[r].pubkey_count; p++) {
|
||||
/* Find this pubkey in followed set. */
|
||||
for (int i = 0; i < followed->count; i++) {
|
||||
if (strcmp(followed->items[i], relays[r].pubkeys[p]) == 0) {
|
||||
if (!covered[i]) count++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count > best_count) {
|
||||
best_count = count;
|
||||
best_relay = r;
|
||||
}
|
||||
}
|
||||
|
||||
if (best_relay < 0 || best_count == 0) break; /* no more coverage possible */
|
||||
|
||||
/* Select this relay. */
|
||||
strncpy(map->selected_relays[map->selected_count],
|
||||
relays[best_relay].url, CR_URL_LEN - 1);
|
||||
map->selected_count++;
|
||||
|
||||
/* Mark covered pubkeys and record coverage. */
|
||||
for (int p = 0; p < relays[best_relay].pubkey_count; p++) {
|
||||
for (int i = 0; i < followed->count; i++) {
|
||||
if (strcmp(followed->items[i], relays[best_relay].pubkeys[p]) == 0) {
|
||||
if (!covered[i]) {
|
||||
covered[i] = 1;
|
||||
uncovered_count--;
|
||||
/* Record which selected relay covers this outbox entry. */
|
||||
for (int oe_i = 0; oe_i < map->outbox_count; oe_i++) {
|
||||
if (strcmp(map->outboxes[oe_i].pubkey,
|
||||
relays[best_relay].pubkeys[p]) == 0) {
|
||||
if (map->coverage[oe_i] < 0) {
|
||||
map->coverage[oe_i] = map->selected_count - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Log results. */
|
||||
DEBUG_INFO("relay_discovery: selected %d relays to cover %d/%d pubkeys",
|
||||
map->selected_count, followed->count - uncovered_count, followed->count);
|
||||
for (int i = 0; i < map->selected_count; i++) {
|
||||
DEBUG_INFO(" relay[%d]: %s", i, map->selected_relays[i]);
|
||||
}
|
||||
if (uncovered_count > 0) {
|
||||
DEBUG_WARN("relay_discovery: %d pubkeys have no outbox relays (covered by bootstrap)",
|
||||
uncovered_count);
|
||||
}
|
||||
|
||||
free(covered);
|
||||
free(relays);
|
||||
}
|
||||
|
||||
/* ---- batched kind-10002 query ---- */
|
||||
|
||||
/* Query kind 10002 for a batch of up to 100 pubkeys from a pool.
|
||||
* Returns a cJSON array of events (caller must cJSON_Delete each + free).
|
||||
* Sets *out_count to the number of events returned. */
|
||||
static cJSON **query_kind10002_batch(nostr_relay_pool_t *pool,
|
||||
const char **pubkeys, int pubkey_count,
|
||||
int *out_count) {
|
||||
cJSON *filter = cJSON_CreateObject();
|
||||
cJSON *authors = cJSON_CreateArray();
|
||||
for (int i = 0; i < pubkey_count; i++)
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkeys[i]));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON *kinds = cJSON_CreateArray();
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
|
||||
const char **urls = NULL;
|
||||
int n = get_pool_urls(pool, &urls);
|
||||
|
||||
/* TRACE: log the query being sent. */
|
||||
if (g_debug_level >= DEBUG_LEVEL_TRACE) {
|
||||
char *filter_str = cJSON_PrintUnformatted(filter);
|
||||
DEBUG_TRACE("query_kind10002_batch: querying %d relays for %d pubkeys", n, pubkey_count);
|
||||
if (filter_str) {
|
||||
/* Only print first 200 chars of filter to avoid huge output */
|
||||
if (strlen(filter_str) > 200) filter_str[200] = '\0';
|
||||
DEBUG_TRACE(" filter: %s...", filter_str);
|
||||
free(filter_str);
|
||||
}
|
||||
}
|
||||
|
||||
int ev_count = 0;
|
||||
cJSON **events = nostr_relay_pool_query_sync(pool, urls, n, filter, &ev_count, 30000);
|
||||
free(urls);
|
||||
cJSON_Delete(filter);
|
||||
|
||||
DEBUG_TRACE("query_kind10002_batch: returned %d events", ev_count);
|
||||
|
||||
*out_count = ev_count;
|
||||
return events;
|
||||
}
|
||||
|
||||
/* Process a batch of kind-10002 events: parse r-tags, populate outbox entries,
|
||||
* publish to local relay. Returns count of events processed. */
|
||||
static int process_10002_batch(cJSON **events, int ev_count,
|
||||
cr_relay_map_t *map, cr_sink_t *sink,
|
||||
const char *source) {
|
||||
int processed = 0;
|
||||
for (int k = 0; k < ev_count; k++) {
|
||||
cJSON *event = events[k];
|
||||
if (!event) continue;
|
||||
|
||||
/* Get pubkey from event. */
|
||||
cJSON *pk = cJSON_GetObjectItem(event, "pubkey");
|
||||
if (!pk || !cJSON_IsString(pk)) { cJSON_Delete(event); continue; }
|
||||
const char *hex = cJSON_GetStringValue(pk);
|
||||
|
||||
/* Find or create outbox entry for this pubkey. */
|
||||
cr_outbox_entry_t *oe = NULL;
|
||||
for (int i = 0; i < map->outbox_count; i++) {
|
||||
if (strcmp(map->outboxes[i].pubkey, hex) == 0) { oe = &map->outboxes[i]; break; }
|
||||
}
|
||||
if (!oe) {
|
||||
if (map->outbox_count >= /* followed->count passed in via map size */ 99999) {
|
||||
cJSON_Delete(event); continue;
|
||||
}
|
||||
oe = &map->outboxes[map->outbox_count];
|
||||
strncpy(oe->pubkey, hex, CR_HEX_LEN - 1);
|
||||
map->outbox_count++;
|
||||
}
|
||||
|
||||
int n = parse_r_tags(event, oe);
|
||||
(void)n;
|
||||
|
||||
if (strcmp(source, "bootstrap") == 0) {
|
||||
cr_sink_publish(sink, event);
|
||||
/* Pump sink pool every 16 events to avoid pending publish overflow
|
||||
* (NOSTR_POOL_MAX_PENDING_PUBLISHES = 32). */
|
||||
if (processed > 0 && (processed % 16) == 0) {
|
||||
cr_sink_pump(sink, 200);
|
||||
}
|
||||
}
|
||||
|
||||
processed++;
|
||||
cJSON_Delete(event);
|
||||
}
|
||||
free(events);
|
||||
|
||||
/* Log sink stats after each batch. */
|
||||
DEBUG_INFO("relay_discovery: batch processed %d events (sink: ok=%ld reject=%ld dup=%ld err=%ld)",
|
||||
processed, sink->published_ok, sink->published_failed,
|
||||
sink->published_dup, 0L);
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/* ---- main discovery function ---- */
|
||||
|
||||
#define CR_10002_BATCH_SIZE 100
|
||||
|
||||
int cr_relay_discovery_run(cr_relay_map_t *map, cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream, cr_sink_t *sink,
|
||||
const cr_pubkey_set_t *followed, int is_first_time) {
|
||||
memset(map, 0, sizeof(*map));
|
||||
map->outboxes = calloc(followed->count, sizeof(cr_outbox_entry_t));
|
||||
if (!map->outboxes) return -1;
|
||||
map->outbox_count = 0;
|
||||
|
||||
DEBUG_INFO("relay_discovery: discovering outbox relays for %d pubkeys (%s)",
|
||||
followed->count, is_first_time ? "first-time" : "local-first");
|
||||
|
||||
int found_local = 0, found_bootstrap = 0, found_none = 0;
|
||||
|
||||
/* Pre-populate outbox entries for all followed pubkeys (empty, for coverage
|
||||
* tracking of pubkeys with no 10002). */
|
||||
for (int i = 0; i < followed->count; i++) {
|
||||
strncpy(map->outboxes[i].pubkey, followed->items[i], CR_HEX_LEN - 1);
|
||||
}
|
||||
map->outbox_count = followed->count;
|
||||
|
||||
/* Track which pubkeys we've found 10002 for. */
|
||||
char *found = calloc(followed->count, 1);
|
||||
|
||||
/* Phase 1: If subsequent startup, batch-query local relay first. */
|
||||
if (!is_first_time && sink && sink->pool) {
|
||||
DEBUG_INFO("relay_discovery: querying local relay for kind-10002 (batched)...");
|
||||
|
||||
for (int start = 0; start < followed->count && !g_shutdown; start += CR_10002_BATCH_SIZE) {
|
||||
int batch = followed->count - start;
|
||||
if (batch > CR_10002_BATCH_SIZE) batch = CR_10002_BATCH_SIZE;
|
||||
|
||||
int ev_count = 0;
|
||||
cJSON **events = query_kind10002_batch(sink->pool,
|
||||
(const char **)&followed->items[start],
|
||||
batch, &ev_count);
|
||||
if (events && ev_count > 0) {
|
||||
for (int k = 0; k < ev_count; k++) {
|
||||
cJSON *pk = cJSON_GetObjectItem(events[k], "pubkey");
|
||||
if (pk && cJSON_IsString(pk)) {
|
||||
const char *hex = cJSON_GetStringValue(pk);
|
||||
for (int i = 0; i < followed->count; i++) {
|
||||
if (strcmp(followed->items[i], hex) == 0) {
|
||||
if (!found[i]) { found[i] = 1; found_local++; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
process_10002_batch(events, ev_count, map, sink, "local");
|
||||
/* Pump sink pool to flush published events. */
|
||||
cr_sink_pump(sink, 500);
|
||||
} else {
|
||||
free(events);
|
||||
}
|
||||
}
|
||||
DEBUG_INFO("relay_discovery: local relay provided %d kind-10002 events", found_local);
|
||||
}
|
||||
|
||||
/* Phase 2: Batch-query bootstrap relays for pubkeys not found locally. */
|
||||
int remaining = 0;
|
||||
for (int i = 0; i < followed->count; i++) if (!found[i]) remaining++;
|
||||
|
||||
if (remaining > 0) {
|
||||
DEBUG_INFO("relay_discovery: querying bootstrap relays for %d remaining pubkeys (batched)...",
|
||||
remaining);
|
||||
|
||||
/* Build list of remaining pubkeys. */
|
||||
const char **remaining_pk = malloc(remaining * sizeof(char *));
|
||||
int ridx = 0;
|
||||
for (int i = 0; i < followed->count; i++) {
|
||||
if (!found[i]) remaining_pk[ridx++] = followed->items[i];
|
||||
}
|
||||
|
||||
for (int start = 0; start < remaining && !g_shutdown; start += CR_10002_BATCH_SIZE) {
|
||||
int batch = remaining - start;
|
||||
if (batch > CR_10002_BATCH_SIZE) batch = CR_10002_BATCH_SIZE;
|
||||
|
||||
int ev_count = 0;
|
||||
cJSON **events = query_kind10002_batch(upstream,
|
||||
&remaining_pk[start],
|
||||
batch, &ev_count);
|
||||
if (events && ev_count > 0) {
|
||||
for (int k = 0; k < ev_count; k++) {
|
||||
cJSON *pk = cJSON_GetObjectItem(events[k], "pubkey");
|
||||
if (pk && cJSON_IsString(pk)) {
|
||||
const char *hex = cJSON_GetStringValue(pk);
|
||||
for (int i = 0; i < followed->count; i++) {
|
||||
if (strcmp(followed->items[i], hex) == 0) {
|
||||
if (!found[i]) { found[i] = 1; found_bootstrap++; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
process_10002_batch(events, ev_count, map, sink, "bootstrap");
|
||||
/* Pump sink pool to flush published events. */
|
||||
cr_sink_pump(sink, 500);
|
||||
} else {
|
||||
free(events);
|
||||
}
|
||||
}
|
||||
free(remaining_pk);
|
||||
}
|
||||
|
||||
found_none = followed->count - found_local - found_bootstrap;
|
||||
DEBUG_INFO("relay_discovery: %d local, %d bootstrap, %d none",
|
||||
found_local, found_bootstrap, found_none);
|
||||
|
||||
free(found);
|
||||
|
||||
/* Compute minimum covering set. */
|
||||
compute_covering_set(map, cfg, followed);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void cr_relay_map_free(cr_relay_map_t *map) {
|
||||
if (map->outboxes) {
|
||||
free(map->outboxes);
|
||||
map->outboxes = NULL;
|
||||
}
|
||||
if (map->coverage) {
|
||||
free(map->coverage);
|
||||
map->coverage = NULL;
|
||||
}
|
||||
map->outbox_count = 0;
|
||||
map->selected_count = 0;
|
||||
}
|
||||
|
||||
const cr_outbox_entry_t *cr_relay_map_get_outbox(const cr_relay_map_t *map, const char *hex) {
|
||||
for (int i = 0; i < map->outbox_count; i++) {
|
||||
if (strcmp(map->outboxes[i].pubkey, hex) == 0) return &map->outboxes[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* caching_relay - NIP-65 outbox relay discovery.
|
||||
*
|
||||
* For each followed pubkey, fetches their kind 10002 (relay list) and parses
|
||||
* the "r" tags to build a per-pubkey outbox relay map. Then computes the
|
||||
* minimum covering set of relays (greedy set cover) that covers all followed
|
||||
* pubkeys.
|
||||
*
|
||||
* On first-time startup, queries bootstrap relays for kind 10002.
|
||||
* On subsequent startups, queries the local relay first (fast), falls back
|
||||
* to bootstrap relays for any pubkey not found locally.
|
||||
*/
|
||||
#ifndef CACHING_RELAY_RELAY_DISCOVERY_H
|
||||
#define CACHING_RELAY_RELAY_DISCOVERY_H
|
||||
|
||||
#include "config.h"
|
||||
#include "state.h"
|
||||
#include "relay_sink.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
#define CR_MAX_RELAYS_PER_PUBKEY 8
|
||||
#define CR_MAX_PUBKEYS_PER_RELAY 512
|
||||
#define CR_MAX_DISCOVERED_RELAYS 128
|
||||
|
||||
/* Per-pubkey outbox relay list. */
|
||||
typedef struct {
|
||||
char pubkey[CR_HEX_LEN];
|
||||
char relays[CR_MAX_RELAYS_PER_PUBKEY][CR_URL_LEN];
|
||||
int relay_count;
|
||||
} cr_outbox_entry_t;
|
||||
|
||||
/* Result of relay discovery. */
|
||||
typedef struct {
|
||||
cr_outbox_entry_t *outboxes; /* per-pubkey relay lists (heap) */
|
||||
int outbox_count;
|
||||
|
||||
/* The selected minimum covering set of relay URLs. */
|
||||
char selected_relays[CR_MAX_DISCOVERED_RELAYS][CR_URL_LEN];
|
||||
int selected_count;
|
||||
|
||||
/* Per-pubkey: which selected relay covers this pubkey (index into selected_relays).
|
||||
* -1 means covered by bootstrap relays only. */
|
||||
int *coverage; /* heap, outbox_count entries */
|
||||
} cr_relay_map_t;
|
||||
|
||||
/* Discover outbox relays for all followed pubkeys.
|
||||
*
|
||||
* cfg - config (provides bootstrap relays, local_relay, root_hex)
|
||||
* upstream - upstream pool (has bootstrap relays; discovered relays will be added)
|
||||
* sink - sink pool (local relay, for publishing 10002 events + querying on subsequent startup)
|
||||
* followed - the followed pubkey set
|
||||
* is_first_time - 1 if first startup (query bootstrap only), 0 if subsequent (local-first)
|
||||
*
|
||||
* Returns 0 on success, -1 on hard error. On success, map is populated.
|
||||
* Caller must call cr_relay_map_free() when done.
|
||||
*/
|
||||
int cr_relay_discovery_run(cr_relay_map_t *map, cr_config_t *cfg,
|
||||
nostr_relay_pool_t *upstream, cr_sink_t *sink,
|
||||
const cr_pubkey_set_t *followed, int is_first_time);
|
||||
|
||||
/* Free heap resources in a relay map. */
|
||||
void cr_relay_map_free(cr_relay_map_t *map);
|
||||
|
||||
/* Get the outbox relays for a specific pubkey. Returns NULL if not found. */
|
||||
const cr_outbox_entry_t *cr_relay_map_get_outbox(const cr_relay_map_t *map, const char *hex);
|
||||
|
||||
#endif /* CACHING_RELAY_RELAY_DISCOVERY_H */
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* caching_relay - relay sink: publishes fetched events to the local relay.
|
||||
*
|
||||
* See relay_sink.h for the dual-mode (WebSocket / PostgreSQL inbox) design.
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include "relay_sink.h"
|
||||
#include "pg_inbox.h"
|
||||
#include "debug.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* WebSocket mode callback */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void sink_publish_cb(const char *relay_url, const char *event_id,
|
||||
int success, const char *message, void *user_data) {
|
||||
cr_sink_t *sink = (cr_sink_t *)user_data;
|
||||
if (success) {
|
||||
sink->published_ok++;
|
||||
DEBUG_TRACE("sink: OK %s from %s", event_id ? event_id : "?",
|
||||
relay_url ? relay_url : "?");
|
||||
} else {
|
||||
sink->published_failed++;
|
||||
/* REJECT is important - show at INFO level so it's visible at level 3. */
|
||||
DEBUG_INFO("sink: REJECT %s from %s: %s", event_id ? event_id : "?",
|
||||
relay_url ? relay_url : "?",
|
||||
message ? message : "(no message)");
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Init */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int cr_sink_init(cr_sink_t *sink, const char *local_relay_url, cr_seen_ring_t *seen) {
|
||||
memset(sink, 0, sizeof(*sink));
|
||||
sink->seen = seen;
|
||||
sink->source_class = CR_SINK_CLASS_LIVE;
|
||||
strncpy(sink->url, local_relay_url, sizeof(sink->url) - 1);
|
||||
|
||||
sink->pool = nostr_relay_pool_create(nostr_pool_reconnect_config_default());
|
||||
if (!sink->pool) {
|
||||
DEBUG_ERROR("sink: failed to create pool");
|
||||
return -1;
|
||||
}
|
||||
if (nostr_relay_pool_add_relay(sink->pool, local_relay_url) != NOSTR_SUCCESS) {
|
||||
DEBUG_ERROR("sink: failed to add relay %s", local_relay_url);
|
||||
nostr_relay_pool_destroy(sink->pool);
|
||||
sink->pool = NULL;
|
||||
return -1;
|
||||
}
|
||||
DEBUG_INFO("sink: initialized (WebSocket) for %s", local_relay_url);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cr_sink_init_pg(cr_sink_t *sink, cr_seen_ring_t *seen) {
|
||||
memset(sink, 0, sizeof(*sink));
|
||||
sink->seen = seen;
|
||||
sink->source_class = CR_SINK_CLASS_LIVE;
|
||||
sink->pool = NULL; /* PostgreSQL inbox mode - no WebSocket pool. */
|
||||
DEBUG_INFO("sink: initialized (PostgreSQL inbox)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
void cr_sink_set_source_class(cr_sink_t *sink, const char *class_ptr) {
|
||||
if (sink) sink->source_class = class_ptr ? class_ptr : CR_SINK_CLASS_LIVE;
|
||||
}
|
||||
|
||||
void cr_sink_destroy(cr_sink_t *sink) {
|
||||
if (sink && sink->pool) {
|
||||
nostr_relay_pool_destroy(sink->pool);
|
||||
sink->pool = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Publish */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int cr_sink_publish_from(cr_sink_t *sink, cJSON *event, const char *source_relay) {
|
||||
if (!sink || !event) return -1;
|
||||
|
||||
cJSON *id = cJSON_GetObjectItem(event, "id");
|
||||
if (!id || !cJSON_IsString(id)) {
|
||||
DEBUG_WARN("sink: event missing id, skipping");
|
||||
return -1;
|
||||
}
|
||||
const char *eid = cJSON_GetStringValue(id);
|
||||
|
||||
if (cr_seen_ring_add(sink->seen, eid) == 0) {
|
||||
sink->published_dup++;
|
||||
return 0; /* already seen recently */
|
||||
}
|
||||
|
||||
/* PostgreSQL inbox mode. */
|
||||
if (!sink->pool) {
|
||||
char *json = cJSON_PrintUnformatted(event);
|
||||
if (!json) {
|
||||
DEBUG_WARN("sink: failed to serialize event %s", eid);
|
||||
sink->published_failed++;
|
||||
return -1;
|
||||
}
|
||||
const char *klass = sink->source_class ? sink->source_class
|
||||
: CR_SINK_CLASS_LIVE;
|
||||
int priority = (strcmp(klass, CR_SINK_CLASS_BACKFILL) == 0) ? 1 : 0;
|
||||
|
||||
cJSON *kind = cJSON_GetObjectItem(event, "kind");
|
||||
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
|
||||
int kind_num = (kind && cJSON_IsNumber(kind)) ? (int)cJSON_GetNumberValue(kind) : -1;
|
||||
DEBUG_TRACE("sink: INBOX event id=%s kind=%d pubkey=%s class=%s",
|
||||
eid, kind_num,
|
||||
pubkey && cJSON_IsString(pubkey) ? cJSON_GetStringValue(pubkey) : "?",
|
||||
klass);
|
||||
|
||||
int rc = pg_inbox_insert_event(json, source_relay, klass, priority);
|
||||
free(json);
|
||||
if (rc != 0) {
|
||||
sink->published_failed++;
|
||||
return -1;
|
||||
}
|
||||
sink->published_ok++;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* WebSocket mode. */
|
||||
const char *urls[1] = { sink->url };
|
||||
cJSON *kind = cJSON_GetObjectItem(event, "kind");
|
||||
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
|
||||
int kind_num = (kind && cJSON_IsNumber(kind)) ? (int)cJSON_GetNumberValue(kind) : -1;
|
||||
DEBUG_TRACE("sink: SEND event id=%s kind=%d pubkey=%s to %s",
|
||||
eid, kind_num,
|
||||
pubkey && cJSON_IsString(pubkey) ? cJSON_GetStringValue(pubkey) : "?",
|
||||
sink->url);
|
||||
|
||||
int rc = nostr_relay_pool_publish_async(sink->pool, urls, 1, event,
|
||||
sink_publish_cb, sink);
|
||||
if (rc < 0) {
|
||||
DEBUG_WARN("sink: publish_async error rc=%d for %s", rc, eid);
|
||||
return -1;
|
||||
}
|
||||
if (rc == 0) {
|
||||
DEBUG_INFO("sink: publish SKIPPED (not connected) for %s", eid);
|
||||
return 0;
|
||||
}
|
||||
DEBUG_TRACE("sink: publish_async rc=%d for %s", rc, eid);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int cr_sink_publish(cr_sink_t *sink, cJSON *event) {
|
||||
return cr_sink_publish_from(sink, event, NULL);
|
||||
}
|
||||
|
||||
void cr_sink_pump(cr_sink_t *sink, int timeout_ms) {
|
||||
if (sink && sink->pool) {
|
||||
nostr_relay_pool_run(sink->pool, timeout_ms);
|
||||
}
|
||||
/* No-op in PostgreSQL inbox mode. */
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* caching_relay - relay sink: publishes fetched events to the local relay.
|
||||
*
|
||||
* Two modes:
|
||||
* - WebSocket mode (legacy): a dedicated single-relay pool publishes via
|
||||
* nostr_relay_pool_publish_async(). Used when no PostgreSQL connection
|
||||
* string is provided.
|
||||
* - PostgreSQL inbox mode: events are inserted directly into the
|
||||
* caching_event_inbox table via pg_inbox_insert_event(). Used when the
|
||||
* daemon is started with --pg-conn.
|
||||
*
|
||||
* The public function signatures are identical in both modes so that callers
|
||||
* (live_subscriber, backfill, relay_discovery) do not need to change.
|
||||
*/
|
||||
#ifndef CACHING_RELAY_RELAY_SINK_H
|
||||
#define CACHING_RELAY_RELAY_SINK_H
|
||||
|
||||
#include "state.h"
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
/* Source class labels for the inbox priority field. */
|
||||
#define CR_SINK_CLASS_LIVE "live"
|
||||
#define CR_SINK_CLASS_DISCOVERY "discovery"
|
||||
#define CR_SINK_CLASS_BACKFILL "backfill"
|
||||
|
||||
typedef struct {
|
||||
/* WebSocket mode only (NULL in PostgreSQL inbox mode). */
|
||||
nostr_relay_pool_t *pool;
|
||||
char url[256];
|
||||
|
||||
/* PostgreSQL inbox mode: source class used for priority assignment.
|
||||
* Defaults to "live". Set to "backfill" by the backfill module.
|
||||
* Ignored in WebSocket mode. */
|
||||
const char *source_class;
|
||||
|
||||
cr_seen_ring_t *seen; /* shared seen ring for dedup */
|
||||
long published_ok;
|
||||
long published_failed;
|
||||
long published_dup;
|
||||
} cr_sink_t;
|
||||
|
||||
/* Create the sink.
|
||||
*
|
||||
* In WebSocket mode (pg_mode = 0): creates a relay pool and adds
|
||||
* local_relay_url.
|
||||
* In PostgreSQL inbox mode (pg_mode != 0): no pool is created; the URL is
|
||||
* ignored. Events are inserted via pg_inbox_insert_event() (the pg_inbox
|
||||
* module must be initialized beforehand).
|
||||
*
|
||||
* The original two-argument signature is preserved via cr_sink_init() below
|
||||
* for backward compatibility; it defaults to WebSocket mode. */
|
||||
int cr_sink_init(cr_sink_t *sink, const char *local_relay_url, cr_seen_ring_t *seen);
|
||||
|
||||
/* Initialize the sink in PostgreSQL inbox mode. */
|
||||
int cr_sink_init_pg(cr_sink_t *sink, cr_seen_ring_t *seen);
|
||||
|
||||
/* Set the source class for priority assignment in PostgreSQL inbox mode.
|
||||
* class_ptr must point to a static string literal (e.g. CR_SINK_CLASS_LIVE). */
|
||||
void cr_sink_set_source_class(cr_sink_t *sink, const char *class_ptr);
|
||||
|
||||
void cr_sink_destroy(cr_sink_t *sink);
|
||||
|
||||
/* Publish a cJSON event. Dedups against the seen ring.
|
||||
* Returns 1 if enqueued/inserted, 0 if dup (skipped), -1 on error. */
|
||||
int cr_sink_publish(cr_sink_t *sink, cJSON *event);
|
||||
|
||||
/* Variant that records the originating relay URL in PostgreSQL inbox mode.
|
||||
* In WebSocket mode the source_relay is ignored. */
|
||||
int cr_sink_publish_from(cr_sink_t *sink, cJSON *event, const char *source_relay);
|
||||
|
||||
/* Pump the sink pool briefly to flush pending publish callbacks.
|
||||
* No-op in PostgreSQL inbox mode. */
|
||||
void cr_sink_pump(cr_sink_t *sink, int timeout_ms);
|
||||
|
||||
#endif /* CACHING_RELAY_RELAY_SINK_H */
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* caching_relay - in-memory runtime state
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include "state.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ---- pubkey set ---- */
|
||||
|
||||
void cr_pubkey_set_init(cr_pubkey_set_t *s) {
|
||||
s->items = NULL;
|
||||
s->count = 0;
|
||||
s->capacity = 0;
|
||||
}
|
||||
|
||||
void cr_pubkey_set_free(cr_pubkey_set_t *s) {
|
||||
for (int i = 0; i < s->count; i++) free(s->items[i]);
|
||||
free(s->items);
|
||||
s->items = NULL;
|
||||
s->count = 0;
|
||||
s->capacity = 0;
|
||||
}
|
||||
|
||||
void cr_pubkey_set_clear(cr_pubkey_set_t *s) {
|
||||
cr_pubkey_set_free(s);
|
||||
cr_pubkey_set_init(s);
|
||||
}
|
||||
|
||||
int cr_pubkey_set_contains(const cr_pubkey_set_t *s, const char *hex) {
|
||||
for (int i = 0; i < s->count; i++) {
|
||||
if (strcmp(s->items[i], hex) == 0) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cr_pubkey_set_add(cr_pubkey_set_t *s, const char *hex) {
|
||||
if (!hex || !hex[0]) return 0;
|
||||
if (cr_pubkey_set_contains(s, hex)) return 0;
|
||||
if (s->count >= s->capacity) {
|
||||
int newcap = s->capacity ? s->capacity * 2 : 64;
|
||||
char **ni = realloc(s->items, newcap * sizeof(char *));
|
||||
if (!ni) return -1;
|
||||
s->items = ni;
|
||||
s->capacity = newcap;
|
||||
}
|
||||
s->items[s->count] = strdup(hex);
|
||||
if (!s->items[s->count]) return -1;
|
||||
s->count++;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---- seen-event ring buffer ---- */
|
||||
|
||||
void cr_seen_ring_init(cr_seen_ring_t *r) {
|
||||
memset(r, 0, sizeof(*r));
|
||||
}
|
||||
|
||||
int cr_seen_ring_contains(const cr_seen_ring_t *r, const char *id) {
|
||||
for (int i = 0; i < r->count; i++) {
|
||||
int idx = (r->head - 1 - i + CR_SEEN_RING_SIZE) % CR_SEEN_RING_SIZE;
|
||||
if (strcmp(r->ids[idx], id) == 0) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cr_seen_ring_add(cr_seen_ring_t *r, const char *id) {
|
||||
if (!id || !id[0]) return 0;
|
||||
if (cr_seen_ring_contains(r, id)) return 0;
|
||||
strncpy(r->ids[r->head], id, CR_HEX_LEN - 1);
|
||||
r->ids[r->head][CR_HEX_LEN - 1] = '\0';
|
||||
r->head = (r->head + 1) % CR_SEEN_RING_SIZE;
|
||||
if (r->count < CR_SEEN_RING_SIZE) r->count++;
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* caching_relay - in-memory runtime state:
|
||||
* - followed-pubkey set (hex pubkeys)
|
||||
* - seen-event-id ring buffer (to avoid redundant publish attempts)
|
||||
*/
|
||||
#ifndef CACHING_RELAY_STATE_H
|
||||
#define CACHING_RELAY_STATE_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/* Hex pubkey is 64 chars + NUL. Event id is 64 hex chars + NUL. */
|
||||
#define CR_HEX_LEN 65
|
||||
|
||||
/* A simple dynamic string set for followed pubkeys. */
|
||||
typedef struct {
|
||||
char **items;
|
||||
int count;
|
||||
int capacity;
|
||||
} cr_pubkey_set_t;
|
||||
|
||||
void cr_pubkey_set_init(cr_pubkey_set_t *s);
|
||||
void cr_pubkey_set_free(cr_pubkey_set_t *s);
|
||||
int cr_pubkey_set_add(cr_pubkey_set_t *s, const char *hex); /* 1 if added, 0 if dup */
|
||||
int cr_pubkey_set_contains(const cr_pubkey_set_t *s, const char *hex);
|
||||
void cr_pubkey_set_clear(cr_pubkey_set_t *s);
|
||||
|
||||
/* Fixed-size ring buffer of event ids for dedup. */
|
||||
#define CR_SEEN_RING_SIZE 4096
|
||||
|
||||
typedef struct {
|
||||
char ids[CR_SEEN_RING_SIZE][CR_HEX_LEN];
|
||||
int head;
|
||||
int count;
|
||||
} cr_seen_ring_t;
|
||||
|
||||
void cr_seen_ring_init(cr_seen_ring_t *r);
|
||||
/* Returns 1 if the id was newly inserted, 0 if it was already present. */
|
||||
int cr_seen_ring_add(cr_seen_ring_t *r, const char *id);
|
||||
int cr_seen_ring_contains(const cr_seen_ring_t *r, const char *id);
|
||||
|
||||
#endif /* CACHING_RELAY_STATE_H */
|
||||
@@ -0,0 +1 @@
|
||||
key,value,data_type,description,category,requires_restart,created_at,updated_at
|
||||
|
-9970
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -1,12 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Restart the service
|
||||
sudo systemctl stop c-relay.service
|
||||
sudo systemctl stop c-relay-pg.service
|
||||
|
||||
# Copy the binary to the deployment location
|
||||
cp build/c_relay_static_x86_64 ~/Storage/c_relay/crelay
|
||||
cp build/c_relay_pg_static_x86_64 ~/Storage/c_relay_pg/crelay
|
||||
|
||||
# Restart the service
|
||||
sudo systemctl restart c-relay.service
|
||||
sudo systemctl restart c-relay-pg.service
|
||||
|
||||
# Show service status
|
||||
sudo systemctl status c-relay.service --no-pager -l
|
||||
sudo systemctl status c-relay-pg.service --no-pager -l
|
||||
|
||||
+193
-18
@@ -1,28 +1,203 @@
|
||||
#!/bin/bash
|
||||
|
||||
# C-Relay Static Binary Deployment Script
|
||||
# Deploys build/c_relay_static_x86_64 to server via ssh
|
||||
# C-Relay-PG PostgreSQL Deployment Script
|
||||
# Deploys static relay binary and configures PostgreSQL 18 + systemd service on remote server.
|
||||
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
LOCAL_BINARY="build/c_relay_static_x86_64"
|
||||
REMOTE_BINARY_PATH="/usr/local/bin/c_relay/c_relay"
|
||||
SERVICE_NAME="c-relay"
|
||||
REMOTE_HOST="ubuntu@laantungir.net"
|
||||
LOCAL_BINARY="build/c_relay_pg_static_x86_64"
|
||||
LOCAL_CACHING_BINARY="build/caching_relay"
|
||||
REMOTE_BINARY_DIR="/usr/local/bin/c_relay_pg"
|
||||
REMOTE_BINARY_PATH="/usr/local/bin/c_relay_pg/c_relay_pg"
|
||||
REMOTE_CACHING_BINARY_PATH="/opt/c-relay-pg/caching_relay"
|
||||
SERVICE_NAME="c-relay-pg"
|
||||
|
||||
# Create backup
|
||||
ssh ubuntu@laantungir.com "sudo cp '$REMOTE_BINARY_PATH' '${REMOTE_BINARY_PATH}.backup.$(date +%Y%m%d_%H%M%S)'" 2>/dev/null || true
|
||||
LOCAL_SERVICE_FILE="systemd/c-relay.service"
|
||||
LOCAL_PG_SETUP_SCRIPT="systemd/setup_postgres_18.sh"
|
||||
|
||||
# Upload binary to temp location
|
||||
scp "$LOCAL_BINARY" "ubuntu@laantungir.com:/tmp/c_relay.tmp"
|
||||
if [ ! -f "$LOCAL_BINARY" ]; then
|
||||
echo "ERROR: Binary not found: $LOCAL_BINARY"
|
||||
echo "Build it first (e.g. ./make_and_restart_relay.sh)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install binary
|
||||
ssh ubuntu@laantungir.com "sudo mv '/tmp/c_relay.tmp' '$REMOTE_BINARY_PATH'"
|
||||
ssh ubuntu@laantungir.com "sudo chown c-relay:c-relay '$REMOTE_BINARY_PATH'"
|
||||
ssh ubuntu@laantungir.com "sudo chmod +x '$REMOTE_BINARY_PATH'"
|
||||
if [ ! -f "$LOCAL_SERVICE_FILE" ]; then
|
||||
echo "ERROR: Service file not found: $LOCAL_SERVICE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Reload systemd and restart service
|
||||
ssh ubuntu@laantungir.com "sudo systemctl daemon-reload"
|
||||
ssh ubuntu@laantungir.com "sudo systemctl restart '$SERVICE_NAME'"
|
||||
if [ ! -f "$LOCAL_PG_SETUP_SCRIPT" ]; then
|
||||
echo "ERROR: PostgreSQL setup script not found: $LOCAL_PG_SETUP_SCRIPT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deployment complete!"
|
||||
echo "==> Uploading artifacts to $REMOTE_HOST"
|
||||
scp "$LOCAL_BINARY" "$REMOTE_HOST:/tmp/c_relay_pg.tmp"
|
||||
scp "$LOCAL_SERVICE_FILE" "$REMOTE_HOST:/tmp/c-relay-pg.service"
|
||||
scp "$LOCAL_PG_SETUP_SCRIPT" "$REMOTE_HOST:/tmp/setup_postgres_18.sh"
|
||||
# Upload the caching_relay binary if it was built locally. The caching
|
||||
# service is NOT started by this deploy (the systemd unit does not pass
|
||||
# --start-caching), but the binary is placed in the relay's WorkingDirectory
|
||||
# so it is ready for when caching is enabled via event-based config.
|
||||
if [ -f "$LOCAL_CACHING_BINARY" ]; then
|
||||
scp "$LOCAL_CACHING_BINARY" "$REMOTE_HOST:/tmp/caching_relay.tmp"
|
||||
else
|
||||
echo "WARNING: caching_relay binary not found locally ($LOCAL_CACHING_BINARY) — skipping caching binary upload"
|
||||
fi
|
||||
|
||||
# Upload admin PHP files (tar to preserve directory structure, exclude
|
||||
# cache logs and the dev-only serve.sh / php_server.log).
|
||||
echo "==> Packaging admin files"
|
||||
tar czf /tmp/admin_deploy.tar.gz \
|
||||
--exclude='admin/serve.sh' \
|
||||
--exclude='admin/php_server.log' \
|
||||
--exclude='admin/cache' \
|
||||
-C . admin/
|
||||
scp /tmp/admin_deploy.tar.gz "$REMOTE_HOST:/tmp/admin_deploy.tar.gz"
|
||||
|
||||
echo "==> Running remote install/configuration"
|
||||
ssh "$REMOTE_HOST" 'bash -s' <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE_NAME="c-relay-pg"
|
||||
RELAY_USER="c-relay-pg"
|
||||
REMOTE_BINARY_DIR="/usr/local/bin/c_relay_pg"
|
||||
REMOTE_BINARY_PATH="/usr/local/bin/c_relay_pg/c_relay_pg"
|
||||
REMOTE_CACHING_BINARY_PATH="/opt/c-relay-pg/caching_relay"
|
||||
|
||||
echo "[remote] Ensuring service user exists"
|
||||
if ! id "$RELAY_USER" >/dev/null 2>&1; then
|
||||
sudo useradd --system --home-dir /opt/c-relay-pg --shell /usr/sbin/nologin "$RELAY_USER"
|
||||
fi
|
||||
|
||||
echo "[remote] Ensuring required directories exist"
|
||||
sudo mkdir -p "$REMOTE_BINARY_DIR" /opt/c-relay-pg /etc/c-relay-pg
|
||||
sudo chown "$RELAY_USER:$RELAY_USER" /opt/c-relay-pg
|
||||
|
||||
echo "[remote] Installing PostgreSQL 18 (PGDG) if missing"
|
||||
if ! dpkg -s postgresql-18 >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl ca-certificates lsb-release gnupg
|
||||
sudo install -d /usr/share/postgresql-common/pgdg
|
||||
sudo curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc
|
||||
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list >/dev/null
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y postgresql-18
|
||||
fi
|
||||
|
||||
sudo systemctl enable postgresql
|
||||
sudo systemctl restart postgresql
|
||||
|
||||
echo "[remote] Configuring PostgreSQL role/database"
|
||||
sudo chmod +x /tmp/setup_postgres_18.sh
|
||||
sudo /tmp/setup_postgres_18.sh
|
||||
|
||||
echo "[remote] Installing relay binary"
|
||||
if [ -f "$REMOTE_BINARY_PATH" ]; then
|
||||
sudo cp "$REMOTE_BINARY_PATH" "${REMOTE_BINARY_PATH}.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
fi
|
||||
sudo mv /tmp/c_relay_pg.tmp "$REMOTE_BINARY_PATH"
|
||||
sudo chown "$RELAY_USER:$RELAY_USER" "$REMOTE_BINARY_PATH"
|
||||
sudo chmod +x "$REMOTE_BINARY_PATH"
|
||||
|
||||
echo "[remote] Installing caching_relay binary (if uploaded)"
|
||||
if [ -f /tmp/caching_relay.tmp ]; then
|
||||
sudo mv /tmp/caching_relay.tmp "$REMOTE_CACHING_BINARY_PATH"
|
||||
sudo chown "$RELAY_USER:$RELAY_USER" "$REMOTE_CACHING_BINARY_PATH"
|
||||
sudo chmod +x "$REMOTE_CACHING_BINARY_PATH"
|
||||
echo " -> caching_relay installed at $REMOTE_CACHING_BINARY_PATH (not started; enable via config when ready)"
|
||||
else
|
||||
echo " -> no caching_relay binary uploaded, skipping"
|
||||
fi
|
||||
|
||||
echo "[remote] Installing admin PHP files"
|
||||
# Back up existing config.php and .htpasswd so we don't lose production
|
||||
# credentials (DB password, basic-auth) across deploys.
|
||||
if [ -f /opt/c-relay-pg/admin/lib/config.php ]; then
|
||||
sudo cp /opt/c-relay-pg/admin/lib/config.php /tmp/admin_config_backup
|
||||
echo " -> backed up config.php"
|
||||
fi
|
||||
if [ -f /opt/c-relay-pg/admin/.htpasswd ]; then
|
||||
sudo cp /opt/c-relay-pg/admin/.htpasswd /tmp/admin_htpasswd_backup
|
||||
echo " -> backed up .htpasswd"
|
||||
fi
|
||||
# Extract the new admin files (overwrites old admin at /opt/c-relay-pg/admin/).
|
||||
sudo mkdir -p /opt/c-relay-pg/admin
|
||||
sudo tar xzf /tmp/admin_deploy.tar.gz -C /opt/c-relay-pg/
|
||||
# Restore config.php and .htpasswd if they were backed up.
|
||||
if [ -f /tmp/admin_config_backup ]; then
|
||||
sudo cp /tmp/admin_config_backup /opt/c-relay-pg/admin/lib/config.php
|
||||
echo " -> restored config.php"
|
||||
fi
|
||||
if [ -f /tmp/admin_htpasswd_backup ]; then
|
||||
sudo cp /tmp/admin_htpasswd_backup /opt/c-relay-pg/admin/.htpasswd
|
||||
echo " -> restored .htpasswd"
|
||||
fi
|
||||
# Set ownership: www-data (nginx/php-fpm) for web-served files,
|
||||
# but keep .htpasswd readable by nginx.
|
||||
sudo chown -R www-data:www-data /opt/c-relay-pg/admin/
|
||||
sudo chmod 640 /opt/c-relay-pg/admin/.htpasswd 2>/dev/null || true
|
||||
# Ensure the cache directory exists (for chart text files).
|
||||
sudo mkdir -p /opt/c-relay-pg/admin/cache
|
||||
sudo chown www-data:www-data /opt/c-relay-pg/admin/cache
|
||||
echo " -> admin files installed at /opt/c-relay-pg/admin/"
|
||||
|
||||
# Also update admin2 if it exists (symlink or copy to keep it in sync).
|
||||
if [ -d /opt/c-relay-pg/admin2 ]; then
|
||||
sudo rm -rf /opt/c-relay-pg/admin2
|
||||
sudo cp -a /opt/c-relay-pg/admin /opt/c-relay-pg/admin2
|
||||
sudo chown -R www-data:www-data /opt/c-relay-pg/admin2/
|
||||
echo " -> admin2 synced at /opt/c-relay-pg/admin2/"
|
||||
fi
|
||||
|
||||
echo "[remote] Installing systemd unit"
|
||||
sudo mv /tmp/c-relay-pg.service /etc/systemd/system/c-relay-pg.service
|
||||
sudo chown root:root /etc/systemd/system/c-relay-pg.service
|
||||
sudo chmod 644 /etc/systemd/system/c-relay-pg.service
|
||||
|
||||
echo "[remote] Killing stale caching_relay processes (safety)"
|
||||
# The caching service is forked by the relay with setsid(), so it detaches
|
||||
# from the relay's process group and survives when the relay is killed.
|
||||
# Without this, every restart orphans the previous caching_relay child and
|
||||
# a new relay forks another, leading to many stale processes with dead PG
|
||||
# connections spamming errors. Kill them here so the new relay starts clean.
|
||||
CACHING_PIDS=$(pgrep -f "caching_relay" || echo "")
|
||||
if [ -n "$CACHING_PIDS" ]; then
|
||||
echo " -> killing stale caching_relay PIDs: $CACHING_PIDS"
|
||||
kill -9 $CACHING_PIDS 2>/dev/null || true
|
||||
sleep 1
|
||||
else
|
||||
echo " -> no stale caching_relay processes found"
|
||||
fi
|
||||
|
||||
echo "[remote] Reloading and restarting service"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable "$SERVICE_NAME"
|
||||
sudo systemctl restart "$SERVICE_NAME"
|
||||
|
||||
echo "[remote] Health checks"
|
||||
sudo systemctl --no-pager --full status "$SERVICE_NAME" | sed -n '1,25p'
|
||||
sudo -u "$RELAY_USER" psql -d crelay -c "SELECT current_user, current_database();"
|
||||
|
||||
# Verify admin page is accessible (HTTP 200 or 401 = auth required, both are OK).
|
||||
ADMIN_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1/admin/" 2>/dev/null || echo "000")
|
||||
if [ "$ADMIN_CODE" = "200" ] || [ "$ADMIN_CODE" = "401" ]; then
|
||||
echo " -> admin page accessible (HTTP $ADMIN_CODE)"
|
||||
else
|
||||
echo " -> WARNING: admin page returned HTTP $ADMIN_CODE (expected 200 or 401)"
|
||||
fi
|
||||
|
||||
# Clean up temp files.
|
||||
rm -f /tmp/admin_deploy.tar.gz
|
||||
sudo rm -f /tmp/admin_htpasswd_backup /tmp/admin_config_backup
|
||||
EOF
|
||||
|
||||
# Clean up local temp tarball.
|
||||
rm -f /tmp/admin_deploy.tar.gz
|
||||
|
||||
echo "Deployment complete: $REMOTE_HOST"
|
||||
echo ""
|
||||
echo "Admin interface: https://laantungir.net/admin/"
|
||||
echo "Caching is NOT auto-started. Enable it via the admin UI when ready."
|
||||
|
||||
+15
-15
@@ -1,14 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
# C-Relay Debug Binary Deployment Script
|
||||
# Deploys build/c_relay_static_x86_64_debug to server for CPU profiling
|
||||
# C-Relay-PG Debug Binary Deployment Script
|
||||
# Deploys build/c_relay_pg_static_x86_64_debug to server for CPU profiling
|
||||
#
|
||||
# Usage:
|
||||
# ./deploy_lt_debug.sh -- deploy debug binary and restart
|
||||
# ./deploy_lt_debug.sh --profile -- deploy, then run perf and fetch results
|
||||
#
|
||||
# After deploying, profile with:
|
||||
# sudo perf record -g -p $(pgrep c_relay) -- sleep 30
|
||||
# sudo perf record -g -p $(pgrep c_relay_pg) -- sleep 30
|
||||
# sudo perf report --stdio --sort=symbol --no-children -n 2>/dev/null | head -80
|
||||
#
|
||||
# Restore production binary:
|
||||
@@ -16,10 +16,10 @@
|
||||
|
||||
set -e
|
||||
|
||||
LOCAL_DEBUG_BINARY="build/c_relay_static_x86_64_debug"
|
||||
REMOTE_BINARY_PATH="/usr/local/bin/c_relay/c_relay"
|
||||
REMOTE_PROD_BACKUP="/usr/local/bin/c_relay/c_relay.production"
|
||||
SERVICE_NAME="c-relay"
|
||||
LOCAL_DEBUG_BINARY="build/c_relay_pg_static_x86_64_debug"
|
||||
REMOTE_BINARY_PATH="/usr/local/bin/c_relay_pg/c_relay_pg"
|
||||
REMOTE_PROD_BACKUP="/usr/local/bin/c_relay_pg/c_relay_pg.production"
|
||||
SERVICE_NAME="c-relay-pg"
|
||||
REMOTE_HOST="ubuntu@laantungir.com"
|
||||
|
||||
# Check debug binary exists
|
||||
@@ -33,7 +33,7 @@ if [ ! -f "$LOCAL_DEBUG_BINARY" ]; then
|
||||
fi
|
||||
|
||||
echo "=========================================="
|
||||
echo "C-Relay Debug Deployment"
|
||||
echo "C-Relay-PG Debug Deployment"
|
||||
echo "=========================================="
|
||||
echo "Binary: $LOCAL_DEBUG_BINARY ($(du -h "$LOCAL_DEBUG_BINARY" | cut -f1))"
|
||||
echo "Target: $REMOTE_HOST:$REMOTE_BINARY_PATH"
|
||||
@@ -55,18 +55,18 @@ ssh "$REMOTE_HOST" "
|
||||
|
||||
# Upload debug binary
|
||||
echo "Uploading debug binary..."
|
||||
scp "$LOCAL_DEBUG_BINARY" "$REMOTE_HOST:/tmp/c_relay_debug.tmp"
|
||||
scp "$LOCAL_DEBUG_BINARY" "$REMOTE_HOST:/tmp/c_relay_pg_debug.tmp"
|
||||
|
||||
# Install debug binary
|
||||
echo "Installing debug binary..."
|
||||
ssh "$REMOTE_HOST" "
|
||||
sudo mv '/tmp/c_relay_debug.tmp' '$REMOTE_BINARY_PATH'
|
||||
sudo chown c-relay:c-relay '$REMOTE_BINARY_PATH'
|
||||
sudo mv '/tmp/c_relay_pg_debug.tmp' '$REMOTE_BINARY_PATH'
|
||||
sudo chown c-relay-pg:c-relay-pg '$REMOTE_BINARY_PATH'
|
||||
sudo chmod +x '$REMOTE_BINARY_PATH'
|
||||
"
|
||||
|
||||
# Restart service
|
||||
echo "Restarting c-relay service..."
|
||||
echo "Restarting c-relay-pg service..."
|
||||
ssh "$REMOTE_HOST" "sudo systemctl daemon-reload && sudo systemctl restart '$SERVICE_NAME'"
|
||||
|
||||
echo ""
|
||||
@@ -84,9 +84,9 @@ if [ "$1" = "--profile" ]; then
|
||||
sleep 3
|
||||
|
||||
ssh "$REMOTE_HOST" "
|
||||
PID=\$(pgrep c_relay)
|
||||
PID=\$(pgrep c_relay_pg)
|
||||
if [ -z \"\$PID\" ]; then
|
||||
echo 'ERROR: c_relay not running'
|
||||
echo 'ERROR: c_relay_pg not running'
|
||||
exit 1
|
||||
fi
|
||||
echo \"Profiling PID \$PID for 30 seconds...\"
|
||||
@@ -101,7 +101,7 @@ else
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "SSH to server and run:"
|
||||
echo " sudo perf record -g -p \$(pgrep c_relay) -- sleep 30"
|
||||
echo " sudo perf record -g -p \$(pgrep c_relay_pg) -- sleep 30"
|
||||
echo " sudo perf report --stdio --sort=symbol --no-children -n 2>/dev/null | head -60"
|
||||
echo ""
|
||||
echo "Or run with --profile to do it automatically:"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# C-Relay Administrator API Implementation Plan
|
||||
# C-Relay-PG Administrator API Implementation Plan
|
||||
|
||||
## Problem Analysis
|
||||
|
||||
@@ -161,7 +161,7 @@ Would require changing schema, migration scripts, and storage logic.
|
||||
#### README.md Documentation Format:
|
||||
|
||||
```markdown
|
||||
# C-Relay Administrator API
|
||||
# C-Relay-PG Administrator API
|
||||
|
||||
## Authentication
|
||||
All admin commands require signing with the admin private key generated during first startup.
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# Agent Browser Testing Guide for C-Relay-PG
|
||||
|
||||
This document explains how to use the `agent-browser` CLI tool to test the c-relay-pg admin web UI from an AI agent context (no physical display required).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **agent-browser** installed globally: `npm install -g agent-browser`
|
||||
- Binary location: `/home/user/.nvm/versions/node/v24.14.1/bin/agent-browser`
|
||||
- The relay must be running locally (default port 8888)
|
||||
- Test keys configured in `.test_keys`
|
||||
|
||||
## Starting the Relay for Local Testing
|
||||
|
||||
```bash
|
||||
./make_and_restart_relay.sh -t
|
||||
```
|
||||
|
||||
This reads `.test_keys` and starts the relay with:
|
||||
- `ADMIN_PUBKEY` — the hex public key of the admin account
|
||||
- `ADMIN_PRIVKEY` — the hex secret key (used for browser login, not by the relay itself)
|
||||
- `SERVER_PRIVKEY` — the relay's own private key
|
||||
|
||||
## Key URLs
|
||||
|
||||
| URL | Purpose |
|
||||
|-----|---------|
|
||||
| `http://127.0.0.1:8888/api/index.html` | Admin web UI (the correct entry point) |
|
||||
| `http://127.0.0.1:8888/` | Returns 406 without NIP-11 Accept header — **do not use for browser testing** |
|
||||
| `http://127.0.0.1:8888/` with `Accept: application/nostr+json` | NIP-11 relay info JSON |
|
||||
|
||||
**Important:** The root URL `/` is a WebSocket/NIP-11 endpoint, not an HTML page. Always use `/api/index.html` for browser testing.
|
||||
|
||||
## Admin Login Credentials
|
||||
|
||||
The admin nsec for the current `.test_keys` configuration:
|
||||
|
||||
```
|
||||
nsec: nsec1zkn0hlt4jvcvn9yt5p7ea4m7f82pf3ph0gj3d4rlcz4s64x5z86satv9qm
|
||||
hex: 15a6fbfd759330c9948ba07d9ed77e49d414c4377a2516d47fc0ab0d54d411f5
|
||||
npub: npub1tlyzk6slzt8d989f4pn3synk98fea64ea3jmrdc7dtd0kw8uxlas9a9fwr
|
||||
hex pubkey: 5fc82b6a1f12ced29ca9a86718127629d39eeab9ec65b1b71e6adafb38fc37fb
|
||||
```
|
||||
|
||||
## Complete Login Flow with agent-browser
|
||||
|
||||
### Step 1: Open the admin UI
|
||||
|
||||
```bash
|
||||
agent-browser open http://127.0.0.1:8888/api/index.html
|
||||
agent-browser wait --load networkidle
|
||||
```
|
||||
|
||||
### Step 2: Take an interactive snapshot to see what is on screen
|
||||
|
||||
```bash
|
||||
agent-browser snapshot -i
|
||||
```
|
||||
|
||||
You will see a login modal with buttons like:
|
||||
- `"Browser Extension"` — skip this
|
||||
- `"Local Key"` — **use this one**
|
||||
- `"Seed Phrase"` — skip
|
||||
- `"Nostr Connect"` — skip
|
||||
- `"Read Only"` — skip
|
||||
|
||||
### Step 3: Click "Local Key"
|
||||
|
||||
```bash
|
||||
agent-browser click @e15
|
||||
```
|
||||
|
||||
(The ref number may vary — use the ref from the snapshot output for the "Local Key" button.)
|
||||
|
||||
After clicking, a text input appears asking for the secret key.
|
||||
|
||||
### Step 4: Enter the admin nsec
|
||||
|
||||
```bash
|
||||
agent-browser fill @e14 "nsec1zkn0hlt4jvcvn9yt5p7ea4m7f82pf3ph0gj3d4rlcz4s64x5z86satv9qm"
|
||||
```
|
||||
|
||||
(Use the ref from the snapshot for the textbox element.)
|
||||
|
||||
### Step 5: Click "Import Key"
|
||||
|
||||
```bash
|
||||
agent-browser snapshot -i
|
||||
```
|
||||
|
||||
Check the snapshot — the "Import Key" button should now be enabled. Click it:
|
||||
|
||||
```bash
|
||||
agent-browser click @e15
|
||||
```
|
||||
|
||||
### Step 6: Click "Continue" on the success screen
|
||||
|
||||
After import, a success screen appears with "Continue" button:
|
||||
|
||||
```bash
|
||||
agent-browser wait 800
|
||||
agent-browser snapshot -i
|
||||
agent-browser click @e19
|
||||
```
|
||||
|
||||
(Use the ref from the snapshot for the "Continue" button.)
|
||||
|
||||
### Step 7: Wait for admin UI to load
|
||||
|
||||
```bash
|
||||
agent-browser wait 5000
|
||||
agent-browser snapshot -i
|
||||
```
|
||||
|
||||
You should now see the admin dashboard with:
|
||||
- Statistics table (Database Size, Total Events, PID, etc.)
|
||||
- Navigation buttons (Statistics, Subscriptions, Configuration, Authorization, etc.)
|
||||
- Admin profile area showing "admin" label
|
||||
|
||||
## Navigating Admin Sections
|
||||
|
||||
After login, use the sidebar navigation buttons:
|
||||
|
||||
```bash
|
||||
# View configuration
|
||||
agent-browser click @e8 # Configuration button ref
|
||||
|
||||
# View authorization rules
|
||||
agent-browser click @e9 # Authorization button ref
|
||||
|
||||
# View statistics
|
||||
agent-browser click @e6 # Statistics button ref
|
||||
|
||||
# Always snapshot after navigation to see results
|
||||
agent-browser wait 2500
|
||||
agent-browser snapshot -i
|
||||
```
|
||||
|
||||
## Checking for Errors
|
||||
|
||||
```bash
|
||||
# View browser console logs
|
||||
agent-browser console
|
||||
|
||||
# View JavaScript errors
|
||||
agent-browser errors
|
||||
|
||||
# View relay server logs
|
||||
tail -n 100 relay.log
|
||||
```
|
||||
|
||||
## Chained Command Example (Full Login in One Shot)
|
||||
|
||||
```bash
|
||||
agent-browser open http://127.0.0.1:8888/api/index.html && \
|
||||
agent-browser wait --load networkidle && \
|
||||
agent-browser snapshot -i
|
||||
```
|
||||
|
||||
Then use refs from the snapshot to complete login steps.
|
||||
|
||||
## Tips for AI Agents
|
||||
|
||||
1. **Always use `/api/index.html`** — never the root URL
|
||||
2. **Use `snapshot -i`** after every action to see the current interactive elements and their refs
|
||||
3. **Refs change** between snapshots — always re-snapshot before clicking
|
||||
4. **Wait after clicks** — use `agent-browser wait 2000` (milliseconds) between actions that trigger async operations
|
||||
5. **The login flow has 3 screens**: method selection → key input → success confirmation
|
||||
6. **Console logs are cumulative** — they show all logs since page load, which is useful for debugging admin API responses
|
||||
7. **The relay log** at `relay.log` shows server-side processing of admin commands
|
||||
8. **Command chaining** with `&&` works — the browser daemon persists between commands
|
||||
|
||||
## Verifying Admin API is Working
|
||||
|
||||
After login, the statistics page should show populated data:
|
||||
- Database Size (e.g., "4 KB")
|
||||
- Process ID (the relay PID)
|
||||
- WebSocket Connections count
|
||||
- Memory Usage
|
||||
|
||||
If these show "-" or "Loading...", check:
|
||||
1. `relay.log` for errors
|
||||
2. `agent-browser console` for JavaScript errors
|
||||
3. `agent-browser errors` for page-level errors
|
||||
|
||||
## Closing the Browser
|
||||
|
||||
```bash
|
||||
agent-browser close --all
|
||||
```
|
||||
@@ -324,7 +324,7 @@ int main() {
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Plan for c-relay
|
||||
## Migration Plan for c-relay-pg
|
||||
|
||||
### Phase 1: Extract Debug System
|
||||
1. Create `c_utils_lib` repository
|
||||
@@ -333,16 +333,16 @@ int main() {
|
||||
4. Add basic tests
|
||||
|
||||
### Phase 2: Add Versioning System
|
||||
1. Extract version generation logic from c-relay
|
||||
1. Extract version generation logic from c-relay-pg
|
||||
2. Create reusable version utilities
|
||||
3. Update c-relay to use new system
|
||||
3. Update c-relay-pg to use new system
|
||||
4. Update nostr_core_lib to use new system
|
||||
|
||||
### Phase 3: Add as Submodule
|
||||
1. Add `c_utils_lib` as submodule to c-relay
|
||||
2. Update c-relay Makefile
|
||||
3. Update includes in c-relay source files
|
||||
4. Remove old debug files from c-relay
|
||||
1. Add `c_utils_lib` as submodule to c-relay-pg
|
||||
2. Update c-relay-pg Makefile
|
||||
3. Update includes in c-relay-pg source files
|
||||
4. Remove old debug files from c-relay-pg
|
||||
|
||||
### Phase 4: Documentation & Examples
|
||||
1. Create comprehensive README
|
||||
@@ -352,7 +352,7 @@ int main() {
|
||||
|
||||
## Benefits
|
||||
|
||||
### For c-relay
|
||||
### For c-relay-pg
|
||||
- Cleaner separation of concerns
|
||||
- Reusable utilities across projects
|
||||
- Easier to maintain and test
|
||||
@@ -379,7 +379,7 @@ int main() {
|
||||
- Memory leak detection (valgrind)
|
||||
|
||||
### Integration Tests
|
||||
- Test with real projects (c-relay, nostr_core_lib)
|
||||
- Test with real projects (c-relay-pg, nostr_core_lib)
|
||||
- Cross-platform testing
|
||||
- Performance benchmarks
|
||||
|
||||
@@ -433,7 +433,7 @@ MIT License - permissive and suitable for learning and commercial use.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ Successfully integrated into c-relay
|
||||
1. ✅ Successfully integrated into c-relay-pg
|
||||
2. ✅ Successfully integrated into nostr_core_lib
|
||||
3. ✅ All tests passing
|
||||
4. ✅ Documentation complete
|
||||
@@ -449,7 +449,7 @@ MIT License - permissive and suitable for learning and commercial use.
|
||||
4. Create build system
|
||||
5. Write tests
|
||||
6. Create documentation
|
||||
7. Integrate into c-relay
|
||||
7. Integrate into c-relay-pg
|
||||
8. Publish to GitHub
|
||||
|
||||
---
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a step-by-step implementation plan for creating the `c_utils_lib` library and integrating it into the c-relay project.
|
||||
This document provides a step-by-step implementation plan for creating the `c_utils_lib` library and integrating it into the c-relay-pg project.
|
||||
|
||||
## Phase 1: Repository Setup & Structure
|
||||
|
||||
### Step 1.1: Create Repository Structure
|
||||
|
||||
**Location**: Create outside c-relay project (sibling directory)
|
||||
**Location**: Create outside c-relay-pg project (sibling directory)
|
||||
|
||||
```bash
|
||||
# Create directory structure
|
||||
@@ -42,7 +42,7 @@ git branch -M main
|
||||
|
||||
### Step 2.1: Move Debug Files
|
||||
|
||||
**Source files** (from c-relay):
|
||||
**Source files** (from c-relay-pg):
|
||||
- `src/debug.c` → `c_utils_lib/src/debug.c`
|
||||
- `src/debug.h` → `c_utils_lib/include/c_utils/debug.h`
|
||||
|
||||
@@ -484,20 +484,20 @@ How to integrate into projects:
|
||||
3. Code examples
|
||||
4. Migration from standalone utilities
|
||||
|
||||
## Phase 7: Integration with c-relay
|
||||
## Phase 7: Integration with c-relay-pg
|
||||
|
||||
### Step 7.1: Add as Submodule
|
||||
|
||||
```bash
|
||||
cd /path/to/c-relay
|
||||
cd /path/to/c-relay-pg
|
||||
git submodule add <repo-url> c_utils_lib
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
### Step 7.2: Update c-relay Makefile
|
||||
### Step 7.2: Update c-relay-pg Makefile
|
||||
|
||||
```makefile
|
||||
# Add to c-relay Makefile
|
||||
# Add to c-relay-pg Makefile
|
||||
C_UTILS_LIB = c_utils_lib/libc_utils.a
|
||||
|
||||
# Update includes
|
||||
@@ -514,7 +514,7 @@ $(C_UTILS_LIB):
|
||||
$(TARGET): $(C_UTILS_LIB) ...
|
||||
```
|
||||
|
||||
### Step 7.3: Update c-relay Source Files
|
||||
### Step 7.3: Update c-relay-pg Source Files
|
||||
|
||||
**Changes needed**:
|
||||
|
||||
@@ -543,7 +543,7 @@ $(TARGET): $(C_UTILS_LIB) ...
|
||||
### Step 7.4: Test Integration
|
||||
|
||||
```bash
|
||||
cd c-relay
|
||||
cd c-relay-pg
|
||||
make clean
|
||||
make
|
||||
./make_and_restart_relay.sh
|
||||
@@ -556,7 +556,7 @@ Verify:
|
||||
|
||||
## Phase 8: Version System Integration
|
||||
|
||||
### Step 8.1: Update c-relay Makefile for Versioning
|
||||
### Step 8.1: Update c-relay-pg Makefile for Versioning
|
||||
|
||||
```makefile
|
||||
# Add version generation
|
||||
@@ -567,7 +567,7 @@ src/version.h: .git/refs/tags/*
|
||||
$(TARGET): src/version.h ...
|
||||
```
|
||||
|
||||
### Step 8.2: Update c-relay to Use Generated Version
|
||||
### Step 8.2: Update c-relay-pg to Use Generated Version
|
||||
|
||||
Replace hardcoded version in `src/main.h` with:
|
||||
```c
|
||||
@@ -583,7 +583,7 @@ Replace hardcoded version in `src/main.h` with:
|
||||
- **Phase 4**: Build System - 2 hours
|
||||
- **Phase 5**: Examples & Tests - 3 hours
|
||||
- **Phase 6**: Documentation - 3 hours
|
||||
- **Phase 7**: c-relay Integration - 2 hours
|
||||
- **Phase 7**: c-relay-pg Integration - 2 hours
|
||||
- **Phase 8**: Version Integration - 2 hours
|
||||
|
||||
**Total**: ~19 hours
|
||||
@@ -593,11 +593,11 @@ Replace hardcoded version in `src/main.h` with:
|
||||
- [ ] c_utils_lib builds successfully
|
||||
- [ ] All tests pass
|
||||
- [ ] Examples compile and run
|
||||
- [ ] c-relay integrates successfully
|
||||
- [ ] Debug output works in c-relay
|
||||
- [ ] c-relay-pg integrates successfully
|
||||
- [ ] Debug output works in c-relay-pg
|
||||
- [ ] Version generation works
|
||||
- [ ] Documentation complete
|
||||
- [ ] No regressions in c-relay functionality
|
||||
- [ ] No regressions in c-relay-pg functionality
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -608,7 +608,7 @@ Replace hardcoded version in `src/main.h` with:
|
||||
5. Create build system
|
||||
6. Write tests and examples
|
||||
7. Create documentation
|
||||
8. Integrate into c-relay
|
||||
8. Integrate into c-relay-pg
|
||||
9. Test thoroughly
|
||||
10. Publish to GitHub
|
||||
|
||||
|
||||
@@ -78,9 +78,9 @@ Configuration events follow the standard Nostr event format with kind 33334:
|
||||
|
||||
#### `relay_software`
|
||||
- **Description**: Software identifier for NIP-11
|
||||
- **Default**: `"c-relay"`
|
||||
- **Default**: `"c-relay-pg"`
|
||||
- **Format**: String, max 64 characters
|
||||
- **Example**: `"c-relay v1.0.0"`
|
||||
- **Example**: `"c-relay-pg v1.0.0"`
|
||||
|
||||
#### `relay_version`
|
||||
- **Description**: Software version string
|
||||
@@ -366,7 +366,7 @@ sqlite3 relay.nrdb "SELECT json_pretty(json_object(
|
||||
#### Invalid Parameter Values
|
||||
```bash
|
||||
# Check relay logs for validation errors
|
||||
journalctl -u c-relay | grep "Configuration.*invalid\|Invalid.*configuration"
|
||||
journalctl -u c-relay-pg | grep "Configuration.*invalid\|Invalid.*configuration"
|
||||
|
||||
# Common issues:
|
||||
# - Numeric values outside valid ranges
|
||||
@@ -395,10 +395,10 @@ ORDER BY date DESC;"
|
||||
#### Resource Usage After Changes
|
||||
```bash
|
||||
# Monitor system resources after configuration updates
|
||||
top -p $(pgrep c_relay)
|
||||
top -p $(pgrep c_relay_pg)
|
||||
|
||||
# Check for memory leaks
|
||||
ps aux | grep c_relay | awk '{print $6}' # RSS memory
|
||||
ps aux | grep c_relay_pg | awk '{print $6}' # RSS memory
|
||||
```
|
||||
|
||||
### Emergency Recovery
|
||||
@@ -425,7 +425,7 @@ nostrtool event \
|
||||
# If database is corrupted, backup and recreate
|
||||
cp relay.nrdb relay.nrdb.backup
|
||||
rm relay.nrdb*
|
||||
./build/c_relay_x86 # Creates fresh database with new keys
|
||||
./build/c_relay_pg_x86 # Creates fresh database with new keys
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+14
-14
@@ -21,22 +21,22 @@ typedef enum {
|
||||
|
||||
```bash
|
||||
# Production (default - no debug output)
|
||||
./c_relay_x86
|
||||
./c_relay_pg_x86
|
||||
|
||||
# Show errors only
|
||||
./c_relay_x86 --debug-level=1
|
||||
./c_relay_pg_x86 --debug-level=1
|
||||
|
||||
# Show errors and warnings
|
||||
./c_relay_x86 --debug-level=2
|
||||
./c_relay_pg_x86 --debug-level=2
|
||||
|
||||
# Show errors, warnings, and info (recommended for development)
|
||||
./c_relay_x86 --debug-level=3
|
||||
./c_relay_pg_x86 --debug-level=3
|
||||
|
||||
# Show all debug messages
|
||||
./c_relay_x86 --debug-level=4
|
||||
./c_relay_pg_x86 --debug-level=4
|
||||
|
||||
# Show everything including trace with file:line (very verbose)
|
||||
./c_relay_x86 --debug-level=5
|
||||
./c_relay_pg_x86 --debug-level=5
|
||||
```
|
||||
|
||||
## Implementation
|
||||
@@ -475,7 +475,7 @@ When `g_debug_level = 0` (constant), you'll see the compiler has removed all deb
|
||||
|
||||
### Level 3 (Errors + Warnings + Info)
|
||||
```
|
||||
[2025-01-12 14:30:15] [INFO ] Initializing C-Relay v0.4.6
|
||||
[2025-01-12 14:30:15] [INFO ] Initializing C-Relay-PG v0.4.6
|
||||
[2025-01-12 14:30:15] [INFO ] Loading configuration from database
|
||||
[2025-01-12 14:30:15] [ERROR] Failed to open database: permission denied
|
||||
[2025-01-12 14:30:16] [WARN ] Port 8888 unavailable, trying 8889
|
||||
@@ -484,7 +484,7 @@ When `g_debug_level = 0` (constant), you'll see the compiler has removed all deb
|
||||
|
||||
### Level 4 (All Debug Messages)
|
||||
```
|
||||
[2025-01-12 14:30:15] [INFO ] Initializing C-Relay v0.4.6
|
||||
[2025-01-12 14:30:15] [INFO ] Initializing C-Relay-PG v0.4.6
|
||||
[2025-01-12 14:30:15] [DEBUG] Opening database: build/abc123...def.db
|
||||
[2025-01-12 14:30:15] [DEBUG] Executing schema initialization
|
||||
[2025-01-12 14:30:15] [INFO ] SQLite WAL mode enabled
|
||||
@@ -496,7 +496,7 @@ When `g_debug_level = 0` (constant), you'll see the compiler has removed all deb
|
||||
|
||||
### Level 5 (Everything Including file:line for ALL messages)
|
||||
```
|
||||
[2025-01-12 14:30:15] [INFO ] [main.c:1607] Initializing C-Relay v0.4.6
|
||||
[2025-01-12 14:30:15] [INFO ] [main.c:1607] Initializing C-Relay-PG v0.4.6
|
||||
[2025-01-12 14:30:15] [DEBUG] [main.c:348] Opening database: build/abc123...def.db
|
||||
[2025-01-12 14:30:15] [TRACE] [main.c:330] Entering init_database()
|
||||
[2025-01-12 14:30:15] [ERROR] [config.c:125] Database locked
|
||||
@@ -525,11 +525,11 @@ Update the existing `log_*` functions to use the new debug macros
|
||||
make clean && make
|
||||
|
||||
# Test different levels
|
||||
./build/c_relay_x86 # No output
|
||||
./build/c_relay_x86 --debug-level=1 # Errors only
|
||||
./build/c_relay_x86 --debug-level=3 # Info + warnings + errors
|
||||
./build/c_relay_x86 --debug-level=4 # All debug messages
|
||||
./build/c_relay_x86 --debug-level=5 # Everything with file:line on TRACE
|
||||
./build/c_relay_pg_x86 # No output
|
||||
./build/c_relay_pg_x86 --debug-level=1 # Errors only
|
||||
./build/c_relay_pg_x86 --debug-level=3 # Info + warnings + errors
|
||||
./build/c_relay_pg_x86 --debug-level=4 # All debug messages
|
||||
./build/c_relay_pg_x86 --debug-level=5 # Everything with file:line on TRACE
|
||||
```
|
||||
|
||||
### Step 5: Gradual Migration (Ongoing)
|
||||
|
||||
@@ -34,7 +34,7 @@ static const struct {
|
||||
// NIP-11 Relay Information (relay keys will be populated at runtime)
|
||||
{"relay_description", "High-performance C Nostr relay with SQLite storage"},
|
||||
{"relay_contact", ""},
|
||||
{"relay_software", "https://git.laantungir.net/laantungir/c-relay.git"},
|
||||
{"relay_software", "https://git.laantungir.net/laantungir/c-relay-pg.git"},
|
||||
{"relay_version", "v1.0.0"},
|
||||
|
||||
// NIP-13 Proof of Work (pow_min_difficulty = 0 means PoW disabled)
|
||||
|
||||
+51
-51
@@ -52,11 +52,11 @@ sudo apt install -y build-essential git sqlite3 libsqlite3-dev \
|
||||
#### User and Directory Setup
|
||||
```bash
|
||||
# Create dedicated system user
|
||||
sudo useradd --system --home-dir /opt/c-relay --shell /bin/false c-relay
|
||||
sudo useradd --system --home-dir /opt/c-relay-pg --shell /bin/false c-relay-pg
|
||||
|
||||
# Create application directory
|
||||
sudo mkdir -p /opt/c-relay
|
||||
sudo chown c-relay:c-relay /opt/c-relay
|
||||
sudo mkdir -p /opt/c-relay-pg
|
||||
sudo chown c-relay-pg:c-relay-pg /opt/c-relay-pg
|
||||
```
|
||||
|
||||
### Build and Installation
|
||||
@@ -64,8 +64,8 @@ sudo chown c-relay:c-relay /opt/c-relay
|
||||
#### Automated Installation (Recommended)
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/your-org/c-relay.git
|
||||
cd c-relay
|
||||
git clone https://github.com/your-org/c-relay-pg.git
|
||||
cd c-relay-pg
|
||||
git submodule update --init --recursive
|
||||
|
||||
# Build
|
||||
@@ -81,12 +81,12 @@ sudo systemd/install-service.sh
|
||||
make clean && make
|
||||
|
||||
# Install binary
|
||||
sudo cp build/c_relay_x86 /opt/c-relay/
|
||||
sudo chown c-relay:c-relay /opt/c-relay/c_relay_x86
|
||||
sudo chmod +x /opt/c-relay/c_relay_x86
|
||||
sudo cp build/c_relay_pg_x86 /opt/c-relay-pg/
|
||||
sudo chown c-relay-pg:c-relay-pg /opt/c-relay-pg/c_relay_pg_x86
|
||||
sudo chmod +x /opt/c-relay-pg/c_relay_pg_x86
|
||||
|
||||
# Install systemd service
|
||||
sudo cp systemd/c-relay.service /etc/systemd/system/
|
||||
sudo cp systemd/c-relay-pg.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
@@ -95,22 +95,22 @@ sudo systemctl daemon-reload
|
||||
#### Start and Enable Service
|
||||
```bash
|
||||
# Start the service
|
||||
sudo systemctl start c-relay
|
||||
sudo systemctl start c-relay-pg
|
||||
|
||||
# Enable auto-start on boot
|
||||
sudo systemctl enable c-relay
|
||||
sudo systemctl enable c-relay-pg
|
||||
|
||||
# Check status
|
||||
sudo systemctl status c-relay
|
||||
sudo systemctl status c-relay-pg
|
||||
```
|
||||
|
||||
#### Capture Admin Keys (CRITICAL)
|
||||
```bash
|
||||
# View startup logs to get admin keys
|
||||
sudo journalctl -u c-relay --since "5 minutes ago" | grep -A 10 "IMPORTANT: SAVE THIS ADMIN PRIVATE KEY"
|
||||
sudo journalctl -u c-relay-pg --since "5 minutes ago" | grep -A 10 "IMPORTANT: SAVE THIS ADMIN PRIVATE KEY"
|
||||
|
||||
# Or check the full log
|
||||
sudo journalctl -u c-relay --no-pager | grep "Admin Private Key"
|
||||
sudo journalctl -u c-relay-pg --no-pager | grep "Admin Private Key"
|
||||
```
|
||||
|
||||
⚠️ **CRITICAL**: Save the admin private key immediately - it's only shown once and is needed for all configuration updates!
|
||||
@@ -151,8 +151,8 @@ sudo iptables-save > /etc/iptables/rules.v4
|
||||
ssh -i your-key.pem ubuntu@your-instance-ip
|
||||
|
||||
# Use the simple deployment script
|
||||
git clone https://github.com/your-org/c-relay.git
|
||||
cd c-relay
|
||||
git clone https://github.com/your-org/c-relay-pg.git
|
||||
cd c-relay-pg
|
||||
sudo examples/deployment/simple-vps/deploy.sh
|
||||
```
|
||||
|
||||
@@ -168,10 +168,10 @@ sudo examples/deployment/simple-vps/deploy.sh
|
||||
sudo mkfs.ext4 /dev/xvdf
|
||||
sudo mkdir /data
|
||||
sudo mount /dev/xvdf /data
|
||||
sudo chown c-relay:c-relay /data
|
||||
sudo chown c-relay-pg:c-relay-pg /data
|
||||
|
||||
# Update systemd service to use /data
|
||||
sudo sed -i 's/WorkingDirectory=\/opt\/c-relay/WorkingDirectory=\/data/' /etc/systemd/system/c-relay.service
|
||||
sudo sed -i 's/WorkingDirectory=\/opt\/c-relay-pg/WorkingDirectory=\/data/' /etc/systemd/system/c-relay-pg.service
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
@@ -180,7 +180,7 @@ sudo systemctl daemon-reload
|
||||
#### Compute Engine Setup
|
||||
```bash
|
||||
# Create VM instance (e2-micro or larger)
|
||||
gcloud compute instances create c-relay-instance \
|
||||
gcloud compute instances create c-relay-pg-instance \
|
||||
--image-family=ubuntu-2204-lts \
|
||||
--image-project=ubuntu-os-cloud \
|
||||
--machine-type=e2-micro \
|
||||
@@ -193,9 +193,9 @@ gcloud compute firewall-rules create allow-nostr-relay \
|
||||
--target-tags nostr-relay
|
||||
|
||||
# SSH and deploy
|
||||
gcloud compute ssh c-relay-instance
|
||||
git clone https://github.com/your-org/c-relay.git
|
||||
cd c-relay
|
||||
gcloud compute ssh c-relay-pg-instance
|
||||
git clone https://github.com/your-org/c-relay-pg.git
|
||||
cd c-relay-pg
|
||||
sudo examples/deployment/simple-vps/deploy.sh
|
||||
```
|
||||
|
||||
@@ -203,13 +203,13 @@ sudo examples/deployment/simple-vps/deploy.sh
|
||||
```bash
|
||||
# Create and attach persistent disk
|
||||
gcloud compute disks create relay-data --size=50GB
|
||||
gcloud compute instances attach-disk c-relay-instance --disk=relay-data
|
||||
gcloud compute instances attach-disk c-relay-pg-instance --disk=relay-data
|
||||
|
||||
# Format and mount
|
||||
sudo mkfs.ext4 /dev/sdb
|
||||
sudo mkdir /data
|
||||
sudo mount /dev/sdb /data
|
||||
sudo chown c-relay:c-relay /data
|
||||
sudo chown c-relay-pg:c-relay-pg /data
|
||||
```
|
||||
|
||||
### DigitalOcean
|
||||
@@ -223,8 +223,8 @@ sudo chown c-relay:c-relay /data
|
||||
ssh root@your-droplet-ip
|
||||
|
||||
# Deploy relay
|
||||
git clone https://github.com/your-org/c-relay.git
|
||||
cd c-relay
|
||||
git clone https://github.com/your-org/c-relay-pg.git
|
||||
cd c-relay-pg
|
||||
examples/deployment/simple-vps/deploy.sh
|
||||
```
|
||||
|
||||
@@ -245,8 +245,8 @@ The `examples/deployment/` directory contains ready-to-use scripts:
|
||||
### Simple VPS Deployment
|
||||
```bash
|
||||
# Clone repository and run automated deployment
|
||||
git clone https://github.com/your-org/c-relay.git
|
||||
cd c-relay
|
||||
git clone https://github.com/your-org/c-relay-pg.git
|
||||
cd c-relay-pg
|
||||
sudo examples/deployment/simple-vps/deploy.sh
|
||||
```
|
||||
|
||||
@@ -417,9 +417,9 @@ LOG_FILE="/var/log/relay-monitor.log"
|
||||
DATE=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Check if relay is running
|
||||
if ! pgrep -f "c_relay_x86" > /dev/null; then
|
||||
if ! pgrep -f "c_relay_pg_x86" > /dev/null; then
|
||||
echo "[$DATE] ERROR: Relay process not running" >> $LOG_FILE
|
||||
systemctl restart c-relay
|
||||
systemctl restart c-relay-pg
|
||||
fi
|
||||
|
||||
# Check port availability
|
||||
@@ -428,14 +428,14 @@ if ! netstat -tln | grep -q ":8888"; then
|
||||
fi
|
||||
|
||||
# Check database file
|
||||
RELAY_DB=$(find /opt/c-relay -name "*.nrdb" | head -1)
|
||||
RELAY_DB=$(find /opt/c-relay-pg -name "*.nrdb" | head -1)
|
||||
if [[ -n "$RELAY_DB" ]]; then
|
||||
DB_SIZE=$(du -h "$RELAY_DB" | cut -f1)
|
||||
echo "[$DATE] INFO: Database size: $DB_SIZE" >> $LOG_FILE
|
||||
fi
|
||||
|
||||
# Check memory usage
|
||||
MEM_USAGE=$(ps aux | grep c_relay_x86 | grep -v grep | awk '{print $6}')
|
||||
MEM_USAGE=$(ps aux | grep c_relay_pg_x86 | grep -v grep | awk '{print $6}')
|
||||
if [[ -n "$MEM_USAGE" ]]; then
|
||||
echo "[$DATE] INFO: Memory usage: ${MEM_USAGE}KB" >> $LOG_FILE
|
||||
fi
|
||||
@@ -454,8 +454,8 @@ sudo chmod +x /usr/local/bin/relay-monitor.sh
|
||||
|
||||
#### Centralized Logging with rsyslog
|
||||
```bash
|
||||
# /etc/rsyslog.d/50-c-relay.conf
|
||||
if $programname == 'c-relay' then /var/log/c-relay.log
|
||||
# /etc/rsyslog.d/50-c-relay-pg.conf
|
||||
if $programname == 'c-relay-pg' then /var/log/c-relay-pg.log
|
||||
& stop
|
||||
```
|
||||
|
||||
@@ -465,7 +465,7 @@ if $programname == 'c-relay' then /var/log/c-relay.log
|
||||
```yaml
|
||||
# /etc/prometheus/prometheus.yml
|
||||
scrape_configs:
|
||||
- job_name: 'c-relay'
|
||||
- job_name: 'c-relay-pg'
|
||||
static_configs:
|
||||
- targets: ['localhost:8888']
|
||||
metrics_path: '/metrics' # If implemented
|
||||
@@ -479,30 +479,30 @@ scrape_configs:
|
||||
#### Service User Restrictions
|
||||
```bash
|
||||
# Restrict service user
|
||||
sudo usermod -s /bin/false c-relay
|
||||
sudo usermod -d /opt/c-relay c-relay
|
||||
sudo usermod -s /bin/false c-relay-pg
|
||||
sudo usermod -d /opt/c-relay-pg c-relay-pg
|
||||
|
||||
# Set proper permissions
|
||||
sudo chmod 700 /opt/c-relay
|
||||
sudo chown -R c-relay:c-relay /opt/c-relay
|
||||
sudo chmod 700 /opt/c-relay-pg
|
||||
sudo chown -R c-relay-pg:c-relay-pg /opt/c-relay-pg
|
||||
```
|
||||
|
||||
#### File System Restrictions
|
||||
```bash
|
||||
# Mount data directory with appropriate options
|
||||
echo "/dev/sdb /opt/c-relay ext4 defaults,noexec,nosuid,nodev 0 2" >> /etc/fstab
|
||||
echo "/dev/sdb /opt/c-relay-pg ext4 defaults,noexec,nosuid,nodev 0 2" >> /etc/fstab
|
||||
```
|
||||
|
||||
### Network Security
|
||||
|
||||
#### Fail2Ban Configuration
|
||||
```ini
|
||||
# /etc/fail2ban/jail.d/c-relay.conf
|
||||
[c-relay-dos]
|
||||
# /etc/fail2ban/jail.d/c-relay-pg.conf
|
||||
[c-relay-pg-dos]
|
||||
enabled = true
|
||||
port = 8888
|
||||
filter = c-relay-dos
|
||||
logpath = /var/log/c-relay.log
|
||||
filter = c-relay-pg-dos
|
||||
logpath = /var/log/c-relay-pg.log
|
||||
maxretry = 10
|
||||
findtime = 60
|
||||
bantime = 300
|
||||
@@ -534,9 +534,9 @@ sudo mkfs.ext4 /dev/mapper/relay-data
|
||||
#!/bin/bash
|
||||
# /usr/local/bin/backup-relay.sh
|
||||
|
||||
BACKUP_DIR="/backup/c-relay"
|
||||
BACKUP_DIR="/backup/c-relay-pg"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
RELAY_DB=$(find /opt/c-relay -name "*.nrdb" | head -1)
|
||||
RELAY_DB=$(find /opt/c-relay-pg -name "*.nrdb" | head -1)
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
@@ -574,7 +574,7 @@ sudo apt install -y awscli
|
||||
aws configure
|
||||
|
||||
# Sync backups to S3
|
||||
aws s3 sync /backup/c-relay/ s3://your-backup-bucket/c-relay/ --delete
|
||||
aws s3 sync /backup/c-relay-pg/ s3://your-backup-bucket/c-relay-pg/ --delete
|
||||
```
|
||||
|
||||
### Disaster Recovery
|
||||
@@ -583,16 +583,16 @@ aws s3 sync /backup/c-relay/ s3://your-backup-bucket/c-relay/ --delete
|
||||
```bash
|
||||
# 1. Restore from backup
|
||||
gunzip backup/relay_backup_20231201_020000.nrdb.gz
|
||||
cp backup/relay_backup_20231201_020000.nrdb /opt/c-relay/
|
||||
cp backup/relay_backup_20231201_020000.nrdb /opt/c-relay-pg/
|
||||
|
||||
# 2. Fix permissions
|
||||
sudo chown c-relay:c-relay /opt/c-relay/*.nrdb
|
||||
sudo chown c-relay-pg:c-relay-pg /opt/c-relay-pg/*.nrdb
|
||||
|
||||
# 3. Restart service
|
||||
sudo systemctl restart c-relay
|
||||
sudo systemctl restart c-relay-pg
|
||||
|
||||
# 4. Verify recovery
|
||||
sudo journalctl -u c-relay --since "1 minute ago"
|
||||
sudo journalctl -u c-relay-pg --since "1 minute ago"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+25
-25
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This guide explains how to build truly portable MUSL-based static binaries of c-relay using Alpine Linux Docker containers. These binaries have **zero runtime dependencies** and work on any Linux distribution.
|
||||
This guide explains how to build truly portable MUSL-based static binaries of c-relay-pg using Alpine Linux Docker containers. These binaries have **zero runtime dependencies** and work on any Linux distribution.
|
||||
|
||||
## Why MUSL?
|
||||
|
||||
@@ -36,8 +36,8 @@ This guide explains how to build truly portable MUSL-based static binaries of c-
|
||||
./build_static.sh
|
||||
|
||||
# The binary will be created at:
|
||||
# build/c_relay_static_musl_x86_64 (on x86_64)
|
||||
# build/c_relay_static_musl_arm64 (on ARM64)
|
||||
# build/c_relay_pg_static_musl_x86_64 (on x86_64)
|
||||
# build/c_relay_pg_static_musl_arm64 (on ARM64)
|
||||
```
|
||||
|
||||
### What Happens During Build
|
||||
@@ -56,7 +56,7 @@ This guide explains how to build truly portable MUSL-based static binaries of c-
|
||||
- Includes required NIPs: 001, 006, 013, 017, 019, 044, 059
|
||||
- Produces static library (~316KB)
|
||||
|
||||
4. **c-relay Compilation**: Links everything statically:
|
||||
4. **c-relay-pg Compilation**: Links everything statically:
|
||||
- All source files compiled with `-static` flag
|
||||
- Fortification disabled to avoid `__*_chk` symbols
|
||||
- Results in ~7.6MB stripped binary
|
||||
@@ -78,7 +78,7 @@ FROM alpine:3.19 AS builder
|
||||
- Install build tools and static libraries
|
||||
- Build dependencies from source
|
||||
- Compile nostr_core_lib with MUSL flags
|
||||
- Compile c-relay with full static linking
|
||||
- Compile c-relay-pg with full static linking
|
||||
- Strip binary to reduce size
|
||||
|
||||
# Stage 2: Output (scratch)
|
||||
@@ -93,7 +93,7 @@ FROM scratch AS output
|
||||
CFLAGS="-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -fPIC -O2"
|
||||
```
|
||||
|
||||
**For c-relay:**
|
||||
**For c-relay-pg:**
|
||||
```bash
|
||||
gcc -static -O2 -Wall -Wextra -std=c99 \
|
||||
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
|
||||
@@ -110,7 +110,7 @@ gcc -static -O2 -Wall -Wextra -std=c99 \
|
||||
- `-U_FORTIFY_SOURCE` (undefine any existing definition)
|
||||
- `-D_FORTIFY_SOURCE=0` (set to 0)
|
||||
|
||||
This must be applied to **both** nostr_core_lib and c-relay compilation.
|
||||
This must be applied to **both** nostr_core_lib and c-relay-pg compilation.
|
||||
|
||||
### NIP Dependencies
|
||||
|
||||
@@ -129,26 +129,26 @@ The build includes these NIPs in nostr_core_lib:
|
||||
|
||||
```bash
|
||||
# Should show "statically linked"
|
||||
file build/c_relay_static_musl_x86_64
|
||||
file build/c_relay_pg_static_musl_x86_64
|
||||
|
||||
# Should show "not a dynamic executable"
|
||||
ldd build/c_relay_static_musl_x86_64
|
||||
ldd build/c_relay_pg_static_musl_x86_64
|
||||
|
||||
# Check size (should be ~7.6MB)
|
||||
ls -lh build/c_relay_static_musl_x86_64
|
||||
ls -lh build/c_relay_pg_static_musl_x86_64
|
||||
```
|
||||
|
||||
### Test Execution
|
||||
|
||||
```bash
|
||||
# Show help
|
||||
./build/c_relay_static_musl_x86_64 --help
|
||||
./build/c_relay_pg_static_musl_x86_64 --help
|
||||
|
||||
# Show version
|
||||
./build/c_relay_static_musl_x86_64 --version
|
||||
./build/c_relay_pg_static_musl_x86_64 --version
|
||||
|
||||
# Run relay
|
||||
./build/c_relay_static_musl_x86_64 --port 8888
|
||||
./build/c_relay_pg_static_musl_x86_64 --port 8888
|
||||
```
|
||||
|
||||
### Cross-Distribution Testing
|
||||
@@ -157,16 +157,16 @@ Test the binary on different distributions to verify portability:
|
||||
|
||||
```bash
|
||||
# Alpine Linux
|
||||
docker run --rm -v $(pwd)/build:/app alpine:latest /app/c_relay_static_musl_x86_64 --version
|
||||
docker run --rm -v $(pwd)/build:/app alpine:latest /app/c_relay_pg_static_musl_x86_64 --version
|
||||
|
||||
# Ubuntu
|
||||
docker run --rm -v $(pwd)/build:/app ubuntu:latest /app/c_relay_static_musl_x86_64 --version
|
||||
docker run --rm -v $(pwd)/build:/app ubuntu:latest /app/c_relay_pg_static_musl_x86_64 --version
|
||||
|
||||
# Debian
|
||||
docker run --rm -v $(pwd)/build:/app debian:latest /app/c_relay_static_musl_x86_64 --version
|
||||
docker run --rm -v $(pwd)/build:/app debian:latest /app/c_relay_pg_static_musl_x86_64 --version
|
||||
|
||||
# CentOS
|
||||
docker run --rm -v $(pwd)/build:/app centos:latest /app/c_relay_static_musl_x86_64 --version
|
||||
docker run --rm -v $(pwd)/build:/app centos:latest /app/c_relay_pg_static_musl_x86_64 --version
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -187,7 +187,7 @@ newgrp docker # Or logout and login again
|
||||
|
||||
**Solution**: Ensure fortification is disabled in both:
|
||||
1. nostr_core_lib build.sh (line 534)
|
||||
2. c-relay compilation flags in Dockerfile
|
||||
2. c-relay-pg compilation flags in Dockerfile
|
||||
|
||||
### Binary Won't Execute
|
||||
|
||||
@@ -213,26 +213,26 @@ newgrp docker # Or logout and login again
|
||||
|
||||
```bash
|
||||
# Copy binary to server
|
||||
scp build/c_relay_static_musl_x86_64 user@server:/opt/c-relay/
|
||||
scp build/c_relay_pg_static_musl_x86_64 user@server:/opt/c-relay-pg/
|
||||
|
||||
# Run on server (no dependencies needed!)
|
||||
ssh user@server
|
||||
cd /opt/c-relay
|
||||
./c_relay_static_musl_x86_64 --port 8888
|
||||
cd /opt/c-relay-pg
|
||||
./c_relay_pg_static_musl_x86_64 --port 8888
|
||||
```
|
||||
|
||||
### SystemD Service
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=C-Relay Nostr Relay (MUSL Static)
|
||||
Description=C-Relay-PG Nostr Relay (MUSL Static)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=c-relay
|
||||
WorkingDirectory=/opt/c-relay
|
||||
ExecStart=/opt/c-relay/c_relay_static_musl_x86_64
|
||||
User=c-relay-pg
|
||||
WorkingDirectory=/opt/c-relay-pg
|
||||
ExecStart=/opt/c-relay-pg/c_relay_pg_static_musl_x86_64
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ We chose Option B because:
|
||||
|
||||
## Detailed Implementation Steps
|
||||
|
||||
### Phase 1: Configuration Setup in c-relay
|
||||
### Phase 1: Configuration Setup in c-relay-pg
|
||||
|
||||
#### 1.1 Add Configuration Parameter
|
||||
**File:** `src/default_config_event.h`
|
||||
@@ -293,7 +293,7 @@ int nostr_nip17_send_dm(cJSON* dm_event,
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Update c-relay Call Sites
|
||||
### Phase 4: Update c-relay-pg Call Sites
|
||||
|
||||
#### 4.1 Update src/api.c
|
||||
**Location:** Line 1319
|
||||
@@ -470,7 +470,7 @@ causing compatibility issues.
|
||||
- [ ] Update `nostr_nip17_send_dm()` signature and implementation
|
||||
- [ ] Update `nip017.h` function declaration and documentation
|
||||
|
||||
### c-relay Changes
|
||||
### c-relay-pg Changes
|
||||
- [ ] Add `nip59_timestamp_max_delay_sec` to `default_config_event.h`
|
||||
- [ ] Add validation in `config.c` for new parameter
|
||||
- [ ] Update `src/api.c` call site to pass `max_delay_sec`
|
||||
@@ -494,7 +494,7 @@ causing compatibility issues.
|
||||
|
||||
If issues arise:
|
||||
1. Revert nostr_core_lib changes (git revert in submodule)
|
||||
2. Revert c-relay changes
|
||||
2. Revert c-relay-pg changes
|
||||
3. Configuration parameter will be ignored if not used
|
||||
4. Default behavior (0) provides maximum compatibility
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the design for a general-purpose SQL query interface for the C-Relay admin API. This allows administrators to execute read-only SQL queries against the relay database through cryptographically signed kind 23456 events with NIP-44 encrypted command arrays.
|
||||
This document describes the design for a general-purpose SQL query interface for the C-Relay-PG admin API. This allows administrators to execute read-only SQL queries against the relay database through cryptographically signed kind 23456 events with NIP-44 encrypted command arrays.
|
||||
|
||||
## Security Model
|
||||
|
||||
@@ -271,7 +271,7 @@ const SQL_QUERY_TEMPLATES = {
|
||||
};
|
||||
|
||||
// Query history management (localStorage)
|
||||
const QUERY_HISTORY_KEY = 'c_relay_sql_history';
|
||||
const QUERY_HISTORY_KEY = 'c_relay_pg_sql_history';
|
||||
const MAX_HISTORY_ITEMS = 20;
|
||||
|
||||
// Load query history from localStorage
|
||||
|
||||
@@ -224,7 +224,7 @@ The script should:
|
||||
- `curl` or `websocat` for WebSocket communication
|
||||
- `jq` for JSON parsing
|
||||
- Nostr CLI tools (optional, for event signing)
|
||||
- Running c-relay instance
|
||||
- Running c-relay-pg instance
|
||||
|
||||
## Example Output
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# C-Relay Complete Startup Flow Documentation
|
||||
# C-Relay-PG Complete Startup Flow Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
C-Relay has two distinct startup paths:
|
||||
C-Relay-PG has two distinct startup paths:
|
||||
1. **First-Time Startup**: No database exists, generates keys and initializes system
|
||||
2. **Existing Relay Startup**: Database exists, loads configuration and resumes operation
|
||||
|
||||
@@ -220,7 +220,7 @@ int populate_default_config_values(void) {
|
||||
|
||||
struct config_default defaults[] = {
|
||||
{"relay_port", "8888", "integer", "WebSocket port", "network", 1},
|
||||
{"relay_name", "C-Relay", "string", "Relay name", "info", 0},
|
||||
{"relay_name", "C-Relay-PG", "string", "Relay name", "info", 0},
|
||||
{"relay_description", "High-performance C Nostr relay", "string", "Description", "info", 0},
|
||||
{"max_subscriptions_per_client", "25", "integer", "Max subs per client", "limits", 0},
|
||||
{"pow_min_difficulty", "0", "integer", "Minimum PoW difficulty", "security", 0},
|
||||
@@ -983,7 +983,7 @@ if (current_version < LATEST_VERSION) {
|
||||
|
||||
**Solutions**:
|
||||
- Use `--port <number>` to specify different port
|
||||
- Kill existing process: `pkill -f c_relay_`
|
||||
- Kill existing process: `pkill -f c_relay_pg_`
|
||||
- Force kill port: `fuser -k 8888/tcp`
|
||||
- Use `--strict-port` to fail fast instead of trying fallback ports
|
||||
|
||||
@@ -994,7 +994,7 @@ if (current_version < LATEST_VERSION) {
|
||||
**Solutions**:
|
||||
- Kill existing relay processes
|
||||
- Remove WAL files: `rm build/*.db-wal build/*.db-shm`
|
||||
- Check for stale processes: `ps aux | grep c_relay_`
|
||||
- Check for stale processes: `ps aux | grep c_relay_pg_`
|
||||
|
||||
#### 3. Missing Admin Private Key
|
||||
|
||||
@@ -1071,7 +1071,7 @@ Typical startup times (on modern hardware):
|
||||
|
||||
## Summary
|
||||
|
||||
The c-relay startup system is designed for:
|
||||
The c-relay-pg startup system is designed for:
|
||||
|
||||
1. **Security**: Admin keys never stored, relay keys encrypted
|
||||
2. **Reliability**: Automatic port fallback, schema migrations
|
||||
|
||||
@@ -18,10 +18,10 @@ The script now attempts to build with `musl-gcc` for truly portable static binar
|
||||
|
||||
SQLite is now built once and cached for future builds:
|
||||
|
||||
- **Cache location**: `~/.cache/c-relay-sqlite/`
|
||||
- **Cache location**: `~/.cache/c-relay-pg-sqlite/`
|
||||
- **Version-specific**: Each SQLite version gets its own cache directory
|
||||
- **Significant speedup**: Subsequent builds skip the SQLite compilation step
|
||||
- **Manual cleanup**: `rm -rf ~/.cache/c-relay-sqlite` to clear cache
|
||||
- **Manual cleanup**: `rm -rf ~/.cache/c-relay-pg-sqlite` to clear cache
|
||||
|
||||
### 3. Smart Package Installation
|
||||
|
||||
@@ -51,13 +51,13 @@ The script will:
|
||||
## Binary Types
|
||||
|
||||
### MUSL Static Binary (Ideal - Currently Not Achievable)
|
||||
- **Filename**: `build/c_relay_static_musl_x86_64`
|
||||
- **Filename**: `build/c_relay_pg_static_musl_x86_64`
|
||||
- **Dependencies**: None (truly static)
|
||||
- **Portability**: Works on any Linux distribution
|
||||
- **Status**: Requires MUSL-compiled libwebsockets and other dependencies (not available by default)
|
||||
|
||||
### Glibc Static Binary (Current Output)
|
||||
- **Filename**: `build/c_relay_static_x86_64` or `build/c_relay_static_glibc_x86_64`
|
||||
- **Filename**: `build/c_relay_pg_static_x86_64` or `build/c_relay_pg_static_glibc_x86_64`
|
||||
- **Dependencies**: None - fully statically linked with glibc
|
||||
- **Portability**: Works on most Linux distributions (glibc is statically included)
|
||||
- **Note**: Despite using glibc, this is a **fully static binary** with no runtime dependencies
|
||||
@@ -68,11 +68,11 @@ The script automatically verifies binaries using `ldd` and `file`:
|
||||
|
||||
```bash
|
||||
# For MUSL binary
|
||||
ldd build/c_relay_static_musl_x86_64
|
||||
ldd build/c_relay_pg_static_musl_x86_64
|
||||
# Output: "not a dynamic executable" (good!)
|
||||
|
||||
# For glibc binary
|
||||
ldd build/c_relay_static_glibc_x86_64
|
||||
ldd build/c_relay_pg_static_glibc_x86_64
|
||||
# Output: Shows glibc dependencies
|
||||
```
|
||||
|
||||
@@ -112,7 +112,7 @@ The script attempts MUSL compilation but falls back to glibc:
|
||||
|
||||
### Clear SQLite Cache
|
||||
```bash
|
||||
rm -rf ~/.cache/c-relay-sqlite
|
||||
rm -rf ~/.cache/c-relay-pg-sqlite
|
||||
```
|
||||
|
||||
### Force Package Reinstall
|
||||
@@ -127,8 +127,8 @@ cat /tmp/musl_build.log
|
||||
|
||||
### Verify Binary Type
|
||||
```bash
|
||||
file build/c_relay_static_*
|
||||
ldd build/c_relay_static_* 2>&1
|
||||
file build/c_relay_pg_static_*
|
||||
ldd build/c_relay_pg_static_* 2>&1
|
||||
```
|
||||
|
||||
## Performance Impact
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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.
|
||||
The c-relay-pg 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
|
||||
|
||||
|
||||
@@ -586,7 +586,7 @@ int add_pubkeys_to_config_table(void) {
|
||||
rm -f *.db
|
||||
|
||||
# Start relay with defaults
|
||||
./build/c_relay_x86
|
||||
./build/c_relay_pg_x86
|
||||
|
||||
# Verify config table complete
|
||||
sqlite3 <relay_pubkey>.db "SELECT COUNT(*) FROM config;"
|
||||
@@ -602,7 +602,7 @@ int add_pubkeys_to_config_table(void) {
|
||||
rm -f *.db
|
||||
|
||||
# Start relay with port override
|
||||
./build/c_relay_x86 --port 9999
|
||||
./build/c_relay_pg_x86 --port 9999
|
||||
|
||||
# Verify port override applied
|
||||
sqlite3 <relay_pubkey>.db "SELECT value FROM config WHERE key='relay_port';"
|
||||
@@ -612,13 +612,13 @@ int add_pubkeys_to_config_table(void) {
|
||||
3. **Restart with Existing Database**
|
||||
```bash
|
||||
# Start relay (creates database)
|
||||
./build/c_relay_x86
|
||||
./build/c_relay_pg_x86
|
||||
|
||||
# Stop relay
|
||||
pkill -f c_relay_
|
||||
pkill -f c_relay_pg_
|
||||
|
||||
# Restart relay
|
||||
./build/c_relay_x86
|
||||
./build/c_relay_pg_x86
|
||||
|
||||
# Verify config unchanged
|
||||
# Check relay.log for validation message
|
||||
@@ -627,13 +627,13 @@ int add_pubkeys_to_config_table(void) {
|
||||
4. **Restart with CLI Overrides**
|
||||
```bash
|
||||
# Start relay (creates database)
|
||||
./build/c_relay_x86
|
||||
./build/c_relay_pg_x86
|
||||
|
||||
# Stop relay
|
||||
pkill -f c_relay_
|
||||
pkill -f c_relay_pg_
|
||||
|
||||
# Restart with port override
|
||||
./build/c_relay_x86 --port 9999
|
||||
./build/c_relay_pg_x86 --port 9999
|
||||
|
||||
# Verify port override applied
|
||||
sqlite3 <relay_pubkey>.db "SELECT value FROM config WHERE key='relay_port';"
|
||||
|
||||
+32
-32
@@ -19,12 +19,12 @@ Complete guide for deploying, configuring, and managing the C Nostr Relay with e
|
||||
```bash
|
||||
# Clone and build
|
||||
git clone <repository-url>
|
||||
cd c-relay
|
||||
cd c-relay-pg
|
||||
git submodule update --init --recursive
|
||||
make
|
||||
|
||||
# Start relay (zero configuration needed)
|
||||
./build/c_relay_x86
|
||||
./build/c_relay_pg_x86
|
||||
```
|
||||
|
||||
### 2. First Startup - Save Keys
|
||||
@@ -78,7 +78,7 @@ brew install git sqlite libwebsockets openssl libsecp256k1 curl zlib
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <repository-url>
|
||||
cd c-relay
|
||||
cd c-relay-pg
|
||||
|
||||
# Initialize submodules
|
||||
git submodule update --init --recursive
|
||||
@@ -87,7 +87,7 @@ git submodule update --init --recursive
|
||||
make clean && make
|
||||
|
||||
# Verify build
|
||||
ls -la build/c_relay_x86
|
||||
ls -la build/c_relay_pg_x86
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
@@ -98,27 +98,27 @@ ls -la build/c_relay_x86
|
||||
sudo systemd/install-service.sh
|
||||
|
||||
# Start service
|
||||
sudo systemctl start c-relay
|
||||
sudo systemctl start c-relay-pg
|
||||
|
||||
# Enable auto-start
|
||||
sudo systemctl enable c-relay
|
||||
sudo systemctl enable c-relay-pg
|
||||
|
||||
# Check status
|
||||
sudo systemctl status c-relay
|
||||
sudo systemctl status c-relay-pg
|
||||
```
|
||||
|
||||
#### Manual Deployment
|
||||
```bash
|
||||
# Create dedicated user
|
||||
sudo useradd --system --home-dir /opt/c-relay --shell /bin/false c-relay
|
||||
sudo useradd --system --home-dir /opt/c-relay-pg --shell /bin/false c-relay-pg
|
||||
|
||||
# Install binary
|
||||
sudo mkdir -p /opt/c-relay
|
||||
sudo cp build/c_relay_x86 /opt/c-relay/
|
||||
sudo chown -R c-relay:c-relay /opt/c-relay
|
||||
sudo mkdir -p /opt/c-relay-pg
|
||||
sudo cp build/c_relay_pg_x86 /opt/c-relay-pg/
|
||||
sudo chown -R c-relay-pg:c-relay-pg /opt/c-relay-pg
|
||||
|
||||
# Run as service user
|
||||
sudo -u c-relay /opt/c-relay/c_relay_x86
|
||||
sudo -u c-relay-pg /opt/c-relay-pg/c_relay_pg_x86
|
||||
```
|
||||
|
||||
## Configuration Management
|
||||
@@ -188,7 +188,7 @@ Send this to your relay via WebSocket, and changes are applied immediately.
|
||||
|-----------|-------------|---------|---------|
|
||||
| `relay_description` | Relay description for NIP-11 | "C Nostr Relay" | "My awesome relay" |
|
||||
| `relay_contact` | Admin contact information | "" | "admin@example.com" |
|
||||
| `relay_software` | Software identifier | "c-relay" | "c-relay v1.0" |
|
||||
| `relay_software` | Software identifier | "c-relay-pg" | "c-relay-pg v1.0" |
|
||||
|
||||
#### Client Limits
|
||||
| Parameter | Description | Default | Range |
|
||||
@@ -273,7 +273,7 @@ chmod 600 admin_keys_backup_*.txt
|
||||
#### Key Recovery
|
||||
If you lose your admin private key:
|
||||
|
||||
1. **Stop the relay**: `pkill c_relay` or `sudo systemctl stop c-relay`
|
||||
1. **Stop the relay**: `pkill c_relay_pg` or `sudo systemctl stop c-relay-pg`
|
||||
2. **Backup events**: `cp <relay_pubkey>.nrdb backup_$(date +%Y%m%d).nrdb`
|
||||
3. **Remove database**: `rm <relay_pubkey>.nrdb*`
|
||||
4. **Restart relay**: This creates new database with new keys
|
||||
@@ -300,7 +300,7 @@ sudo ufw allow 8888/tcp
|
||||
```bash
|
||||
# Secure database file permissions
|
||||
chmod 600 <relay_pubkey>.nrdb
|
||||
chown c-relay:c-relay <relay_pubkey>.nrdb
|
||||
chown c-relay-pg:c-relay-pg <relay_pubkey>.nrdb
|
||||
|
||||
# Regular backups
|
||||
cp <relay_pubkey>.nrdb backup/relay_backup_$(date +%Y%m%d_%H%M%S).nrdb
|
||||
@@ -311,10 +311,10 @@ cp <relay_pubkey>.nrdb backup/relay_backup_$(date +%Y%m%d_%H%M%S).nrdb
|
||||
### Service Status
|
||||
```bash
|
||||
# Check if relay is running
|
||||
ps aux | grep c_relay
|
||||
ps aux | grep c_relay_pg
|
||||
|
||||
# SystemD status
|
||||
sudo systemctl status c-relay
|
||||
sudo systemctl status c-relay-pg
|
||||
|
||||
# Network connections
|
||||
netstat -tln | grep 8888
|
||||
@@ -324,16 +324,16 @@ sudo ss -tlpn | grep 8888
|
||||
### Log Monitoring
|
||||
```bash
|
||||
# Real-time logs (systemd)
|
||||
sudo journalctl -u c-relay -f
|
||||
sudo journalctl -u c-relay-pg -f
|
||||
|
||||
# Recent logs
|
||||
sudo journalctl -u c-relay --since "1 hour ago"
|
||||
sudo journalctl -u c-relay-pg --since "1 hour ago"
|
||||
|
||||
# Error logs only
|
||||
sudo journalctl -u c-relay -p err
|
||||
sudo journalctl -u c-relay-pg -p err
|
||||
|
||||
# Configuration changes
|
||||
sudo journalctl -u c-relay | grep "Configuration updated via kind 33334"
|
||||
sudo journalctl -u c-relay-pg | grep "Configuration updated via kind 33334"
|
||||
```
|
||||
|
||||
### Database Analytics
|
||||
@@ -365,13 +365,13 @@ ORDER BY created_at DESC;
|
||||
du -sh <relay_pubkey>.nrdb*
|
||||
|
||||
# Memory usage
|
||||
ps aux | grep c_relay | awk '{print $6}' # RSS memory in KB
|
||||
ps aux | grep c_relay_pg | awk '{print $6}' # RSS memory in KB
|
||||
|
||||
# Connection count (approximate)
|
||||
netstat -an | grep :8888 | grep ESTABLISHED | wc -l
|
||||
|
||||
# System resources
|
||||
top -p $(pgrep c_relay)
|
||||
top -p $(pgrep c_relay_pg)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -385,11 +385,11 @@ netstat -tln | grep 8888
|
||||
# If port in use, find process: sudo lsof -i :8888
|
||||
|
||||
# Check binary permissions
|
||||
ls -la build/c_relay_x86
|
||||
chmod +x build/c_relay_x86
|
||||
ls -la build/c_relay_pg_x86
|
||||
chmod +x build/c_relay_pg_x86
|
||||
|
||||
# Check dependencies
|
||||
ldd build/c_relay_x86
|
||||
ldd build/c_relay_pg_x86
|
||||
```
|
||||
|
||||
#### Configuration Not Updating
|
||||
@@ -442,7 +442,7 @@ sqlite3 recovered.nrdb < recovered.sql
|
||||
|
||||
# If repair fails, start fresh (loses all events)
|
||||
mv <relay_pubkey>.nrdb <relay_pubkey>.nrdb.corrupted
|
||||
./build/c_relay_x86 # Creates new database
|
||||
./build/c_relay_pg_x86 # Creates new database
|
||||
```
|
||||
|
||||
#### Lost Configuration Recovery
|
||||
@@ -455,12 +455,12 @@ If configuration is lost but database is intact:
|
||||
#### Emergency Restart
|
||||
```bash
|
||||
# Quick restart with clean state
|
||||
sudo systemctl stop c-relay
|
||||
sudo systemctl stop c-relay-pg
|
||||
mv <relay_pubkey>.nrdb <relay_pubkey>.nrdb.backup
|
||||
sudo systemctl start c-relay
|
||||
sudo systemctl start c-relay-pg
|
||||
|
||||
# Check logs for new admin keys
|
||||
sudo journalctl -u c-relay --since "5 minutes ago" | grep "Admin Private Key"
|
||||
sudo journalctl -u c-relay-pg --since "5 minutes ago" | grep "Admin Private Key"
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
@@ -503,7 +503,7 @@ ws.on('open', function() {
|
||||
# backup-relay.sh
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
DB_FILE=$(ls *.nrdb | head -1)
|
||||
BACKUP_DIR="/backup/c-relay"
|
||||
BACKUP_DIR="/backup/c-relay-pg"
|
||||
|
||||
mkdir -p $BACKUP_DIR
|
||||
cp $DB_FILE $BACKUP_DIR/relay_backup_$DATE.nrdb
|
||||
@@ -533,7 +533,7 @@ tar czf relay_migration.tar.gz *.nrdb* relay.log
|
||||
|
||||
# Target server
|
||||
tar xzf relay_migration.tar.gz
|
||||
./build/c_relay_x86 # Will detect existing database and continue
|
||||
./build/c_relay_pg_x86 # Will detect existing database and continue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user