Compare commits

...

8 Commits

  1. 12
      Dockerfile
  2. 82
      Dockerfile.with-web
  3. 182
      README.md
  4. 129
      RUN_REMOTE.md
  5. 9
      app/handle-negentropy.go
  6. 40
      app/handle-req.go
  7. 410
      app/server.go
  8. 91
      app/web/dist/bundle.css
  9. 25
      app/web/dist/bundle.js
  10. 1
      app/web/dist/bundle.js.map
  11. BIN
      app/web/dist/favicon.png
  12. 69
      app/web/dist/global.css
  13. 45
      app/web/dist/index.html
  14. BIN
      app/web/dist/orly.png
  15. 33
      app/web/package-lock.json
  16. 157
      app/web/src/App.svelte
  17. 98
      app/web/src/ComposeView.svelte
  18. 145
      app/web/src/EventsView.svelte
  19. 2
      app/web/src/FilterBuilder.svelte
  20. 2
      app/web/src/FilterDisplay.svelte
  21. 5
      app/web/src/ImportView.svelte
  22. 3
      app/web/src/constants.js
  23. 7
      app/web/src/helpers.js
  24. 61
      build-image-v0.48.10.sh
  25. 187
      build-image-v0.58.5.sh
  26. 12
      cmd/aggregator/main.go
  27. 3
      cmd/orly/db/db.go
  28. 131
      cmd/orly/db/import.go
  29. 1
      cmd/orly/main.go
  30. 94
      docker-compose-orly.yml
  31. 3
      go.mod
  32. 2
      go.sum
  33. 6
      main.go
  34. 6
      package-lock.json
  35. 18
      pkg/spider/directory.go
  36. 127
      pkg/spider/spider.go
  37. 89
      run-orly-docker.sh
  38. 17
      scripts/update-embedded-web.sh

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 /
```

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 {

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)
}
}
}()

410
app/server.go

@ -9,17 +9,30 @@ import ( @@ -9,17 +9,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 +42,18 @@ import ( @@ -29,25 +42,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/wireguard"
"next.orly.dev/pkg/archive"
"next.orly.dev/pkg/tor"
"next.orly.dev/pkg/wireguard"
)
type Server struct {
@ -440,6 +446,8 @@ func (s *Server) UserInterface() { @@ -440,6 +446,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 +1117,12 @@ func (s *Server) handleEventsMine(w http.ResponseWriter, r *http.Request) { @@ -1109,6 +1117,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,38 +1130,96 @@ func (s *Server) handleEventsMine(w http.ResponseWriter, r *http.Request) { @@ -1116,38 +1130,96 @@ 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
pubkey, err = s.authenticateLocalhost(r)
if err != nil {
errorMsg = err.Error()
// Fall back to NIP-98 authentication
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 - 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
}
}
// Check if this is a folder import
folderPath := r.URL.Query().Get("folder")
if folderPath != "" {
s.handleImportFolder(w, r, folderPath)
return
}
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); chk.E(err) { // 32MB memory, rest to temp files
@ -1174,6 +1246,304 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { @@ -1174,6 +1246,304 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"success": true, "message": "Import started"}`))
}
// 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) {
if r.Method != http.MethodGet {

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,

157
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) {
@ -2400,11 +2410,26 @@ @@ -2400,11 +2410,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 +2444,88 @@ @@ -2419,24 +2444,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();
@ -2718,20 +2807,50 @@ @@ -2718,20 +2807,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 +2914,12 @@ @@ -2795,6 +2914,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 +2966,12 @@ @@ -2841,6 +2966,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;
@ -3076,8 +3207,6 @@ @@ -3076,8 +3207,6 @@
bind:composeEventJson
bind:localOnly={composeLocalOnly}
{userPubkey}
{userRole}
{policyEnabled}
publishError={composePublishError}
on:reformatJson={reformatJson}
on:signEvent={signEvent}

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%;

145
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) {
@ -125,6 +114,7 @@ @@ -125,6 +114,7 @@
<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 +129,27 @@ @@ -139,11 +129,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
@ -387,6 +393,14 @@ @@ -387,6 +393,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 +410,27 @@ @@ -396,6 +410,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;
}

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()) {

61
build-image-v0.48.10.sh

@ -0,0 +1,61 @@ @@ -0,0 +1,61 @@
#!/bin/bash
# Build script for next-orly v0.48.10 Docker image
set -e
# 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
CURRENT_TAG=$(git describe --tags --exact-match HEAD 2>/dev/null || echo "")
if [ "$CURRENT_TAG" != "v0.48.10" ]; then
echo "Checking out v0.48.10..."
git checkout v0.48.10
fi
# Check if app/web/dist exists (web UI already built)
if [ -d "app/web/dist" ]; then
echo "Web UI already built, using existing Dockerfile..."
DOCKERFILE="Dockerfile"
else
echo "Web UI not found, using Dockerfile.with-web (will build web UI in Docker)..."
DOCKERFILE="Dockerfile.with-web"
fi
# Build the Docker image with both version and latest tags
echo "Building Docker image silberengel/next-orly:v0.48.10 using $DOCKER_CMD..."
$DOCKER_CMD build -t silberengel/next-orly:v0.48.10 -t silberengel/next-orly:latest -f "$DOCKERFILE" .
echo ""
echo "Build complete! Image tags:"
echo " - silberengel/next-orly:v0.48.10"
echo " - silberengel/next-orly:latest"
echo ""
echo "To push to Docker Hub:"
echo " $DOCKER_CMD push silberengel/next-orly:v0.48.10"
echo " $DOCKER_CMD push silberengel/next-orly:latest"
echo ""
echo "To run with Docker Compose:"
echo " docker compose -f docker-compose-orly.yml up -d"
echo " docker compose -f docker-compose-orly.yml logs -f"
echo " docker compose -f docker-compose-orly.yml down"

187
build-image-v0.58.5.sh

@ -0,0 +1,187 @@ @@ -0,0 +1,187 @@
#!/bin/bash
# Build script for next-orly v0.58.5 Docker image
set -e
# 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
CURRENT_TAG=$(git describe --tags --exact-match HEAD 2>/dev/null || echo "")
if [ "$CURRENT_TAG" != "v0.58.5" ]; then
echo "Warning: Not on v0.58.5 tag (current: $CURRENT_TAG)"
echo "Continuing anyway..."
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
echo "Building Docker image silberengel/next-orly:v0.58.5 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 silberengel/next-orly:v0.58.5 -t silberengel/next-orly: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 silberengel/next-orly:v0.58.5 -t silberengel/next-orly:latest -f "$DOCKERFILE" .
else
# Last resort - no DNS config
echo "Warning: No DNS configuration available, build may fail..."
$DOCKER_CMD build -t silberengel/next-orly:v0.58.5 -t silberengel/next-orly: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 silberengel/next-orly:v0.58.5 -t silberengel/next-orly:latest -f "$DOCKERFILE" .
else
$DOCKER_CMD build -t silberengel/next-orly:v0.58.5 -t silberengel/next-orly:latest -f "$DOCKERFILE" .
fi
fi
echo ""
echo "Build complete! Image tags:"
echo " - silberengel/next-orly:v0.58.5"
echo " - silberengel/next-orly:latest"
echo ""
echo "To push to Docker Hub:"
echo " $DOCKER_CMD push silberengel/next-orly:v0.58.5"
echo " $DOCKER_CMD push silberengel/next-orly: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 silberengel/next-orly:v0.58.5
$DOCKER_CMD push silberengel/next-orly: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

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 => ../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": {}
}

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()
}
}
}

89
run-orly-docker.sh

@ -0,0 +1,89 @@ @@ -0,0 +1,89 @@
#!/bin/bash
# Run Orly relay using docker run (alternative to docker-compose)
# Optimized for large dataset imports (20GB+)
set -e
CONTAINER_NAME="orly-relay"
IMAGE="silberengel/next-orly:v0.58.5"
# 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=6144m \
--cpus="2.0" \
--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=Info \
-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=512 \
-e ORLY_DB_INDEX_CACHE_MB=256 \
-e ORLY_SERIAL_CACHE_PUBKEYS=100000 \
-e ORLY_SERIAL_CACHE_EVENT_IDS=500000 \
-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=1000 \
-e ORLY_MAX_EVENT_SIZE=65536 \
-e ORLY_MAX_SUBSCRIPTIONS=20 \
-e ORLY_WEB_DISABLE=false \
-e ORLY_WEB_DEV_PROXY_URL="" \
-e ORLY_RATE_LIMIT_EMERGENCY_THRESHOLD=4.0 \
-e ORLY_RATE_LIMIT_RECOVERY_THRESHOLD=3.0 \
${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"

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