Compare commits

...

18 Commits

Author SHA1 Message Date
Silberengel e0a40925a9 working on import API 3 months ago
Silberengel 9a0841d321 Merge imwald-v0.58.5 into imwald-v0.58.10 3 months ago
Silberengel 71f5a91b76 update docs and scripts 3 months ago
woikos ce2b5b63b4
Add optional server tag requirement for Blossom DELETE replay protection 3 months ago
woikos 69eccb21e2
fix: make transport startup failures non-fatal 3 months ago
woikos e36c39e3f0
Fix blossom blob list URLs missing file extensions and add MIME sniffing 4 months ago
woikos 951e98ca3e
Remove local replace directive for git.mleku.dev/mleku/nostr 4 months ago
woikos 7d7f16dee7
Fix web UI reactivity and center login prompts 4 months ago
silberengel 4bd9421a1d implement mass-import function 4 months ago
silberengel 402b298e42 sink import to write permissios 4 months ago
silberengel 90765506ee sink import to write permissios 4 months ago
silberengel a22194e97e rebuild web app with build-script 4 months ago
silberengel b160c32a4b bug-fixes 4 months ago
woikos 435a7b3302
Update CLAUDE.md with transport system and deployment docs 4 months ago
silberengel 219fa7a85f see if spider works 4 months ago
woikos b3e8aad8f0
Extract network transports into pluggable Transport module 4 months ago
silberengel ecc5f7f050 adapt to imwald setup 4 months ago
woikos 583ced9b7a
Optimize ACL follows startup with batched QueryEvents 4 months ago
  1. 79
      CLAUDE.md
  2. 12
      Dockerfile
  3. 82
      Dockerfile.with-web
  4. 182
      README.md
  5. 129
      RUN_REMOTE.md
  6. 2
      app/blossom.go
  7. 3
      app/config/config.go
  8. 9
      app/handle-negentropy.go
  9. 8
      app/handle-relayinfo.go
  10. 40
      app/handle-req.go
  11. 143
      app/main.go
  12. 735
      app/server.go
  13. 132
      app/tls.go
  14. 91
      app/web/dist/bundle.css
  15. 25
      app/web/dist/bundle.js
  16. 1
      app/web/dist/bundle.js.map
  17. BIN
      app/web/dist/favicon.png
  18. 69
      app/web/dist/global.css
  19. 45
      app/web/dist/index.html
  20. BIN
      app/web/dist/orly.png
  21. 33
      app/web/package-lock.json
  22. 217
      app/web/src/App.svelte
  23. 14
      app/web/src/BlossomView.svelte
  24. 98
      app/web/src/ComposeView.svelte
  25. 164
      app/web/src/EventsView.svelte
  26. 2
      app/web/src/FilterBuilder.svelte
  27. 2
      app/web/src/FilterDisplay.svelte
  28. 5
      app/web/src/ImportView.svelte
  29. 3
      app/web/src/constants.js
  30. 7
      app/web/src/helpers.js
  31. 222
      build-image.sh
  32. 12
      cmd/aggregator/main.go
  33. 5
      cmd/orly-acl-follows/main.go
  34. 3
      cmd/orly/db/db.go
  35. 131
      cmd/orly/db/import.go
  36. 1
      cmd/orly/main.go
  37. 94
      docker-compose-orly.yml
  38. 3
      go.mod
  39. 2
      go.sum
  40. 6
      main.go
  41. 6
      package-lock.json
  42. 71
      pkg/acl/follows.go
  43. 52
      pkg/blossom/auth.go
  44. 37
      pkg/blossom/handlers.go
  45. 6
      pkg/blossom/server.go
  46. 194
      pkg/database/import_utils.go
  47. 16
      pkg/interfaces/transport/transport.go
  48. 18
      pkg/spider/directory.go
  49. 127
      pkg/spider/spider.go
  50. 86
      pkg/transport/manager.go
  51. 78
      pkg/transport/tcp/tcp.go
  52. 247
      pkg/transport/tls/tls.go
  53. 91
      pkg/transport/tor/tor.go
  54. 2
      pkg/version/version
  55. 124
      run-orly-docker.sh
  56. 1
      scripts/app/web/dist/index.html
  57. 20
      scripts/cleanup-stuck-imports.sh
  58. 59
      scripts/create-test-event.go
  59. 754
      scripts/import-exports-remote.sh
  60. 17
      scripts/update-embedded-web.sh

79
CLAUDE.md

@ -66,9 +66,17 @@ app/ @@ -66,9 +66,17 @@ app/
config/ → Environment configuration (go-simpler.org/env)
web/ → Svelte frontend (embedded via go:embed)
pkg/
interfaces/
transport/ → Transport interface (pluggable network transports)
transport/
manager.go → Transport lifecycle manager (ordered start/stop)
tcp/ → Plain HTTP transport
tls/ → TLS/ACME transport (autocert + manual certs)
tor/ → Tor hidden service transport (wraps pkg/tor)
database/ → Database interface + Badger implementation
neo4j/ → Neo4j backend with WoT extensions
wasmdb/ → WebAssembly IndexedDB backend
tor/ → Tor subprocess management and hostname watching
protocol/ → Nostr protocol (ws/, auth/, publish/)
encoders/ → Optimized JSON encoding with buffer pools
policy/ → Event filtering/validation
@ -109,7 +117,7 @@ pubkeyHex := hex.Enc(ev.Pubkey[:]) @@ -109,7 +117,7 @@ pubkeyHex := hex.Enc(ev.Pubkey[:])
- **Define interfaces in `pkg/interfaces/<name>/`** - prevents circular deps
- **Never use interface literals** in type assertions: `.(interface{ Method() })` is forbidden
- Existing: `acl/`, `neterr/`, `resultiter/`, `store/`, `publisher/`, `typer/`
- Existing: `acl/`, `neterr/`, `resultiter/`, `store/`, `publisher/`, `transport/`, `typer/`
### 4. Constants
@ -250,6 +258,75 @@ if (isValidNsec(nsec)) { ... } @@ -250,6 +258,75 @@ if (isValidNsec(nsec)) { ... }
| Event kinds database | `app/web/src/eventKinds.js` |
| Nsec encryption | `app/web/src/nsec-crypto.js` |
## Transport System
Network transports are pluggable via `pkg/interfaces/transport.Transport`:
```go
type Transport interface {
Name() string
Start(ctx context.Context) error
Stop(ctx context.Context) error
Addresses() []string
}
```
**Current transports**: `tcp`, `tls`, `tor`. TCP and TLS are mutually exclusive (TLS replaces TCP when `ORLY_TLS_DOMAINS` is set). Tor runs in parallel.
**Adding a new transport** (e.g., QUIC):
1. Create `pkg/transport/quic/quic.go` implementing the interface
2. Add `l.transportMgr.Add(quicTransport)` in `app/main.go`
The transport manager handles ordered startup (Start fails fast, rolls back) and reverse-order shutdown. Addresses from all transports are aggregated for NIP-11 relay info.
## Deploying to relay.orly.dev
- **Architecture**: **x86_64 (amd64)** — NOT arm64, always use `GOARCH=amd64`
- **OS**: Ubuntu 24.04 LTS
- **SSH**: `ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes root@69.164.249.71`
- **Service**: `systemctl {start|stop|restart|status} orly`
- **Logs**: `journalctl -u orly -f`
- **Binaries**: `/home/mleku/.local/bin/` (orly, orly-db-badger, orly-acl-follows, orly-launcher)
- **Mode**: Split IPC (orly-launcher manages orly + orly-db-badger + orly-acl-follows)
### Build & Deploy (blue-green)
```bash
# 1. Build for amd64
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o orly .
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o orly-db-badger ./cmd/orly-db-badger
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o orly-acl-follows ./cmd/orly-acl-follows
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o orly-launcher ./cmd/orly-launcher
# 2. Stop service
ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes root@69.164.249.71 'systemctl stop orly'
# 3. Deploy binaries
rsync -avz --compress -e "ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes" \
orly orly-db-badger orly-acl-follows orly-launcher \
root@69.164.249.71:/home/mleku/.local/bin/
# 4. Fix ownership
ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes root@69.164.249.71 \
'chown mleku:mleku /home/mleku/.local/bin/orly*'
# 5. Start service
ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes root@69.164.249.71 'systemctl start orly'
# 6. Verify
ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes root@69.164.249.71 \
'sleep 3 && systemctl status orly'
```
**Future improvements**: Build on VPS directly (git pull + go build) to avoid slow binary transfers. Implement proper blue-green with symlink swap between `/opt/orly/blue/` and `/opt/orly/green/` dirs, with instant rollback via symlink flip.
## Git Remotes
- **origin**: `ssh://git@git.nostrdev.com:29418/mleku/next.orly.dev.git` (contract work)
- **gitea**: `ssh://mleku@git.mleku.dev:2222/mleku/next.orly.dev.git` (primary, mleku's own host)
Push to both remotes. Use `GIT_SSH_COMMAND="ssh -i ~/.ssh/id_ed25519"` for gitea.
## Dependencies
- `github.com/dgraph-io/badger/v4` - Badger DB (LSM, SSD-optimized)

12
Dockerfile

@ -5,20 +5,22 @@ @@ -5,20 +5,22 @@
FROM golang:1.25-bookworm AS builder
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends git make && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends git make ca-certificates && rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /build
# Copy go mod files
# Copy go mod files, vendored dependencies, and local nostr clone
COPY go.mod go.sum ./
RUN go mod download
COPY vendor/ ./vendor/
COPY .docker-build-context/ ./docker-build-context/
# Copy source code
COPY . .
# Build the binary with CGO disabled
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o orly -ldflags="-w -s" .
# Build from vendored code only - completely offline, no network access, no external addresses
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -mod=vendor -o orly -ldflags="-w -s" .
# Stage 2: Runtime stage
# Use Debian slim instead of Alpine because Debian's libsecp256k1 includes

82
Dockerfile.with-web

@ -0,0 +1,82 @@ @@ -0,0 +1,82 @@
# Multi-stage Dockerfile for ORLY relay with embedded web UI
# Stage 1: Web UI build
FROM node:20-bookworm AS web-builder
WORKDIR /web
# Copy web UI files
COPY app/web/package.json app/web/package-lock.json* app/web/ ./
# Install dependencies and build
RUN npm ci && npm run build
# Stage 2: Go build stage
FROM golang:1.25-bookworm AS builder
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends git make && rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /build
# Copy go mod files and build context first (needed for replace directives in go.mod)
COPY go.mod go.sum ./
COPY .docker-build-context/ ./docker-build-context/
# Copy vendored dependencies (created by build script)
COPY vendor/ ./vendor/
# Download dependencies (will use vendor if available)
RUN go mod download
# Copy source code
COPY . .
# Copy built web UI from web-builder stage
COPY --from=web-builder /web/dist ./app/web/dist
# Build the binary with CGO disabled
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o orly -ldflags="-w -s" .
# Stage 3: Runtime stage
FROM debian:bookworm-slim
# Install runtime dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl libsecp256k1-1 && \
rm -rf /var/lib/apt/lists/*
# Create app user
RUN groupadd -g 1000 orly && \
useradd -m -u 1000 -g orly orly
# Set working directory
WORKDIR /app
# Copy binary (libsecp256k1.so.1 is already installed via apt)
COPY --from=builder /build/orly /app/orly
# Create data directory
RUN mkdir -p /data && chown -R orly:orly /data /app
# Switch to app user
USER orly
# Expose ports
EXPOSE 3334
# Health check
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=3 \
CMD curl -f http://localhost:3334/ || exit 1
# Set default environment variables
ENV ORLY_LISTEN=0.0.0.0 \
ORLY_PORT=3334 \
ORLY_DATA_DIR=/data \
ORLY_LOG_LEVEL=info \
ORLY_WEB_DISABLE=false \
ORLY_WEB_DEV_PROXY_URL=""
# Run the binary
ENTRYPOINT ["/app/orly"]

182
README.md

@ -48,6 +48,8 @@ See [docs/IPC_SYNC_SERVICES.md](./docs/IPC_SYNC_SERVICES.md) for detailed API do @@ -48,6 +48,8 @@ See [docs/IPC_SYNC_SERVICES.md](./docs/IPC_SYNC_SERVICES.md) for detailed API do
- [Building with Web UI](#building-with-web-ui)
- [Core Features](#core-features)
- [Web UI](#web-ui)
- [Event Import](#event-import)
- [Event Streaming and Synchronization](#event-streaming-and-synchronization)
- [Sprocket Event Processing](#sprocket-event-processing)
- [Policy System](#policy-system)
- [Deployment](#deployment)
@ -122,7 +124,7 @@ ORLY is a standard Go application that can be built using the Go toolchain. @@ -122,7 +124,7 @@ ORLY is a standard Go application that can be built using the Go toolchain.
- Go 1.25.3 or later
- Git
- For web UI: [Bun](https://bun.sh/) JavaScript runtime
- For web UI: Node.js and npm (JavaScript runtime and package manager)
### Basic Build
@ -141,8 +143,8 @@ To build with the embedded web interface: @@ -141,8 +143,8 @@ To build with the embedded web interface:
```bash
# Build the Svelte web application
cd app/web
bun install
bun run build
npm install
npm run build
# Build the Go binary from project root
cd ../../
@ -156,7 +158,7 @@ The recommended way to build and embed the web UI is using the provided script: @@ -156,7 +158,7 @@ The recommended way to build and embed the web UI is using the provided script:
```
This script will:
- Build the Svelte app in `app/web` to `app/web/dist` using Bun (preferred) or fall back to npm/yarn/pnpm
- Build the Svelte app in `app/web` to `app/web/dist` using npm (or fall back to yarn/pnpm if npm is not available)
- Run `go install` from the repository root so the binary picks up the new embedded assets
- Automatically detect and use the best available JavaScript package manager
@ -167,8 +169,8 @@ For manual builds, you can also use: @@ -167,8 +169,8 @@ For manual builds, you can also use:
# build.sh
echo "Building Svelte app..."
cd app/web
bun install
bun run build
npm install
npm run build
echo "Building Go binary..."
cd ../../
@ -210,8 +212,8 @@ For development with hot-reloading, ORLY can proxy web requests to a local dev s @@ -210,8 +212,8 @@ For development with hot-reloading, ORLY can proxy web requests to a local dev s
```bash
cd app/web
bun install
bun run dev
npm install
npm run dev
```
Note the port sirv is listening on (e.g., `http://localhost:8080`).
@ -244,7 +246,7 @@ Example with the relay on port 3334 and sirv on port 8080: @@ -244,7 +246,7 @@ Example with the relay on port 3334 and sirv on port 8080:
```bash
# Terminal 1: Dev server
cd app/web && bun run dev
cd app/web && npm run dev
# Output: Your application is ready~!
# Local: http://localhost:8080
@ -259,6 +261,168 @@ export ORLY_PORT=3334 @@ -259,6 +261,168 @@ export ORLY_PORT=3334
If you only want to disable the embedded web UI (without proxying to a dev server), just set `ORLY_WEB_DISABLE=true` without setting `ORLY_WEB_DEV_PROXY_URL`. The relay will return 404 for web UI requests while still handling WebSocket and API requests.
### Event Import
ORLY provides flexible event import capabilities for bulk data migration and synchronization. The import system supports both single files and entire directories, with efficient disk-based buffering for large datasets.
#### Features
- **Large File Support**: Handles multi-gigabyte files efficiently with disk buffering
- **Folder Import**: Import entire directories of JSONL files in one operation
- **Localhost Authentication**: Automatic authentication for localhost requests using `$NOSTR_PRIVATE_KEY`
- **Progress Tracking**: Real-time progress logging for long-running imports
- **Error Handling**: Continues processing even if individual events fail
#### HTTP API Import
Import events via the `/api/import` endpoint:
**Single File Upload:**
```bash
# Using multipart form data
curl -X POST http://localhost:3334/api/import \
-H "Authorization: Nostr <base64_nip98_auth>" \
-F "file=@events.jsonl"
# Using raw body
curl -X POST http://localhost:3334/api/import \
-H "Authorization: Nostr <base64_nip98_auth>" \
-H "Content-Type: application/x-ndjson" \
--data-binary @events.jsonl
```
**Folder Import:**
Import all `.jsonl` files from a directory:
```bash
curl -X POST "http://localhost:3334/api/import?folder=../scripts/exports" \
-H "Authorization: Nostr <base64_nip98_auth>"
```
The folder import will:
- Process all `.jsonl` files in the specified directory
- Return a summary with results for each file
- Continue processing even if individual files fail
**Localhost Authentication:**
For localhost requests, you can use the `$NOSTR_PRIVATE_KEY` environment variable instead of NIP-98 authentication:
```bash
export NOSTR_PRIVATE_KEY=nsec1your_private_key_here
# Import from localhost (no NIP-98 header needed)
curl -X POST http://localhost:3334/api/import \
-F "file=@events.jsonl"
```
**Security:** Localhost authentication only works for requests from `127.0.0.1`, `::1`, or `localhost`. Folder imports are restricted to allowed directories (e.g., `../scripts/exports`) for security.
#### Command-Line Import
For direct database access, use the `orly db import` command:
```bash
# Import a single file directly to the database
orly db import events.jsonl
# Or specify with flag
orly db import --file /path/to/large-export.jsonl
```
This bypasses the HTTP API and is faster for very large imports when you have direct database access.
#### Import Format
Events must be in JSONL (JSON Lines) format - one JSON event per line:
```json
{"id":"abc123...","pubkey":"def456...","kind":1,"created_at":1234567890,"content":"Hello","sig":"..."}
{"id":"xyz789...","pubkey":"ghi012...","kind":1,"created_at":1234567891,"content":"World","sig":"..."}
```
### Event Streaming and Synchronization
ORLY includes powerful event streaming capabilities to forward events from a local relay to remote relays, supporting both standard Nostr protocol and efficient negentropy-based synchronization.
#### Features
- **WebSocket Streaming**: Stream events to any Nostr-compatible relay
- **Negentropy Support**: Efficient set reconciliation using NIP-77 protocol
- **Batch Processing**: Processes events in batches for optimal performance
- **Progress Tracking**: Real-time logging of streaming progress
- **Error Recovery**: Handles network failures gracefully
#### HTTP API Streaming
Stream events from your local relay to a remote relay via the `/api/stream-to-relay` endpoint:
**Basic Streaming:**
```bash
curl -X POST "http://localhost:3334/api/stream-to-relay?target=wss://remote-relay.com" \
-H "Authorization: Nostr <base64_nip98_auth>"
```
**Default Target:**
If no target is specified, defaults to `wss://orly-relay.imwald.eu`:
```bash
curl -X POST http://localhost:3334/api/stream-to-relay \
-H "Authorization: Nostr <base64_nip98_auth>"
```
**Localhost Authentication:**
For localhost requests, use `$NOSTR_PRIVATE_KEY`:
```bash
export NOSTR_PRIVATE_KEY=nsec1your_private_key_here
curl -X POST "http://localhost:3334/api/stream-to-relay?target=wss://remote-relay.com"
```
**How It Works:**
1. The endpoint accepts the request and returns immediately with a status message
2. Streaming happens asynchronously in the background
3. All events from the local database are queried and streamed to the target relay
4. Events are sent in batches using standard Nostr EVENT messages
5. Progress is logged to the relay's log output
**Example Workflow:**
```bash
# 1. Import events to local relay
curl -X POST http://localhost:3334/api/import \
-F "file=@book-events.jsonl"
# 2. Stream events to remote relay
curl -X POST "http://localhost:3334/api/stream-to-relay?target=wss://orly-relay.imwald.eu" \
-H "Authorization: Nostr <base64_nip98_auth>"
```
#### Negentropy Synchronization
For more efficient synchronization, you can use negentropy (NIP-77) set reconciliation:
```bash
curl -X POST "http://localhost:3334/api/stream-to-relay?target=wss://remote-relay.com&use_negentropy=true" \
-H "Authorization: Nostr <base64_nip98_auth>"
```
**Note:** Full negentropy support requires the target relay to be configured as a peer in the sync manager. For ad-hoc streaming, use the standard EVENT streaming method.
#### Use Cases
- **Data Migration**: Move events from one relay to another
- **Backup Synchronization**: Keep multiple relays in sync
- **Local to Remote**: Transfer events from a local development relay to production
- **Relay Consolidation**: Merge events from multiple sources
### Sprocket Event Processing
ORLY includes a powerful sprocket system for external event processing scripts. Sprocket scripts enable custom filtering, validation, and processing logic for Nostr events before storage.

129
RUN_REMOTE.md

@ -0,0 +1,129 @@ @@ -0,0 +1,129 @@
# Running ORLY Relay Remotely
## Quick Start
### 1. Pull the Image
```bash
docker pull silberengel/next-orly:v0.58.5
```
### 2. Run with Docker Run
```bash
docker run -d \
--name orly-relay \
--restart always \
-p 3334:3334 \
-v /var/lib/orly:/data \
-e ORLY_DATA_DIR=/data \
-e ORLY_LISTEN=0.0.0.0 \
-e ORLY_PORT=3334 \
-e ORLY_LOG_LEVEL=info \
silberengel/next-orly:v0.58.5
```
### 3. Or Use Docker Compose
Create a `docker-compose.yml`:
```yaml
version: '3.8'
services:
orly-relay:
image: silberengel/next-orly:v0.58.5
container_name: orly-relay
restart: always
ports:
- "3334:3334"
volumes:
- /var/lib/orly:/data
environment:
- ORLY_DATA_DIR=/data
- ORLY_LISTEN=0.0.0.0
- ORLY_PORT=3334
- ORLY_LOG_LEVEL=info
```
Then run:
```bash
docker-compose up -d
```
## Configuration Options
### Basic Configuration
- `ORLY_DATA_DIR` - Data directory (default: `/data`)
- `ORLY_LISTEN` - Listen address (default: `0.0.0.0`)
- `ORLY_PORT` - Port number (default: `3334`)
- `ORLY_LOG_LEVEL` - Log level: `debug`, `info`, `warn`, `error`
### Advanced Configuration
- `ORLY_ADMINS` - Comma-separated list of admin npub keys
- `ORLY_OWNERS` - Comma-separated list of owner npub keys
- `ORLY_ACL_MODE` - ACL mode: `open`, `follows`, `managed`
- `ORLY_SPIDER_MODE` - Spider mode: `off`, `follows`, `all`
- `ORLY_RELAY_URL` - Public relay URL (for metadata)
- `ORLY_MAX_CONNECTIONS` - Max concurrent connections (default: 1000)
- `ORLY_MAX_EVENT_SIZE` - Max event size in bytes (default: 65536)
## Useful Commands
### View Logs
```bash
docker logs -f orly-relay
```
### Stop Container
```bash
docker stop orly-relay
```
### Start Container
```bash
docker start orly-relay
```
### Restart Container
```bash
docker restart orly-relay
```
### Remove Container
```bash
docker rm -f orly-relay
```
### Update to Latest Version
```bash
docker pull silberengel/next-orly:v0.58.5
docker stop orly-relay
docker rm orly-relay
# Then run your docker run or docker-compose command again
```
## Behind a Reverse Proxy
If running behind nginx/Caddy/etc, bind to localhost only:
```bash
docker run -d \
--name orly-relay \
--restart always \
-p 127.0.0.1:3334:3334 \
-v /var/lib/orly:/data \
-e ORLY_DATA_DIR=/data \
-e ORLY_LISTEN=0.0.0.0 \
-e ORLY_PORT=3334 \
silberengel/next-orly:v0.58.5
```
## Data Persistence
Data is stored in `/var/lib/orly` on the host (or whatever path you mount).
To backup:
```bash
tar -czf orly-backup-$(date +%Y%m%d).tar.gz /var/lib/orly
```
To restore:
```bash
tar -xzf orly-backup-YYYYMMDD.tar.gz -C /
```

2
app/blossom.go

@ -26,6 +26,8 @@ func initializeBlossomServer( @@ -26,6 +26,8 @@ func initializeBlossomServer(
RateLimitEnabled: cfg.BlossomRateLimitEnabled,
DailyLimitMB: cfg.BlossomDailyLimitMB,
BurstLimitMB: cfg.BlossomBurstLimitMB,
// Delete replay protection (proposed BUD enhancement)
DeleteRequireServerTag: cfg.BlossomDeleteRequireServerTag,
}
// Create blossom server with relay's ACL registry

3
app/config/config.go

@ -82,6 +82,9 @@ type C struct { @@ -82,6 +82,9 @@ type C struct {
BlossomDailyLimitMB int64 `env:"ORLY_BLOSSOM_DAILY_LIMIT_MB" default:"10" usage:"daily upload limit in MB for non-followed users (EMA averaged)"`
BlossomBurstLimitMB int64 `env:"ORLY_BLOSSOM_BURST_LIMIT_MB" default:"50" usage:"max burst upload in MB (bucket cap)"`
// Blossom delete replay protection (proposed BUD enhancement)
BlossomDeleteRequireServerTag bool `env:"ORLY_BLOSSOM_DELETE_REQUIRE_SERVER_TAG" default:"false" usage:"require server tag in delete auth events to prevent cross-server replay attacks (not yet ratified in spec)"`
// Web UI and dev mode settings
WebDisableEmbedded bool `env:"ORLY_WEB_DISABLE" default:"false" usage:"disable serving the embedded web UI; useful for hot-reload during development"`
WebDevProxyURL string `env:"ORLY_WEB_DEV_PROXY_URL" usage:"when ORLY_WEB_DISABLE is true, reverse-proxy non-API paths to this dev server URL (e.g. http://localhost:5173)"`

9
app/handle-negentropy.go

@ -314,6 +314,15 @@ func (l *Listener) sendEventsForIDs(subscriptionID string, ids [][]byte) error { @@ -314,6 +314,15 @@ func (l *Listener) sendEventsForIDs(subscriptionID string, ids [][]byte) error {
return err
}
// Ensure events are freed after use
defer func() {
for _, ev := range events {
if ev != nil {
ev.Free()
}
}
}()
// Send each event via EVENT envelope with subscription ID
sent := 0
for _, ev := range events {

8
app/handle-relayinfo.go

@ -200,11 +200,9 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) { @@ -200,11 +200,9 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
addresses = append(addresses, s.Config.RelayAddresses...)
}
// Add Tor hidden service address if available
if s.torService != nil {
if onionAddr := s.torService.OnionWSAddress(); onionAddr != "" {
addresses = append(addresses, onionAddr)
}
// Add addresses from all transports (Tor .onion, etc.)
if s.transportMgr != nil {
addresses = append(addresses, s.transportMgr.Addresses()...)
}
// Build graph query config if enabled

40
app/handle-req.go

@ -8,10 +8,6 @@ import ( @@ -8,10 +8,6 @@ import (
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/acl"
"git.mleku.dev/mleku/nostr/encoders/bech32encoding"
"git.mleku.dev/mleku/nostr/encoders/envelopes/authenvelope"
"git.mleku.dev/mleku/nostr/encoders/envelopes/closedenvelope"
@ -24,12 +20,16 @@ import ( @@ -24,12 +20,16 @@ import (
"git.mleku.dev/mleku/nostr/encoders/kind"
"git.mleku.dev/mleku/nostr/encoders/reason"
"git.mleku.dev/mleku/nostr/encoders/tag"
"git.mleku.dev/mleku/nostr/utils/normalize"
"git.mleku.dev/mleku/nostr/utils/pointers"
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/policy"
"next.orly.dev/pkg/protocol/graph"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/protocol/publish"
"git.mleku.dev/mleku/nostr/utils/normalize"
"git.mleku.dev/mleku/nostr/utils/pointers"
)
func (l *Listener) HandleReq(msg []byte) (err error) {
@ -292,15 +292,33 @@ func (l *Listener) HandleReq(msg []byte) (err error) { @@ -292,15 +292,33 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
for _, ev := range cachedEvents {
var res *eventenvelope.Result
if res, err = eventenvelope.NewResultWith(env.Subscription, ev); chk.E(err) {
// Free remaining cached events on error
for _, remainingEv := range cachedEvents {
if remainingEv != nil {
remainingEv.Free()
}
}
return
}
if err = res.Write(l); err != nil {
if !strings.Contains(err.Error(), "context canceled") {
chk.E(err)
}
// Free remaining cached events on error
for _, remainingEv := range cachedEvents {
if remainingEv != nil {
remainingEv.Free()
}
}
return
}
}
// Free cached events after sending
for _, ev := range cachedEvents {
if ev != nil {
ev.Free()
}
}
// Send EOSE
if err = eoseenvelope.NewFrom(env.Subscription).Write(l); chk.E(err) {
return
@ -877,6 +895,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) { @@ -877,6 +895,7 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
var err error
if res, err = eventenvelope.NewResultWith(subID, ev); chk.E(err) {
log.E.F("failed to create event envelope for subscription %s: %v", subID, err)
ev.Free() // Free event on error
continue
}
@ -885,13 +904,20 @@ func (l *Listener) HandleReq(msg []byte) (err error) { @@ -885,13 +904,20 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
if !strings.Contains(err.Error(), "context canceled") {
log.E.F("failed to write event to subscription %s @ %s: %v", subID, l.remote, err)
}
ev.Free() // Free event on write error
// Don't return here - write errors shouldn't kill the subscription
// The connection cleanup will handle removing the subscription
continue
}
// Capture event ID before freeing for logging
eventID := hexenc.Enc(ev.ID)
// Free event after successfully sending
ev.Free()
log.D.F("delivered real-time event %s to subscription %s @ %s",
hexenc.Enc(ev.ID), subID, l.remote)
eventID, subID, l.remote)
}
}
}()

143
app/main.go

@ -3,7 +3,6 @@ package app @@ -3,7 +3,6 @@ package app
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
@ -11,7 +10,6 @@ import ( @@ -11,7 +10,6 @@ import (
"time"
"github.com/adrg/xdg"
"golang.org/x/crypto/acme/autocert"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/app/branding"
@ -32,9 +30,12 @@ import ( @@ -32,9 +30,12 @@ import (
"next.orly.dev/pkg/spider"
"next.orly.dev/pkg/storage"
dsync "next.orly.dev/pkg/sync"
"next.orly.dev/pkg/transport"
"next.orly.dev/pkg/transport/tcp"
tlstransport "next.orly.dev/pkg/transport/tls"
tortransport "next.orly.dev/pkg/transport/tor"
"next.orly.dev/pkg/wireguard"
"next.orly.dev/pkg/archive"
"next.orly.dev/pkg/tor"
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
)
@ -616,32 +617,20 @@ func Run( @@ -616,32 +617,20 @@ func Run(
log.I.F("archive relay manager initialized with %d relays", len(archiveRelays))
}
// Initialize Tor hidden service if enabled (spawns tor subprocess)
// Build transport manager
l.transportMgr = transport.NewManager()
// Add Tor transport if enabled (can start before db is ready)
torEnabled, torPort, torDataDir, torBinary, torSOCKSPort := cfg.GetTorConfigValues()
if torEnabled {
torCfg := &tor.Config{
tt := tortransport.New(&tortransport.Config{
Port: torPort,
DataDir: torDataDir,
Binary: torBinary,
SOCKSPort: torSOCKSPort,
Handler: l,
}
var err error
l.torService, err = tor.New(torCfg)
if err != nil {
log.W.F("Tor disabled: %v", err)
} else {
if err = l.torService.Start(); err != nil {
log.W.F("failed to start Tor service: %v", err)
l.torService = nil
} else {
if addr := l.torService.OnionWSAddress(); addr != "" {
log.I.F("Tor hidden service listening on port %d, address: %s", torPort, addr)
} else {
log.I.F("Tor hidden service listening on port %d (waiting for .onion address)", torPort)
}
}
}
})
l.transportMgr.Add(tt)
}
// Start rate limiter if enabled
@ -653,81 +642,26 @@ func Run( @@ -653,81 +642,26 @@ func Run(
// Wait for database to be ready before accepting requests
log.I.F("waiting for database warmup to complete...")
<-db.Ready()
log.I.F("database ready, starting HTTP servers")
// Check if TLS is enabled
var tlsEnabled bool
var tlsServer *http.Server
var httpServer *http.Server
log.I.F("database ready, starting transports")
// Add TLS or plain TCP transport (mutually exclusive)
if len(cfg.TLSDomains) > 0 {
// Validate TLS configuration
if err = ValidateTLSConfig(cfg.TLSDomains, cfg.Certs); chk.E(err) {
log.E.F("invalid TLS configuration: %v", err)
} else {
tlsEnabled = true
log.I.F("TLS enabled for domains: %v", cfg.TLSDomains)
// Create cache directory for autocert
cacheDir := filepath.Join(cfg.DataDir, "autocert")
if err = os.MkdirAll(cacheDir, 0700); chk.E(err) {
log.E.F("failed to create autocert cache directory: %v", err)
tlsEnabled = false
} else {
// Set up autocert manager
m := &autocert.Manager{
Prompt: autocert.AcceptTOS,
Cache: autocert.DirCache(cacheDir),
HostPolicy: autocert.HostWhitelist(cfg.TLSDomains...),
}
// Create TLS server on port 443
tlsServer = &http.Server{
Addr: ":443",
l.transportMgr.Add(tlstransport.New(&tlstransport.Config{
Domains: cfg.TLSDomains,
Certs: cfg.Certs,
DataDir: cfg.DataDir,
Handler: l,
TLSConfig: TLSConfig(m, cfg.Certs...),
}
// Create HTTP server for ACME challenges and redirects on port 80
httpServer = &http.Server{
Addr: ":80",
Handler: m.HTTPHandler(nil),
}
// Start TLS server
go func() {
log.I.F("starting TLS listener on https://:443")
if err := tlsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
log.E.F("TLS server error: %v", err)
}
}()
// Start HTTP server for ACME challenges
go func() {
log.I.F("starting HTTP listener on http://:80 for ACME challenges")
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.E.F("HTTP server error: %v", err)
}
}()
}
}
}
// Start regular HTTP server if TLS is not enabled or as fallback
if !tlsEnabled {
addr := fmt.Sprintf("%s:%d", cfg.Listen, cfg.Port)
log.I.F("starting listener on http://%s", addr)
httpServer = &http.Server{
Addr: addr,
}))
} else {
l.transportMgr.Add(tcp.New(&tcp.Config{
Addr: fmt.Sprintf("%s:%d", cfg.Listen, cfg.Port),
Handler: l,
}))
}
go func() {
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.E.F("HTTP server error: %v", err)
}
}()
// Start all transports
if err := l.transportMgr.StartAll(ctx); err != nil {
log.E.F("transport startup failed: %v", err)
}
// Graceful shutdown handler
@ -759,12 +693,6 @@ func Run( @@ -759,12 +693,6 @@ func Run(
log.I.F("archive manager stopped")
}
// Stop Tor service if running
if l.torService != nil {
l.torService.Stop()
log.I.F("Tor service stopped")
}
// Stop garbage collector if running
if l.garbageCollector != nil {
l.garbageCollector.Stop()
@ -795,25 +723,12 @@ func Run( @@ -795,25 +723,12 @@ func Run(
log.I.F("WireGuard server stopped")
}
// Create shutdown context with timeout
// Stop all transports (TCP/TLS/Tor)
if l.transportMgr != nil {
shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelShutdown()
// Shutdown TLS server if running
if tlsServer != nil {
if err := tlsServer.Shutdown(shutdownCtx); err != nil {
log.E.F("TLS server shutdown error: %v", err)
} else {
log.I.F("TLS server shutdown completed")
}
}
// Shutdown HTTP server
if httpServer != nil {
if err := httpServer.Shutdown(shutdownCtx); err != nil {
log.E.F("HTTP server shutdown error: %v", err)
} else {
log.I.F("HTTP server shutdown completed")
if err := l.transportMgr.StopAll(shutdownCtx); err != nil {
log.E.F("transport shutdown error: %v", err)
}
}

735
app/server.go

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
package app
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
@ -9,17 +11,30 @@ import ( @@ -9,17 +11,30 @@ import (
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"git.mleku.dev/mleku/nostr/encoders/bech32encoding"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter"
"git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/encoders/tag"
"git.mleku.dev/mleku/nostr/encoders/timestamp"
"git.mleku.dev/mleku/nostr/httpauth"
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
"git.mleku.dev/mleku/nostr/protocol/auth"
"github.com/gorilla/websocket"
"lol.mleku.dev/chk"
"next.orly.dev/app/branding"
"next.orly.dev/app/config"
"next.orly.dev/pkg/acl"
acliface "next.orly.dev/pkg/interfaces/acl"
"next.orly.dev/pkg/archive"
"next.orly.dev/pkg/blossom"
"next.orly.dev/pkg/bunker"
"next.orly.dev/pkg/database"
domainevents "next.orly.dev/pkg/domain/events"
"next.orly.dev/pkg/domain/events/subscribers"
@ -29,25 +44,18 @@ import ( @@ -29,25 +44,18 @@ import (
"next.orly.dev/pkg/event/routing"
"next.orly.dev/pkg/event/specialkinds"
"next.orly.dev/pkg/event/validation"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter"
"git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/encoders/tag"
acliface "next.orly.dev/pkg/interfaces/acl"
"next.orly.dev/pkg/policy"
"git.mleku.dev/mleku/nostr/protocol/auth"
"git.mleku.dev/mleku/nostr/httpauth"
"next.orly.dev/pkg/protocol/graph"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/bunker"
"next.orly.dev/pkg/protocol/nrc"
"next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/ratelimit"
"next.orly.dev/pkg/spider"
"next.orly.dev/pkg/storage"
dsync "next.orly.dev/pkg/sync"
"next.orly.dev/pkg/transport"
"next.orly.dev/pkg/wireguard"
"next.orly.dev/pkg/archive"
"next.orly.dev/pkg/tor"
)
type Server struct {
@ -122,8 +130,8 @@ type Server struct { @@ -122,8 +130,8 @@ type Server struct {
accessTracker *storage.AccessTracker
garbageCollector *storage.GarbageCollector
// Tor hidden service
torService *tor.Service
// Transport manager for network transports (TCP, TLS, Tor, etc.)
transportMgr *transport.Manager
// Branding/white-label customization
brandingMgr *branding.Manager
@ -440,6 +448,8 @@ func (s *Server) UserInterface() { @@ -440,6 +448,8 @@ func (s *Server) UserInterface() {
s.mux.HandleFunc("/api/events/mine", s.handleEventsMine)
// Import endpoint (admin only)
s.mux.HandleFunc("/api/import", s.handleImport)
// Streaming endpoint to forward events to remote relay
s.mux.HandleFunc("/api/stream-to-relay", s.handleStreamToRelay)
// Sprocket endpoints (owner only)
s.mux.HandleFunc("/api/sprocket/status", s.handleSprocketStatus)
s.mux.HandleFunc("/api/sprocket/update", s.handleSprocketUpdate)
@ -1109,6 +1119,12 @@ func (s *Server) handleEventsMine(w http.ResponseWriter, r *http.Request) { @@ -1109,6 +1119,12 @@ func (s *Server) handleEventsMine(w http.ResponseWriter, r *http.Request) {
// Marshal and write the response
jsonData, err := json.Marshal(response)
if chk.E(err) {
// Free events before returning error
for _, ev := range events {
if ev != nil {
ev.Free()
}
}
http.Error(
w, "Error generating response", http.StatusInternalServerError,
)
@ -1116,39 +1132,200 @@ func (s *Server) handleEventsMine(w http.ResponseWriter, r *http.Request) { @@ -1116,39 +1132,200 @@ func (s *Server) handleEventsMine(w http.ResponseWriter, r *http.Request) {
}
w.Write(jsonData)
// Free events after successfully sending response
for _, ev := range events {
if ev != nil {
ev.Free()
}
}
}
// handleImport receives a JSONL/NDJSON file or body and enqueues an async import using NIP-98 authentication. Admins only.
// authenticateLocalhost authenticates requests from localhost using $NOSTR_PRIVATE_KEY
// Returns the pubkey if authentication succeeds, nil otherwise
func (s *Server) authenticateLocalhost(r *http.Request) ([]byte, error) {
// Check if request is from localhost
remoteIP := strings.Split(r.RemoteAddr, ":")[0]
if remoteIP != "127.0.0.1" && remoteIP != "::1" && remoteIP != "localhost" {
return nil, fmt.Errorf("not a localhost request")
}
// Read NOSTR_PRIVATE_KEY from environment
nsec := os.Getenv("NOSTR_PRIVATE_KEY")
if nsec == "" {
return nil, fmt.Errorf("NOSTR_PRIVATE_KEY environment variable not set")
}
// Decode nsec to get private key bytes
secretBytes, err := bech32encoding.NsecToBytes([]byte(nsec))
if err != nil {
return nil, fmt.Errorf("failed to decode nsec: %w", err)
}
// Create signer from private key
signer, err := p8k.New()
if err != nil {
return nil, fmt.Errorf("failed to create signer: %w", err)
}
if err = signer.InitSec(secretBytes); err != nil {
return nil, fmt.Errorf("failed to initialize signer: %w", err)
}
// Get public key from signer
pubkey := signer.Pub()
return pubkey, nil
}
// handleImport receives a JSONL/NDJSON file or body and enqueues an async import using NIP-98 authentication. Write, admin, or owner roles required.
// Supports folder imports via ?folder=/path/to/folder query parameter.
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var pubkey []byte
var err error
// Skip authentication and permission checks when ACL is "none" (open relay mode)
if acl.Registry.GetMode() != "none" {
// Validate NIP-98 authentication
valid, pubkey, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid {
errorMsg := "NIP-98 authentication validation failed"
// Try localhost authentication first (uses NOSTR_PRIVATE_KEY env var, no AUTH header required)
pubkey, err = s.authenticateLocalhost(r)
if err != nil {
errorMsg = err.Error()
// If NOSTR_PRIVATE_KEY is set, allow the request without NIP-98 auth
// This is useful for containerized environments where requests come from within the container
if nsec := os.Getenv("NOSTR_PRIVATE_KEY"); nsec != "" {
// Try to get pubkey from NOSTR_PRIVATE_KEY even if not localhost
secretBytes, decodeErr := bech32encoding.NsecToBytes([]byte(nsec))
if decodeErr == nil {
signer, signerErr := p8k.New()
if signerErr == nil {
if initErr := signer.InitSec(secretBytes); initErr == nil {
pubkey = signer.Pub()
log.Printf("[HTTP API IMPORT] Authenticated using NOSTR_PRIVATE_KEY (no NIP-98 header required)")
}
}
}
}
// If we still don't have a pubkey, try NIP-98 authentication
if pubkey == nil {
valid, pk, authErr := httpauth.CheckAuth(r)
if chk.E(authErr) || !valid {
errorMsg := "Authentication required. Use NOSTR_PRIVATE_KEY env var or NIP-98 Authorization header"
if authErr != nil {
errorMsg = authErr.Error()
}
http.Error(w, errorMsg, http.StatusUnauthorized)
return
}
pubkey = pk
}
} else {
log.Printf("[HTTP API IMPORT] Authenticated via localhost using NOSTR_PRIVATE_KEY")
}
// Check permissions - require admin or owner level
// Check permissions - require write, admin, or owner level
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
if accessLevel != "admin" && accessLevel != "owner" {
if accessLevel != "write" && accessLevel != "admin" && accessLevel != "owner" {
http.Error(
w, "Admin or owner permission required", http.StatusForbidden,
w, "Write, admin, or owner permission required", http.StatusForbidden,
)
return
}
} else {
// Open relay mode - no authentication required
log.Printf("[HTTP API IMPORT] Open relay mode - no authentication required")
}
// Check if this is a folder import
folderPath := r.URL.Query().Get("folder")
if folderPath != "" {
s.handleImportFolder(w, r, folderPath)
return
}
// Check if importing directly to a remote relay
targetURL := r.URL.Query().Get("target")
if targetURL != "" {
// Normalize WebSocket URL
if strings.HasPrefix(targetURL, "http://") {
targetURL = strings.Replace(targetURL, "http://", "ws://", 1)
} else if strings.HasPrefix(targetURL, "https://") {
targetURL = strings.Replace(targetURL, "https://", "wss://", 1)
} else if !strings.HasPrefix(targetURL, "ws://") && !strings.HasPrefix(targetURL, "wss://") {
targetURL = "wss://" + targetURL
}
// Get the file/body reader
var reader io.Reader
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); chk.E(err) {
http.Error(w, "Failed to parse form", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("file")
if chk.E(err) {
http.Error(w, "Missing file", http.StatusBadRequest)
return
}
defer file.Close()
reader = file
} else {
if r.Body == nil {
http.Error(w, "Empty request body", http.StatusBadRequest)
return
}
reader = r.Body
}
// Check if we should wait for completion (default: yes, for sequential processing)
waitForCompletion := r.URL.Query().Get("async") != "true"
if waitForCompletion {
// Run synchronously - wait for completion before returning
// This allows the script to process files sequentially
if err := s.importToRemoteRelay(context.Background(), reader, targetURL); err != nil {
http.Error(w, fmt.Sprintf("Import to remote relay failed: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"success": true, "message": "Import to remote relay completed", "target": "` + targetURL + `"}`))
} else {
// Run asynchronously in background
// IMPORTANT: Read the body into memory first, as r.Body will be closed when handler returns
bodyBytes, err := io.ReadAll(reader)
if err != nil {
log.Printf("ERROR: Failed to read request body for remote import: %v", err)
http.Error(w, fmt.Sprintf("Failed to read request body: %v", err), http.StatusInternalServerError)
return
}
log.Printf("Starting async import to remote relay %s (body size: %d bytes)", targetURL, len(bodyBytes))
go func() {
bodyReader := bytes.NewReader(bodyBytes)
if err := s.importToRemoteRelay(context.Background(), bodyReader, targetURL); err != nil {
log.Printf("ERROR: Import to remote relay %s failed: %v", targetURL, err)
}
}()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"success": true, "message": "Import to remote relay started", "target": "` + targetURL + `"}`))
}
return
}
// Default: import to local database
// Check if we should wait for completion (default: yes, for reliability)
waitForCompletion := r.URL.Query().Get("async") != "true"
ct := r.Header.Get("Content-Type")
var reader io.Reader
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); chk.E(err) { // 32MB memory, rest to temp files
http.Error(w, "Failed to parse form", http.StatusBadRequest)
@ -1160,19 +1337,529 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { @@ -1160,19 +1337,529 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
return
}
defer file.Close()
s.DB.Import(file)
reader = file
} else {
if r.Body == nil {
http.Error(w, "Empty request body", http.StatusBadRequest)
return
}
s.DB.Import(r.Body)
reader = r.Body
}
if waitForCompletion {
// Run synchronously - wait for completion before returning
// This ensures the import is complete and we can return proper status
log.Printf("================================================")
log.Printf("[HTTP API IMPORT] Starting synchronous import")
log.Printf("[HTTP API IMPORT] This may take several minutes for large files")
log.Printf("================================================")
s.DB.Import(reader)
log.Printf("================================================")
log.Printf("[HTTP API IMPORT] Import completed successfully")
log.Printf("================================================")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"success": true, "message": "Import completed"}`))
} else {
// Run asynchronously in background
// IMPORTANT: Read the body into memory first, as r.Body will be closed when handler returns
bodyBytes, err := io.ReadAll(reader)
if err != nil {
log.Printf("ERROR: Failed to read request body for async import: %v", err)
http.Error(w, fmt.Sprintf("Failed to read request body: %v", err), http.StatusInternalServerError)
return
}
log.Printf("Starting async import (body size: %d bytes)", len(bodyBytes))
go func() {
bodyReader := bytes.NewReader(bodyBytes)
s.DB.Import(bodyReader)
log.Printf("Async import completed")
}()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"success": true, "message": "Import started"}`))
}
}
// importToRemoteRelay reads JSONL from a reader and sends events directly to a remote relay
func (s *Server) importToRemoteRelay(ctx context.Context, reader io.Reader, targetURL string) error {
log.Printf("Starting import to remote relay: %s", targetURL)
// Connect to remote relay
dialer := websocket.Dialer{
HandshakeTimeout: 30 * time.Second,
}
log.Printf("Attempting WebSocket connection to: %s", targetURL)
conn, _, err := dialer.DialContext(ctx, targetURL, http.Header{})
if err != nil {
log.Printf("ERROR: Failed to connect to remote relay %s: %v", targetURL, err)
return fmt.Errorf("failed to connect to relay: %w", err)
}
defer conn.Close()
log.Printf("Successfully connected to remote relay: %s", targetURL)
// Read JSONL line by line and send events
scanner := bufio.NewScanner(reader)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 10*1024*1024) // 10MB max line size
var totalSent, totalAccepted, totalRejected int64
rejectedKinds := make(map[uint16]int)
rejectionReasons := make(map[string]int) // reason -> count
pendingEvents := make(map[string]uint16) // eventID -> kind (for matching OK responses)
// Start reading OK responses in background
type okResponse struct {
EventID string
Accepted bool
Reason string
}
okChan := make(chan okResponse, 10000)
doneReading := make(chan struct{})
go func() {
defer close(doneReading)
for {
var raw []interface{}
if err := conn.ReadJSON(&raw); err != nil {
return
}
if len(raw) >= 3 && raw[0] == "OK" {
eventID, _ := raw[1].(string)
accepted, _ := raw[2].(bool)
reason := ""
if len(raw) >= 4 {
reason, _ = raw[3].(string)
}
select {
case okChan <- okResponse{EventID: eventID, Accepted: accepted, Reason: reason}:
default:
}
}
}
}()
// Process events line by line
for scanner.Scan() {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
line := scanner.Bytes()
if len(line) == 0 {
continue
}
// Parse event
ev := event.New()
if _, err := ev.Unmarshal(line); err != nil {
ev.Free()
continue
}
// Serialize event for sending
eventJSON := ev.Serialize()
var eventMap map[string]interface{}
if err := json.Unmarshal(eventJSON, &eventMap); err != nil {
ev.Free()
continue
}
// Send EVENT message
eventMsg := []interface{}{"EVENT", eventMap}
if err := conn.WriteJSON(eventMsg); err != nil {
ev.Free()
return fmt.Errorf("failed to send event: %w", err)
}
eventID := hex.Enc(ev.ID)
eventKind := ev.Kind
totalSent++
// Store pending event for OK response matching
pendingEvents[eventID] = eventKind
// Process any available OK responses (non-blocking)
for {
select {
case ok := <-okChan:
if kind, exists := pendingEvents[ok.EventID]; exists {
if ok.Accepted {
totalAccepted++
} else {
totalRejected++
rejectedKinds[kind]++
if ok.Reason != "" {
rejectionReasons[ok.Reason]++
}
}
delete(pendingEvents, ok.EventID)
}
default:
// No more responses available right now
goto continueLoop
}
}
continueLoop:
ev.Free()
// Progress logging every 1000 events
if totalSent%1000 == 0 {
log.Printf("Import to %s: %d sent (accepted: %d, rejected: %d, pending: %d)", targetURL, totalSent, totalAccepted, totalRejected, len(pendingEvents))
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scanner error: %w", err)
}
// Wait for remaining OK responses (up to 30 seconds for large batches)
timeout := time.After(30 * time.Second)
waitStart := time.Now()
for len(pendingEvents) > 0 && time.Since(waitStart) < 30*time.Second {
select {
case ok := <-okChan:
if kind, exists := pendingEvents[ok.EventID]; exists {
if ok.Accepted {
totalAccepted++
} else {
totalRejected++
rejectedKinds[kind]++
if ok.Reason != "" {
rejectionReasons[ok.Reason]++
}
}
delete(pendingEvents, ok.EventID)
}
case <-timeout:
log.Printf("Timeout waiting for OK responses, %d events still pending", len(pendingEvents))
break
}
}
log.Printf("Import to %s completed: %d sent (accepted: %d, rejected: %d, no response: %d)", targetURL, totalSent, totalAccepted, totalRejected, len(pendingEvents))
if totalRejected > 0 {
log.Printf("Rejected events by kind: %v", rejectedKinds)
if len(rejectionReasons) > 0 {
log.Printf("Rejection reasons:")
for reason, count := range rejectionReasons {
log.Printf(" %s: %d", reason, count)
}
}
}
if len(pendingEvents) > 0 {
log.Printf("Warning: %d events sent but no OK response received (may have been accepted silently)", len(pendingEvents))
}
return nil
}
// handleImportFolder imports all .jsonl files from a folder
func (s *Server) handleImportFolder(w http.ResponseWriter, r *http.Request, folderPath string) {
// Security: Only allow imports from specific allowed directories
// For now, allow ../scripts/exports and absolute paths that are explicitly allowed
allowedPrefixes := []string{
"../scripts/exports",
"./scripts/exports",
"scripts/exports",
}
isAllowed := false
for _, prefix := range allowedPrefixes {
if strings.HasPrefix(folderPath, prefix) {
isAllowed = true
break
}
}
// Also allow absolute paths that are explicitly in allowed directories
if !isAllowed && filepath.IsAbs(folderPath) {
// Check if it's under a reasonable directory (e.g., user's home or current working directory)
// For security, we'll be conservative and only allow relative paths for now
http.Error(w, "Absolute paths not allowed for security reasons", http.StatusForbidden)
return
}
if !isAllowed {
http.Error(w, "Folder path not in allowed directories", http.StatusForbidden)
return
}
// Resolve the folder path
absPath, err := filepath.Abs(folderPath)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid folder path: %v", err), http.StatusBadRequest)
return
}
// Check if folder exists
info, err := os.Stat(absPath)
if err != nil {
http.Error(w, fmt.Sprintf("Folder not found: %v", err), http.StatusNotFound)
return
}
if !info.IsDir() {
http.Error(w, "Path is not a directory", http.StatusBadRequest)
return
}
// Read all .jsonl files in the folder
entries, err := os.ReadDir(absPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to read folder: %v", err), http.StatusInternalServerError)
return
}
var jsonlFiles []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(strings.ToLower(entry.Name()), ".jsonl") {
jsonlFiles = append(jsonlFiles, filepath.Join(absPath, entry.Name()))
}
}
if len(jsonlFiles) == 0 {
http.Error(w, "No .jsonl files found in folder", http.StatusNotFound)
return
}
// Process each file sequentially
type fileResult struct {
File string `json:"file"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
results := make([]fileResult, 0, len(jsonlFiles))
for _, filePath := range jsonlFiles {
file, err := os.Open(filePath)
if err != nil {
results = append(results, fileResult{
File: filePath,
Success: false,
Error: err.Error(),
})
continue
}
// Import the file
s.DB.Import(file)
file.Close()
results = append(results, fileResult{
File: filePath,
Success: true,
})
}
// Return results
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
response := map[string]interface{}{
"success": true,
"message": fmt.Sprintf("Import started for %d files", len(jsonlFiles)),
"files": results,
}
jsonData, _ := json.Marshal(response)
w.Write(jsonData)
}
// handleStreamToRelay streams events from the local relay to a remote relay via WebSocket
// Supports both regular EVENT streaming and negentropy sync (NIP-77)
func (s *Server) handleStreamToRelay(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var pubkey []byte
var err error
// Authenticate request (localhost or NIP-98)
if acl.Registry.GetMode() != "none" {
pubkey, err = s.authenticateLocalhost(r)
if err != nil {
valid, pk, authErr := httpauth.CheckAuth(r)
if chk.E(authErr) || !valid {
errorMsg := "NIP-98 authentication validation failed"
if authErr != nil {
errorMsg = authErr.Error()
}
http.Error(w, errorMsg, http.StatusUnauthorized)
return
}
pubkey = pk
}
// Check permissions
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
if accessLevel != "write" && accessLevel != "admin" && accessLevel != "owner" {
http.Error(w, "Write, admin, or owner permission required", http.StatusForbidden)
return
}
}
// Get target relay URL (default: wss://orly-relay.imwald.eu)
targetURL := r.URL.Query().Get("target")
if targetURL == "" {
targetURL = "wss://orly-relay.imwald.eu"
}
// Normalize WebSocket URL
if strings.HasPrefix(targetURL, "http://") {
targetURL = strings.Replace(targetURL, "http://", "ws://", 1)
} else if strings.HasPrefix(targetURL, "https://") {
targetURL = strings.Replace(targetURL, "https://", "wss://", 1)
} else if !strings.HasPrefix(targetURL, "ws://") && !strings.HasPrefix(targetURL, "wss://") {
targetURL = "wss://" + targetURL
}
// Check if negentropy sync is requested
useNegentropy := r.URL.Query().Get("use_negentropy") == "true"
// Start streaming in background
go func() {
ctx := context.Background()
if useNegentropy && s.syncManager != nil {
// Use negentropy sync
_, err := s.streamWithNegentropy(ctx, targetURL)
if err != nil {
log.Printf("Negentropy stream to %s failed: %v", targetURL, err)
}
} else {
// Use regular EVENT streaming
err := s.streamEvents(ctx, targetURL)
if err != nil {
log.Printf("Event stream to %s failed: %v", targetURL, err)
}
}
}()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
response := map[string]interface{}{
"success": true,
"message": "Streaming started",
"target": targetURL,
"use_negentropy": useNegentropy,
}
jsonData, _ := json.Marshal(response)
w.Write(jsonData)
}
// streamEvents streams all events from local relay to remote relay using EVENT messages
// Uses pagination to avoid loading all events into memory at once
func (s *Server) streamEvents(ctx context.Context, targetURL string) error {
// Connect to remote relay
dialer := websocket.Dialer{
HandshakeTimeout: 30 * time.Second,
}
conn, _, err := dialer.DialContext(ctx, targetURL, http.Header{})
if err != nil {
return fmt.Errorf("failed to connect to relay: %w", err)
}
defer conn.Close()
log.Printf("Connected to remote relay: %s", targetURL)
// Stream events in batches to avoid memory exhaustion
// Query events in chunks using timestamp-based pagination
const batchLimit = 1000 // Query 1000 events at a time
var since *timestamp.T
var totalSent int64
for {
// Create filter for this batch
f := filter.New()
if since != nil {
f.Since = since
}
limit := uint(batchLimit)
f.Limit = &limit
// Query batch of events
events, err := s.DB.QueryEvents(ctx, f)
if err != nil {
return fmt.Errorf("failed to query events: %w", err)
}
if len(events) == 0 {
// No more events
break
}
// Stream this batch - ensure all events are freed
processedCount := 0
for i, ev := range events {
if ev == nil {
continue
}
// Capture timestamp before freeing
createdAt := ev.CreatedAt
// Send EVENT message: ["EVENT", event]
eventMsg := []interface{}{"EVENT", ev}
if err := conn.WriteJSON(eventMsg); err != nil {
// Free this event and all remaining events in the batch
ev.Free()
for j := i + 1; j < len(events); j++ {
if events[j] != nil {
events[j].Free()
}
}
return fmt.Errorf("failed to send event: %w", err)
}
// Free the event to return it to the pool
ev.Free()
processedCount++
totalSent++
if totalSent%1000 == 0 {
log.Printf("Streamed %d events to %s", totalSent, targetURL)
}
// Update since timestamp for next batch (use created_at + 1 to avoid duplicates)
since = timestamp.FromUnix(createdAt + 1)
}
// If we got fewer events than the limit, we've reached the end
if len(events) < batchLimit {
break
}
// Small delay between batches to avoid overwhelming the remote relay
time.Sleep(100 * time.Millisecond)
}
log.Printf("Successfully streamed %d events to %s", totalSent, targetURL)
return nil
}
// streamWithNegentropy streams events using negentropy sync (NIP-77)
// This is a simplified implementation that directly performs negentropy sync
func (s *Server) streamWithNegentropy(ctx context.Context, targetURL string) (int64, error) {
// For now, return a helpful error message
// Full negentropy implementation would require:
// 1. Building negentropy storage from local events
// 2. Creating negentropy instance
// 3. Performing NIP-77 protocol exchange
// 4. Fetching and pushing events
// This is a placeholder - the sync manager's negentropy functionality
// should be used via the existing sync infrastructure
return 0, fmt.Errorf("negentropy sync via HTTP endpoint not yet fully implemented. Use the sync manager's peer configuration or implement direct negentropy protocol")
}
// handleSprocketStatus returns the current status of the sprocket script
func (s *Server) handleSprocketStatus(w http.ResponseWriter, r *http.Request) {

132
app/tls.go

@ -1,132 +0,0 @@ @@ -1,132 +0,0 @@
package app
import (
"crypto/tls"
"crypto/x509"
"fmt"
"strings"
"sync"
"golang.org/x/crypto/acme/autocert"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
)
// TLSConfig returns a TLS configuration that works with LetsEncrypt automatic SSL cert issuer
// as well as any provided certificate files from providers.
//
// The certs are provided in the form of paths where .pem and .key files exist
func TLSConfig(m *autocert.Manager, certs ...string) (tc *tls.Config) {
certMap := make(map[string]*tls.Certificate)
var mx sync.Mutex
for _, certPath := range certs {
if certPath == "" {
continue
}
var err error
var c tls.Certificate
// Load certificate and key files
if c, err = tls.LoadX509KeyPair(
certPath+".pem", certPath+".key",
); chk.E(err) {
log.E.F("failed to load certificate from %s: %v", certPath, err)
continue
}
// Extract domain names from certificate
if len(c.Certificate) > 0 {
if x509Cert, err := x509.ParseCertificate(c.Certificate[0]); err == nil {
// Use the common name as the primary domain
if x509Cert.Subject.CommonName != "" {
certMap[x509Cert.Subject.CommonName] = &c
log.I.F("loaded certificate for domain: %s", x509Cert.Subject.CommonName)
}
// Also add any subject alternative names
for _, san := range x509Cert.DNSNames {
if san != "" {
certMap[san] = &c
log.I.F("loaded certificate for SAN domain: %s", san)
}
}
}
}
}
if m == nil {
// Create a basic TLS config without autocert
tc = &tls.Config{
GetCertificate: func(helo *tls.ClientHelloInfo) (*tls.Certificate, error) {
mx.Lock()
defer mx.Unlock()
// Check for exact match first
if cert, exists := certMap[helo.ServerName]; exists {
return cert, nil
}
// Check for wildcard matches
for domain, cert := range certMap {
if strings.HasPrefix(domain, "*.") {
baseDomain := domain[2:] // Remove "*."
if strings.HasSuffix(helo.ServerName, baseDomain) {
return cert, nil
}
}
}
return nil, fmt.Errorf("no certificate found for %s", helo.ServerName)
},
}
} else {
tc = m.TLSConfig()
tc.GetCertificate = func(helo *tls.ClientHelloInfo) (*tls.Certificate, error) {
mx.Lock()
// Check for exact match first
if cert, exists := certMap[helo.ServerName]; exists {
mx.Unlock()
return cert, nil
}
// Check for wildcard matches
for domain, cert := range certMap {
if strings.HasPrefix(domain, "*.") {
baseDomain := domain[2:] // Remove "*."
if strings.HasSuffix(helo.ServerName, baseDomain) {
mx.Unlock()
return cert, nil
}
}
}
mx.Unlock()
// Fall back to autocert for domains not in our certificate map
return m.GetCertificate(helo)
}
}
return tc
}
// ValidateTLSConfig checks if the TLS configuration is valid
func ValidateTLSConfig(domains []string, certs []string) (err error) {
if len(domains) == 0 {
return fmt.Errorf("no TLS domains specified")
}
// Validate domain names
for _, domain := range domains {
if domain == "" {
continue
}
if strings.Contains(domain, " ") || strings.Contains(domain, "\t") {
return fmt.Errorf("invalid domain name: %s", domain)
}
}
return nil
}

91
app/web/dist/bundle.css vendored

File diff suppressed because one or more lines are too long

25
app/web/dist/bundle.js vendored

File diff suppressed because one or more lines are too long

1
app/web/dist/bundle.js.map vendored

File diff suppressed because one or more lines are too long

BIN
app/web/dist/favicon.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

69
app/web/dist/global.css vendored

@ -1,69 +0,0 @@ @@ -1,69 +0,0 @@
html,
body {
position: relative;
width: 100%;
height: 100%;
}
body {
color: #333;
margin: 0;
padding: 8px;
box-sizing: border-box;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu,
Cantarell, "Helvetica Neue", sans-serif;
}
a {
color: rgb(0, 100, 200);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a:visited {
color: rgb(0, 80, 160);
}
label {
display: block;
}
input,
button,
select,
textarea {
font-family: inherit;
font-size: inherit;
-webkit-padding: 0.4em 0;
padding: 0.4em;
margin: 0 0 0.5em 0;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 2px;
}
input:disabled {
color: #ccc;
}
button {
color: #333;
background-color: #f4f4f4;
outline: none;
}
button:disabled {
color: #999;
}
button:not(:disabled):active {
background-color: #ddd;
}
button:focus {
border-color: #666;
}

45
app/web/dist/index.html vendored

@ -1,44 +1 @@ @@ -1,44 +1 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>ORLY?</title>
<style>
:root {
color-scheme: light dark;
}
html, body {
background-color: #fff;
color: #000;
}
@media (prefers-color-scheme: dark) {
html, body {
background-color: #000;
color: #fff;
}
}
</style>
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<meta name="theme-color" content="#000000" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<link rel="stylesheet" href="/global.css" />
<link rel="stylesheet" href="/bundle.css" />
<script defer src="/bundle.js"></script>
</head>
<body></body>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
</script>
</html>
<!-- Placeholder - will be replaced by Docker build -->

BIN
app/web/dist/orly.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 KiB

33
app/web/package-lock.json generated

@ -20,6 +20,7 @@ @@ -20,6 +20,7 @@
"devDependencies": {
"@rollup/plugin-commonjs": "^24.0.0",
"@rollup/plugin-node-resolve": "^15.0.0",
"@rollup/plugin-replace": "^5.0.0",
"@rollup/plugin-terser": "^0.4.0",
"rollup": "^3.15.0",
"rollup-plugin-copy": "^3.5.0",
@ -192,6 +193,38 @@ @@ -192,6 +193,38 @@
}
}
},
"node_modules/@rollup/plugin-replace": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-5.0.7.tgz",
"integrity": "sha512-PqxSfuorkHz/SPpyngLyg5GCEkOcee9M1bkxiVDr41Pd61mqP1PLOoDPbpl44SB2mQGKwV/In74gqQmGITOhEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"magic-string": "^0.30.3"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-replace/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/@rollup/plugin-terser": {
"version": "0.4.4",
"dev": true,

217
app/web/src/App.svelte

@ -24,7 +24,7 @@ @@ -24,7 +24,7 @@
import { isStandaloneMode, relayUrl, relayInfo as relayInfoStore, relayConnectionStatus, isOrlyRelay } from "./stores.js";
// Utility imports
import { buildFilter } from "./helpers.tsx";
import { buildFilter } from "./helpers.js";
import { replaceableKinds, kindNames, CACHE_DURATION } from "./constants.js";
import { getKindName, truncatePubkey, truncateContent, formatTimestamp, escapeHtml, aboutToHtml, copyToClipboard, showCopyFeedback } from "./utils.js";
import * as api from "./api.js";
@ -893,8 +893,18 @@ @@ -893,8 +893,18 @@
authMethod = storedAuthMethod;
// Restore signer for extension method
if (storedAuthMethod === "extension" && window.nostr) {
if (storedAuthMethod === "extension") {
if (window.nostr) {
userSigner = window.nostr;
} else {
// Extension might not be loaded yet, try again after a short delay
setTimeout(() => {
if (window.nostr && !userSigner) {
userSigner = window.nostr;
console.log("Extension signer restored after delay");
}
}, 500);
}
}
}
@ -911,9 +921,9 @@ @@ -911,9 +921,9 @@
async function loadRelayData() {
// Fetch user role for already logged in users
if (isLoggedIn) {
fetchUserRole();
await fetchUserRole();
}
fetchACLMode();
await fetchACLMode();
// Load sprocket configuration
loadSprocketConfig();
@ -958,7 +968,7 @@ @@ -958,7 +968,7 @@
searchTabs = [];
// Reload all relay-dependent data
loadRelayData();
await loadRelayData();
// If the events tab is currently active, reload events
if (selectedTab === "events" && isLoggedIn) {
@ -1834,6 +1844,17 @@ @@ -1834,6 +1844,17 @@
$: filteredBaseTabs = baseTabs.filter((tab) => {
const currentRole = currentEffectiveRole;
// Import tab: allow if ACL is "none" (open relay) or user has write/admin/owner permissions
if (tab.id === "import" && tab.requiresAdmin) {
if (aclMode === "none") {
return true; // Open relay, allow import
}
if (isLoggedIn && (currentRole === "write" || currentRole === "admin" || currentRole === "owner")) {
return true; // User has required permissions
}
return false; // Hide if no access
}
if (
tab.requiresAdmin &&
(!isLoggedIn ||
@ -1966,6 +1987,14 @@ @@ -1966,6 +1987,14 @@
// Fetch user role/permissions
await fetchUserRole();
await fetchACLMode();
// Trigger event loading if currently on events tab
if (selectedTab === "events") {
hasAttemptedEventLoad = false;
const authors =
showOnlyMyEvents && userPubkey ? [userPubkey] : null;
loadAllEvents(true, authors);
}
}
function handleLogout() {
@ -2400,11 +2429,26 @@ @@ -2400,11 +2429,26 @@
async function importEvents() {
// Skip login/permission check when ACL is "none" (open relay mode)
if (aclMode !== "none" && (!isLoggedIn || (userRole !== "admin" && userRole !== "owner"))) {
importMessage = "Admin or owner permission required";
if (aclMode !== "none") {
// Ensure user is logged in
if (!isLoggedIn) {
importMessage = "Please log in first";
setTimeout(() => { importMessage = ""; }, 5000);
return;
}
// If role is not yet loaded, fetch it first
if (!userRole && isLoggedIn && userPubkey) {
await fetchUserRole();
}
// Check permissions
if (userRole !== "write" && userRole !== "admin" && userRole !== "owner") {
importMessage = "Write, admin, or owner permission required";
setTimeout(() => { importMessage = ""; }, 5000);
return;
}
}
if (!selectedFile) {
importMessage = "Please select a file";
@ -2419,24 +2463,88 @@ @@ -2419,24 +2463,88 @@
// Build headers - only include auth when ACL is not "none"
const headers = {};
if (aclMode !== "none" && isLoggedIn) {
// Ensure signer is available for extension users
if (!userSigner && authMethod === "extension" && window.nostr) {
userSigner = window.nostr;
console.log("Restored extension signer for import");
}
// Use the actual request URL for NIP-98 auth
// If behind reverse proxy, server may see HTTP instead of HTTPS
// Use the same URL we're fetching, but try HTTP if HTTPS fails
const importUrl = `${getApiBase()}/api/import`;
// Convert to HTTP if HTTPS (reverse proxy may forward as HTTP internally)
const authUrl = importUrl.replace('https://', 'http://');
console.log("Creating NIP-98 auth for import URL:", authUrl, "(request URL:", importUrl, ")");
try {
headers.Authorization = await createNIP98AuthHeader(
`${getApiBase()}/api/import`,
authUrl,
"POST",
);
console.log("NIP-98 auth header created successfully");
} catch (error) {
console.error("Failed to create NIP-98 auth header:", error);
importMessage = "Failed to create authentication: " + error.message;
setTimeout(() => { importMessage = ""; }, 5000);
return;
}
}
const formData = new FormData();
formData.append("file", selectedFile);
console.log("Uploading file:", {
name: selectedFile.name,
size: selectedFile.size,
type: selectedFile.type,
sizeKB: Math.round(selectedFile.size / 1024),
});
// Use AbortController for timeout handling
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
importMessage = "Upload timeout: The request took too long. Please try again or contact the administrator.";
setTimeout(() => { importMessage = ""; }, 10000);
}, 300000); // 5 minute timeout
console.log("Sending import request to:", `${getApiBase()}/api/import`);
console.log("Request headers:", Object.keys(headers));
console.log("File size:", selectedFile.size, "bytes");
const response = await fetch(`${getApiBase()}/api/import`, {
method: "POST",
headers,
body: formData,
signal: controller.signal,
});
clearTimeout(timeoutId);
console.log("Import request completed, status:", response.status, response.statusText);
if (!response.ok) {
throw new Error(
`Import failed: ${response.status} ${response.statusText}`,
);
// Try to get error message from response body
let errorMsg = `Import failed: ${response.status} ${response.statusText}`;
// Provide helpful messages for common errors
if (response.status === 502) {
errorMsg = "Server error (502): The file may be too large or the server is temporarily unavailable. Try a smaller file or contact the administrator.";
} else if (response.status === 413) {
errorMsg = "File too large: The file exceeds the server's size limit. Please split it into smaller files.";
} else if (response.status === 504) {
errorMsg = "Request timeout: The file upload took too long. Try a smaller file or check your connection.";
} else {
try {
const errorData = await response.text();
if (errorData && !errorData.includes("<!DOCTYPE")) {
// Only show non-HTML error messages
const shortError = errorData.substring(0, 200);
errorMsg += ` - ${shortError}`;
}
} catch (e) {
// Ignore error reading response
}
}
throw new Error(errorMsg);
}
const result = await response.json();
@ -2673,6 +2781,9 @@ @@ -2673,6 +2781,9 @@
}
function handleScroll(event) {
if (!event || !event.target) {
return; // Event or target is null, skip handling
}
const { scrollTop, scrollHeight, clientHeight } = event.target;
const threshold = 100; // Load more when 100px from bottom
@ -2718,20 +2829,50 @@ @@ -2718,20 +2829,50 @@
throw new Error("No valid signer available");
}
// Ensure URL is normalized (remove trailing slash, ensure proper format)
let normalizedUrl = url.trim();
if (normalizedUrl.endsWith('/') && normalizedUrl.length > 1) {
normalizedUrl = normalizedUrl.slice(0, -1);
}
// If the URL uses HTTPS but we're behind a reverse proxy that forwards as HTTP,
// try using HTTP instead to match what the server sees
// The server checks the request URL against the signed URL
let authUrl = normalizedUrl;
if (normalizedUrl.startsWith('https://')) {
// Try HTTP version in case reverse proxy forwards as HTTP internally
const httpUrl = normalizedUrl.replace('https://', 'http://');
// Use the same URL we're actually requesting (which should match server's view)
authUrl = normalizedUrl; // Keep HTTPS for now, server should handle both
}
// Create NIP-98 auth event
const authEvent = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", url],
["u", authUrl],
["method", method.toUpperCase()],
],
content: "",
pubkey: userPubkey,
};
console.log("NIP-98 auth event before signing:", {
kind: authEvent.kind,
url: authUrl,
method: method.toUpperCase(),
pubkey: userPubkey,
});
const signedEvent = await userSigner.signEvent(authEvent);
console.log("NIP-98 auth event after signing:", {
id: signedEvent.id,
pubkey: signedEvent.pubkey,
sig: signedEvent.sig ? signedEvent.sig.substring(0, 16) + "..." : "missing",
});
// Encode as base64
const eventJson = JSON.stringify(signedEvent);
const base64Event = btoa(eventJson);
@ -2795,6 +2936,12 @@ @@ -2795,6 +2936,12 @@
return;
}
// If userSigner is null but auth method is extension, try to restore it
if (!userSigner && authMethod === "extension" && window.nostr) {
userSigner = window.nostr;
console.log("Restored extension signer");
}
if (!userSigner) {
alert(
"No signer available. Please log in with a valid authentication method.",
@ -2841,6 +2988,12 @@ @@ -2841,6 +2988,12 @@
return;
}
// If userSigner is null but auth method is extension, try to restore it
if (!userSigner && authMethod === "extension" && window.nostr) {
userSigner = window.nostr;
console.log("Restored extension signer for publishing");
}
if (!userSigner) {
composePublishError = "No signer available. Please log in with a valid authentication method.";
return;
@ -3040,6 +3193,7 @@ @@ -3040,6 +3193,7 @@
on:openLoginModal={openLoginModal}
/>
{:else if selectedTab === "events"}
{#if isLoggedIn && (userRole === "read" || userRole === "write" || userRole === "admin" || userRole === "owner")}
<EventsView
{isLoggedIn}
{userRole}
@ -3061,6 +3215,16 @@ @@ -3061,6 +3215,16 @@
on:filterApply={handleFilterApply}
on:filterClear={handleFilterClear}
/>
{:else if isLoggedIn && !userRole}
<div class="events-loading-permissions">
<div class="spinner"></div>
<p>Checking permissions...</p>
</div>
{:else}
<div class="permission-denied">
<p>Read, write, admin, or owner permission required to view events.</p>
</div>
{/if}
{:else if selectedTab === "blossom"}
{#key $relayUrl}
<BlossomView
@ -3076,8 +3240,6 @@ @@ -3076,8 +3240,6 @@
bind:composeEventJson
bind:localOnly={composeLocalOnly}
{userPubkey}
{userRole}
{policyEnabled}
publishError={composePublishError}
on:reformatJson={reformatJson}
on:signEvent={signEvent}
@ -3851,10 +4013,9 @@ @@ -3851,10 +4013,9 @@
background-color: var(--bg-color);
color: var(--text-color);
display: flex;
align-items: flex-start;
align-items: center;
justify-content: flex-start;
flex-direction: column;
display: flex;
}
.welcome-message {
@ -4797,4 +4958,28 @@ @@ -4797,4 +4958,28 @@
.change-relay-btn:hover {
background: #00acc1;
}
/* Centered permission/login prompts within main-content */
.events-loading-permissions,
.permission-denied,
.access-denied {
margin: auto;
text-align: center;
color: var(--text-color);
}
.main-content :global(.login-prompt),
.main-content :global(.permission-denied) {
margin: auto;
}
.events-loading-permissions .spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-color);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1em;
}
</style>

14
app/web/src/BlossomView.svelte

@ -38,6 +38,7 @@ @@ -38,6 +38,7 @@
$: canAccess = isLoggedIn && userPubkey;
$: isAdmin = currentEffectiveRole === "admin" || currentEffectiveRole === "owner";
$: displayBlobs = selectedAdminUser ? selectedUserBlobs : blobs;
// Track if we've loaded once to prevent repeated loads
let hasLoadedOnce = false;
@ -429,12 +430,7 @@ @@ -429,12 +430,7 @@
}
}
function getDisplayBlobs() {
if (selectedAdminUser) {
return selectedUserBlobs;
}
return blobs;
}
</script>
<svelte:window on:keydown={handleKeydown} />
@ -549,15 +545,15 @@ @@ -549,15 +545,15 @@
{/if}
{:else}
<!-- Normal blob list view (own files or selected user's files) -->
{#if isLoading && getDisplayBlobs().length === 0}
{#if isLoading && displayBlobs.length === 0}
<div class="loading">Loading blobs...</div>
{:else if getDisplayBlobs().length === 0}
{:else if displayBlobs.length === 0}
<div class="empty-state">
<p>{selectedAdminUser ? "No files found for this user." : "No files found in your Blossom storage."}</p>
</div>
{:else}
<div class="blob-list">
{#each getDisplayBlobs() as blob}
{#each displayBlobs as blob}
<div
class="blob-item"
on:click={() => openModal(blob)}

98
app/web/src/ComposeView.svelte

@ -1,8 +1,6 @@ @@ -1,8 +1,6 @@
<script>
export let composeEventJson = "";
export let userPubkey = "";
export let userRole = "";
export let policyEnabled = false;
export let publishError = "";
export let localOnly = true;
@ -13,6 +11,17 @@ @@ -13,6 +11,17 @@
let isTemplateSelectorOpen = false;
// Check if event is signed
$: isSigned = (() => {
if (!composeEventJson.trim()) return false;
try {
const event = JSON.parse(composeEventJson);
return !!(event.id && event.sig && event.pubkey);
} catch {
return false;
}
})();
function reformatJson() {
dispatch("reformatJson");
}
@ -53,15 +62,40 @@ @@ -53,15 +62,40 @@
<button class="compose-btn reformat-btn" on:click={reformatJson}
>Reformat</button
>
<button class="compose-btn sign-btn" on:click={signEvent}>Sign</button>
<button
class="compose-btn sign-btn"
class:signed={isSigned}
on:click={signEvent}
title={isSigned ? "Event is signed ✓" : "Sign the event before publishing"}
>
{isSigned ? "✓ Signed" : "Sign"}
</button>
<label class="local-only-label">
<input type="checkbox" bind:checked={localOnly} />
This relay only
</label>
<button class="compose-btn publish-btn" on:click={publishEvent}
>Publish</button
<button
class="compose-btn publish-btn"
class:disabled={!isSigned}
on:click={publishEvent}
disabled={!isSigned}
title={!isSigned ? "Please sign the event first" : "Publish to relay"}
>
Publish
</button>
</div>
{#if !isSigned && composeEventJson.trim()}
<div class="info-banner">
<div class="info-content">
<span class="info-icon"></span>
<span class="info-message">
<strong>Step 1:</strong> Click "Sign" to sign your event with your private key.
<strong>Step 2:</strong> Click "Publish" to send it to the relay.
</span>
</div>
</div>
{/if}
{#if publishError}
<div class="error-banner">
@ -77,7 +111,12 @@ @@ -77,7 +111,12 @@
<textarea
bind:value={composeEventJson}
class="compose-textarea"
placeholder="Enter your Nostr event JSON here, or click 'Generate Template' to start with a template..."
placeholder="Enter your Nostr event JSON here, or click 'Generate Template' to start with a template...
Workflow:
1. Generate Template (or write JSON manually)
2. Click 'Sign' to sign the event with your private key
3. Click 'Publish' to send it to the relay"
spellcheck="false"
></textarea>
</div>
@ -236,6 +275,53 @@ @@ -236,6 +275,53 @@
padding: 0;
}
.sign-btn.signed {
background: var(--success);
color: var(--text-color);
}
.publish-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
background: var(--secondary);
}
.publish-btn.disabled:hover {
background: var(--secondary);
filter: none;
}
.info-banner {
display: flex;
align-items: center;
padding: 0.75em 1em;
margin: 0 0.5em;
background: var(--info);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
color: var(--text-color);
font-size: 0.9rem;
}
.info-content {
display: flex;
align-items: center;
gap: 0.5em;
}
.info-icon {
font-size: 1.2em;
flex-shrink: 0;
}
.info-message {
line-height: 1.4;
}
.info-message strong {
font-weight: 600;
}
.compose-textarea {
flex: 1;
width: 100%;

164
app/web/src/EventsView.svelte

@ -8,10 +8,15 @@ @@ -8,10 +8,15 @@
export let showOnlyMyEvents = false;
export let showFilterBuilder = false;
import { createEventDispatcher } from "svelte";
import { createEventDispatcher, onMount } from "svelte";
import FilterBuilder from "./FilterBuilder.svelte";
import { fetchUserProfile } from "./nostr.js";
import { getKindName, truncatePubkey } from "./helpers.js";
const dispatch = createEventDispatcher();
// Profile cache to avoid fetching the same profile multiple times
let profileCache = new Map();
// Local state for JSON editor toggle
let showJsonEditor = false;
@ -55,59 +60,43 @@ @@ -55,59 +60,43 @@
dispatch("filterClear");
}
function truncatePubkey(pubkey) {
if (!pubkey) return "";
return pubkey.slice(0, 8) + "..." + pubkey.slice(-8);
}
function getKindName(kind) {
const kindNames = {
0: "Profile",
1: "Text Note",
2: "Recommend Relay",
3: "Contacts",
4: "Encrypted DM",
5: "Delete",
6: "Repost",
7: "Reaction",
8: "Badge Award",
16: "Generic Repost",
40: "Channel Creation",
41: "Channel Metadata",
42: "Channel Message",
43: "Channel Hide Message",
44: "Channel Mute User",
1984: "Reporting",
9734: "Zap Request",
9735: "Zap",
10000: "Mute List",
10001: "Pin List",
10002: "Relay List",
22242: "Client Auth",
24133: "Nostr Connect",
27235: "HTTP Auth",
30000: "Categorized People",
30001: "Categorized Bookmarks",
30008: "Profile Badges",
30009: "Badge Definition",
30017: "Create or update a stall",
30018: "Create or update a product",
30023: "Long-form Content",
30024: "Draft Long-form Content",
30078: "Application-specific Data",
30311: "Live Event",
30315: "User Statuses",
30402: "Classified Listing",
30403: "Draft Classified Listing",
31922: "Date-Based Calendar Event",
31923: "Time-Based Calendar Event",
31924: "Calendar",
31925: "Calendar Event RSVP",
31989: "Handler recommendation",
31990: "Handler information",
34550: "Community Definition",
};
return kindNames[kind] || `Kind ${kind}`;
// Fetch profile for a pubkey (with caching)
async function getProfile(pubkey) {
if (!pubkey) return null;
// Check cache first
if (profileCache.has(pubkey)) {
return profileCache.get(pubkey);
}
// Fetch profile
try {
const profile = await fetchUserProfile(pubkey);
profileCache.set(pubkey, profile);
return profile;
} catch (error) {
console.warn("Failed to fetch profile for", pubkey, error);
// Cache null to avoid repeated failed fetches
profileCache.set(pubkey, null);
return null;
}
}
// Load profiles for all unique pubkeys in filteredEvents
$: if (filteredEvents.length > 0) {
const uniquePubkeys = new Set(
filteredEvents
.map(e => e.pubkey)
.filter(p => p && !profileCache.has(p))
);
// Fetch profiles for new pubkeys (fire-and-forget, errors are handled in getProfile)
uniquePubkeys.forEach(pubkey => {
getProfile(pubkey).catch(err => {
// Error already logged in getProfile, just prevent unhandled rejection
console.debug("Profile fetch failed (already handled):", pubkey);
});
});
}
function formatTimestamp(timestamp) {
@ -121,10 +110,10 @@ @@ -121,10 +110,10 @@
</script>
<div class="events-view-container">
{#if isLoggedIn && (userRole === "read" || userRole === "write" || userRole === "admin" || userRole === "owner")}
<div class="events-view-content" on:scroll={handleScroll}>
{#if filteredEvents.length > 0}
{#each filteredEvents as event}
{@const profile = profileCache.get(event.pubkey)}
<div
class="events-view-item"
class:expanded={expandedEvents.has(event.id)}
@ -139,11 +128,27 @@ @@ -139,11 +128,27 @@
tabindex="0"
>
<div class="events-view-avatar">
{#if profile?.picture}
<img
src={profile.picture}
alt={profile.name || truncatePubkey(event.pubkey)}
class="avatar-image"
/>
{:else}
<div class="avatar-placeholder">👤</div>
{/if}
</div>
<div class="events-view-info">
<div class="events-view-author">
{truncatePubkey(event.pubkey)}
{#if profile}
<span class="author-name">{profile.name || truncatePubkey(event.pubkey)}</span>
{#if profile.nip05}
<span class="author-nip05" title={profile.nip05}>@{profile.nip05}</span>
{/if}
<span class="author-pubkey" title={event.pubkey}>{truncatePubkey(event.pubkey)}</span>
{:else}
<span class="author-pubkey">{truncatePubkey(event.pubkey)}</span>
{/if}
</div>
<div class="events-view-kind">
<span
@ -230,15 +235,6 @@ @@ -230,15 +235,6 @@
</div>
{/if}
</div>
{:else}
<div class="permission-denied">
<p>
❌ Read, write, admin, or owner permission required to view all
events.
</p>
</div>
{/if}
{#if isLoggedIn && (userRole === "read" || userRole === "write" || userRole === "admin" || userRole === "owner")}
<div class="events-view-footer">
<!-- Filter Builder Slide-up Panel -->
<div class="filter-panel" class:open={showFilterBuilder}>
@ -307,7 +303,6 @@ @@ -307,7 +303,6 @@
</div>
</div>
</div>
{/if}
</div>
<style>
@ -387,6 +382,14 @@ @@ -387,6 +382,14 @@
border: 0;
}
.avatar-image {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--border-color);
}
.events-view-info {
flex-shrink: 0;
min-width: 120px;
@ -396,6 +399,27 @@ @@ -396,6 +399,27 @@
font-weight: 600;
color: var(--text-color);
font-size: 0.9em;
display: flex;
flex-direction: column;
gap: 0.2em;
}
.author-name {
font-weight: 600;
color: var(--text-color);
}
.author-nip05 {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.8;
font-style: italic;
}
.author-pubkey {
font-size: 0.75em;
color: var(--text-color);
opacity: 0.6;
font-family: monospace;
}
@ -554,14 +578,6 @@ @@ -554,14 +578,6 @@
}
}
.permission-denied {
text-align: center;
padding: 2em;
background-color: var(--card-bg);
border-radius: 8px;
border: 1px solid var(--border-color);
color: var(--text-color);
}
.events-view-footer {
position: relative;

2
app/web/src/FilterBuilder.svelte

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
<script>
import { createEventDispatcher, onDestroy } from "svelte";
import { KIND_NAMES, isValidPubkey, isValidEventId, isValidTagName, formatDateTimeLocal, parseDateTimeLocal } from "./helpers.tsx";
import { KIND_NAMES, isValidPubkey, isValidEventId, isValidTagName, formatDateTimeLocal, parseDateTimeLocal } from "./helpers.js";
const dispatch = createEventDispatcher();

2
app/web/src/FilterDisplay.svelte

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
<script>
import { createEventDispatcher } from "svelte";
import { prettyPrintFilter } from "./helpers.tsx";
import { prettyPrintFilter } from "./helpers.js";
const dispatch = createEventDispatcher();

5
app/web/src/ImportView.svelte

@ -9,7 +9,8 @@ @@ -9,7 +9,8 @@
const dispatch = createEventDispatcher();
// When ACL is "none", allow access without login
$: canImport = aclMode === "none" || (isLoggedIn && (currentEffectiveRole === "admin" || currentEffectiveRole === "owner"));
// Write, admin, and owner roles can import events
$: canImport = aclMode === "none" || (isLoggedIn && (currentEffectiveRole === "write" || currentEffectiveRole === "admin" || currentEffectiveRole === "owner"));
function handleFileSelect(event) {
dispatch("fileSelect", event);
@ -52,7 +53,7 @@ @@ -52,7 +53,7 @@
<div class="permission-denied">
<h3 class="recovery-header">Import Events</h3>
<p class="recovery-description">
Admin or owner permission required for import functionality.
Write, admin, or owner permission required for import functionality.
</p>
</div>
{:else}

3
app/web/src/constants.js

@ -92,7 +92,8 @@ export const replaceableKinds = [ @@ -92,7 +92,8 @@ export const replaceableKinds = [
{ value: 30403, label: "Draft Classified Listing (30403)" },
{ value: 30617, label: "Repository announcements (30617)" },
{ value: 30618, label: "Repository state announcements (30618)" },
{ value: 30818, label: "Wiki article (30818)" },
{ value: 30817, label: "Wiki Article Markdown (30817)" },
{ value: 30818, label: "Wiki Article Asciidoc (30818)" },
{ value: 30819, label: "Redirects (30819)" },
{ value: 31234, label: "Draft Event (31234)" },
{ value: 31388, label: "Link Set (31388)" },

7
app/web/src/helpers.tsx → app/web/src/helpers.js

@ -56,6 +56,8 @@ export const KIND_NAMES = { @@ -56,6 +56,8 @@ export const KIND_NAMES = {
30023: "Long-form Content",
30024: "Draft Long-form Content",
30030: "Emoji Sets",
30040: "Curated Publication Index",
30041: "Curated Publication Content",
30063: "Release Artifact Sets",
30078: "Application-specific Data",
30311: "Live Event",
@ -65,7 +67,8 @@ export const KIND_NAMES = { @@ -65,7 +67,8 @@ export const KIND_NAMES = {
30403: "Draft Classified Listing",
30617: "Repository Announcement",
30618: "Repository State Announcement",
30818: "Wiki Article",
30817: "Wiki Article Markdown",
30818: "Wiki Article Asciidoc",
30819: "Redirects",
31922: "Date-Based Calendar Event",
31923: "Time-Based Calendar Event",
@ -149,7 +152,7 @@ export function buildFilter({ @@ -149,7 +152,7 @@ export function buildFilter({
since = null,
until = null,
limit = null,
}) {
} = {}) {
const filter = {};
if (searchText && searchText.trim()) {

222
build-image.sh

@ -0,0 +1,222 @@ @@ -0,0 +1,222 @@
#!/bin/bash
# Build script for next-orly Docker image (auto-detects version)
set -e
# Auto-detect version from git tag, version file, or use default
detect_version() {
# Try git tag first (most reliable)
VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "")
# Fallback to version file
if [ -z "$VERSION" ] && [ -f "pkg/version/version" ]; then
VERSION=$(cat pkg/version/version | tr -d ' \n' | sed 's/^v//')
fi
# Fallback to latest git tag
if [ -z "$VERSION" ]; then
VERSION=$(git tag | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 | sed 's/^v//' || echo "")
fi
# Last resort: use default or prompt
if [ -z "$VERSION" ]; then
echo "Warning: Could not auto-detect version. Using default v0.58.10"
VERSION="0.58.10"
fi
# Ensure it starts with 'v' for consistency
if [[ ! "$VERSION" =~ ^v ]]; then
VERSION="v${VERSION}"
fi
echo "$VERSION"
}
VERSION=$(detect_version)
echo "Detected version: $VERSION"
echo ""
# Check for Docker
DOCKER_CMD=""
if command -v docker >/dev/null 2>&1; then
DOCKER_CMD="docker"
elif command -v podman >/dev/null 2>&1; then
DOCKER_CMD="podman"
elif command -v docker.io >/dev/null 2>&1; then
DOCKER_CMD="docker.io"
else
echo "Error: Docker is not installed or not in PATH."
echo ""
echo "To install Docker on Ubuntu/Debian:"
echo " sudo apt install docker.io"
echo " sudo systemctl enable --now docker"
echo " sudo usermod -aG docker $USER"
echo " # Then log out and back in"
echo ""
echo "Or install Podman (Docker alternative):"
echo " sudo apt install podman"
echo ""
exit 1
fi
# Check if we're on the correct tag (informational only)
CURRENT_TAG=$(git describe --tags --exact-match HEAD 2>/dev/null || echo "")
if [ -n "$CURRENT_TAG" ] && [ "$CURRENT_TAG" != "$VERSION" ]; then
echo "Info: Current git tag is $CURRENT_TAG, but building version $VERSION"
echo "Continuing with version $VERSION..."
echo ""
fi
# Always rebuild web UI to ensure latest changes are included
# Remove old dist folder if it exists to force fresh build
if [ -d "app/web/dist" ]; then
echo "Removing old web UI build to force fresh rebuild..."
rm -rf app/web/dist
fi
# Create minimal placeholder for Go embed directive (needed for local development)
# Docker will replace this with the actual build
mkdir -p app/web/dist
echo "<!-- Placeholder - will be replaced by Docker build -->" > app/web/dist/index.html
echo "Using Dockerfile.with-web (will build web UI in Docker with latest changes)..."
DOCKERFILE="Dockerfile.with-web"
# Ensure dev proxy is disabled in Dockerfile to prevent 502 errors
echo "Ensuring dev proxy is disabled in Dockerfile..."
# Remove any existing ORLY_WEB lines to avoid duplicates
sed -i '/ORLY_WEB_DISABLE/d' "$DOCKERFILE"
sed -i '/ORLY_WEB_DEV_PROXY_URL/d' "$DOCKERFILE"
# Remove trailing backslash from ORLY_LOG_LEVEL if it exists (we'll add it back)
sed -i 's/^\( ORLY_LOG_LEVEL=info\) \\$/\1/' "$DOCKERFILE"
# Now add the new lines properly formatted
awk '/^ ORLY_LOG_LEVEL=info$/ {
print $0 " \\"
print " ORLY_WEB_DISABLE=false \\"
print " ORLY_WEB_DEV_PROXY_URL=\"\""
next
} 1' "$DOCKERFILE" > "$DOCKERFILE.tmp" && mv "$DOCKERFILE.tmp" "$DOCKERFILE"
echo "Added ORLY_WEB_DISABLE=false and ORLY_WEB_DEV_PROXY_URL=\"\" to Dockerfile"
# Check if local nostr clone exists and prepare it for Docker build
NOSTR_PATH="${NOSTR_PATH:-/home/firefly/Dokumente/repos/nostr}"
mkdir -p .docker-build-context
# Backup go.mod and go.sum before making changes
cp go.mod go.mod.backup
cp go.sum go.sum.backup 2>/dev/null || true
if [ -d "$NOSTR_PATH" ] && [ -f "$NOSTR_PATH/go.mod" ]; then
echo "Found local nostr clone at: $NOSTR_PATH"
# Remove any existing replace directive
sed -i '/^replace git.mleku.dev\/mleku\/nostr/d' go.mod
# First, use the actual local path for vendoring (absolute path)
echo "replace git.mleku.dev/mleku/nostr => $NOSTR_PATH" >> go.mod
echo "Using local nostr module for vendoring"
# Vendor all dependencies locally (using local network, not Docker's network)
echo "Vendoring all dependencies locally..."
go mod vendor || {
echo "Error: Failed to vendor dependencies. Make sure you have network access."
exit 1
}
# Copy nostr into build context for Docker
rm -rf .docker-build-context/nostr
cp -r "$NOSTR_PATH" .docker-build-context/nostr
# Now update go.mod to use Docker build context path
echo "Preparing for Docker build..."
sed -i '/^replace git.mleku.dev\/mleku\/nostr/d' go.mod
echo "replace git.mleku.dev/mleku/nostr => ./docker-build-context/nostr" >> go.mod
# Update vendor/modules.txt to match the new replace path
# The nostr code is already in vendor/, we just need to update the metadata
if [ -f vendor/modules.txt ]; then
# Replace the absolute path with the relative Docker build context path
sed -i "s|=> $NOSTR_PATH|=> ./docker-build-context/nostr|g" vendor/modules.txt
fi
echo "Using local nostr module from build context"
else
echo "Local nostr clone not found at $NOSTR_PATH"
echo "Will try to fetch from remote (may have DNS issues)..."
# Create empty directory so COPY doesn't fail
mkdir -p .docker-build-context/nostr
# Remove any existing replace directive
sed -i '/^replace git.mleku.dev\/mleku\/nostr/d' go.mod
# Vendor all dependencies locally
echo "Vendoring all dependencies locally..."
go mod vendor || {
echo "Error: Failed to vendor dependencies. Make sure you have network access."
exit 1
}
fi
# Function to restore go.mod and go.sum on exit
restore_gomod() {
if [ -f go.mod.backup ]; then
mv go.mod.backup go.mod
echo "Restored go.mod"
fi
if [ -f go.sum.backup ]; then
mv go.sum.backup go.sum
echo "Restored go.sum"
fi
# Clean up build context and vendor
rm -rf .docker-build-context
rm -rf vendor
}
trap restore_gomod EXIT
# Build the Docker image with both version and latest tags
# Local nostr clone (if found) is already copied into build context
IMAGE_NAME="silberengel/next-orly"
echo "Building Docker image ${IMAGE_NAME}:${VERSION} using $DOCKER_CMD..."
if [ "$DOCKER_CMD" = "docker" ]; then
# Try using host network mode first (uses host DNS) - best for DNS issues
if docker build --help 2>/dev/null | grep -q "\-\-network"; then
echo "Using host network mode for better DNS resolution..."
$DOCKER_CMD build --network=host -t "${IMAGE_NAME}:${VERSION}" -t "${IMAGE_NAME}:latest" -f "$DOCKERFILE" .
elif docker build --help 2>/dev/null | grep -q "\-\-dns"; then
# Fallback to DNS configuration
echo "Using DNS configuration (8.8.8.8, 8.8.4.4)..."
$DOCKER_CMD build --dns 8.8.8.8 --dns 8.8.4.4 -t "${IMAGE_NAME}:${VERSION}" -t "${IMAGE_NAME}:latest" -f "$DOCKERFILE" .
else
# Last resort - no DNS config
echo "Warning: No DNS configuration available, build may fail..."
$DOCKER_CMD build -t "${IMAGE_NAME}:${VERSION}" -t "${IMAGE_NAME}:latest" -f "$DOCKERFILE" .
fi
else
# buildx or other - try DNS if available
if docker buildx build --help 2>/dev/null | grep -q "\-\-dns"; then
$DOCKER_CMD build --dns 8.8.8.8 --dns 8.8.4.4 -t "${IMAGE_NAME}:${VERSION}" -t "${IMAGE_NAME}:latest" -f "$DOCKERFILE" .
else
$DOCKER_CMD build -t "${IMAGE_NAME}:${VERSION}" -t "${IMAGE_NAME}:latest" -f "$DOCKERFILE" .
fi
fi
echo ""
echo "Build complete! Image tags:"
echo " - ${IMAGE_NAME}:${VERSION}"
echo " - ${IMAGE_NAME}:latest"
echo ""
echo "To push to Docker Hub:"
echo " $DOCKER_CMD push ${IMAGE_NAME}:${VERSION}"
echo " $DOCKER_CMD push ${IMAGE_NAME}:latest"
echo ""
echo "Or run this script with --push to build and push automatically:"
echo " $0 --push"
# Push if requested
if [ "$1" == "--push" ]; then
echo ""
echo "Pushing images to Docker Hub..."
$DOCKER_CMD push "${IMAGE_NAME}:${VERSION}"
$DOCKER_CMD push "${IMAGE_NAME}:latest"
echo ""
echo "✅ Images pushed successfully!"
fi

12
cmd/aggregator/main.go

@ -15,10 +15,6 @@ import ( @@ -15,10 +15,6 @@ import (
"sync"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
"github.com/minio/sha256-simd"
"git.mleku.dev/mleku/nostr/encoders/bech32encoding"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/filter"
@ -27,7 +23,11 @@ import ( @@ -27,7 +23,11 @@ import (
"git.mleku.dev/mleku/nostr/encoders/tag"
"git.mleku.dev/mleku/nostr/encoders/timestamp"
"git.mleku.dev/mleku/nostr/interfaces/signer"
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
"git.mleku.dev/mleku/nostr/ws"
"github.com/minio/sha256-simd"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
)
const (
@ -877,6 +877,7 @@ func (a *Aggregator) fetchTimeWindow(client *ws.Client, relayURL string, window @@ -877,6 +877,7 @@ func (a *Aggregator) fetchTimeWindow(client *ws.Client, relayURL string, window
// Check if we've already seen this event
if a.isEventSeen(eventID) {
ev.Free()
continue
}
@ -895,6 +896,9 @@ func (a *Aggregator) fetchTimeWindow(client *ws.Client, relayURL string, window @@ -895,6 +896,9 @@ func (a *Aggregator) fetchTimeWindow(client *ws.Client, relayURL string, window
if err = a.outputEvent(ev); chk.E(err) {
log.E.F("failed to output event: %v", err)
}
// Free event after processing
ev.Free()
case <-sub.EndOfStoredEvents:
log.I.F("end of stored events for batch on relay %s", relayURL)
batchComplete = true

5
cmd/orly-acl-follows/main.go

@ -129,8 +129,9 @@ func main() { @@ -129,8 +129,9 @@ func main() {
// Create and configure server
srv := server.New(db, serverCfg, ownsDB)
if err := srv.ConfigureACL(ctx); chk.E(err) {
log.E.F("failed to configure ACL: %v", err)
os.Exit(1)
// Don't exit on configure error - the syncer will populate follows from
// external relays. This handles empty databases gracefully.
log.W.F("ACL configure returned error (will start with 0 follows): %v", err)
}
// Start server

3
cmd/orly/db/db.go

@ -74,6 +74,9 @@ func Run(args []string) { @@ -74,6 +74,9 @@ func Run(args []string) {
} else if arg == "repair" {
runRepair(args[i+1:])
return
} else if arg == "import" {
runImport(args[i+1:])
return
}
}

131
cmd/orly/db/import.go

@ -0,0 +1,131 @@ @@ -0,0 +1,131 @@
//go:build !(js && wasm)
package db
import (
"context"
"fmt"
"os"
"strings"
"lol.mleku.dev"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/database"
)
func runImport(args []string) {
var inputFile string
var showHelp bool
for i, arg := range args {
if arg == "--file" || arg == "-f" {
if i+1 < len(args) {
inputFile = args[i+1]
}
} else if arg == "--help" || arg == "-h" {
showHelp = true
} else if arg != "" && !strings.HasPrefix(arg, "-") && inputFile == "" {
inputFile = arg
}
}
if showHelp {
printImportHelp()
return
}
if inputFile == "" {
fmt.Fprintln(os.Stderr, "error: input file required")
fmt.Fprintln(os.Stderr, "usage: orly db import <file.jsonl>")
fmt.Fprintln(os.Stderr, " or: orly db import --file <file.jsonl>")
os.Exit(1)
}
cfg := loadConfig()
lol.SetLogLevel(cfg.LogLevel)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Create database configuration
dbCfg := &database.DatabaseConfig{
DataDir: cfg.DataDir,
LogLevel: cfg.LogLevel,
BlockCacheMB: cfg.BlockCacheMB,
IndexCacheMB: cfg.IndexCacheMB,
QueryCacheSizeMB: cfg.QueryCacheSizeMB,
QueryCacheMaxAge: cfg.QueryCacheMaxAge,
QueryCacheDisabled: cfg.QueryCacheDisabled,
SerialCachePubkeys: cfg.SerialCachePubkeys,
SerialCacheEventIds: cfg.SerialCacheEventIds,
ZSTDLevel: cfg.ZSTDLevel,
}
// Initialize database directly
log.I.F("initializing database at %s for import", cfg.DataDir)
db, err := database.NewWithConfig(ctx, cancel, dbCfg)
if chk.E(err) {
log.E.F("failed to initialize database: %v", err)
os.Exit(1)
}
defer db.Close()
// Wait for database to be ready
log.I.F("waiting for database to be ready...")
<-db.Ready()
log.I.F("database ready")
// Open input file
log.I.F("opening input file: %s", inputFile)
file, err := os.Open(inputFile)
if chk.E(err) {
log.E.F("failed to open input file: %v", err)
os.Exit(1)
}
defer file.Close()
// Get file size for progress
fileInfo, err := file.Stat()
if chk.E(err) {
log.E.F("failed to stat file: %v", err)
os.Exit(1)
}
fileSizeMB := float64(fileInfo.Size()) / 1024 / 1024
log.I.F("importing %.2f MB from %s", fileSizeMB, inputFile)
// Import events
log.I.F("starting import...")
db.Import(file)
log.I.F("import completed successfully")
fmt.Fprintf(os.Stdout, "✓ Imported events from %s (%.2f MB)\n", inputFile, fileSizeMB)
}
func printImportHelp() {
fmt.Println(`
Import events from a JSONL file directly into the database.
Usage:
orly db import <file.jsonl>
orly db import --file <file.jsonl>
This command imports events from a JSONL (JSON Lines) file directly into
the Badger database, bypassing the HTTP API. This is much faster for large
imports when you have direct access to the database.
The input file should contain one JSON event per line (JSONL format).
Environment variables:
ORLY_DATA_DIR - Database data directory (required)
ORLY_DB_LOG_LEVEL - Log level (default: info)
ORLY_DB_BLOCK_CACHE_MB - Block cache size in MB (default: 1024)
ORLY_DB_INDEX_CACHE_MB - Index cache size in MB (default: 512)
ORLY_DB_ZSTD_LEVEL - ZSTD compression level (default: 3)
Examples:
orly db import events.jsonl
orly db import --file /path/to/large-export.jsonl
`)
}

1
cmd/orly/main.go

@ -25,6 +25,7 @@ @@ -25,6 +25,7 @@
// orly db health # Run database health check
// orly db repair # Repair database issues
// orly db repair --dry-run # Preview repairs without applying
// orly db import <file.jsonl> # Import events directly from JSONL file
// orly acl --driver=follows # Run follows ACL server
// orly sync --driver=negentropy # Run negentropy sync service
// orly launcher # Run process supervisor

94
docker-compose-orly.yml

@ -0,0 +1,94 @@ @@ -0,0 +1,94 @@
version: '3.8'
services:
orly-relay:
image: silberengel/next-orly:v0.48.10
container_name: orly-relay
restart: always
ports:
- "127.0.0.1:3334:3334"
- "127.0.0.1:7777:7777"
volumes:
# Use bind mount to host filesystem for large datasets (20GB+)
# Change /var/lib/orly to your desired data directory on the host
- /var/lib/orly:/data
environment:
# Relay Configuration
- ORLY_DATA_DIR=/data
- ORLY_LISTEN=0.0.0.0
- ORLY_PORT=7777
- ORLY_LOG_LEVEL=Info
# Admin and Owner Configuration
- ORLY_ADMINS=npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl,npub1v30tsz9vw6ylpz63g0a702nj3xa26t3m7p5us8f2y2sd8v6cnsvq465zjx,npub12umrfdjgvdxt45g0y3ghwcyfagssjrv5qlm3t6pu2aa5vydwdmwq8q0z04,npub18cddpua960qjy3wmw7y9gmzr4h3ajlrwq3k9jnmqzlxke4qkg6gqeyaztw
- ORLY_OWNERS=npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl,npub1v30tsz9vw6ylpz63g0a702nj3xa26t3m7p5us8f2y2sd8v6cnsvq465zjx,npub12umrfdjgvdxt45g0y3ghwcyfagssjrv5qlm3t6pu2aa5vydwdmwq8q0z04,npub18cddpua960qjy3wmw7y9gmzr4h3ajlrwq3k9jnmqzlxke4qkg6gqeyaztw
# ACL Configuration (follows mode for access control based on admin follow lists)
- ORLY_ACL_MODE=follows
# Spider Configuration (syncs events for followed pubkeys)
- ORLY_SPIDER_MODE=follows
# Relay URL (for dashboard and metadata)
- ORLY_RELAY_URL=wss://orly-relay.imwald.eu
# Sprocket Configuration (event processing plugin system)
- ORLY_SPROCKET_ENABLED=false
# Database Logging
- ORLY_DB_LOG_LEVEL=error
# Database Cache Tuning for Large Datasets (20GB+)
# Increased caches for better performance with large working sets
- ORLY_DB_BLOCK_CACHE_MB=2048 # 2GB block cache (default: 1024MB)
- ORLY_DB_INDEX_CACHE_MB=1024 # 1GB index cache (default: 512MB)
- ORLY_SERIAL_CACHE_PUBKEYS=500000 # 500k pubkeys cache (default: 100k)
- ORLY_SERIAL_CACHE_EVENT_IDS=2000000 # 2M event IDs cache (default: 500k)
- ORLY_DB_ZSTD_LEVEL=9 # ZSTD compression level 9 (best compression, reduces disk IO)
# Storage GC Configuration for Large Archives
# Enable GC with aggressive eviction to manage storage growth
- ORLY_GC_ENABLED=true # Enable storage garbage collection
- ORLY_GC_BATCH_SIZE=5000 # GC batch size for efficient processing
- ORLY_MAX_STORAGE_BYTES=107374182400 # 100GB storage cap (adjust as needed)
# Bootstrap relay for initial sync
- ORLY_BOOTSTRAP_RELAYS=wss://profiles.nostr1.com,wss://purplepag.es,wss://relay.damus.io
# Disable subscription/payment requirements
- ORLY_SUBSCRIPTION_ENABLED=false
- ORLY_MONTHLY_PRICE_SAT=0
# Performance Settings
- ORLY_MAX_CONNECTIONS=1000
- ORLY_MAX_EVENT_SIZE=65536
- ORLY_MAX_SUBSCRIPTIONS=20
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:7777"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
# Resource limits - Increased for large imports (20GB+)
# Memory increased to handle Badger caches and import operations
deploy:
resources:
limits:
memory: 4096M # 4GB - increased from 1GB for large dataset imports
cpus: "2.0" # Increased CPU for faster compaction during imports
reservations:
memory: 2048M # 2GB reservation
cpus: "1.0"
# Logging configuration
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# Note: Using bind mount instead of named volume for large datasets
# Data is stored directly on host filesystem at /var/lib/orly
# To use a different path, change the volume mount above

3
go.mod

@ -268,6 +268,5 @@ require ( @@ -268,6 +268,5 @@ require (
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
)
replace git.mleku.dev/mleku/nostr => /home/mleku/src/git.mleku.dev/mleku/nostr
retract v1.0.3
replace git.mleku.dev/mleku/nostr => ./docker-build-context/nostr

2
go.sum

@ -38,6 +38,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl @@ -38,6 +38,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
git.mleku.dev/mleku/nostr v1.0.17 h1:FtJtKJWILra0/yO3hQ7+W3nF3OXpAeGiBcoSCWIY1Ds=
git.mleku.dev/mleku/nostr v1.0.17/go.mod h1:WzCvfe5iJjgoWtxIzSaNxAkpaz42ZL5cyCVQeR73CUs=
github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 h1:Dy3M9aegiI7d7PF1LUdjbVigJReo+QOceYsMyFh9qoE=
github.com/AdamSLevy/jsonrpc2/v14 v14.1.0/go.mod h1:ZakZtbCXxCz82NJvq7MoREtiQesnDfrtF6RFUGzQfLo=
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=

6
main.go

@ -11,6 +11,9 @@ import ( @@ -11,6 +11,9 @@ import (
"sync"
"time"
"git.mleku.dev/mleku/nostr/crypto/keys"
"git.mleku.dev/mleku/nostr/encoders/bech32encoding"
"git.mleku.dev/mleku/nostr/encoders/hex"
"github.com/adrg/xdg"
"golang.org/x/term"
"lol.mleku.dev/chk"
@ -18,10 +21,7 @@ import ( @@ -18,10 +21,7 @@ import (
"next.orly.dev/app"
"next.orly.dev/app/branding"
"next.orly.dev/app/config"
"git.mleku.dev/mleku/nostr/crypto/keys"
"git.mleku.dev/mleku/nostr/encoders/bech32encoding"
"next.orly.dev/pkg/database"
"git.mleku.dev/mleku/nostr/encoders/hex"
"next.orly.dev/pkg/relay"
"next.orly.dev/pkg/version"
)

6
package-lock.json generated

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
{
"name": "next.orly.dev",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

71
pkg/acl/follows.go

@ -15,7 +15,6 @@ import ( @@ -15,7 +15,6 @@ import (
"lol.mleku.dev/log"
"next.orly.dev/app/config"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/database/indexes/types"
"git.mleku.dev/mleku/nostr/encoders/bech32encoding"
"git.mleku.dev/mleku/nostr/encoders/envelopes"
"git.mleku.dev/mleku/nostr/encoders/envelopes/eoseenvelope"
@ -104,7 +103,8 @@ func (f *Follows) Configure(cfg ...any) (err error) { @@ -104,7 +103,8 @@ func (f *Follows) Configure(cfg ...any) (err error) {
newOwnersSet[hex.EncodeToString(own)] = struct{}{}
}
// find admin follow lists (database I/O happens here, but no lock held)
// parse admin pubkeys
var adminBinaries [][]byte
for _, admin := range f.cfg.Admins {
var adm []byte
if a, e := bech32encoding.NpubOrHexToPublicKeyBinary(admin); chk.E(e) {
@ -114,29 +114,27 @@ func (f *Follows) Configure(cfg ...any) (err error) { @@ -114,29 +114,27 @@ func (f *Follows) Configure(cfg ...any) (err error) {
}
newAdmins = append(newAdmins, adm)
newAdminsSet[hex.EncodeToString(adm)] = struct{}{}
adminBinaries = append(adminBinaries, adm)
}
// Batch query all admin follow lists in a single DB call
// Kind 3 is replaceable, so QueryEvents returns only the latest per author
if len(adminBinaries) > 0 {
ctx := f.Ctx
if ctx == nil {
ctx = context.Background()
}
fl := &filter.F{
Authors: tag.NewFromAny(adm),
Authors: tag.NewFromBytesSlice(adminBinaries...),
Kinds: kind.NewS(kind.New(kind.FollowList.K)),
Limit: values.ToUintPointer(uint(len(adminBinaries))),
}
var idxs []database.Range
if idxs, err = database.GetIndexesFromFilter(fl); chk.E(err) {
return
}
var sers types.Uint40s
for _, idx := range idxs {
var s types.Uint40s
if s, err = f.db.GetSerialsByRange(idx); chk.E(err) {
continue
}
sers = append(sers, s...)
}
if len(sers) > 0 {
for _, s := range sers {
var ev *event.E
if ev, err = f.db.FetchEventBySerial(s); chk.E(err) {
continue
var evs event.S
if evs, err = f.db.QueryEvents(ctx, fl); err != nil {
log.W.F("follows ACL: error querying admin follow lists: %v", err)
err = nil // Don't fail Configure on query error
}
for _, ev := range evs {
for _, v := range ev.Tags.GetAll([]byte("p")) {
// ValueHex() automatically handles both binary and hex storage formats
if b, e := hex.DecodeString(string(v.ValueHex())); chk.E(e) {
@ -151,7 +149,6 @@ func (f *Follows) Configure(cfg ...any) (err error) { @@ -151,7 +149,6 @@ func (f *Follows) Configure(cfg ...any) (err error) {
}
}
}
}
// Now acquire the lock ONLY for the quick swap operation
f.followsMx.Lock()
@ -294,29 +291,23 @@ func (f *Follows) adminRelays() (urls []string) { @@ -294,29 +291,23 @@ func (f *Follows) adminRelays() (urls []string) {
}
}
// First, try to get relay URLs from admin kind 10002 events
for _, adm := range admins {
// Batch query all admin relay list events in a single DB call
// Kind 10002 is replaceable, so QueryEvents returns only the latest per author
if len(admins) > 0 {
ctx := f.Ctx
if ctx == nil {
ctx = context.Background()
}
fl := &filter.F{
Authors: tag.NewFromAny(adm),
Authors: tag.NewFromBytesSlice(admins...),
Kinds: kind.NewS(kind.New(kind.RelayListMetadata.K)),
Limit: values.ToUintPointer(uint(len(admins))),
}
idxs, err := database.GetIndexesFromFilter(fl)
if chk.E(err) {
continue
}
var sers types.Uint40s
for _, idx := range idxs {
s, err := f.db.GetSerialsByRange(idx)
if chk.E(err) {
continue
}
sers = append(sers, s...)
}
for _, s := range sers {
ev, err := f.db.FetchEventBySerial(s)
if chk.E(err) || ev == nil {
continue
evs, qerr := f.db.QueryEvents(ctx, fl)
if qerr != nil {
log.W.F("follows ACL: error querying admin relay lists: %v", qerr)
}
for _, ev := range evs {
for _, v := range ev.Tags.GetAll([]byte("r")) {
u := string(v.Value())
n := string(normalize.URL(u))

52
pkg/blossom/auth.go

@ -273,6 +273,58 @@ func ValidateAuthEventForGet( @@ -273,6 +273,58 @@ func ValidateAuthEventForGet(
return
}
// ValidateAuthEventForDelete validates authorization for DELETE requests (BUD-02)
// If requireServerTag is true, the auth event must include a 'server' tag matching the serverURL
// This prevents cross-server replay attacks where a malicious server replays delete auth events
func ValidateAuthEventForDelete(
r *http.Request, serverURL string, sha256Hash []byte, requireServerTag bool,
) (authEv *AuthEvent, err error) {
// First do the standard validation
if authEv, err = ValidateAuthEvent(r, "delete", sha256Hash); chk.E(err) {
return
}
// If server tag is not required, we're done
if !requireServerTag {
return
}
// Extract event again to check server tags
var ev *event.E
if ev, err = ExtractAuthEvent(r); chk.E(err) {
return
}
// Check for server tag
serverTags := ev.Tags.GetAll([]byte("server"))
if len(serverTags) == 0 {
err = errorf.E(
"delete authorization requires 'server' tag for replay protection",
)
return
}
// Verify at least one server tag matches
found := false
for _, serverTag := range serverTags {
serverTagValue := string(serverTag.Value())
if strings.HasPrefix(serverURL, serverTagValue) {
found = true
break
}
}
if !found {
err = errorf.E(
"no 'server' tag matches this server URL '%s'",
serverURL,
)
return
}
return
}
// GetPubkeyFromRequest extracts pubkey from Authorization header if present
func GetPubkeyFromRequest(r *http.Request) (pubkey []byte, err error) {
authHeader := r.Header.Get(AuthorizationHeader)

37
pkg/blossom/handlers.go

@ -183,9 +183,11 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { @@ -183,9 +183,11 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
// Optional authorization validation (do this BEFORE ACL check)
// For upload, we don't pass sha256Hash because upload auth events don't have 'x' tags
// (the hash isn't known at auth event creation time)
if r.Header.Get(AuthorizationHeader) != "" {
authHeader := r.Header.Get(AuthorizationHeader)
if authHeader != "" {
authEv, err := ValidateAuthEvent(r, "upload", nil)
if err != nil {
log.W.F("blossom upload: auth validation failed: %v", err)
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
return
}
@ -221,11 +223,16 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { @@ -221,11 +223,16 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
// Note: pubkey may be nil for anonymous uploads if ACL allows it
// The storage layer will handle anonymous uploads appropriately
// Detect MIME type
// Detect MIME type from header, extension, or content sniffing
mimeType := DetectMimeType(
r.Header.Get("Content-Type"),
GetFileExtensionFromPath(r.URL.Path),
)
if mimeType == "application/octet-stream" && len(body) > 0 {
if sniffed := http.DetectContentType(body); sniffed != "application/octet-stream" {
mimeType = sniffed
}
}
// Extract extension from path or infer from MIME type
ext := GetFileExtensionFromPath(r.URL.Path)
@ -467,9 +474,10 @@ func (s *Server) handleListBlobs(w http.ResponseWriter, r *http.Request) { @@ -467,9 +474,10 @@ func (s *Server) handleListBlobs(w http.ResponseWriter, r *http.Request) {
return
}
// Set URLs for descriptors
// Set URLs for descriptors (include file extension for proper MIME handling)
for _, desc := range descriptors {
desc.URL = BuildBlobURL(s.getBaseURL(r), desc.SHA256, "")
ext := GetExtensionFromMimeType(desc.Type)
desc.URL = BuildBlobURL(s.getBaseURL(r), desc.SHA256, ext)
}
// Return JSON array
@ -534,7 +542,10 @@ func (s *Server) handleDeleteBlob(w http.ResponseWriter, r *http.Request) { @@ -534,7 +542,10 @@ func (s *Server) handleDeleteBlob(w http.ResponseWriter, r *http.Request) {
}
// Authorization required for delete
authEv, err := ValidateAuthEvent(r, "delete", sha256Hash)
// Use ValidateAuthEventForDelete which optionally requires server tag for replay protection
authEv, err := ValidateAuthEventForDelete(
r, s.getBaseURL(r), sha256Hash, s.deleteRequireServerTag,
)
if err != nil {
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
return
@ -661,11 +672,16 @@ func (s *Server) handleMirror(w http.ResponseWriter, r *http.Request) { @@ -661,11 +672,16 @@ func (s *Server) handleMirror(w http.ResponseWriter, r *http.Request) {
// Note: pubkey may be nil for anonymous uploads if ACL allows it
// Detect MIME type from remote response
// Detect MIME type from remote response, extension, or content sniffing
mimeType := DetectMimeType(
resp.Header.Get("Content-Type"),
GetFileExtensionFromPath(mirrorURL.Path),
)
if mimeType == "application/octet-stream" && len(body) > 0 {
if sniffed := http.DetectContentType(body); sniffed != "application/octet-stream" {
mimeType = sniffed
}
}
// Extract extension from path or infer from MIME type
ext := GetFileExtensionFromPath(mirrorURL.Path)
@ -746,11 +762,18 @@ func (s *Server) handleMediaUpload(w http.ResponseWriter, r *http.Request) { @@ -746,11 +762,18 @@ func (s *Server) handleMediaUpload(w http.ResponseWriter, r *http.Request) {
// Note: pubkey may be nil for anonymous uploads if ACL allows it
// Optimize media (placeholder - actual optimization would be implemented here)
// Detect MIME type from header, extension, or content sniffing
originalMimeType := DetectMimeType(
r.Header.Get("Content-Type"),
GetFileExtensionFromPath(r.URL.Path),
)
if originalMimeType == "application/octet-stream" && len(body) > 0 {
if sniffed := http.DetectContentType(body); sniffed != "application/octet-stream" {
originalMimeType = sniffed
}
}
// Optimize media (placeholder - actual optimization would be implemented here)
optimizedData, mimeType := OptimizeMedia(body, originalMimeType)
// Extract extension from path or infer from MIME type

6
pkg/blossom/server.go

@ -19,6 +19,7 @@ type Server struct { @@ -19,6 +19,7 @@ type Server struct {
maxBlobSize int64
allowedMimeTypes map[string]bool
requireAuth bool
deleteRequireServerTag bool
// Rate limiting for uploads
bandwidthLimiter *BandwidthLimiter
@ -35,6 +36,10 @@ type Config struct { @@ -35,6 +36,10 @@ type Config struct {
RateLimitEnabled bool
DailyLimitMB int64
BurstLimitMB int64
// Delete replay protection (proposed BUD enhancement)
// When true, DELETE auth events must include a 'server' tag matching this server
DeleteRequireServerTag bool
}
// NewServer creates a new Blossom server instance
@ -78,6 +83,7 @@ func NewServer(db database.Database, aclRegistry *acl.S, cfg *Config) *Server { @@ -78,6 +83,7 @@ func NewServer(db database.Database, aclRegistry *acl.S, cfg *Config) *Server {
maxBlobSize: cfg.MaxBlobSize,
allowedMimeTypes: allowedMap,
requireAuth: cfg.RequireAuth,
deleteRequireServerTag: cfg.DeleteRequireServerTag,
bandwidthLimiter: bwLimiter,
}
}

194
pkg/database/import_utils.go

@ -6,15 +6,16 @@ package database @@ -6,15 +6,16 @@ package database
import (
"bufio"
"context"
"fmt"
"io"
"os"
"runtime/debug"
"strings"
"time"
"git.mleku.dev/mleku/nostr/encoders/event"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"git.mleku.dev/mleku/nostr/encoders/event"
)
const maxLen = 500000000
@ -22,54 +23,89 @@ const maxLen = 500000000 @@ -22,54 +23,89 @@ const maxLen = 500000000
// ImportEventsFromReader imports events from an io.Reader containing JSONL data
func (d *D) ImportEventsFromReader(ctx context.Context, rr io.Reader) error {
startTime := time.Now()
log.I.F("import: starting import operation")
log.I.F("================================================")
log.I.F("[HTTP API IMPORT] Starting import operation")
log.I.F("[HTTP API IMPORT] State: Initializing - preparing to receive file data")
log.I.F("================================================")
// store to disk so we can return fast
tmpPath := os.TempDir() + string(os.PathSeparator) + "orly"
os.MkdirAll(tmpPath, 0700)
tmp, err := os.CreateTemp(tmpPath, "")
if chk.E(err) {
log.E.F("[HTTP API IMPORT] ERROR: Failed to create temp file: %v", err)
log.E.F("[HTTP API IMPORT] Action: Check disk space and temp directory permissions")
return err
}
defer os.Remove(tmp.Name()) // Clean up temp file when done
log.I.F("import: buffering upload to %s", tmp.Name())
log.I.F("[HTTP API IMPORT] State: Buffering - receiving file data from client")
log.I.F("[HTTP API IMPORT] Temp file: %s", tmp.Name())
bufferStart := time.Now()
bytesBuffered, err := io.Copy(tmp, rr)
if chk.E(err) {
log.E.F("[HTTP API IMPORT] ERROR: Failed to buffer file data: %v", err)
log.E.F("[HTTP API IMPORT] Action: Check network connection and file integrity")
return err
}
bufferElapsed := time.Since(bufferStart)
log.I.F("import: buffered %.2f MB in %v (%.2f MB/sec)",
float64(bytesBuffered)/1024/1024, bufferElapsed.Round(time.Millisecond),
float64(bytesBuffered)/bufferElapsed.Seconds()/1024/1024)
fileSizeMB := float64(bytesBuffered) / 1024 / 1024
bufferSpeedMBps := fileSizeMB / bufferElapsed.Seconds()
log.I.F("[HTTP API IMPORT] State: Buffering complete")
log.I.F("[HTTP API IMPORT] File size: %.2f MB | Buffer time: %v | Speed: %.2f MB/sec",
fileSizeMB, bufferElapsed.Round(time.Millisecond), bufferSpeedMBps)
if _, err = tmp.Seek(0, 0); chk.E(err) {
log.E.F("[HTTP API IMPORT] ERROR: Failed to rewind temp file: %v", err)
return err
}
processErr := d.processJSONLEvents(ctx, tmp)
// Estimate number of events (rough estimate: ~500 bytes per event average)
estimatedEvents := int(float64(bytesBuffered) / 500)
log.I.F("[HTTP API IMPORT] State: Processing - reading and importing events")
log.I.F("[HTTP API IMPORT] Estimated events: ~%d (based on file size)", estimatedEvents)
log.I.F("[HTTP API IMPORT] Progress updates will appear every 5 seconds")
log.I.F("================================================")
processErr := d.processJSONLEvents(ctx, tmp, bytesBuffered)
totalElapsed := time.Since(startTime)
log.I.F("import: total operation time: %v", totalElapsed.Round(time.Millisecond))
log.I.F("================================================")
log.I.F("[HTTP API IMPORT] State: Completed")
log.I.F("[HTTP API IMPORT] Total time: %v", totalElapsed.Round(time.Millisecond))
if processErr != nil {
log.E.F("[HTTP API IMPORT] Result: FAILED")
log.E.F("[HTTP API IMPORT] Error: %v", processErr)
log.E.F("[HTTP API IMPORT] Action: Check error details above, verify file format is valid JSONL")
log.I.F("================================================")
} else {
log.I.F("[HTTP API IMPORT] Result: SUCCESS")
log.I.F("[HTTP API IMPORT] All events have been imported and are now available on the relay")
log.I.F("================================================")
}
return processErr
}
// ImportEventsFromStrings imports events from a slice of JSON strings with policy filtering
func (d *D) ImportEventsFromStrings(ctx context.Context, eventJSONs []string, policyManager interface{ CheckPolicy(action string, ev *event.E, pubkey []byte, remote string) (bool, error) }) error {
func (d *D) ImportEventsFromStrings(ctx context.Context, eventJSONs []string, policyManager interface {
CheckPolicy(action string, ev *event.E, pubkey []byte, remote string) (bool, error)
}) error {
// Create a reader from the string slice
reader := strings.NewReader(strings.Join(eventJSONs, "\n"))
return d.processJSONLEventsWithPolicy(ctx, reader, policyManager)
// Estimate total bytes (rough estimate)
totalBytes := int64(len(strings.Join(eventJSONs, "\n")))
return d.processJSONLEventsWithPolicy(ctx, reader, policyManager, totalBytes)
}
// processJSONLEvents processes JSONL events from a reader
func (d *D) processJSONLEvents(ctx context.Context, rr io.Reader) error {
return d.processJSONLEventsWithPolicy(ctx, rr, nil)
func (d *D) processJSONLEvents(ctx context.Context, rr io.Reader, totalBytes int64) error {
return d.processJSONLEventsWithPolicy(ctx, rr, nil, totalBytes)
}
// processJSONLEventsWithPolicy processes JSONL events from a reader with optional policy filtering
func (d *D) processJSONLEventsWithPolicy(ctx context.Context, rr io.Reader, policyManager interface{ CheckPolicy(action string, ev *event.E, pubkey []byte, remote string) (bool, error) }) error {
func (d *D) processJSONLEventsWithPolicy(ctx context.Context, rr io.Reader, policyManager interface {
CheckPolicy(action string, ev *event.E, pubkey []byte, remote string) (bool, error)
}, totalBytes int64) error {
// Create a scanner to read the buffer line by line
scan := bufio.NewScanner(rr)
scanBuf := make([]byte, maxLen)
@ -80,17 +116,26 @@ func (d *D) processJSONLEventsWithPolicy(ctx context.Context, rr io.Reader, poli @@ -80,17 +116,26 @@ func (d *D) processJSONLEventsWithPolicy(ctx context.Context, rr io.Reader, poli
lastLogTime := startTime
const logInterval = 5 * time.Second
var count, total, skipped, policyRejected, unmarshalErrors, saveErrors int
var count, bytesRead, skipped, policyRejected, unmarshalErrors, saveErrors int
var kind3Count, largeEventCount int // Track noisy event types
// Track bytes read for progress percentage
bytesReadTotal := int64(0)
for scan.Scan() {
select {
case <-ctx.Done():
log.I.F("import: context closed after %d events", count)
log.I.F("[HTTP API IMPORT] State: Cancelled")
log.I.F("[HTTP API IMPORT] Processed %d events before cancellation", count)
return ctx.Err()
default:
}
b := scan.Bytes()
total += len(b) + 1
lineLen := len(b) + 1 // +1 for newline
bytesReadTotal += int64(lineLen)
bytesRead += lineLen
if len(b) < 1 {
skipped++
continue
@ -101,28 +146,41 @@ func (d *D) processJSONLEventsWithPolicy(ctx context.Context, rr io.Reader, poli @@ -101,28 +146,41 @@ func (d *D) processJSONLEventsWithPolicy(ctx context.Context, rr io.Reader, poli
// return the pooled buffer on error
ev.Free()
unmarshalErrors++
log.W.F("failed to unmarshal event: %v", err)
// Only log unmarshal errors if they're frequent (not spam)
if unmarshalErrors <= 10 || unmarshalErrors%100 == 0 {
log.W.F("[HTTP API IMPORT] Failed to unmarshal event #%d: %v", unmarshalErrors, err)
}
continue
}
// Track event kinds for summary (but don't log each one)
if ev.Kind == 3 {
kind3Count++
}
if len(b) > 100000 { // Events larger than 100KB
largeEventCount++
}
// Apply policy checking if policy manager is provided
if policyManager != nil {
// For sync imports, we treat events as coming from system/trusted source
// Use nil pubkey and empty remote to indicate system-level import
allowed, policyErr := policyManager.CheckPolicy("write", ev, nil, "")
if policyErr != nil {
log.W.F("policy check failed for event %x: %v", ev.ID, policyErr)
ev.Free()
policyRejected++
// Only log policy errors if they're frequent (not spam)
if policyRejected <= 10 || policyRejected%100 == 0 {
log.W.F("[HTTP API IMPORT] Policy check failed for event %x: %v", ev.ID, policyErr)
}
continue
}
if !allowed {
log.D.F("policy rejected event %x during sync import", ev.ID)
ev.Free()
policyRejected++
// Don't log individual policy rejections (too noisy)
continue
}
log.D.F("policy allowed event %x during sync import", ev.ID)
}
// Apply rate limiting before write operation if limiter is configured
@ -134,7 +192,10 @@ func (d *D) processJSONLEventsWithPolicy(ctx context.Context, rr io.Reader, poli @@ -134,7 +192,10 @@ func (d *D) processJSONLEventsWithPolicy(ctx context.Context, rr io.Reader, poli
// return the pooled buffer on error paths too
ev.Free()
saveErrors++
log.W.F("failed to save event: %v", err)
// Only log save errors if they're frequent (not spam)
if saveErrors <= 10 || saveErrors%100 == 0 {
log.W.F("[HTTP API IMPORT] Failed to save event #%d: %v", saveErrors, err)
}
continue
}
@ -143,30 +204,99 @@ func (d *D) processJSONLEventsWithPolicy(ctx context.Context, rr io.Reader, poli @@ -143,30 +204,99 @@ func (d *D) processJSONLEventsWithPolicy(ctx context.Context, rr io.Reader, poli
b = nil
count++
// Progress logging every logInterval
// Progress logging every logInterval with detailed information
if time.Since(lastLogTime) >= logInterval {
elapsed := time.Since(startTime)
eventsPerSec := float64(count) / elapsed.Seconds()
mbPerSec := float64(total) / elapsed.Seconds() / 1024 / 1024
log.I.F("import: progress %d events saved, %.2f MB read, %.0f events/sec, %.2f MB/sec",
count, float64(total)/1024/1024, eventsPerSec, mbPerSec)
mbRead := float64(bytesRead) / 1024 / 1024
mbPerSec := mbRead / elapsed.Seconds()
// Calculate progress percentage if we have total bytes
progressPercent := ""
eta := ""
if totalBytes > 0 {
percent := float64(bytesReadTotal) / float64(totalBytes) * 100
if percent > 100 {
percent = 100
}
progressPercent = fmt.Sprintf(" | Progress: %.1f%%", percent)
// Estimate time remaining
if percent > 0 && percent < 100 && eventsPerSec > 0 {
remainingBytes := totalBytes - bytesReadTotal
remainingMB := float64(remainingBytes) / 1024 / 1024
if mbPerSec > 0 {
remainingSeconds := remainingMB / mbPerSec
eta = fmt.Sprintf(" | ETA: ~%v", time.Duration(remainingSeconds)*time.Second)
}
}
}
log.I.F("[HTTP API IMPORT] Progress: %d events saved | %.2f MB read | %.0f events/sec | %.2f MB/sec%s%s",
count, mbRead, eventsPerSec, mbPerSec, progressPercent, eta)
// Show error summary if there are errors
if unmarshalErrors > 0 || saveErrors > 0 || policyRejected > 0 {
log.W.F("[HTTP API IMPORT] Errors so far: %d unmarshal, %d save, %d policy rejected",
unmarshalErrors, saveErrors, policyRejected)
}
lastLogTime = time.Now()
debug.FreeOSMemory()
}
}
// Final summary
// Final summary with actionable information
elapsed := time.Since(startTime)
eventsPerSec := float64(count) / elapsed.Seconds()
mbPerSec := float64(total) / elapsed.Seconds() / 1024 / 1024
log.I.F("import: completed - %d events saved, %.2f MB in %v (%.0f events/sec, %.2f MB/sec)",
count, float64(total)/1024/1024, elapsed.Round(time.Millisecond), eventsPerSec, mbPerSec)
if unmarshalErrors > 0 || saveErrors > 0 || policyRejected > 0 || skipped > 0 {
log.I.F("import: stats - %d unmarshal errors, %d save errors, %d policy rejected, %d skipped empty lines",
unmarshalErrors, saveErrors, policyRejected, skipped)
mbRead := float64(bytesRead) / 1024 / 1024
mbPerSec := mbRead / elapsed.Seconds()
log.I.F("[HTTP API IMPORT] ================================================")
log.I.F("[HTTP API IMPORT] Processing complete")
log.I.F("[HTTP API IMPORT] Events saved: %d | Data processed: %.2f MB | Time: %v",
count, mbRead, elapsed.Round(time.Millisecond))
log.I.F("[HTTP API IMPORT] Performance: %.0f events/sec | %.2f MB/sec", eventsPerSec, mbPerSec)
// Show statistics
if kind3Count > 0 || largeEventCount > 0 {
log.I.F("[HTTP API IMPORT] Event types: %d kind-3 (follow lists), %d large events (>100KB)",
kind3Count, largeEventCount)
}
// Show error summary with actionable information
hasErrors := false
if unmarshalErrors > 0 {
log.W.F("[HTTP API IMPORT] WARNING: %d events failed to unmarshal", unmarshalErrors)
log.W.F("[HTTP API IMPORT] Action: Check if file format is valid JSONL (one JSON object per line)")
hasErrors = true
}
if saveErrors > 0 {
log.W.F("[HTTP API IMPORT] WARNING: %d events failed to save", saveErrors)
log.W.F("[HTTP API IMPORT] Action: Check database disk space and permissions")
hasErrors = true
}
if policyRejected > 0 {
log.W.F("[HTTP API IMPORT] WARNING: %d events rejected by policy", policyRejected)
log.W.F("[HTTP API IMPORT] Action: Review ACL/policy settings if this is unexpected")
hasErrors = true
}
if skipped > 0 {
log.I.F("[HTTP API IMPORT] Info: %d empty lines skipped (this is normal)", skipped)
}
if !hasErrors {
log.I.F("[HTTP API IMPORT] Result: All events imported successfully")
log.I.F("[HTTP API IMPORT] Action: Events are now available on the relay")
} else {
log.W.F("[HTTP API IMPORT] Result: Import completed with errors (see warnings above)")
log.W.F("[HTTP API IMPORT] Action: Review error messages and take appropriate action")
}
log.I.F("[HTTP API IMPORT] ================================================")
if err := scan.Err(); err != nil {
log.E.F("[HTTP API IMPORT] ERROR: Scanner error: %v", err)
log.E.F("[HTTP API IMPORT] Action: Check file integrity and format")
return err
}

16
pkg/interfaces/transport/transport.go

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
// Package transport defines the interface for pluggable network transports.
package transport
import "context"
// Transport represents a network transport that serves the relay.
type Transport interface {
// Name returns the transport identifier (e.g., "tcp", "tls", "tor").
Name() string
// Start begins accepting connections through this transport.
Start(ctx context.Context) error
// Stop gracefully shuts down the transport.
Stop(ctx context.Context) error
// Addresses returns the addresses this transport is reachable on.
Addresses() []string
}

18
pkg/spider/directory.go

@ -557,19 +557,31 @@ func (ds *DirectorySpider) fetchKindFromRelay(relayURL string, k uint16) ([]*eve @@ -557,19 +557,31 @@ func (ds *DirectorySpider) fetchKindFromRelay(relayURL string, k uint16) ([]*eve
// storeEvents saves events to the database and publishes new ones.
func (ds *DirectorySpider) storeEvents(events []*event.E) (saved, duplicates int) {
for _, ev := range events {
_, err := ds.db.SaveEvent(ds.ctx, ev)
savedEvent, err := ds.db.SaveEvent(ds.ctx, ev)
if err != nil {
if chk.T(err) {
// Most errors are duplicates, which is expected
duplicates++
}
// Free event on error
ev.Free()
continue
}
if savedEvent {
saved++
// Publish event to active subscribers
// Clone the event before delivering since Deliver sends it to channels
// and the receiver goroutines will handle freeing the cloned event
if ds.pub != nil {
go ds.pub.Deliver(ev)
cloned := ev.Clone()
go ds.pub.Deliver(cloned)
}
// Free the original event after cloning
ev.Free()
} else {
// Event was duplicate, free it
duplicates++
ev.Free()
}
}
return

127
pkg/spider/spider.go

@ -235,6 +235,10 @@ func (s *Spider) mainLoop() { @@ -235,6 +235,10 @@ func (s *Spider) mainLoop() {
log.I.F("spider: main loop started, checking every %v", MainLoopInterval)
// Do an immediate check on startup
log.I.F("spider: performing initial connection check")
s.updateConnections()
for {
select {
case <-s.ctx.Done():
@ -243,7 +247,7 @@ func (s *Spider) mainLoop() { @@ -243,7 +247,7 @@ func (s *Spider) mainLoop() {
log.I.F("spider: follow list updated, refreshing connections")
s.updateConnections()
case <-ticker.C:
log.D.F("spider: periodic check triggered")
log.I.F("spider: periodic check triggered")
s.updateConnections()
}
}
@ -251,44 +255,105 @@ func (s *Spider) mainLoop() { @@ -251,44 +255,105 @@ func (s *Spider) mainLoop() {
// updateConnections updates relay connections based on current admin relays and follow lists
func (s *Spider) updateConnections() {
s.mu.Lock()
defer s.mu.Unlock()
s.mu.RLock()
running := s.running
s.mu.RUnlock()
if !s.running {
if !running {
return
}
// Get current admin relays and follow list
adminRelays := s.getAdminRelays()
followList := s.getFollowList()
if s.getAdminRelays == nil {
log.W.F("spider: getAdminRelays callback is nil")
return
}
if s.getFollowList == nil {
log.W.F("spider: getFollowList callback is nil")
return
}
// Call callbacks with panic recovery
var adminRelays []string
var followList [][]byte
func() {
defer func() {
if r := recover(); r != nil {
log.E.F("spider: panic in getAdminRelays callback: %v", r)
adminRelays = nil
}
}()
adminRelays = s.getAdminRelays()
}()
func() {
defer func() {
if r := recover(); r != nil {
log.E.F("spider: panic in getFollowList callback: %v", r)
followList = nil
}
}()
followList = s.getFollowList()
}()
log.I.F("spider: updateConnections - admin relays: %d, follow list: %d", len(adminRelays), len(followList))
if len(adminRelays) > 0 {
log.I.F("spider: admin relays: %v", adminRelays)
} else {
log.I.F("spider: admin relays callback returned empty/nil - checking bootstrap relay configuration")
}
if len(followList) > 0 {
log.D.F("spider: follow list has %d pubkeys", len(followList))
} else {
log.I.F("spider: follow list callback returned empty/nil - check if admin follow lists have been fetched")
}
if len(adminRelays) == 0 || len(followList) == 0 {
log.D.F("spider: no admin relays (%d) or follow list (%d) available",
log.I.F("spider: no admin relays (%d) or follow list (%d) available - cannot create connections",
len(adminRelays), len(followList))
if len(adminRelays) == 0 {
log.I.F("spider: admin relays callback returned empty list - check if bootstrap relays are configured")
}
if len(followList) == 0 {
log.I.F("spider: follow list callback returned empty list - check if admin follow lists have been fetched")
}
return
}
// Update connections for current admin relays (filtering out self)
// Note: We release the mutex before the loop because isSelfRelay() needs to acquire it
log.I.F("spider: processing %d admin relays (will filter out self-relays)", len(adminRelays))
currentRelays := make(map[string]bool)
for _, url := range adminRelays {
for i, url := range adminRelays {
log.I.F("spider: checking relay %d/%d: %s", i+1, len(adminRelays), url)
// Check if this relay URL is ourselves
if s.isSelfRelay(url) {
log.D.F("spider: skipping self-relay: %s", url)
isSelf := s.isSelfRelay(url)
log.D.F("spider: isSelfRelay(%s) = %v", url, isSelf)
if isSelf {
log.I.F("spider: skipping self-relay: %s", url)
continue
}
currentRelays[url] = true
if conn, exists := s.connections[url]; exists {
// Acquire lock to check and modify connections
s.mu.Lock()
conn, exists := s.connections[url]
s.mu.Unlock()
if exists {
// Update existing connection
log.I.F("spider: updating existing connection to %s", url)
conn.updateSubscriptions(followList)
} else {
// Create new connection
log.I.F("spider: creating new connection to %s", url)
s.createConnection(url, followList)
}
}
log.I.F("spider: processed %d relays, created/updated %d connections", len(adminRelays), len(currentRelays))
// Remove connections for relays no longer in admin list
s.mu.Lock()
for url, conn := range s.connections {
if !currentRelays[url] {
log.I.F("spider: removing connection to %s (no longer in admin relays)", url)
@ -296,6 +361,7 @@ func (s *Spider) updateConnections() { @@ -296,6 +361,7 @@ func (s *Spider) updateConnections() {
delete(s.connections, url)
}
}
s.mu.Unlock()
}
// createConnection creates a new relay connection
@ -312,7 +378,10 @@ func (s *Spider) createConnection(url string, followList [][]byte) { @@ -312,7 +378,10 @@ func (s *Spider) createConnection(url string, followList [][]byte) {
reconnectDelay: ReconnectDelay,
}
// Acquire lock to add connection
s.mu.Lock()
s.connections[url] = conn
s.mu.Unlock()
// Start connection in goroutine
go conn.manage(followList)
@ -633,15 +702,25 @@ func (bs *BatchSubscription) handleEvents() { @@ -633,15 +702,25 @@ func (bs *BatchSubscription) handleEvents() {
<-ticker.C
// Save event to database
if _, err := bs.relay.spider.db.SaveEvent(bs.relay.ctx, ev); err != nil {
// Ignore duplicate events and other errors
saved, err := bs.relay.spider.db.SaveEvent(bs.relay.ctx, ev)
if err != nil {
// Ignore duplicate events and other errors, but free the event
log.T.F("spider: failed to save event from %s: %v", bs.relay.url, err)
} else {
ev.Free() // event.E method
} else if saved {
// Publish event if it was newly saved
// Clone the event before delivering since Deliver sends it to channels
// and the receiver goroutines will handle freeing the cloned event
if bs.relay.spider.pub != nil {
go bs.relay.spider.pub.Deliver(ev)
cloned := ev.Clone() // event.E method
go bs.relay.spider.pub.Deliver(cloned)
}
// Free the original event after cloning
ev.Free() // event.E method
log.T.F("spider: saved event from %s", bs.relay.url)
} else {
// Event was duplicate, free it
ev.Free() // event.E method
}
}
}
@ -782,15 +861,25 @@ func (rc *RelayConnection) performCatchup(sub *BatchSubscription, disconnectTime @@ -782,15 +861,25 @@ func (rc *RelayConnection) performCatchup(sub *BatchSubscription, disconnectTime
eventCount++
// Save event to database
if _, err := rc.spider.db.SaveEvent(rc.ctx, ev); err != nil {
// Silently ignore errors (mostly duplicates)
} else {
saved, err := rc.spider.db.SaveEvent(rc.ctx, ev)
if err != nil {
// Silently ignore errors (mostly duplicates), but free the event
ev.Free()
} else if saved {
// Publish event if it was newly saved
// Clone the event before delivering since Deliver sends it to channels
// and the receiver goroutines will handle freeing the cloned event
if rc.spider.pub != nil {
go rc.spider.pub.Deliver(ev)
cloned := ev.Clone()
go rc.spider.pub.Deliver(cloned)
}
// Free the original event after cloning
ev.Free()
log.T.F("spider: catch-up saved event %s from %s",
hex.Enc(ev.ID[:]), rc.url)
} else {
// Event was duplicate, free it
ev.Free()
}
}
}

86
pkg/transport/manager.go

@ -0,0 +1,86 @@ @@ -0,0 +1,86 @@
// Package transport provides a manager for pluggable network transports.
package transport
import (
"context"
"fmt"
"sync"
"lol.mleku.dev/log"
iface "next.orly.dev/pkg/interfaces/transport"
)
// Manager manages multiple transports and coordinates their lifecycle.
type Manager struct {
mu sync.RWMutex
transports []iface.Transport
}
// NewManager creates a new transport manager.
func NewManager() *Manager {
return &Manager{}
}
// Add registers a transport with the manager.
func (m *Manager) Add(t iface.Transport) {
m.mu.Lock()
defer m.mu.Unlock()
m.transports = append(m.transports, t)
}
// StartAll starts all registered transports in order.
// If a transport fails to start, it is logged and skipped.
// Returns an error only if no transports started successfully.
func (m *Manager) StartAll(ctx context.Context) error {
m.mu.RLock()
defer m.mu.RUnlock()
started := 0
for _, t := range m.transports {
log.I.F("starting transport: %s", t.Name())
if err := t.Start(ctx); err != nil {
log.E.F("transport %s failed to start: %v (skipping)", t.Name(), err)
continue
}
log.I.F("transport started: %s", t.Name())
started++
}
if started == 0 {
return fmt.Errorf("no transports started successfully")
}
return nil
}
// StopAll stops all transports in reverse order.
func (m *Manager) StopAll(ctx context.Context) error {
m.mu.RLock()
defer m.mu.RUnlock()
var firstErr error
for i := len(m.transports) - 1; i >= 0; i-- {
t := m.transports[i]
log.I.F("stopping transport: %s", t.Name())
if err := t.Stop(ctx); err != nil {
log.E.F("failed to stop transport %s: %v", t.Name(), err)
if firstErr == nil {
firstErr = err
}
} else {
log.I.F("transport stopped: %s", t.Name())
}
}
return firstErr
}
// Addresses returns all addresses from all transports.
func (m *Manager) Addresses() []string {
m.mu.RLock()
defer m.mu.RUnlock()
var addrs []string
for _, t := range m.transports {
addrs = append(addrs, t.Addresses()...)
}
return addrs
}

78
pkg/transport/tcp/tcp.go

@ -0,0 +1,78 @@ @@ -0,0 +1,78 @@
// Package tcp provides a plain HTTP transport for the relay.
package tcp
import (
"context"
"fmt"
"net/http"
"sync"
"lol.mleku.dev/log"
)
// Config holds TCP transport configuration.
type Config struct {
// Addr is the listen address (e.g., "0.0.0.0:3334").
Addr string
// Handler is the HTTP handler to serve.
Handler http.Handler
}
// Transport serves HTTP over plain TCP.
type Transport struct {
cfg *Config
server *http.Server
mu sync.Mutex
}
// New creates a new TCP transport.
func New(cfg *Config) *Transport {
return &Transport{cfg: cfg}
}
func (t *Transport) Name() string { return "tcp" }
func (t *Transport) Start(ctx context.Context) error {
t.mu.Lock()
defer t.mu.Unlock()
t.server = &http.Server{
Addr: t.cfg.Addr,
Handler: t.cfg.Handler,
}
log.I.F("starting listener on http://%s", t.cfg.Addr)
errCh := make(chan error, 1)
go func() {
if err := t.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- err
}
close(errCh)
}()
// Give the server a moment to fail on bind errors
select {
case err := <-errCh:
if err != nil {
return fmt.Errorf("tcp listen on %s: %w", t.cfg.Addr, err)
}
default:
}
return nil
}
func (t *Transport) Stop(ctx context.Context) error {
t.mu.Lock()
defer t.mu.Unlock()
if t.server == nil {
return nil
}
return t.server.Shutdown(ctx)
}
func (t *Transport) Addresses() []string {
return []string{"ws://" + t.cfg.Addr + "/"}
}

247
pkg/transport/tls/tls.go

@ -0,0 +1,247 @@ @@ -0,0 +1,247 @@
// Package tls provides a TLS/ACME transport for the relay.
package tls
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"golang.org/x/crypto/acme/autocert"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
)
// Config holds TLS transport configuration.
type Config struct {
// Domains is the list of domains for ACME auto-cert.
Domains []string
// Certs is a list of manual certificate paths (without extension).
// For each path, .pem and .key files are loaded.
Certs []string
// DataDir is the base data directory for the autocert cache.
DataDir string
// Handler is the HTTP handler to serve.
Handler http.Handler
}
// Transport serves HTTPS with automatic or manual TLS certificates.
// It runs two servers: HTTPS on :443 and HTTP on :80 for ACME challenges.
type Transport struct {
cfg *Config
tlsServer *http.Server
httpServer *http.Server
mu sync.Mutex
}
// New creates a new TLS transport.
func New(cfg *Config) *Transport {
return &Transport{cfg: cfg}
}
func (t *Transport) Name() string { return "tls" }
func (t *Transport) Start(ctx context.Context) error {
t.mu.Lock()
defer t.mu.Unlock()
if err := ValidateConfig(t.cfg.Domains, t.cfg.Certs); err != nil {
return fmt.Errorf("invalid TLS configuration: %w", err)
}
// Create cache directory for autocert
cacheDir := filepath.Join(t.cfg.DataDir, "autocert")
if err := os.MkdirAll(cacheDir, 0700); err != nil {
return fmt.Errorf("failed to create autocert cache directory: %w", err)
}
// Set up autocert manager
m := &autocert.Manager{
Prompt: autocert.AcceptTOS,
Cache: autocert.DirCache(cacheDir),
HostPolicy: autocert.HostWhitelist(t.cfg.Domains...),
}
// Create TLS server on port 443
t.tlsServer = &http.Server{
Addr: ":443",
Handler: t.cfg.Handler,
TLSConfig: tlsConfig(m, t.cfg.Certs...),
}
// Create HTTP server for ACME challenges and redirects on port 80
t.httpServer = &http.Server{
Addr: ":80",
Handler: m.HTTPHandler(nil),
}
log.I.F("TLS enabled for domains: %v", t.cfg.Domains)
// Start TLS server
go func() {
log.I.F("starting TLS listener on https://:443")
if err := t.tlsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
log.E.F("TLS server error: %v", err)
}
}()
// Start HTTP server for ACME challenges
go func() {
log.I.F("starting HTTP listener on http://:80 for ACME challenges")
if err := t.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.E.F("HTTP server error: %v", err)
}
}()
return nil
}
func (t *Transport) Stop(ctx context.Context) error {
t.mu.Lock()
defer t.mu.Unlock()
var firstErr error
if t.tlsServer != nil {
if err := t.tlsServer.Shutdown(ctx); err != nil {
log.E.F("TLS server shutdown error: %v", err)
firstErr = err
} else {
log.I.F("TLS server shutdown completed")
}
}
if t.httpServer != nil {
if err := t.httpServer.Shutdown(ctx); err != nil {
log.E.F("HTTP server shutdown error: %v", err)
if firstErr == nil {
firstErr = err
}
} else {
log.I.F("HTTP server shutdown completed")
}
}
return firstErr
}
func (t *Transport) Addresses() []string {
var addrs []string
for _, domain := range t.cfg.Domains {
addrs = append(addrs, "wss://"+domain+"/")
}
return addrs
}
// ValidateConfig checks if the TLS configuration is valid.
func ValidateConfig(domains []string, certs []string) error {
if len(domains) == 0 {
return fmt.Errorf("no TLS domains specified")
}
for _, domain := range domains {
if domain == "" {
continue
}
if strings.Contains(domain, " ") || strings.Contains(domain, "\t") {
return fmt.Errorf("invalid domain name: %s", domain)
}
}
return nil
}
// tlsConfig returns a TLS configuration that works with LetsEncrypt automatic
// SSL cert issuer as well as any provided certificate files.
//
// Certs are provided as paths where .pem and .key files exist.
func tlsConfig(m *autocert.Manager, certs ...string) *tls.Config {
certMap := make(map[string]*tls.Certificate)
var mx sync.Mutex
for _, certPath := range certs {
if certPath == "" {
continue
}
var err error
var c tls.Certificate
if c, err = tls.LoadX509KeyPair(
certPath+".pem", certPath+".key",
); chk.E(err) {
log.E.F("failed to load certificate from %s: %v", certPath, err)
continue
}
if len(c.Certificate) > 0 {
if x509Cert, err := x509.ParseCertificate(c.Certificate[0]); err == nil {
if x509Cert.Subject.CommonName != "" {
certMap[x509Cert.Subject.CommonName] = &c
log.I.F("loaded certificate for domain: %s", x509Cert.Subject.CommonName)
}
for _, san := range x509Cert.DNSNames {
if san != "" {
certMap[san] = &c
log.I.F("loaded certificate for SAN domain: %s", san)
}
}
}
}
}
if m == nil {
return &tls.Config{
GetCertificate: func(helo *tls.ClientHelloInfo) (*tls.Certificate, error) {
mx.Lock()
defer mx.Unlock()
if cert, exists := certMap[helo.ServerName]; exists {
return cert, nil
}
for domain, cert := range certMap {
if strings.HasPrefix(domain, "*.") {
baseDomain := domain[2:]
if strings.HasSuffix(helo.ServerName, baseDomain) {
return cert, nil
}
}
}
return nil, fmt.Errorf("no certificate found for %s", helo.ServerName)
},
}
}
tc := m.TLSConfig()
tc.GetCertificate = func(helo *tls.ClientHelloInfo) (*tls.Certificate, error) {
mx.Lock()
if cert, exists := certMap[helo.ServerName]; exists {
mx.Unlock()
return cert, nil
}
for domain, cert := range certMap {
if strings.HasPrefix(domain, "*.") {
baseDomain := domain[2:]
if strings.HasSuffix(helo.ServerName, baseDomain) {
mx.Unlock()
return cert, nil
}
}
}
mx.Unlock()
return m.GetCertificate(helo)
}
return tc
}

91
pkg/transport/tor/tor.go

@ -0,0 +1,91 @@ @@ -0,0 +1,91 @@
// Package tor provides a Tor hidden service transport for the relay.
// It wraps the existing pkg/tor service as a pluggable transport.
package tor
import (
"context"
"net/http"
"lol.mleku.dev/log"
torservice "next.orly.dev/pkg/tor"
)
// Config holds Tor transport configuration.
type Config struct {
// Port is the internal port for the hidden service.
Port int
// DataDir is the directory for Tor data (torrc, keys, hostname, etc.).
DataDir string
// Binary is the path to the tor executable.
Binary string
// SOCKSPort is the port for outbound SOCKS connections (0 = disabled).
SOCKSPort int
// Handler is the HTTP handler to serve.
Handler http.Handler
}
// Transport serves the relay as a Tor hidden service.
type Transport struct {
cfg *Config
service *torservice.Service
}
// New creates a new Tor transport.
func New(cfg *Config) *Transport {
return &Transport{cfg: cfg}
}
func (t *Transport) Name() string { return "tor" }
func (t *Transport) Start(ctx context.Context) error {
svcCfg := &torservice.Config{
Port: t.cfg.Port,
DataDir: t.cfg.DataDir,
Binary: t.cfg.Binary,
SOCKSPort: t.cfg.SOCKSPort,
Handler: t.cfg.Handler,
}
var err error
t.service, err = torservice.New(svcCfg)
if err != nil {
return err
}
if err = t.service.Start(); err != nil {
t.service = nil
return err
}
if addr := t.service.OnionWSAddress(); addr != "" {
log.I.F("Tor hidden service listening on port %d, address: %s", t.cfg.Port, addr)
} else {
log.I.F("Tor hidden service listening on port %d (waiting for .onion address)", t.cfg.Port)
}
return nil
}
func (t *Transport) Stop(ctx context.Context) error {
if t.service == nil {
return nil
}
return t.service.Stop()
}
func (t *Transport) Addresses() []string {
if t.service == nil {
return nil
}
if addr := t.service.OnionWSAddress(); addr != "" {
return []string{addr}
}
return nil
}
// Service returns the underlying Tor service for access to Tor-specific
// functionality (e.g., OnionAddress, DataDir).
func (t *Transport) Service() *torservice.Service {
return t.service
}

2
pkg/version/version

@ -1 +1 @@ @@ -1 +1 @@
v0.58.5
v0.58.10

124
run-orly-docker.sh

@ -0,0 +1,124 @@ @@ -0,0 +1,124 @@
#!/bin/bash
# Run Orly relay using docker run (alternative to docker-compose)
# Optimized for large dataset imports (20GB+) with memory-conscious settings
#
# Memory optimizations applied:
# - Reduced cache sizes (block: 256MB, index: 128MB)
# - Lower serial caches (pubkeys: 50k, event IDs: 250k)
# - Explicit rate limit target (1500MB) to prevent auto-detection issues
# - Fixed emergency thresholds (1.167/0.833 instead of 4.0/3.0)
# - Query result limit (256) to prevent unbounded memory usage
# - Reduced max connections (500 instead of 1000)
set -e
CONTAINER_NAME="orly-relay"
# Auto-detect version or use default
# Try multiple methods to detect version
VERSION=""
if command -v git &> /dev/null && [ -d .git ]; then
VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "")
fi
if [ -z "$VERSION" ] && [ -f pkg/version/version ]; then
VERSION=$(cat pkg/version/version 2>/dev/null | tr -d ' \n' | sed 's/^v//' || echo "")
fi
# Fallback to default if still empty
if [ -z "$VERSION" ]; then
VERSION="0.58.10"
fi
# Ensure version starts with 'v'
if [[ ! "$VERSION" =~ ^v ]]; then
VERSION="v${VERSION}"
fi
IMAGE="silberengel/next-orly:${VERSION}"
# Data directory on host filesystem (change this to your desired path)
# Using bind mount instead of volume for better performance with large datasets
DATA_DIR="${ORLY_DATA_DIR:-/var/lib/orly}"
# Create data directory if it doesn't exist
mkdir -p "${DATA_DIR}"
# Set ownership to UID 1000 (orly user in container) and permissions
chown -R 1000:1000 "${DATA_DIR}" 2>/dev/null || {
echo "Warning: Could not set ownership of ${DATA_DIR} to UID 1000"
echo "You may need to run: sudo chown -R 1000:1000 ${DATA_DIR}"
}
chmod 755 "${DATA_DIR}"
# Pull the latest image
echo "Pulling ${IMAGE}..."
docker pull ${IMAGE}
# Check if container already exists
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
echo "Container ${CONTAINER_NAME} already exists."
echo "Removing existing container..."
docker rm -f ${CONTAINER_NAME} 2>/dev/null || true
fi
# Run the container
echo "Starting ${CONTAINER_NAME}..."
docker run -d \
--name ${CONTAINER_NAME} \
--restart always \
-p 0.0.0.0:3334:3334 \
-p 0.0.0.0:7777:7777 \
-v "${DATA_DIR}:/data" \
--memory=4096m \
--cpus="2.0" \
--oom-kill-disable=false \
--health-cmd="curl -f http://localhost:7777/ || exit 1" \
--health-interval=10s \
--health-timeout=5s \
--health-start-period=20s \
--health-retries=3 \
-e ORLY_DATA_DIR=/data \
-e ORLY_LISTEN=0.0.0.0 \
-e ORLY_PORT=7777 \
-e ORLY_LOG_LEVEL=Warn \
-e NOSTR_PRIVATE_KEY="${NOSTR_PRIVATE_KEY:-}" \
-e ORLY_ADMINS=npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl,npub1v30tsz9vw6ylpz63g0a702nj3xa26t3m7p5us8f2y2sd8v6cnsvq465zjx,npub12umrfdjgvdxt45g0y3ghwcyfagssjrv5qlm3t6pu2aa5vydwdmwq8q0z04,npub18cddpua960qjy3wmw7y9gmzr4h3ajlrwq3k9jnmqzlxke4qkg6gqeyaztw \
-e ORLY_OWNERS=npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl,npub1v30tsz9vw6ylpz63g0a702nj3xa26t3m7p5us8f2y2sd8v6cnsvq465zjx,npub12umrfdjgvdxt45g0y3ghwcyfagssjrv5qlm3t6pu2aa5vydwdmwq8q0z04,npub18cddpua960qjy3wmw7y9gmzr4h3ajlrwq3k9jnmqzlxke4qkg6gqeyaztw \
-e ORLY_ACL_MODE=follows \
-e ORLY_SPIDER_MODE=follows \
-e ORLY_RELAY_URL=wss://orly-relay.imwald.eu \
-e ORLY_SPROCKET_ENABLED=false \
-e ORLY_DB_LOG_LEVEL=error \
-e ORLY_DB_BLOCK_CACHE_MB=256 \
-e ORLY_DB_INDEX_CACHE_MB=128 \
-e ORLY_SERIAL_CACHE_PUBKEYS=50000 \
-e ORLY_SERIAL_CACHE_EVENT_IDS=250000 \
-e ORLY_DB_ZSTD_LEVEL=9 \
-e ORLY_GC_ENABLED=true \
-e ORLY_GC_BATCH_SIZE=5000 \
-e ORLY_MAX_STORAGE_BYTES=107374182400 \
-e ORLY_BOOTSTRAP_RELAYS=wss://profiles.nostr1.com,wss://purplepag.es,wss://relay.nostr.band,wss://relay.damus.io \
-e ORLY_SUBSCRIPTION_ENABLED=false \
-e ORLY_MONTHLY_PRICE_SAT=0 \
-e ORLY_MAX_CONNECTIONS=500 \
-e ORLY_MAX_EVENT_SIZE=65536 \
-e ORLY_MAX_SUBSCRIPTIONS=20 \
-e ORLY_QUERY_RESULT_LIMIT=256 \
-e ORLY_WEB_DISABLE=false \
-e ORLY_WEB_DEV_PROXY_URL="" \
-e ORLY_RATE_LIMIT_ENABLED=true \
-e ORLY_RATE_LIMIT_TARGET_MB=1500 \
-e ORLY_RATE_LIMIT_WRITE_TARGET=0.70 \
-e ORLY_RATE_LIMIT_WRITE_KP=1.0 \
-e ORLY_RATE_LIMIT_WRITE_KI=0.2 \
-e ORLY_RATE_LIMIT_MAX_WRITE_MS=2000 \
-e ORLY_RATE_LIMIT_EMERGENCY_THRESHOLD=1.167 \
-e ORLY_RATE_LIMIT_RECOVERY_THRESHOLD=0.833 \
-e ORLY_RATE_LIMIT_EMERGENCY_MAX_MS=5000 \
${IMAGE}
echo ""
echo "Container started!"
echo "Data directory: ${DATA_DIR}"
echo "View logs: docker logs -f ${CONTAINER_NAME}"
echo "Stop: docker stop ${CONTAINER_NAME}"
echo "Start: docker start ${CONTAINER_NAME}"
echo "Remove: docker rm -f ${CONTAINER_NAME}"
echo ""
echo "For large imports (20GB+), use the web UI or API:"
echo " curl -X POST -F 'file=@your-events.jsonl' http://localhost:7777/api/import"

1
scripts/app/web/dist/index.html vendored

@ -0,0 +1 @@ @@ -0,0 +1 @@
<!-- Placeholder - will be replaced by Docker build -->

20
scripts/cleanup-stuck-imports.sh

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
#!/bin/bash
# Cleanup stuck import containers on remote server
# Usage: ./scripts/cleanup-stuck-imports.sh [remote_host] [remote_user]
REMOTE_HOST="${REMOTE_HOST:-${1:-217.154.126.125}}"
REMOTE_USER="${REMOTE_USER:-${2:-root}}"
echo "Cleaning up stuck import containers on ${REMOTE_USER}@${REMOTE_HOST}..."
# Find and stop all containers running orly db import
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker ps --filter 'ancestor=silberengel/next-orly:v0.58.10' --format '{{.ID}} {{.Command}}' | grep -E 'db import|sh -c.*orly db' | awk '{print \$1}'" | while read container_id; do
if [ -n "$container_id" ]; then
echo "Stopping container: $container_id"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker stop $container_id 2>/dev/null && docker rm $container_id 2>/dev/null" || true
fi
done
echo "Cleanup complete. Remaining orly containers:"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker ps -a | grep orly || echo 'No orly containers found'"

59
scripts/create-test-event.go

@ -0,0 +1,59 @@ @@ -0,0 +1,59 @@
package main
import (
"fmt"
"os"
"time"
"git.mleku.dev/mleku/nostr/encoders/bech32encoding"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/kind"
"git.mleku.dev/mleku/nostr/encoders/tag"
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
)
func main() {
// Get NOSTR_PRIVATE_KEY from environment
nsec := os.Getenv("NOSTR_PRIVATE_KEY")
if nsec == "" {
fmt.Fprintf(os.Stderr, "ERROR: NOSTR_PRIVATE_KEY environment variable not set\n")
os.Exit(1)
}
// Decode nsec to get private key bytes
secretBytes, err := bech32encoding.NsecToBytes([]byte(nsec))
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: Failed to decode nsec: %v\n", err)
os.Exit(1)
}
// Create signer and initialize with private key
signer, err := p8k.New()
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: Failed to create signer: %v\n", err)
os.Exit(1)
}
if err = signer.InitSec(secretBytes); err != nil {
fmt.Fprintf(os.Stderr, "ERROR: Failed to initialize signer: %v\n", err)
os.Exit(1)
}
// Create a test event (kind 1 - text note)
ev := event.New()
ev.CreatedAt = time.Now().Unix()
ev.Kind = kind.TextNote.K
ev.Content = []byte("Pre-flight test event for import verification")
ev.Tags = tag.NewS()
// Sign the event
if err := ev.Sign(signer); err != nil {
fmt.Fprintf(os.Stderr, "ERROR: Failed to sign event: %v\n", err)
os.Exit(1)
}
// Serialize to JSON
eventJSON := ev.Serialize()
// Output the JSON event
fmt.Println(string(eventJSON))
}

754
scripts/import-exports-remote.sh

@ -0,0 +1,754 @@ @@ -0,0 +1,754 @@
#!/bin/bash
# Import JSONL files to remote Orly relay
# This script copies files from local exports directory to remote server and imports them
# Usage: ./scripts/import-exports-remote.sh [remote_host] [remote_user]
set -e
# Configuration
REMOTE_HOST="${REMOTE_HOST:-${1:-217.154.126.125}}"
REMOTE_USER="${REMOTE_USER:-${2:-root}}"
REMOTE_TEMP_DIR="${REMOTE_TEMP_DIR:-/root/tmp/orly}"
DOCKER_CONTAINER="${DOCKER_CONTAINER:-orly-relay}"
# NOSTR_PRIVATE_KEY for localhost authentication (reads from environment, e.g., .bashrc)
NOSTR_PRIVATE_KEY="${NOSTR_PRIVATE_KEY:-}"
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
# Get script directory and calculate exports path
# Script is in scripts/, exports are in ../scripts/exports relative to script
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
EXPORTS_DIR="$(cd "$SCRIPT_DIR/../../scripts/exports" 2>/dev/null && pwd || echo "")"
# IMPORTANT: This script NEVER deletes source files from EXPORTS_DIR
# It only copies files to remote server and moves them to a "done" folder after successful import
# Helper function to query an event ID from the relay
# Returns 0 if event found, 1 if not found
query_event_on_relay() {
local event_id="$1"
local relay_ws_http="ws://${REMOTE_HOST}:7777/"
local relay_ws_https="wss://${REMOTE_HOST}:7777/"
local event_found=0
for relay_ws in "$relay_ws_http" "$relay_ws_https"; do
if command -v websocat >/dev/null 2>&1; then
local query_msg="[\"REQ\",\"verify-query-$(date +%s)\",{\"ids\":[\"${event_id}\"]}]"
local query_result=$(timeout 10 bash -c "echo '$query_msg' | websocat '$relay_ws' 2>&1" | head -20)
if echo "$query_result" | grep -q "\"id\":\"${event_id}\""; then
return 0
fi
elif command -v python3 >/dev/null 2>&1; then
python3 <<PYEOF
import json
import sys
import asyncio
import ssl
try:
import websockets
except ImportError:
sys.exit(1)
async def query_event(relay_url, event_id):
try:
ssl_context = None
if relay_url.startswith('wss://'):
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
async with websockets.connect(relay_url, timeout=10, ssl=ssl_context) as ws:
# Read initial messages (AUTH, etc.) but don't return True for them
try:
initial_response = await asyncio.wait_for(ws.recv(), timeout=3.0)
# Just consume it, don't check it
except asyncio.TimeoutError:
pass
except Exception:
pass
# Send REQ for the specific event ID
req_msg = ["REQ", f"verify-query-{int(__import__('time').time())}", {"ids": [event_id]}]
await ws.send(json.dumps(req_msg))
# Wait for responses - we need to find the actual EVENT with matching ID
# Keep reading until we get EOSE or timeout
found_event = False
try:
while True:
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
data = json.loads(response)
if isinstance(data, list) and len(data) > 0:
if data[0] == "EVENT" and len(data) > 2:
event_data = data[2]
if isinstance(event_data, dict) and event_data.get("id") == event_id:
found_event = True
break
elif data[0] == "EOSE":
# End of stored events - stop waiting
break
except asyncio.TimeoutError:
pass
except Exception:
pass
return found_event
except Exception:
return False
relay_url = "${relay_ws}"
event_id = "${event_id}"
result = asyncio.run(query_event(relay_url, event_id))
if result:
sys.exit(0)
else:
sys.exit(1)
PYEOF
if [ $? -eq 0 ]; then
return 0
fi
fi
done
return 1
}
# Check if exports directory exists
if [ -z "$EXPORTS_DIR" ] || [ ! -d "$EXPORTS_DIR" ]; then
echo -e "${RED}Error: Exports directory not found at: $SCRIPT_DIR/../scripts/exports${NC}"
echo "Expected path: $(cd "$SCRIPT_DIR/.." && pwd)/scripts/exports"
exit 1
fi
# Find all JSONL files
jsonl_files=("$EXPORTS_DIR"/*.jsonl)
# Check if any JSONL files exist
if [ ! -e "${jsonl_files[0]}" ]; then
echo -e "${YELLOW}No JSONL files found in: $EXPORTS_DIR${NC}"
exit 0
fi
# Count files
file_count=0
for file in "${jsonl_files[@]}"; do
if [ -f "$file" ]; then
file_count=$((file_count + 1))
fi
done
if [ "$file_count" -eq 0 ]; then
echo -e "${YELLOW}No JSONL files found in: $EXPORTS_DIR${NC}"
exit 0
fi
echo -e "${BLUE}=== Import Exports to Remote Server ===${NC}"
echo ""
echo "Exports directory: $EXPORTS_DIR"
echo "Remote server: ${REMOTE_USER}@${REMOTE_HOST}"
echo "Remote temp directory: ${REMOTE_TEMP_DIR}"
echo "Docker container: ${DOCKER_CONTAINER}"
echo "Files found: $file_count"
echo ""
# Check if SSH is available
if ! command -v ssh >/dev/null 2>&1; then
echo -e "${RED}Error: ssh command not found. Please install OpenSSH client.${NC}"
exit 1
fi
# Check if SCP is available
if ! command -v scp >/dev/null 2>&1; then
echo -e "${RED}Error: scp command not found. Please install OpenSSH client.${NC}"
exit 1
fi
# Test SSH connection
echo -e "${CYAN}Testing SSH connection to ${REMOTE_USER}@${REMOTE_HOST}...${NC}"
if ! ssh -o ConnectTimeout=10 -o BatchMode=yes "${REMOTE_USER}@${REMOTE_HOST}" "echo 'Connection successful'" 2>/dev/null; then
echo -e "${YELLOW}Warning: SSH connection test failed. You may be prompted for password/key.${NC}"
echo "Continuing anyway..."
fi
echo ""
# Check if Docker container exists on remote
echo -e "${CYAN}Checking Docker container on remote server...${NC}"
if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker ps --format '{{.Names}}' | grep -q '^${DOCKER_CONTAINER}$'" 2>/dev/null; then
echo -e "${RED}Error: Docker container '${DOCKER_CONTAINER}' not found on remote server.${NC}"
echo "Available containers:"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker ps --format '{{.Names}}'" 2>/dev/null || true
exit 1
fi
echo -e "${GREEN}✓ Docker container '${DOCKER_CONTAINER}' found${NC}"
echo ""
# Check if NOSTR_PRIVATE_KEY is available (local or in container)
echo -e "${CYAN}Checking for NOSTR_PRIVATE_KEY...${NC}"
container_has_key=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u orly ${DOCKER_CONTAINER} sh -c 'echo \$NOSTR_PRIVATE_KEY'" 2>/dev/null | grep -v '^$' || echo "")
if [ -n "$container_has_key" ]; then
echo -e "${GREEN}✓ NOSTR_PRIVATE_KEY: Found in container (will use for localhost authentication)${NC}"
elif [ -n "$NOSTR_PRIVATE_KEY" ]; then
echo -e "${GREEN}✓ NOSTR_PRIVATE_KEY: Found in local environment${NC}"
echo -e "${CYAN} Will pass it to container for localhost authentication${NC}"
else
echo -e "${YELLOW}⚠ NOSTR_PRIVATE_KEY: Not found${NC}"
echo -e "${YELLOW} Set it locally: export NOSTR_PRIVATE_KEY=nsec1your_key_here${NC}"
echo -e "${YELLOW} Or add to container environment when running docker${NC}"
fi
echo ""
# Create temp directory on remote (needed for pre-flight test)
echo -e "${CYAN}Creating temporary directory on remote server...${NC}"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "mkdir -p '${REMOTE_TEMP_DIR}'" || {
echo -e "${RED}Error: Failed to create temp directory on remote server${NC}"
exit 1
}
echo -e "${GREEN}✓ Temp directory created${NC}"
echo ""
# Pre-flight test: Send a test event and verify it appears on the relay
# This runs BEFORE copying files to catch configuration issues early
echo -e "${CYAN}=== Pre-flight Test: Verifying Import Works ===${NC}"
echo " Creating and sending a test event..."
echo " (This verifies configuration before copying large files)"
# Determine which NOSTR_PRIVATE_KEY to use
TEST_NOSTR_KEY=""
if [ -n "$container_has_key" ]; then
TEST_NOSTR_KEY="$container_has_key"
echo " Using NOSTR_PRIVATE_KEY from container for test event"
elif [ -n "$NOSTR_PRIVATE_KEY" ]; then
TEST_NOSTR_KEY="$NOSTR_PRIVATE_KEY"
echo " Using NOSTR_PRIVATE_KEY from local environment for test event"
else
echo -e "${RED}✗ NOSTR_PRIVATE_KEY not found - cannot create signed test event${NC}"
echo -e "${RED}✗ Pre-flight test FAILED - Need NOSTR_PRIVATE_KEY for write permissions${NC}"
exit 1
fi
# Create a test event using Go
TEST_EVENT_FILE=$(mktemp /tmp/orly-test-event-XXXXXX.jsonl)
TEST_EVENT_ID=""
# Get the script directory to find the Go program
GO_PROGRAM="${SCRIPT_DIR}/create-test-event.go"
# Compile and run the Go program
if command -v go >/dev/null 2>&1; then
echo " Creating signed test event with Go..."
# Compile the Go program to a temporary binary
TEMP_BINARY=$(mktemp /tmp/orly-test-event-XXXXXX)
TEMP_ERR=$(mktemp /tmp/orly-test-event-err-XXXXXX)
if ! go build -o "$TEMP_BINARY" "$GO_PROGRAM" 2>"$TEMP_ERR"; then
COMPILE_ERR=$(cat "$TEMP_ERR" 2>/dev/null || echo "")
echo -e "${RED}✗ Failed to compile test event generator${NC}"
if [ -n "$COMPILE_ERR" ]; then
echo " Compile error: $COMPILE_ERR"
fi
rm -f "$TEST_EVENT_FILE" "$TEMP_BINARY" "$TEMP_ERR"
exit 1
fi
rm -f "$TEMP_ERR"
# Run it with NOSTR_PRIVATE_KEY
TEST_EVENT_JSON=$(NOSTR_PRIVATE_KEY="$TEST_NOSTR_KEY" "$TEMP_BINARY" 2>"$TEMP_ERR")
EXIT_CODE=$?
ERROR_OUTPUT=$(cat "$TEMP_ERR" 2>/dev/null || echo "")
# Clean up binary and error file
rm -f "$TEMP_BINARY" "$TEMP_ERR"
if [ $EXIT_CODE -ne 0 ] || [ -z "$TEST_EVENT_JSON" ]; then
echo -e "${RED}✗ Failed to create signed test event${NC}"
if [ -n "$ERROR_OUTPUT" ]; then
echo " Error: $ERROR_OUTPUT"
fi
rm -f "$TEST_EVENT_FILE"
exit 1
fi
# Save the event JSON
echo "$TEST_EVENT_JSON" > "$TEST_EVENT_FILE"
# Extract event ID
if command -v jq >/dev/null 2>&1; then
TEST_EVENT_ID=$(echo "$TEST_EVENT_JSON" | jq -r '.id' 2>/dev/null || echo "")
elif command -v python3 >/dev/null 2>&1; then
TEST_EVENT_ID=$(echo "$TEST_EVENT_JSON" | python3 -c "import sys, json; print(json.load(sys.stdin).get('id', ''))" 2>/dev/null || echo "")
else
# Fallback: extract ID using grep/sed
TEST_EVENT_ID=$(echo "$TEST_EVENT_JSON" | grep -o '"id":"[^"]*"' | sed 's/"id":"\([^"]*\)"/\1/' | head -1)
fi
else
echo -e "${RED}✗ Go compiler not found - cannot create signed test event${NC}"
echo -e "${YELLOW} Install Go: https://golang.org/dl/${NC}"
exit 1
fi
if [ -z "$TEST_EVENT_ID" ] || [ ! -s "$TEST_EVENT_FILE" ]; then
echo -e "${RED}✗ Failed to create test event or extract event ID${NC}"
rm -f "$TEST_EVENT_FILE"
exit 1
fi
echo " Test event ID: ${TEST_EVENT_ID:0:16}..."
echo " Uploading test event to container..."
# Copy test event to remote server first, then into container
REMOTE_TEMP_EVENT="/tmp/orly-test-event-$(date +%s).jsonl"
if ! scp "$TEST_EVENT_FILE" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_TEMP_EVENT}" 2>&1; then
echo -e "${RED}✗ Failed to copy test event to remote server${NC}"
rm -f "$TEST_EVENT_FILE"
exit 1
fi
# Now copy from remote server into container
if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker cp ${REMOTE_TEMP_EVENT} ${DOCKER_CONTAINER}:/tmp/test_event.jsonl" 2>&1; then
echo -e "${RED}✗ Failed to copy test event to container${NC}"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -f ${REMOTE_TEMP_EVENT}" 2>/dev/null || true
rm -f "$TEST_EVENT_FILE"
exit 1
fi
# Fix ownership and permissions so the orly user can read it
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u root ${DOCKER_CONTAINER} chown orly:orly /tmp/test_event.jsonl && docker exec -u root ${DOCKER_CONTAINER} chmod 644 /tmp/test_event.jsonl" 2>/dev/null || true
# Clean up remote temp file
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -f ${REMOTE_TEMP_EVENT}" 2>/dev/null || true
# Send test event via import API
echo " Sending test event via import API..."
REMOTE_RESPONSE_FILE="/tmp/orly-test-response-$(date +%s).txt"
if [ -n "$container_has_key" ]; then
ssh "${REMOTE_USER}@${REMOTE_HOST}" "timeout 30 bash -c 'docker exec -u orly ${DOCKER_CONTAINER} curl --max-time 25 --connect-timeout 5 -s -w \"\\n%{http_code}\" -X POST -F \"file=@/tmp/test_event.jsonl\" \"http://127.0.0.1:7777/api/import?async=true\" > ${REMOTE_RESPONSE_FILE} 2>&1'" 2>&1
CURL_EXIT=$?
elif [ -n "$NOSTR_PRIVATE_KEY" ]; then
escaped_key=$(echo "$NOSTR_PRIVATE_KEY" | sed "s/'/'\\\\''/g")
ssh "${REMOTE_USER}@${REMOTE_HOST}" "timeout 30 bash -c 'docker exec -u orly ${DOCKER_CONTAINER} sh -c \"export NOSTR_PRIVATE_KEY=\\\"$escaped_key\\\" && curl --max-time 25 --connect-timeout 5 -s -w \\\"\\\\n%{http_code}\\\" -X POST -F \\\"file=@/tmp/test_event.jsonl\\\" \\\"http://127.0.0.1:7777/api/import?async=true\\\"\" > ${REMOTE_RESPONSE_FILE} 2>&1'" 2>&1
CURL_EXIT=$?
else
ssh "${REMOTE_USER}@${REMOTE_HOST}" "timeout 30 bash -c 'docker exec -u orly ${DOCKER_CONTAINER} curl --max-time 25 --connect-timeout 5 -s -w \"\\n%{http_code}\" -X POST -F \"file=@/tmp/test_event.jsonl\" \"http://127.0.0.1:7777/api/import?async=true\" > ${REMOTE_RESPONSE_FILE} 2>&1'" 2>&1
CURL_EXIT=$?
fi
# Retrieve the response file
test_response=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat ${REMOTE_RESPONSE_FILE} 2>/dev/null" 2>/dev/null || echo "")
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -f ${REMOTE_RESPONSE_FILE}" 2>/dev/null || true
# Check if curl timed out or failed
if [ $CURL_EXIT -eq 124 ]; then
echo -e "${RED}✗ Test upload timed out after 30 seconds${NC}"
rm -f "$TEST_EVENT_FILE"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u orly ${DOCKER_CONTAINER} rm -f /tmp/test_event.jsonl" 2>/dev/null || true
exit 1
elif [ $CURL_EXIT -ne 0 ]; then
echo -e "${RED}✗ Test upload failed (exit code: $CURL_EXIT)${NC}"
rm -f "$TEST_EVENT_FILE"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u orly ${DOCKER_CONTAINER} rm -f /tmp/test_event.jsonl" 2>/dev/null || true
exit 1
fi
test_http_code=$(echo "$test_response" | tail -n1 | tr -d '[:space:]')
test_body=$(echo "$test_response" | sed '$d')
if [ "$test_http_code" -ne 200 ] && [ "$test_http_code" -ne 202 ]; then
echo -e "${RED}✗ Test upload failed (HTTP $test_http_code)${NC}"
rm -f "$TEST_EVENT_FILE"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u orly ${DOCKER_CONTAINER} rm -f /tmp/test_event.jsonl" 2>/dev/null || true
exit 1
fi
echo -e "${GREEN}✓ Test event uploaded successfully (HTTP $test_http_code)${NC}"
echo " Waiting 5 seconds for event to be processed..."
sleep 5
echo " Querying relay for test event..."
RELAY_WS_HTTP="ws://${REMOTE_HOST}:7777/"
RELAY_WS_HTTPS="wss://${REMOTE_HOST}:7777/"
RELAY_WS=""
EVENT_FOUND=0
# Try to query using websocat or Python
for RELAY_WS in "$RELAY_WS_HTTP" "$RELAY_WS_HTTPS"; do
echo " Trying ${RELAY_WS}..."
if command -v websocat >/dev/null 2>&1; then
QUERY_MSG="[\"REQ\",\"test-query-$(date +%s)\",{\"ids\":[\"${TEST_EVENT_ID}\"]}]"
QUERY_RESULT=$(timeout 10 bash -c "echo '$QUERY_MSG' | websocat '$RELAY_WS' 2>&1" | head -20)
if echo "$QUERY_RESULT" | grep -q "\"id\":\"${TEST_EVENT_ID}\""; then
EVENT_FOUND=1
break
fi
elif command -v python3 >/dev/null 2>&1; then
echo " Using Python websockets to query..."
QUERY_RESULT=$(python3 <<PYEOF
import json
import sys
import asyncio
import ssl
try:
import websockets
except ImportError:
print("ERROR: websockets library not installed", file=sys.stderr)
sys.exit(1)
async def query_event(relay_url, event_id):
try:
ssl_context = None
if relay_url.startswith('wss://'):
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
async with websockets.connect(relay_url, timeout=10, ssl=ssl_context) as ws:
# Read initial messages (AUTH, etc.) but don't return True for them
try:
initial_response = await asyncio.wait_for(ws.recv(), timeout=3.0)
# Just consume it, don't check it
except asyncio.TimeoutError:
pass
except Exception:
pass
# Send REQ for the specific event ID
req_msg = ["REQ", f"test-query-{int(__import__('time').time())}", {"ids": [event_id]}]
await ws.send(json.dumps(req_msg))
# Wait for responses - we need to find the actual EVENT with matching ID
# Keep reading until we get EOSE or timeout
found_event = False
try:
while True:
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
data = json.loads(response)
if isinstance(data, list) and len(data) > 0:
if data[0] == "EVENT" and len(data) > 2:
event_data = data[2]
if isinstance(event_data, dict) and event_data.get("id") == event_id:
found_event = True
break
elif data[0] == "EOSE":
# End of stored events - stop waiting
break
except asyncio.TimeoutError:
pass
except Exception:
pass
return found_event
except Exception:
return False
relay_url = "${RELAY_WS}"
event_id = "${TEST_EVENT_ID}"
result = asyncio.run(query_event(relay_url, event_id))
if result:
sys.exit(0)
else:
sys.exit(1)
PYEOF
)
QUERY_EXIT=$?
if [ $QUERY_EXIT -eq 0 ]; then
EVENT_FOUND=1
break
fi
else
echo -e "${YELLOW} Warning: Neither websocat nor Python websockets available${NC}"
break
fi
done
# Clean up test files
rm -f "$TEST_EVENT_FILE"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u orly ${DOCKER_CONTAINER} rm -f /tmp/test_event.jsonl" 2>/dev/null || true
if [ "$EVENT_FOUND" -eq 1 ]; then
echo -e "${GREEN}✓ Test event found on relay${NC}"
echo -e "${GREEN}✓ Pre-flight test PASSED - Import is working${NC}"
else
echo -e "${RED}✗ Test event NOT found on relay${NC}"
echo -e "${RED}✗ Pre-flight test FAILED - Import is not working${NC}"
echo -e "${YELLOW} Fix configuration issues before proceeding${NC}"
exit 1
fi
echo ""
echo -e "${GREEN}✓ All pre-flight checks passed. Proceeding with file copy and import...${NC}"
echo ""
# Process files one at a time: copy, extract event ID, import, verify, move to done
echo -e "${BLUE}=== Processing Files One at a Time ===${NC}"
echo -e "${CYAN}Method: Using HTTP API import (synchronous, no container restart needed)${NC}"
echo ""
success_count=0
fail_count=0
current=0
DONE_DIR="${REMOTE_TEMP_DIR}/done"
# Ensure temp and done directories exist on remote
ssh "${REMOTE_USER}@${REMOTE_HOST}" "mkdir -p '${REMOTE_TEMP_DIR}' '${DONE_DIR}'" 2>/dev/null || true
# Process each local file one at a time
for local_file in "${jsonl_files[@]}"; do
if [ ! -f "$local_file" ]; then
continue
fi
current=$((current + 1))
filename=$(basename "$local_file")
file_size=$(du -h "$local_file" | cut -f1)
remote_file="${REMOTE_TEMP_DIR}/${filename}"
echo ""
echo -e "${BLUE}=== [$current/$file_count] Processing: $filename (${file_size}) ===${NC}"
echo ""
# Step 1: Copy file to remote server
echo -e "${CYAN}[1/5] Copying file to remote server...${NC}"
if ! scp "$local_file" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_TEMP_DIR}/" 2>&1; then
echo -e "${RED}✗ Failed to copy: $filename${NC}"
fail_count=$((fail_count + 1))
echo -e "${YELLOW} Skipping this file and continuing to next...${NC}"
continue
fi
echo -e "${GREEN}✓ Copied: $filename${NC}"
echo ""
# Step 2: Extract event ID from the remote file
echo -e "${CYAN}[2/5] Extracting event ID from file...${NC}"
file_event_id=""
# Extract first event ID from the file (look for "id":"..." pattern)
# Try multiple patterns to handle different JSON formats
file_event_id=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "head -1 '${remote_file}' 2>/dev/null | grep -oE '\"id\"[[:space:]]*:[[:space:]]*\"[a-f0-9]{64}\"' | head -1 | sed -E 's/\"id\"[[:space:]]*:[[:space:]]*\"([^\"]+)\"/\1/'" || echo "")
# If that didn't work, try simpler pattern
if [ -z "$file_event_id" ]; then
file_event_id=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "head -1 '${remote_file}' 2>/dev/null | grep -o '\"id\":\"[a-f0-9]\{64\}\"' | head -1 | sed 's/\"id\":\"\([^\"]*\)\"/\1/'" || echo "")
fi
# If still no ID, try to find any 64-char hex string that looks like an event ID
if [ -z "$file_event_id" ]; then
file_event_id=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "head -1 '${remote_file}' 2>/dev/null | grep -oE '[a-f0-9]{64}' | head -1" || echo "")
fi
if [ -n "$file_event_id" ]; then
echo -e "${GREEN}✓ Extracted event ID: ${file_event_id:0:16}...${NC}"
else
echo -e "${YELLOW}⚠ Could not extract event ID (will skip verification)${NC}"
fi
echo ""
# Step 3: Import the file via HTTP API
echo -e "${CYAN}[3/5] Importing file via HTTP API...${NC}"
# Check file size first
file_size_bytes=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "stat -c%s '${remote_file}' 2>/dev/null" || echo "0")
file_size_mb=$(echo "scale=2; $file_size_bytes / 1024 / 1024" | bc 2>/dev/null || echo "0")
echo " File size: ${file_size_mb} MB"
import_success=false
# Copy file into container for HTTP API import
echo " Copying file into container..."
if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker cp '${remote_file}' ${DOCKER_CONTAINER}:/tmp/" 2>&1; then
echo -e "${RED}✗ Failed to copy file into container${NC}"
fail_count=$((fail_count + 1))
echo -e "${YELLOW} Skipping this file and continuing to next...${NC}"
continue
fi
# Fix ownership and permissions
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u root ${DOCKER_CONTAINER} chown orly:orly /tmp/${filename} && docker exec -u root ${DOCKER_CONTAINER} chmod 644 /tmp/${filename}" 2>/dev/null || true
# Calculate timeout based on file size
timeout_seconds=$((file_size_bytes / 1024 / 1024 * 30)) # ~30 seconds per MB
if [ $timeout_seconds -lt 300 ]; then
timeout_seconds=300 # Minimum 5 minutes
elif [ $timeout_seconds -gt 7200 ]; then
timeout_seconds=7200 # Maximum 2 hours
fi
echo " Using timeout: ${timeout_seconds}s for ${file_size_mb} MB file"
echo ""
# Start streaming container logs in background to show import progress
# Use prominent log prefix so it's easy to find in logs
LOG_PREFIX="[IMPORT ${filename}]"
echo -e "${CYAN} Starting import via HTTP API (synchronous mode)...${NC}"
echo -e "${CYAN} Streaming container logs with prefix: ${LOG_PREFIX}${NC}"
echo ""
# Start log streaming in background
LOG_TAIL_PID=""
(ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker logs -f --tail 0 ${DOCKER_CONTAINER} 2>&1" | while IFS= read -r line; do
# Show all lines with [HTTP API IMPORT] prefix (our new prominent logging)
# Also show other import-related lines for context
if echo "$line" | grep -qiE "\[HTTP API IMPORT\]|import.*progress|import.*complete|import.*failed|import.*error"; then
echo -e "${CYAN} ${LOG_PREFIX} $line${NC}" >&2
fi
done) &
LOG_TAIL_PID=$!
sleep 1 # Give log stream a moment to start
# Get NOSTR_PRIVATE_KEY for authentication
container_has_key=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u orly ${DOCKER_CONTAINER} sh -c 'echo \$NOSTR_PRIVATE_KEY'" 2>/dev/null | grep -v '^$' || echo "")
REMOTE_IMPORT_RESPONSE="/tmp/orly-import-response-$(date +%s).txt"
# Show start time
UPLOAD_START=$(date +%s)
echo " [$(date +%H:%M:%S)] Starting HTTP API import..."
echo -e "${CYAN} ${LOG_PREFIX} Import started at $(date)${NC}"
# Run import via HTTP API (synchronous - waits for completion)
if [ -n "$container_has_key" ]; then
echo " Using NOSTR_PRIVATE_KEY from container (localhost authentication)"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "timeout ${timeout_seconds} bash -c 'docker exec -u orly ${DOCKER_CONTAINER} curl --max-time $((timeout_seconds - 5)) --connect-timeout 5 -s -w \"\\n%{http_code}\" -X POST -F \"file=@/tmp/${filename}\" \"http://127.0.0.1:7777/api/import\" > ${REMOTE_IMPORT_RESPONSE} 2>&1'" 2>&1
CURL_EXIT=$?
elif [ -n "$NOSTR_PRIVATE_KEY" ]; then
echo " Passing NOSTR_PRIVATE_KEY to container for localhost authentication"
escaped_key=$(echo "$NOSTR_PRIVATE_KEY" | sed "s/'/'\\\\''/g")
ssh "${REMOTE_USER}@${REMOTE_HOST}" "timeout ${timeout_seconds} bash -c 'docker exec -u orly ${DOCKER_CONTAINER} sh -c \"export NOSTR_PRIVATE_KEY=\\\"$escaped_key\\\" && curl --max-time $((timeout_seconds - 5)) --connect-timeout 5 -s -w \\\"\\\\n%{http_code}\\\" -X POST -F \\\"file=@/tmp/${filename}\\\" \\\"http://127.0.0.1:7777/api/import\\\"\" > ${REMOTE_IMPORT_RESPONSE} 2>&1'" 2>&1
CURL_EXIT=$?
else
echo -e "${YELLOW} Warning: No NOSTR_PRIVATE_KEY available${NC}"
echo " Trying without authentication (may fail if ACL requires auth)..."
ssh "${REMOTE_USER}@${REMOTE_HOST}" "timeout ${timeout_seconds} bash -c 'docker exec -u orly ${DOCKER_CONTAINER} curl --max-time $((timeout_seconds - 5)) --connect-timeout 5 -s -w \"\\n%{http_code}\" -X POST -F \"file=@/tmp/${filename}\" \"http://127.0.0.1:7777/api/import\" > ${REMOTE_IMPORT_RESPONSE} 2>&1'" 2>&1
CURL_EXIT=$?
fi
UPLOAD_END=$(date +%s)
UPLOAD_DURATION=$((UPLOAD_END - UPLOAD_START))
echo " [$(date +%H:%M:%S)] HTTP request completed in ${UPLOAD_DURATION}s"
# Stop log streaming
if [ -n "$LOG_TAIL_PID" ] && kill -0 "$LOG_TAIL_PID" 2>/dev/null; then
kill "$LOG_TAIL_PID" 2>/dev/null || true
wait "$LOG_TAIL_PID" 2>/dev/null || true
fi
# Get response
import_response=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat ${REMOTE_IMPORT_RESPONSE} 2>/dev/null" 2>/dev/null || echo "")
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -f ${REMOTE_IMPORT_RESPONSE}" 2>/dev/null || true
# Clean up file from container
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u orly ${DOCKER_CONTAINER} rm -f '/tmp/${filename}'" 2>/dev/null || true
# Check if curl timed out
if [ $CURL_EXIT -eq 124 ]; then
echo -e "${RED}✗ Import timed out after ${timeout_seconds} seconds${NC}"
echo -e "${CYAN} ${LOG_PREFIX} Import timed out${NC}"
fail_count=$((fail_count + 1))
echo -e "${YELLOW} Skipping this file and continuing to next...${NC}"
continue
elif [ $CURL_EXIT -ne 0 ]; then
echo -e "${RED}✗ Import failed (exit code: $CURL_EXIT)${NC}"
echo -e "${CYAN} ${LOG_PREFIX} Import failed with exit code $CURL_EXIT${NC}"
fail_count=$((fail_count + 1))
echo -e "${YELLOW} Skipping this file and continuing to next...${NC}"
continue
fi
# Extract HTTP status code and body
import_http_code=$(echo "$import_response" | tail -n1 | tr -d '[:space:]')
import_body=$(echo "$import_response" | sed '$d')
# Check if import was successful
if [ -z "$import_http_code" ] || ! [[ "$import_http_code" =~ ^[0-9]+$ ]]; then
fail_count=$((fail_count + 1))
echo -e "${RED}✗ Failed to import: $filename (invalid HTTP response)${NC}"
echo -e "${CYAN} ${LOG_PREFIX} Invalid HTTP response${NC}"
elif [ "$import_http_code" -eq 200 ]; then
if echo "$import_body" | grep -qi '"success":\s*true\|"message".*completed'; then
echo -e "${GREEN}✓ Imported: $filename (HTTP $import_http_code)${NC}"
echo -e "${CYAN} ${LOG_PREFIX} Import completed successfully${NC}"
import_success=true
success_count=$((success_count + 1))
else
fail_count=$((fail_count + 1))
echo -e "${RED}✗ Failed to import: $filename (HTTP $import_http_code, but no success in response)${NC}"
echo -e "${CYAN} ${LOG_PREFIX} Import response indicates failure${NC}"
fi
else
fail_count=$((fail_count + 1))
echo -e "${RED}✗ Failed to import: $filename (HTTP $import_http_code)${NC}"
echo -e "${CYAN} ${LOG_PREFIX} Import failed with HTTP $import_http_code${NC}"
if [ -n "$import_body" ]; then
echo " Response: $import_body"
fi
fi
# Step 4: Verify import
if [ "$import_success" = true ]; then
echo ""
echo -e "${CYAN}[4/5] Verifying import...${NC}"
if [ -n "$file_event_id" ]; then
echo " Verifying imported event exists on relay..."
sleep 3 # Give relay a moment to process the imported events
if query_event_on_relay "$file_event_id"; then
echo -e "${GREEN}✓ Verified: Event ${file_event_id:0:16}... from ${filename} found on relay${NC}"
else
echo -e "${YELLOW}⚠ Warning: Event ${file_event_id:0:16}... from ${filename} not yet found on relay (may need more time)${NC}"
fi
else
echo -e "${YELLOW}⚠ Skipping verification (no event ID extracted)${NC}"
fi
else
echo ""
echo -e "${CYAN}[4/5] Skipping verification (import failed)${NC}"
fi
echo ""
# Step 5: Move to done folder (only if import was successful)
if [ "$import_success" = true ]; then
echo -e "${CYAN}[5/5] Moving file to done folder...${NC}"
if ssh "${REMOTE_USER}@${REMOTE_HOST}" "test -f '${remote_file}'" 2>/dev/null; then
if ssh "${REMOTE_USER}@${REMOTE_HOST}" "mv '${remote_file}' '${DONE_DIR}/${filename}'" 2>/dev/null; then
echo -e "${GREEN}✓ Moved: ${filename} to ${DONE_DIR}${NC}"
else
echo -e "${YELLOW}⚠ Warning: Could not move ${filename} to done folder${NC}"
fi
else
echo -e "${YELLOW}⚠ Warning: File ${filename} not found on remote (may have been moved already)${NC}"
fi
else
echo -e "${CYAN}[5/5] Skipping move to done (import failed - file remains in ${REMOTE_TEMP_DIR})${NC}"
fi
echo ""
done
# Summary
echo ""
echo -e "${BLUE}=== Import Summary ===${NC}"
echo -e "${GREEN}Files succeeded: $success_count${NC}"
if [ "$fail_count" -gt 0 ]; then
echo -e "${RED}Files failed: $fail_count${NC}"
fi
echo ""
if [ "$fail_count" -eq 0 ]; then
echo -e "${GREEN}All files imported successfully!${NC}"
echo -e "${GREEN}All successful files have been moved to ${DONE_DIR}${NC}"
exit 0
else
echo -e "${YELLOW}Some imports failed. Check the errors above.${NC}"
echo -e "${YELLOW}Failed files remain in ${REMOTE_TEMP_DIR} for retry${NC}"
echo -e "${GREEN}Successful files have been moved to ${DONE_DIR}${NC}"
exit 1
fi

17
scripts/update-embedded-web.sh

@ -3,8 +3,8 @@ @@ -3,8 +3,8 @@
# Build the embedded web UI and then install the Go binary.
#
# This script will:
# - Build the React app in app/web to app/web/dist using Bun (preferred),
# or fall back to npm/yarn/pnpm if Bun isn't available.
# - Build the Svelte app in app/web to app/web/dist using npm (preferred),
# or fall back to yarn/pnpm if npm isn't available.
# - Run `go install` from the repository root so the binary picks up the new
# embedded assets.
#
@ -13,7 +13,7 @@ @@ -13,7 +13,7 @@
#
# Requirements:
# - Go 1.18+ installed (for `go install` and go:embed support)
# - Bun (https://bun.sh) recommended; alternatively Node.js with npm/yarn/pnpm
# - Node.js with npm (recommended); alternatively yarn or pnpm
#
set -euo pipefail
@ -32,16 +32,14 @@ fi @@ -32,16 +32,14 @@ fi
# Choose a JS package runner
JS_RUNNER=""
if command -v bun >/dev/null 2>&1; then
JS_RUNNER="bun"
elif command -v npm >/dev/null 2>&1; then
if command -v npm >/dev/null 2>&1; then
JS_RUNNER="npm"
elif command -v yarn >/dev/null 2>&1; then
JS_RUNNER="yarn"
elif command -v pnpm >/dev/null 2>&1; then
JS_RUNNER="pnpm"
else
err "No JavaScript package manager found. Install Bun (recommended) or npm/yarn/pnpm."
err "No JavaScript package manager found. Install npm (recommended), yarn, or pnpm."
exit 1
fi
@ -51,11 +49,6 @@ log "Using JavaScript runner: ${JS_RUNNER}" @@ -51,11 +49,6 @@ log "Using JavaScript runner: ${JS_RUNNER}"
log "Installing frontend dependencies..."
pushd "${WEB_DIR}" >/dev/null
case "${JS_RUNNER}" in
bun)
bun install
log "Building web app with Bun..."
bun run build
;;
npm)
npm ci || npm install
log "Building web app with npm..."

Loading…
Cancel
Save