Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
cc63fe751a
|
|||
|
d96d10723a
|
|||
|
ec50afdec0
|
|||
|
ade987c9ac
|
|||
|
9f39ca8a62
|
|||
|
f85a8b99a3
|
|||
|
d7bda40e18
|
|||
|
b67961773d
|
|||
|
5fd58681c9
|
|||
|
2bdc1b7bc0
|
|||
|
332b9b05f7
|
|||
|
c43ddb77e0
|
|||
|
e90fc619f2
|
|||
|
29e5444545
|
|||
|
7ee613bb0e
|
|||
|
23985719ba
|
|||
|
3314a2a892
|
|||
|
7c14c72e9d
|
|||
|
dbdc5d703e
|
@@ -38,7 +38,7 @@ describing how the item is used.
|
||||
For documentation on package, summarise in up to 3 sentences the functions and
|
||||
purpose of the package
|
||||
|
||||
Do not use markdown ** or __ or any similar things in initial words of a bullet
|
||||
Do not use markdown \*\* or \_\_ or any similar things in initial words of a bullet
|
||||
point, instead use standard godoc style # prefix for header sections
|
||||
|
||||
ALWAYS separate each bullet point with an empty line, and ALWAYS indent them
|
||||
@@ -90,10 +90,10 @@ A good typical example:
|
||||
|
||||
```
|
||||
|
||||
use the source of the relay-tester to help guide what expectations the test has,
|
||||
and use context7 for information about the nostr protocol, and use additional
|
||||
use the source of the relay-tester to help guide what expectations the test has,
|
||||
and use context7 for information about the nostr protocol, and use additional
|
||||
log statements to help locate the cause of bugs
|
||||
|
||||
always use Go v1.25.1 for everything involving Go
|
||||
|
||||
always use the nips repository also for information, found at ../github.com/nostr-protocol/nips attached to the project
|
||||
always use the nips repository also for information, found at ../github.com/nostr-protocol/nips attached to the project
|
||||
|
||||
18
.github/workflows/go.yml
vendored
18
.github/workflows/go.yml
vendored
@@ -16,10 +16,9 @@ name: Go
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -28,26 +27,25 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.25'
|
||||
go-version: "1.25"
|
||||
|
||||
- name: Install libsecp256k1
|
||||
run: ./scripts/ubuntu_install_libsecp256k1.sh
|
||||
run: ./scripts/ubuntu_install_libsecp256k1.sh
|
||||
|
||||
- name: Build with cgo
|
||||
run: go build -v ./...
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Test with cgo
|
||||
run: go test -v ./...
|
||||
run: go test -v ./...
|
||||
|
||||
- name: Set CGO off
|
||||
run: echo "CGO_ENABLED=0" >> $GITHUB_ENV
|
||||
run: echo "CGO_ENABLED=0" >> $GITHUB_ENV
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Test
|
||||
run: go test -v ./...
|
||||
|
||||
run: go test -v ./...
|
||||
# release:
|
||||
# needs: build
|
||||
# runs-on: ubuntu-latest
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -76,7 +76,7 @@ cmd/benchmark/data
|
||||
!*.css
|
||||
!*.ts
|
||||
!*.html
|
||||
!Dockerfile
|
||||
!contrib/stella/Dockerfile
|
||||
!*.lock
|
||||
!*.nix
|
||||
!license
|
||||
@@ -88,10 +88,10 @@ cmd/benchmark/data
|
||||
!.gitignore
|
||||
!version
|
||||
!out.jsonl
|
||||
!Dockerfile*
|
||||
!contrib/stella/Dockerfile
|
||||
!strfry.conf
|
||||
!config.toml
|
||||
!.dockerignore
|
||||
!contrib/stella/.dockerignore
|
||||
!*.jsx
|
||||
!*.tsx
|
||||
!app/web/dist
|
||||
@@ -99,6 +99,7 @@ cmd/benchmark/data
|
||||
!/app/web/dist/*
|
||||
!/app/web/dist/**
|
||||
!bun.lock
|
||||
!*.svelte
|
||||
# ...even if they are in subdirectories
|
||||
!*/
|
||||
/blocklist.json
|
||||
@@ -120,4 +121,5 @@ pkg/database/testrealy
|
||||
/.idea/inspectionProfiles/Project_Default.xml
|
||||
/.idea/.name
|
||||
/ctxproxy.config.yml
|
||||
cmd/benchmark/external/**
|
||||
cmd/benchmark/external/**
|
||||
app/web/dist/**
|
||||
|
||||
@@ -25,7 +25,7 @@ func (l *Listener) HandleAuth(b []byte) (err error) {
|
||||
var valid bool
|
||||
if valid, err = auth.Validate(
|
||||
env.Event, l.challenge.Load(),
|
||||
l.ServiceURL(l.req),
|
||||
l.WebSocketURL(l.req),
|
||||
); err != nil {
|
||||
e := err.Error()
|
||||
if err = Ok.Error(l, env, e); chk.E(err) {
|
||||
@@ -50,7 +50,7 @@ func (l *Listener) HandleAuth(b []byte) (err error) {
|
||||
env.Event.Pubkey,
|
||||
)
|
||||
l.authedPubkey.Store(env.Event.Pubkey)
|
||||
|
||||
|
||||
// Check if this is a first-time user and create welcome note
|
||||
go l.handleFirstTimeUser(env.Event.Pubkey)
|
||||
}
|
||||
@@ -65,17 +65,17 @@ func (l *Listener) handleFirstTimeUser(pubkey []byte) {
|
||||
log.E.F("failed to check first-time user status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if !isFirstTime {
|
||||
return // Not a first-time user
|
||||
}
|
||||
|
||||
|
||||
// Get payment processor to create welcome note
|
||||
if l.Server.paymentProcessor != nil {
|
||||
// Set the dashboard URL based on the current HTTP request
|
||||
dashboardURL := l.Server.DashboardURL(l.req)
|
||||
l.Server.paymentProcessor.SetDashboardURL(dashboardURL)
|
||||
|
||||
|
||||
if err := l.Server.paymentProcessor.CreateWelcomeNote(pubkey); err != nil {
|
||||
log.E.F("failed to create welcome note for first-time user: %v", err)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
)
|
||||
|
||||
func (l *Listener) HandleEvent(msg []byte) (err error) {
|
||||
log.D.F("handling event: %s", msg)
|
||||
// decode the envelope
|
||||
env := eventenvelope.NewSubmission()
|
||||
if msg, err = env.Unmarshal(msg); chk.E(err) {
|
||||
|
||||
@@ -19,64 +19,78 @@ func (l *Listener) HandleMessage(msg []byte, remote string) {
|
||||
if len(msgPreview) > 150 {
|
||||
msgPreview = msgPreview[:150] + "..."
|
||||
}
|
||||
log.D.F("%s processing message (len=%d): %s", remote, len(msg), msgPreview)
|
||||
|
||||
// log.D.F("%s processing message (len=%d): %s", remote, len(msg), msgPreview)
|
||||
|
||||
l.msgCount++
|
||||
var err error
|
||||
var t string
|
||||
var rem []byte
|
||||
|
||||
|
||||
// Attempt to identify the envelope type
|
||||
if t, rem, err = envelopes.Identify(msg); err != nil {
|
||||
log.E.F("%s envelope identification FAILED (len=%d): %v", remote, len(msg), err)
|
||||
log.D.F("%s malformed message content: %q", remote, msgPreview)
|
||||
log.E.F(
|
||||
"%s envelope identification FAILED (len=%d): %v", remote, len(msg),
|
||||
err,
|
||||
)
|
||||
log.T.F("%s malformed message content: %q", remote, msgPreview)
|
||||
chk.E(err)
|
||||
// Send error notice to client
|
||||
if noticeErr := noticeenvelope.NewFrom("malformed message: " + err.Error()).Write(l); noticeErr != nil {
|
||||
log.E.F("%s failed to send malformed message notice: %v", remote, noticeErr)
|
||||
log.E.F(
|
||||
"%s failed to send malformed message notice: %v", remote,
|
||||
noticeErr,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.D.F("%s identified envelope type: %s (payload_len=%d)", remote, t, len(rem))
|
||||
|
||||
|
||||
log.T.F(
|
||||
"%s identified envelope type: %s (payload_len=%d)", remote, t, len(rem),
|
||||
)
|
||||
|
||||
// Process the identified envelope type
|
||||
switch t {
|
||||
case eventenvelope.L:
|
||||
log.D.F("%s processing EVENT envelope", remote)
|
||||
log.T.F("%s processing EVENT envelope", remote)
|
||||
l.eventCount++
|
||||
err = l.HandleEvent(rem)
|
||||
case reqenvelope.L:
|
||||
log.D.F("%s processing REQ envelope", remote)
|
||||
log.T.F("%s processing REQ envelope", remote)
|
||||
l.reqCount++
|
||||
err = l.HandleReq(rem)
|
||||
case closeenvelope.L:
|
||||
log.D.F("%s processing CLOSE envelope", remote)
|
||||
log.T.F("%s processing CLOSE envelope", remote)
|
||||
err = l.HandleClose(rem)
|
||||
case authenvelope.L:
|
||||
log.D.F("%s processing AUTH envelope", remote)
|
||||
log.T.F("%s processing AUTH envelope", remote)
|
||||
err = l.HandleAuth(rem)
|
||||
case countenvelope.L:
|
||||
log.D.F("%s processing COUNT envelope", remote)
|
||||
log.T.F("%s processing COUNT envelope", remote)
|
||||
err = l.HandleCount(rem)
|
||||
default:
|
||||
err = fmt.Errorf("unknown envelope type %s", t)
|
||||
log.E.F("%s unknown envelope type: %s (payload: %q)", remote, t, string(rem))
|
||||
log.E.F(
|
||||
"%s unknown envelope type: %s (payload: %q)", remote, t,
|
||||
string(rem),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Handle any processing errors
|
||||
if err != nil {
|
||||
log.E.F("%s message processing FAILED (type=%s): %v", remote, t, err)
|
||||
log.D.F("%s error context - original message: %q", remote, msgPreview)
|
||||
|
||||
log.T.F("%s error context - original message: %q", remote, msgPreview)
|
||||
|
||||
// Send error notice to client
|
||||
noticeMsg := fmt.Sprintf("%s: %s", t, err.Error())
|
||||
if noticeErr := noticeenvelope.NewFrom(noticeMsg).Write(l); noticeErr != nil {
|
||||
log.E.F("%s failed to send error notice after %s processing failure: %v", remote, t, noticeErr)
|
||||
log.E.F(
|
||||
"%s failed to send error notice after %s processing failure: %v",
|
||||
remote, t, noticeErr,
|
||||
)
|
||||
return
|
||||
}
|
||||
log.D.F("%s sent error notice for %s processing failure", remote, t)
|
||||
log.T.F("%s sent error notice for %s processing failure", remote, t)
|
||||
} else {
|
||||
log.D.F("%s message processing SUCCESS (type=%s)", remote, t)
|
||||
log.T.F("%s message processing SUCCESS (type=%s)", remote, t)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,13 +29,20 @@ import (
|
||||
)
|
||||
|
||||
func (l *Listener) HandleReq(msg []byte) (err error) {
|
||||
log.D.F("HandleReq: START processing from %s", l.remote)
|
||||
log.D.F("handling REQ: %s", msg)
|
||||
log.T.F("HandleReq: START processing from %s", l.remote)
|
||||
// var rem []byte
|
||||
env := reqenvelope.New()
|
||||
if _, err = env.Unmarshal(msg); chk.E(err) {
|
||||
return normalize.Error.Errorf(err.Error())
|
||||
}
|
||||
log.D.C(func() string { return fmt.Sprintf("REQ sub=%s filters=%d", env.Subscription, len(*env.Filters)) })
|
||||
log.T.C(
|
||||
func() string {
|
||||
return fmt.Sprintf(
|
||||
"REQ sub=%s filters=%d", env.Subscription, len(*env.Filters),
|
||||
)
|
||||
},
|
||||
)
|
||||
// send a challenge to the client to auth if an ACL is active
|
||||
if acl.Registry.Active.Load() != "none" {
|
||||
if err = authenvelope.NewChallengeWith(l.challenge.Load()).
|
||||
@@ -100,9 +107,15 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
|
||||
if f.Until != nil {
|
||||
until = f.Until.Int()
|
||||
}
|
||||
log.D.C(func() string {
|
||||
return fmt.Sprintf("REQ %s filter: kinds.len=%d authors.len=%d ids.len=%d d=%q limit=%v since=%v until=%v", env.Subscription, kindsLen, authorsLen, idsLen, dtag, lim, since, until)
|
||||
})
|
||||
log.T.C(
|
||||
func() string {
|
||||
return fmt.Sprintf(
|
||||
"REQ %s filter: kinds.len=%d authors.len=%d ids.len=%d d=%q limit=%v since=%v until=%v",
|
||||
env.Subscription, kindsLen, authorsLen, idsLen, dtag,
|
||||
lim, since, until,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if f != nil && pointers.Present(f.Limit) {
|
||||
if *f.Limit == 0 {
|
||||
@@ -229,7 +242,7 @@ privCheck:
|
||||
events = tmp
|
||||
seen := make(map[string]struct{})
|
||||
for _, ev := range events {
|
||||
log.D.C(
|
||||
log.T.C(
|
||||
func() string {
|
||||
return fmt.Sprintf(
|
||||
"REQ %s: sending EVENT id=%s kind=%d", env.Subscription,
|
||||
@@ -256,7 +269,7 @@ privCheck:
|
||||
}
|
||||
// write the EOSE to signal to the client that all events found have been
|
||||
// sent.
|
||||
log.D.F("sending EOSE to %s", l.remote)
|
||||
log.T.F("sending EOSE to %s", l.remote)
|
||||
if err = eoseenvelope.NewFrom(env.Subscription).
|
||||
Write(l); chk.E(err) {
|
||||
return
|
||||
@@ -264,7 +277,7 @@ privCheck:
|
||||
// if the query was for just Ids, we know there can't be any more results,
|
||||
// so cancel the subscription.
|
||||
cancel := true
|
||||
log.D.F(
|
||||
log.T.F(
|
||||
"REQ %s: computing cancel/subscription; events_sent=%d",
|
||||
env.Subscription, len(events),
|
||||
)
|
||||
@@ -318,6 +331,6 @@ privCheck:
|
||||
} else {
|
||||
// suppress server-sent CLOSED; client will close subscription if desired
|
||||
}
|
||||
log.D.F("HandleReq: COMPLETED processing from %s", l.remote)
|
||||
log.T.F("HandleReq: COMPLETED processing from %s", l.remote)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const (
|
||||
DefaultPongWait = 60 * time.Second
|
||||
DefaultPingWait = DefaultPongWait / 2
|
||||
DefaultWriteTimeout = 3 * time.Second
|
||||
DefaultMaxMessageSize = 1 * units.Mb
|
||||
DefaultMaxMessageSize = 100 * units.Mb
|
||||
|
||||
// CloseMessage denotes a close control message. The optional message
|
||||
// payload contains a numeric code and text. Use the FormatCloseMessage
|
||||
@@ -62,6 +62,8 @@ whitelist:
|
||||
OriginPatterns: []string{"*"}, // Allow all origins for proxy compatibility
|
||||
// Don't check origin when behind a proxy - let the proxy handle it
|
||||
InsecureSkipVerify: true,
|
||||
// Try to set a higher compression threshold to allow larger messages
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
}
|
||||
|
||||
if conn, err = websocket.Accept(w, r, acceptOptions); chk.E(err) {
|
||||
@@ -69,7 +71,10 @@ whitelist:
|
||||
return
|
||||
}
|
||||
log.T.F("websocket accepted from %s path=%s", remote, r.URL.String())
|
||||
|
||||
// Set read limit immediately after connection is established
|
||||
conn.SetReadLimit(DefaultMaxMessageSize)
|
||||
log.D.F("set read limit to %d bytes (%d MB) for %s", DefaultMaxMessageSize, DefaultMaxMessageSize/units.Mb, remote)
|
||||
defer conn.CloseNow()
|
||||
listener := &Listener{
|
||||
ctx: ctx,
|
||||
@@ -145,6 +150,14 @@ whitelist:
|
||||
log.T.F("connection from %s closed: %v", remote, err)
|
||||
return
|
||||
}
|
||||
// Handle message too big errors specifically
|
||||
if strings.Contains(err.Error(), "MessageTooBig") ||
|
||||
strings.Contains(err.Error(), "read limited at") {
|
||||
log.D.F("client %s hit message size limit: %v", remote, err)
|
||||
// Don't log this as an error since it's a client-side limit
|
||||
// Just close the connection gracefully
|
||||
return
|
||||
}
|
||||
status := websocket.CloseStatus(err)
|
||||
switch status {
|
||||
case websocket.StatusNormalClosure,
|
||||
@@ -155,6 +168,8 @@ whitelist:
|
||||
log.T.F(
|
||||
"connection from %s closed with status: %v", remote, status,
|
||||
)
|
||||
case websocket.StatusMessageTooBig:
|
||||
log.D.F("client %s sent message too big: %v", remote, err)
|
||||
default:
|
||||
log.E.F("unexpected close error from %s: %v", remote, err)
|
||||
}
|
||||
@@ -190,6 +205,10 @@ whitelist:
|
||||
writeCancel()
|
||||
continue
|
||||
}
|
||||
// Log message size for debugging
|
||||
if len(msg) > 1000 { // Only log for larger messages
|
||||
log.D.F("received large message from %s: %d bytes", remote, len(msg))
|
||||
}
|
||||
// log.T.F("received message from %s: %s", remote, string(msg))
|
||||
listener.HandleMessage(msg, remote)
|
||||
}
|
||||
@@ -244,7 +263,7 @@ func (s *Server) Pinger(
|
||||
|
||||
pingCancel()
|
||||
case <-ctx.Done():
|
||||
log.D.F("pinger context cancelled after %d pings", pingCount)
|
||||
log.T.F("pinger context cancelled after %d pings", pingCount)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@ type Listener struct {
|
||||
authedPubkey atomic.Bytes
|
||||
startTime time.Time
|
||||
// Diagnostics: per-connection counters
|
||||
msgCount int
|
||||
reqCount int
|
||||
eventCount int
|
||||
msgCount int
|
||||
reqCount int
|
||||
eventCount int
|
||||
}
|
||||
|
||||
// Ctx returns the listener's context, but creates a new context for each operation
|
||||
@@ -35,14 +35,16 @@ func (l *Listener) Ctx() context.Context {
|
||||
func (l *Listener) Write(p []byte) (n int, err error) {
|
||||
start := time.Now()
|
||||
msgLen := len(p)
|
||||
|
||||
|
||||
// Log message attempt with content preview (first 200 chars for diagnostics)
|
||||
preview := string(p)
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200] + "..."
|
||||
}
|
||||
log.D.F("ws->%s attempting write: len=%d preview=%q", l.remote, msgLen, preview)
|
||||
|
||||
log.T.F(
|
||||
"ws->%s attempting write: len=%d preview=%q", l.remote, msgLen, preview,
|
||||
)
|
||||
|
||||
// Use a separate context with timeout for writes to prevent race conditions
|
||||
// where the main connection context gets cancelled while writing events
|
||||
writeCtx, cancel := context.WithTimeout(
|
||||
@@ -55,37 +57,50 @@ func (l *Listener) Write(p []byte) (n int, err error) {
|
||||
if err = l.conn.Write(writeCtx, websocket.MessageText, p); err != nil {
|
||||
writeDuration := time.Since(writeStart)
|
||||
totalDuration := time.Since(start)
|
||||
|
||||
|
||||
// Log detailed failure information
|
||||
log.E.F("ws->%s WRITE FAILED: len=%d duration=%v write_duration=%v error=%v preview=%q",
|
||||
l.remote, msgLen, totalDuration, writeDuration, err, preview)
|
||||
|
||||
log.E.F(
|
||||
"ws->%s WRITE FAILED: len=%d duration=%v write_duration=%v error=%v preview=%q",
|
||||
l.remote, msgLen, totalDuration, writeDuration, err, preview,
|
||||
)
|
||||
|
||||
// Check if this is a context timeout
|
||||
if writeCtx.Err() != nil {
|
||||
log.E.F("ws->%s write timeout after %v (limit=%v)", l.remote, writeDuration, DefaultWriteTimeout)
|
||||
log.E.F(
|
||||
"ws->%s write timeout after %v (limit=%v)", l.remote,
|
||||
writeDuration, DefaultWriteTimeout,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Check connection state
|
||||
if l.conn != nil {
|
||||
log.D.F("ws->%s connection state during failure: remote_addr=%v", l.remote, l.req.RemoteAddr)
|
||||
log.T.F(
|
||||
"ws->%s connection state during failure: remote_addr=%v",
|
||||
l.remote, l.req.RemoteAddr,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
chk.E(err) // Still call the original error handler
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Log successful write with timing
|
||||
writeDuration := time.Since(writeStart)
|
||||
totalDuration := time.Since(start)
|
||||
n = msgLen
|
||||
|
||||
log.D.F("ws->%s WRITE SUCCESS: len=%d duration=%v write_duration=%v",
|
||||
l.remote, n, totalDuration, writeDuration)
|
||||
|
||||
|
||||
log.T.F(
|
||||
"ws->%s WRITE SUCCESS: len=%d duration=%v write_duration=%v",
|
||||
l.remote, n, totalDuration, writeDuration,
|
||||
)
|
||||
|
||||
// Log slow writes for performance diagnostics
|
||||
if writeDuration > time.Millisecond*100 {
|
||||
log.D.F("ws->%s SLOW WRITE detected: %v (>100ms) len=%d", l.remote, writeDuration, n)
|
||||
log.T.F(
|
||||
"ws->%s SLOW WRITE detected: %v (>100ms) len=%d", l.remote,
|
||||
writeDuration, n,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
522
app/server.go
522
app/server.go
@@ -22,6 +22,7 @@ import (
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
"next.orly.dev/pkg/protocol/auth"
|
||||
"next.orly.dev/pkg/protocol/httpauth"
|
||||
"next.orly.dev/pkg/protocol/publish"
|
||||
)
|
||||
|
||||
@@ -94,61 +95,47 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) ServiceURL(req *http.Request) (st string) {
|
||||
// Get host from various proxy headers
|
||||
host := req.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = req.Header.Get("Host")
|
||||
}
|
||||
if host == "" {
|
||||
host = req.Host
|
||||
}
|
||||
|
||||
// Get protocol from various proxy headers
|
||||
func (s *Server) ServiceURL(req *http.Request) (url string) {
|
||||
proto := req.Header.Get("X-Forwarded-Proto")
|
||||
if proto == "" {
|
||||
proto = req.Header.Get("X-Forwarded-Scheme")
|
||||
}
|
||||
if proto == "" {
|
||||
// Check if we're behind a proxy by looking for common proxy headers
|
||||
hasProxyHeaders := req.Header.Get("X-Forwarded-For") != "" ||
|
||||
req.Header.Get("X-Real-IP") != "" ||
|
||||
req.Header.Get("Forwarded") != ""
|
||||
|
||||
if hasProxyHeaders {
|
||||
// If we have proxy headers, assume HTTPS/WSS
|
||||
proto = "wss"
|
||||
} else if host == "localhost" {
|
||||
proto = "ws"
|
||||
} else if strings.Contains(host, ":") {
|
||||
// has a port number
|
||||
proto = "ws"
|
||||
} else if _, err := strconv.Atoi(
|
||||
strings.ReplaceAll(
|
||||
host, ".",
|
||||
"",
|
||||
),
|
||||
); chk.E(err) {
|
||||
// it's a naked IP
|
||||
proto = "ws"
|
||||
if req.TLS != nil {
|
||||
proto = "https"
|
||||
} else {
|
||||
proto = "wss"
|
||||
proto = "http"
|
||||
}
|
||||
} else if proto == "https" {
|
||||
proto = "wss"
|
||||
} else if proto == "http" {
|
||||
proto = "ws"
|
||||
}
|
||||
host := req.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = req.Host
|
||||
}
|
||||
return proto + "://" + host
|
||||
}
|
||||
|
||||
// DashboardURL constructs HTTPS URL for the dashboard based on the HTTP request
|
||||
func (s *Server) DashboardURL(req *http.Request) string {
|
||||
func (s *Server) WebSocketURL(req *http.Request) (url string) {
|
||||
proto := req.Header.Get("X-Forwarded-Proto")
|
||||
if proto == "" {
|
||||
if req.TLS != nil {
|
||||
proto = "wss"
|
||||
} else {
|
||||
proto = "ws"
|
||||
}
|
||||
} else {
|
||||
// Convert HTTP scheme to WebSocket scheme
|
||||
if proto == "https" {
|
||||
proto = "wss"
|
||||
} else if proto == "http" {
|
||||
proto = "ws"
|
||||
}
|
||||
}
|
||||
host := req.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = req.Host
|
||||
}
|
||||
return "https://" + host
|
||||
return proto + "://" + host
|
||||
}
|
||||
|
||||
func (s *Server) DashboardURL(req *http.Request) (url string) {
|
||||
return s.ServiceURL(req) + "/"
|
||||
}
|
||||
|
||||
// UserInterface sets up a basic Nostr NDK interface that allows users to log into the relay user interface
|
||||
@@ -199,9 +186,8 @@ func (s *Server) UserInterface() {
|
||||
s.mux.HandleFunc("/api/auth/status", s.handleAuthStatus)
|
||||
s.mux.HandleFunc("/api/auth/logout", s.handleAuthLogout)
|
||||
s.mux.HandleFunc("/api/permissions/", s.handlePermissions)
|
||||
// Export endpoints
|
||||
// Export endpoint
|
||||
s.mux.HandleFunc("/api/export", s.handleExport)
|
||||
s.mux.HandleFunc("/api/export/mine", s.handleExportMine)
|
||||
// Events endpoints
|
||||
s.mux.HandleFunc("/api/events/mine", s.handleEventsMine)
|
||||
// Import endpoint (admin only)
|
||||
@@ -211,40 +197,58 @@ func (s *Server) UserInterface() {
|
||||
// handleLoginInterface serves the main user interface for login
|
||||
func (s *Server) handleLoginInterface(w http.ResponseWriter, r *http.Request) {
|
||||
// In dev mode with proxy configured, forward to dev server
|
||||
if s.Config != nil && s.Config.WebDisableEmbedded && s.devProxy != nil {
|
||||
if s.devProxy != nil {
|
||||
s.devProxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// If embedded UI is disabled but no proxy configured, return a helpful message
|
||||
if s.Config != nil && s.Config.WebDisableEmbedded {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("Web UI disabled (ORLY_WEB_DISABLE=true). Run the web app in standalone dev mode (e.g., npm run dev) or set ORLY_WEB_DEV_PROXY_URL to proxy through this server."))
|
||||
return
|
||||
}
|
||||
// Default: serve embedded React app
|
||||
fileServer := http.FileServer(GetReactAppFS())
|
||||
fileServer.ServeHTTP(w, r)
|
||||
|
||||
// Serve embedded web interface
|
||||
ServeEmbeddedWeb(w, r)
|
||||
}
|
||||
|
||||
// handleAuthChallenge generates and returns an authentication challenge
|
||||
// handleAuthChallenge generates a new authentication challenge
|
||||
func (s *Server) handleAuthChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a proper challenge using the auth package
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Generate a new challenge
|
||||
challenge := auth.GenerateChallenge()
|
||||
challengeHex := hex.Enc(challenge)
|
||||
|
||||
// Store the challenge using the hex value as the key for easy lookup
|
||||
// Store the challenge with expiration (5 minutes)
|
||||
s.challengeMutex.Lock()
|
||||
if s.challenges == nil {
|
||||
s.challenges = make(map[string][]byte)
|
||||
}
|
||||
s.challenges[challengeHex] = challenge
|
||||
s.challengeMutex.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"challenge": "` + challengeHex + `"}`))
|
||||
// Clean up expired challenges
|
||||
go func() {
|
||||
time.Sleep(5 * time.Minute)
|
||||
s.challengeMutex.Lock()
|
||||
delete(s.challenges, challengeHex)
|
||||
s.challengeMutex.Unlock()
|
||||
}()
|
||||
|
||||
// Return the challenge
|
||||
response := struct {
|
||||
Challenge string `json:"challenge"`
|
||||
}{
|
||||
Challenge: challengeHex,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(response)
|
||||
if chk.E(err) {
|
||||
http.Error(w, "Error generating challenge", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(jsonData)
|
||||
}
|
||||
|
||||
// handleAuthLogin processes authentication requests
|
||||
@@ -294,7 +298,7 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
delete(s.challenges, challengeHex)
|
||||
s.challengeMutex.Unlock()
|
||||
|
||||
relayURL := s.ServiceURL(r)
|
||||
relayURL := s.WebSocketURL(r)
|
||||
|
||||
// Validate the authentication event with the correct challenge
|
||||
// The challenge in the event tag is hex-encoded, so we need to pass the hex string as bytes
|
||||
@@ -318,10 +322,11 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
MaxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
w.Write([]byte(`{"success": true, "pubkey": "` + hex.Enc(evt.Pubkey) + `", "message": "Authentication successful"}`))
|
||||
|
||||
w.Write([]byte(`{"success": true}`))
|
||||
}
|
||||
|
||||
// handleAuthStatus returns the current authentication status
|
||||
// handleAuthStatus checks if the user is authenticated
|
||||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -329,35 +334,63 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Check for auth cookie
|
||||
if c, err := r.Cookie("orly_auth"); err == nil && c.Value != "" {
|
||||
// Validate pubkey format (hex)
|
||||
if _, err := hex.Dec(c.Value); !chk.E(err) {
|
||||
w.Write([]byte(`{"authenticated": true, "pubkey": "` + c.Value + `"}`))
|
||||
return
|
||||
}
|
||||
c, err := r.Cookie("orly_auth")
|
||||
if err != nil || c.Value == "" {
|
||||
w.Write([]byte(`{"authenticated": false}`))
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`{"authenticated": false}`))
|
||||
|
||||
// Validate the pubkey format
|
||||
pubkey, err := hex.Dec(c.Value)
|
||||
if chk.E(err) {
|
||||
w.Write([]byte(`{"authenticated": false}`))
|
||||
return
|
||||
}
|
||||
|
||||
// Get user permissions
|
||||
permission := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
|
||||
|
||||
response := struct {
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Pubkey string `json:"pubkey"`
|
||||
Permission string `json:"permission"`
|
||||
}{
|
||||
Authenticated: true,
|
||||
Pubkey: c.Value,
|
||||
Permission: permission,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(response)
|
||||
if chk.E(err) {
|
||||
w.Write([]byte(`{"authenticated": false}`))
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(jsonData)
|
||||
}
|
||||
|
||||
// handleAuthLogout clears the auth cookie
|
||||
// handleAuthLogout clears the authentication cookie
|
||||
func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
// Expire the cookie
|
||||
http.SetCookie(
|
||||
w, &http.Cookie{
|
||||
Name: "orly_auth",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
},
|
||||
)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Clear the auth cookie
|
||||
cookie := &http.Cookie{
|
||||
Name: "orly_auth",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1, // Expire immediately
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
w.Write([]byte(`{"success": true}`))
|
||||
}
|
||||
|
||||
@@ -407,148 +440,98 @@ func (s *Server) handlePermissions(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(jsonData)
|
||||
}
|
||||
|
||||
// handleExport streams all events as JSONL (NDJSON). Admins only.
|
||||
// handleExport streams events as JSONL (NDJSON) using NIP-98 authentication.
|
||||
// Supports both GET (query params) and POST (JSON body) for pubkey filtering.
|
||||
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Require auth cookie
|
||||
c, err := r.Cookie("orly_auth")
|
||||
if err != nil || c.Value == "" {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
requesterPubHex := c.Value
|
||||
requesterPub, err := hex.Dec(requesterPubHex)
|
||||
if chk.E(err) {
|
||||
http.Error(w, "Invalid auth cookie", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Check permissions
|
||||
if acl.Registry.GetAccessLevel(requesterPub, r.RemoteAddr) != "admin" {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
// Validate NIP-98 authentication
|
||||
valid, pubkey, err := httpauth.CheckAuth(r)
|
||||
if chk.E(err) || !valid {
|
||||
errorMsg := "NIP-98 authentication validation failed"
|
||||
if err != nil {
|
||||
errorMsg = err.Error()
|
||||
}
|
||||
http.Error(w, errorMsg, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Optional filtering by pubkey(s)
|
||||
// Check permissions - require write, admin, or owner level
|
||||
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
|
||||
}
|
||||
|
||||
// Parse pubkeys from request
|
||||
var pks [][]byte
|
||||
q := r.URL.Query()
|
||||
for _, pkHex := range q["pubkey"] {
|
||||
if pkHex == "" {
|
||||
continue
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
// Parse JSON body for pubkeys
|
||||
var requestBody struct {
|
||||
Pubkeys []string `json:"pubkeys"`
|
||||
}
|
||||
if pk, err := hex.Dec(pkHex); !chk.E(err) {
|
||||
pks = append(pks, pk)
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err == nil {
|
||||
// If JSON parsing succeeds, use pubkeys from body
|
||||
for _, pkHex := range requestBody.Pubkeys {
|
||||
if pkHex == "" {
|
||||
continue
|
||||
}
|
||||
if pk, err := hex.Dec(pkHex); !chk.E(err) {
|
||||
pks = append(pks, pk)
|
||||
}
|
||||
}
|
||||
}
|
||||
// If JSON parsing fails, fall back to empty pubkeys (export all)
|
||||
} else {
|
||||
// GET method - parse query parameters
|
||||
q := r.URL.Query()
|
||||
for _, pkHex := range q["pubkey"] {
|
||||
if pkHex == "" {
|
||||
continue
|
||||
}
|
||||
if pk, err := hex.Dec(pkHex); !chk.E(err) {
|
||||
pks = append(pks, pk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine filename based on whether filtering by pubkeys
|
||||
var filename string
|
||||
if len(pks) == 0 {
|
||||
filename = "all-events-" + time.Now().UTC().Format("20060102-150405Z") + ".jsonl"
|
||||
} else if len(pks) == 1 {
|
||||
filename = "my-events-" + time.Now().UTC().Format("20060102-150405Z") + ".jsonl"
|
||||
} else {
|
||||
filename = "filtered-events-" + time.Now().UTC().Format("20060102-150405Z") + ".jsonl"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
filename := "events-" + time.Now().UTC().Format("20060102-150405Z") + ".jsonl"
|
||||
w.Header().Set(
|
||||
"Content-Disposition", "attachment; filename=\""+filename+"\"",
|
||||
)
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||
|
||||
// Stream export
|
||||
s.D.Export(s.Ctx, w, pks...)
|
||||
}
|
||||
|
||||
// handleExportMine streams only the authenticated user's events as JSONL (NDJSON).
|
||||
func (s *Server) handleExportMine(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Require auth cookie
|
||||
c, err := r.Cookie("orly_auth")
|
||||
if err != nil || c.Value == "" {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
pubkey, err := hex.Dec(c.Value)
|
||||
if chk.E(err) {
|
||||
http.Error(w, "Invalid auth cookie", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
filename := "my-events-" + time.Now().UTC().Format("20060102-150405Z") + ".jsonl"
|
||||
w.Header().Set(
|
||||
"Content-Disposition", "attachment; filename=\""+filename+"\"",
|
||||
)
|
||||
|
||||
// Stream export for this user's pubkey only
|
||||
s.D.Export(s.Ctx, w, pubkey)
|
||||
}
|
||||
|
||||
// handleImport receives a JSONL/NDJSON file or body and enqueues an async import. Admins only.
|
||||
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Require auth cookie
|
||||
c, err := r.Cookie("orly_auth")
|
||||
if err != nil || c.Value == "" {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
requesterPub, err := hex.Dec(c.Value)
|
||||
if chk.E(err) {
|
||||
http.Error(w, "Invalid auth cookie", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Admins only
|
||||
if acl.Registry.GetAccessLevel(requesterPub, r.RemoteAddr) != "admin" {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
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
|
||||
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()
|
||||
s.D.Import(file)
|
||||
} else {
|
||||
if r.Body == nil {
|
||||
http.Error(w, "Empty request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.D.Import(r.Body)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
w.Write([]byte(`{"success": true, "message": "Import started"}`))
|
||||
}
|
||||
|
||||
// handleEventsMine returns the authenticated user's events in JSON format with pagination
|
||||
// handleEventsMine returns the authenticated user's events in JSON format with pagination using NIP-98 authentication.
|
||||
func (s *Server) handleEventsMine(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Require auth cookie
|
||||
c, err := r.Cookie("orly_auth")
|
||||
if err != nil || c.Value == "" {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
pubkey, err := hex.Dec(c.Value)
|
||||
if chk.E(err) {
|
||||
http.Error(w, "Invalid auth cookie", http.StatusUnauthorized)
|
||||
// Validate NIP-98 authentication
|
||||
valid, pubkey, err := httpauth.CheckAuth(r)
|
||||
if chk.E(err) || !valid {
|
||||
errorMsg := "NIP-98 authentication validation failed"
|
||||
if err != nil {
|
||||
errorMsg = err.Error()
|
||||
}
|
||||
http.Error(w, errorMsg, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -582,64 +565,93 @@ func (s *Server) handleEventsMine(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
log.Printf("DEBUG: QueryEvents returned %d events", len(events))
|
||||
|
||||
// If no events found, let's also check if there are any events at all in the database
|
||||
if len(events) == 0 {
|
||||
// Create a filter to get any events (no authors filter)
|
||||
allEventsFilter := &filter.F{}
|
||||
allEvents, err := s.D.QueryEvents(s.Ctx, allEventsFilter)
|
||||
if err == nil {
|
||||
log.Printf("DEBUG: Total events in database: %d", len(allEvents))
|
||||
} else {
|
||||
log.Printf("DEBUG: Failed to query all events: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Events are already sorted by QueryEvents in reverse chronological order
|
||||
|
||||
// Apply offset and limit manually since QueryEvents doesn't support offset
|
||||
// Apply pagination
|
||||
totalEvents := len(events)
|
||||
start := offset
|
||||
if start > totalEvents {
|
||||
start = totalEvents
|
||||
}
|
||||
end := start + limit
|
||||
if end > totalEvents {
|
||||
end = totalEvents
|
||||
}
|
||||
|
||||
paginatedEvents := events[start:end]
|
||||
|
||||
// Convert events to JSON response format
|
||||
type EventResponse struct {
|
||||
ID string `json:"id"`
|
||||
Kind int `json:"kind"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Content string `json:"content"`
|
||||
RawJSON string `json:"raw_json"`
|
||||
}
|
||||
|
||||
response := struct {
|
||||
Events []EventResponse `json:"events"`
|
||||
Total int `json:"total"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit"`
|
||||
}{
|
||||
Events: make([]EventResponse, len(paginatedEvents)),
|
||||
Total: totalEvents,
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
}
|
||||
|
||||
for i, ev := range paginatedEvents {
|
||||
response.Events[i] = EventResponse{
|
||||
ID: hex.Enc(ev.ID),
|
||||
Kind: int(ev.Kind),
|
||||
CreatedAt: int64(ev.CreatedAt),
|
||||
Content: string(ev.Content),
|
||||
RawJSON: string(ev.Serialize()),
|
||||
if offset >= totalEvents {
|
||||
events = event.S{} // Empty slice
|
||||
} else {
|
||||
end := offset + limit
|
||||
if end > totalEvents {
|
||||
end = totalEvents
|
||||
}
|
||||
events = events[offset:end]
|
||||
}
|
||||
|
||||
// Set content type and write JSON response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Format response as proper JSON
|
||||
response := struct {
|
||||
Events []*event.E `json:"events"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}{
|
||||
Events: events,
|
||||
Total: totalEvents,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
// Marshal and write the response
|
||||
jsonData, err := json.Marshal(response)
|
||||
if chk.E(err) {
|
||||
http.Error(
|
||||
w, "Error generating response", http.StatusInternalServerError,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(jsonData)
|
||||
}
|
||||
|
||||
// handleImport receives a JSONL/NDJSON file or body and enqueues an async import using NIP-98 authentication. Admins only.
|
||||
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate NIP-98 authentication
|
||||
valid, pubkey, err := httpauth.CheckAuth(r)
|
||||
if chk.E(err) || !valid {
|
||||
errorMsg := "NIP-98 authentication validation failed"
|
||||
if err != nil {
|
||||
errorMsg = err.Error()
|
||||
}
|
||||
http.Error(w, errorMsg, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Check permissions - require admin or owner level
|
||||
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
|
||||
if accessLevel != "admin" && accessLevel != "owner" {
|
||||
http.Error(w, "Admin or owner permission required", http.StatusForbidden)
|
||||
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
|
||||
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()
|
||||
s.D.Import(file)
|
||||
} else {
|
||||
if r.Body == nil {
|
||||
http.Error(w, "Empty request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.D.Import(r.Body)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
w.Write([]byte(`{"success": true, "message": "Import started"}`))
|
||||
}
|
||||
|
||||
@@ -16,4 +16,10 @@ func GetReactAppFS() http.FileSystem {
|
||||
panic("Failed to load embedded web app: " + err.Error())
|
||||
}
|
||||
return http.FS(webDist)
|
||||
}
|
||||
}
|
||||
|
||||
// ServeEmbeddedWeb serves the embedded web application
|
||||
func ServeEmbeddedWeb(w http.ResponseWriter, r *http.Request) {
|
||||
// Serve the embedded web app
|
||||
http.FileServer(GetReactAppFS()).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
41
app/web/.gitignore
vendored
41
app/web/.gitignore
vendored
@@ -1,30 +1,11 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Bun
|
||||
.bunfig.toml
|
||||
bun.lockb
|
||||
|
||||
# Build directories
|
||||
build
|
||||
|
||||
# Cache and logs
|
||||
.cache
|
||||
.temp
|
||||
.log
|
||||
*.log
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
.tanstack/
|
||||
.idea/
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
/.idea/
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# Orly Web Application
|
||||
|
||||
This is a React web application that uses Bun for building and bundling, and is automatically embedded into the Go binary when built.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) - JavaScript runtime and toolkit
|
||||
- Go 1.16+ (for embedding functionality)
|
||||
|
||||
## Development
|
||||
|
||||
There are two ways to develop the web app:
|
||||
|
||||
1) Standalone (recommended for hot reload)
|
||||
- Start the Go relay with the embedded web UI disabled so the React app can run on its own dev server with HMR.
|
||||
- Configure the relay via environment variables:
|
||||
|
||||
```bash
|
||||
# In another shell at repo root
|
||||
export ORLY_WEB_DISABLE=true
|
||||
# Optional: if you want same-origin URLs, you can set a proxy target and access the relay on the same port
|
||||
# export ORLY_WEB_DEV_PROXY_URL=http://localhost:5173
|
||||
|
||||
# Start the relay as usual
|
||||
go run .
|
||||
```
|
||||
|
||||
- Then start the React dev server:
|
||||
|
||||
```bash
|
||||
cd app/web
|
||||
bun install
|
||||
bun dev
|
||||
```
|
||||
|
||||
When ORLY_WEB_DISABLE=true is set, the Go server still serves the API and websocket endpoints and sends permissive CORS headers, so the dev server can access them cross-origin. If ORLY_WEB_DEV_PROXY_URL is set, the Go server will reverse-proxy non-/api paths to the dev server so you can use the same origin.
|
||||
|
||||
2) Embedded (no hot reload)
|
||||
- Build the web app and run the Go server with defaults:
|
||||
|
||||
```bash
|
||||
cd app/web
|
||||
bun install
|
||||
bun run build
|
||||
cd ../../
|
||||
go run .
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
The React application needs to be built before compiling the Go binary to ensure that the embedded files are available:
|
||||
|
||||
```bash
|
||||
# Build the React application
|
||||
cd app/web
|
||||
bun install
|
||||
bun run build
|
||||
|
||||
# Build the Go binary from project root
|
||||
cd ../../
|
||||
go build
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. The React application is built to the `app/web/dist` directory
|
||||
2. The Go embed directive in `app/web.go` embeds these files into the binary
|
||||
3. When the server runs, it serves the embedded React app at the root path
|
||||
|
||||
## Build Automation
|
||||
|
||||
You can create a shell script to automate the build process:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# build.sh
|
||||
echo "Building React app..."
|
||||
cd app/web
|
||||
bun install
|
||||
bun run build
|
||||
|
||||
echo "Building Go binary..."
|
||||
cd ../../
|
||||
go build
|
||||
|
||||
echo "Build complete!"
|
||||
```
|
||||
|
||||
Make it executable with `chmod +x build.sh` and run with `./build.sh`.
|
||||
183
app/web/bun.lock
183
app/web/bun.lock
@@ -2,44 +2,189 @@
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "orly-web",
|
||||
"name": "svelte-app",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-json-pretty": "^2.2.0",
|
||||
"sirv-cli": "^2.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
"@rollup/plugin-commonjs": "^24.0.0",
|
||||
"@rollup/plugin-node-resolve": "^15.0.0",
|
||||
"@rollup/plugin-terser": "^0.4.0",
|
||||
"rollup": "^3.15.0",
|
||||
"rollup-plugin-css-only": "^4.3.0",
|
||||
"rollup-plugin-livereload": "^2.0.0",
|
||||
"rollup-plugin-svelte": "^7.1.2",
|
||||
"svelte": "^3.55.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="],
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="],
|
||||
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@24.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "glob": "^8.0.3", "is-reference": "1.2.1", "magic-string": "^0.27.0" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0" }, "optionalPeers": ["rollup"] }, "sha512-eSL45hjhCWI0jCCXcNtLVqM5N1JlBGvlFfY0m6oOYnLCJ6N0qEXoZql4sY2MOUArzhH4SA/qBpTxvvZp2Sc+DQ=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@15.3.1", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA=="],
|
||||
|
||||
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
"@rollup/plugin-terser": ["@rollup/plugin-terser@0.4.4", "", { "dependencies": { "serialize-javascript": "^6.0.1", "smob": "^1.0.0", "terser": "^5.17.4" }, "peerDependencies": { "rollup": "^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A=="],
|
||||
|
||||
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
||||
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"react-json-pretty": ["react-json-pretty@2.2.0", "", { "dependencies": { "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=15.0", "react-dom": ">=15.0" } }, "sha512-3UMzlAXkJ4R8S4vmkRKtvJHTewG4/rn1Q18n0zqdu/ipZbUPLVZD+QwC7uVcD/IAY3s8iNVHlgR2dMzIUS0n1A=="],
|
||||
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||
|
||||
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="],
|
||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
|
||||
|
||||
"console-clear": ["console-clear@1.1.1", "", {}, "sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ=="],
|
||||
|
||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-port": ["get-port@3.2.0", "", {}, "sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg=="],
|
||||
|
||||
"glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="],
|
||||
|
||||
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||
|
||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"livereload": ["livereload@0.9.3", "", { "dependencies": { "chokidar": "^3.5.0", "livereload-js": "^3.3.1", "opts": ">= 1.2.0", "ws": "^7.4.3" }, "bin": { "livereload": "bin/livereload.js" } }, "sha512-q7Z71n3i4X0R9xthAryBdNGVGAO2R5X+/xXpmKeuPMrteg+W2U8VusTKV3YiJbXZwKsOlFlHe+go6uSNjfxrZw=="],
|
||||
|
||||
"livereload-js": ["livereload-js@3.4.1", "", {}, "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g=="],
|
||||
|
||||
"local-access": ["local-access@1.1.0", "", {}, "sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw=="],
|
||||
|
||||
"magic-string": ["magic-string@0.27.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.13" } }, "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA=="],
|
||||
|
||||
"minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"opts": ["opts@2.0.2", "", {}, "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
|
||||
|
||||
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
|
||||
|
||||
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
|
||||
|
||||
"rollup": ["rollup@3.29.5", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w=="],
|
||||
|
||||
"rollup-plugin-css-only": ["rollup-plugin-css-only@4.5.5", "", { "dependencies": { "@rollup/pluginutils": "5" }, "peerDependencies": { "rollup": "<5" } }, "sha512-O2m2Sj8qsAtjUVqZyGTDXJypaOFFNV4knz8OlS6wJBws6XEICIiLsXmI56SbQEmWDqYU5TgRgWmslGj4THofJQ=="],
|
||||
|
||||
"rollup-plugin-livereload": ["rollup-plugin-livereload@2.0.5", "", { "dependencies": { "livereload": "^0.9.1" } }, "sha512-vqQZ/UQowTW7VoiKEM5ouNW90wE5/GZLfdWuR0ELxyKOJUIaj+uismPZZaICU4DnWPVjnpCDDxEqwU7pcKY/PA=="],
|
||||
|
||||
"rollup-plugin-svelte": ["rollup-plugin-svelte@7.2.3", "", { "dependencies": { "@rollup/pluginutils": "^4.1.0", "resolve.exports": "^2.0.0" }, "peerDependencies": { "rollup": ">=2.0.0", "svelte": ">=3.5.0" } }, "sha512-LlniP+h00DfM+E4eav/Kk8uGjgPUjGIBfrAS/IxQvsuFdqSM0Y2sXf31AdxuIGSW9GsmocDqOfaxR5QNno/Tgw=="],
|
||||
|
||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"semiver": ["semiver@1.1.0", "", {}, "sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg=="],
|
||||
|
||||
"serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="],
|
||||
|
||||
"sirv": ["sirv@2.0.4", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ=="],
|
||||
|
||||
"sirv-cli": ["sirv-cli@2.0.2", "", { "dependencies": { "console-clear": "^1.1.0", "get-port": "^3.2.0", "kleur": "^4.1.4", "local-access": "^1.0.1", "sade": "^1.6.0", "semiver": "^1.0.0", "sirv": "^2.0.0", "tinydate": "^1.0.0" }, "bin": { "sirv": "bin.js" } }, "sha512-OtSJDwxsF1NWHc7ps3Sa0s+dPtP15iQNJzfKVz+MxkEo3z72mCD+yu30ct79rPr0CaV1HXSOBp+MIY5uIhHZ1A=="],
|
||||
|
||||
"smob": ["smob@1.5.0", "", {}, "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"svelte": ["svelte@3.59.2", "", {}, "sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA=="],
|
||||
|
||||
"terser": ["terser@5.44.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w=="],
|
||||
|
||||
"tinydate": ["tinydate@1.3.0", "", {}, "sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"rollup-plugin-svelte/@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
|
||||
|
||||
"rollup-plugin-svelte/@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
}
|
||||
}
|
||||
|
||||
161
app/web/dist/index-kk1m7jg4.js
vendored
161
app/web/dist/index-kk1m7jg4.js
vendored
File diff suppressed because one or more lines are too long
1
app/web/dist/index-q4cwd1fy.css
vendored
1
app/web/dist/index-q4cwd1fy.css
vendored
File diff suppressed because one or more lines are too long
34
app/web/dist/index.html
vendored
34
app/web/dist/index.html
vendored
@@ -1,30 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nostr Relay</title>
|
||||
|
||||
<link rel="stylesheet" crossorigin href="./index-q4cwd1fy.css"><script type="module" crossorigin src="./index-kk1m7jg4.js"></script></head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Next Orly</title>
|
||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||
<link rel="stylesheet" href="/bundle.css" />
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
// Apply system theme preference immediately to avoid flash of wrong theme
|
||||
function applyTheme(isDark) {
|
||||
document.body.classList.remove('bg-white', 'bg-gray-900');
|
||||
document.body.classList.add(isDark ? 'bg-gray-900' : 'bg-white');
|
||||
}
|
||||
|
||||
// Set initial theme
|
||||
applyTheme(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
// Listen for theme changes
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
||||
applyTheme(e.matches);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
|
||||
<div id="app"></div>
|
||||
<script src="/bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
112
app/web/dist/tailwind.min.css
vendored
112
app/web/dist/tailwind.min.css
vendored
@@ -1,112 +0,0 @@
|
||||
/*
|
||||
Local Tailwind CSS (minimal subset for this UI)
|
||||
Note: This file includes just the utilities used by the app to keep size small.
|
||||
You can replace this with a full Tailwind build if desired.
|
||||
*/
|
||||
|
||||
/* Preflight-like resets (very minimal) */
|
||||
*,::before,::after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}
|
||||
html,body,#root{height:100%}
|
||||
html{line-height:1.5;-webkit-text-size-adjust:100%;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,Noto Sans,\"Apple Color Emoji\",\"Segoe UI Emoji\"}
|
||||
body{margin:0}
|
||||
button,input{font:inherit;color:inherit}
|
||||
img{display:block;max-width:100%;height:auto}
|
||||
|
||||
/* Layout */
|
||||
.sticky{position:sticky}.relative{position:relative}.absolute{position:absolute}
|
||||
.top-0{top:0}.left-0{left:0}.inset-0{top:0;right:0;bottom:0;left:0}
|
||||
.z-50{z-index:50}.z-10{z-index:10}
|
||||
.block{display:block}.flex{display:flex}
|
||||
.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}
|
||||
.flex-grow{flex-grow:1}.shrink-0{flex-shrink:0}
|
||||
.overflow-hidden{overflow:hidden}
|
||||
|
||||
/* Sizing */
|
||||
.w-full{width:100%}.w-auto{width:auto}.w-16{width:4rem}
|
||||
.h-full{height:100%}.h-16{height:4rem}
|
||||
.aspect-square{aspect-ratio:1/1}
|
||||
.max-w-3xl{max-width:48rem}
|
||||
|
||||
/* Spacing */
|
||||
.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-6{padding:1.5rem}
|
||||
.px-2{padding-left:.5rem;padding-right:.5rem}
|
||||
.mr-0{margin-right:0}.mr-2{margin-right:.5rem}
|
||||
.mt-2{margin-top:.5rem}.mt-5{margin-top:1.25rem}
|
||||
.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}
|
||||
.mx-auto{margin-left:auto;margin-right:auto}
|
||||
|
||||
/* Borders & Radius */
|
||||
.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}
|
||||
.border-0{border-width:0}.border-2{border-width:2px}
|
||||
.border-white{border-color:#fff}
|
||||
.border{border-width:1px}.border-gray-300{border-color:#d1d5db}.border-gray-600{border-color:#4b5563}
|
||||
.border-red-500{border-color:#ef4444}.border-red-700{border-color:#b91c1c}
|
||||
|
||||
/* Colors / Backgrounds */
|
||||
.bg-white{background-color:#fff}
|
||||
.bg-gray-100{background-color:#f3f4f6}
|
||||
.bg-gray-200{background-color:#e5e7eb}
|
||||
.bg-gray-300{background-color:#d1d5db}
|
||||
.bg-gray-600{background-color:#4b5563}
|
||||
.bg-gray-700{background-color:#374151}
|
||||
.bg-gray-800{background-color:#1f2937}
|
||||
.bg-gray-900{background-color:#111827}
|
||||
.bg-blue-500{background-color:#3b82f6}
|
||||
.bg-blue-600{background-color:#2563eb}.hover\:bg-blue-700:hover{background-color:#1d4ed8}
|
||||
.hover\:bg-blue-600:hover{background-color:#2563eb}
|
||||
.bg-red-600{background-color:#dc2626}.hover\:bg-red-700:hover{background-color:#b91c1c}
|
||||
.bg-cyan-100{background-color:#cffafe}
|
||||
.bg-green-100{background-color:#d1fae5}
|
||||
.bg-red-100{background-color:#fee2e2}
|
||||
.bg-red-50{background-color:#fef2f2}
|
||||
.bg-green-900{background-color:#064e3b}
|
||||
.bg-red-900{background-color:#7f1d1d}
|
||||
.bg-cyan-900{background-color:#164e63}
|
||||
.bg-cover{background-size:cover}.bg-center{background-position:center}
|
||||
.bg-transparent{background-color:transparent}
|
||||
|
||||
/* Text */
|
||||
.text-left{text-align:left}
|
||||
.text-white{color:#fff}
|
||||
.text-gray-300{color:#d1d5db}
|
||||
.text-gray-500{color:#6b7280}.hover\:text-gray-800:hover{color:#1f2937}
|
||||
.hover\:text-gray-100:hover{color:#f3f4f6}
|
||||
.text-gray-700{color:#374151}
|
||||
.text-gray-800{color:#1f2937}
|
||||
.text-gray-900{color:#111827}
|
||||
.text-gray-100{color:#f3f4f6}
|
||||
.text-green-800{color:#065f46}
|
||||
.text-green-100{color:#dcfce7}
|
||||
.text-red-800{color:#991b1b}
|
||||
.text-red-200{color:#fecaca}
|
||||
.text-red-100{color:#fee2e2}
|
||||
.text-cyan-800{color:#155e75}
|
||||
.text-cyan-100{color:#cffafe}
|
||||
.text-base{font-size:1rem;line-height:1.5rem}
|
||||
.text-lg{font-size:1.125rem;line-height:1.75rem}
|
||||
.text-2xl{font-size:1.5rem;line-height:2rem}
|
||||
.font-bold{font-weight:700}
|
||||
|
||||
/* Opacity */
|
||||
.opacity-70{opacity:.7}
|
||||
|
||||
/* Effects */
|
||||
.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,0.1),0 1px 2px -1px rgba(0,0,0,0.1);box-shadow:var(--tw-shadow)}
|
||||
|
||||
/* Cursor */
|
||||
.cursor-pointer{cursor:pointer}
|
||||
|
||||
/* Box model */
|
||||
.box-border{box-sizing:border-box}
|
||||
|
||||
/* Utilities */
|
||||
.hover\:bg-transparent:hover{background-color:transparent}
|
||||
.hover\:bg-gray-200:hover{background-color:#e5e7eb}
|
||||
.hover\:bg-gray-600:hover{background-color:#4b5563}
|
||||
.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}
|
||||
.focus\:ring-blue-200:focus{--tw-ring-color:rgba(191, 219, 254, var(--tw-ring-opacity))}
|
||||
.focus\:ring-blue-500:focus{--tw-ring-color:rgba(59, 130, 246, var(--tw-ring-opacity))}
|
||||
.disabled\:opacity-50:disabled{opacity:.5}
|
||||
.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}
|
||||
|
||||
/* Height for avatar images in header already inherit from container */
|
||||
BIN
app/web/favicon.ico
Normal file
BIN
app/web/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 485 KiB |
@@ -1,19 +1,24 @@
|
||||
{
|
||||
"name": "orly-web",
|
||||
"version": "0.1.0",
|
||||
"name": "svelte-app",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --hot --port 5173 public/dev.html",
|
||||
"build": "rm -rf dist && bun build ./public/index.html --outdir ./dist --minify --splitting && cp -r public/tailwind.min.css dist/",
|
||||
"preview": "bun x serve dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-json-pretty": "^2.2.0"
|
||||
"build": "rollup -c",
|
||||
"dev": "rollup -c -w",
|
||||
"start": "sirv public --no-clear"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
"@rollup/plugin-commonjs": "^24.0.0",
|
||||
"@rollup/plugin-node-resolve": "^15.0.0",
|
||||
"@rollup/plugin-terser": "^0.4.0",
|
||||
"rollup": "^3.15.0",
|
||||
"rollup-plugin-css-only": "^4.3.0",
|
||||
"rollup-plugin-livereload": "^2.0.0",
|
||||
"rollup-plugin-svelte": "^7.1.2",
|
||||
"svelte": "^3.55.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"sirv-cli": "^2.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
app/web/public/build/bundle.css
Normal file
2
app/web/public/build/bundle.css
Normal file
File diff suppressed because one or more lines are too long
2
app/web/public/build/bundle.js
Normal file
2
app/web/public/build/bundle.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nostr Relay (Dev)</title>
|
||||
<link rel="stylesheet" href="tailwind.min.css" />
|
||||
</head>
|
||||
<body class="bg-white">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
app/web/public/favicon.png
Normal file
BIN
app/web/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
69
app/web/public/global.css
Normal file
69
app/web/public/global.css
Normal file
@@ -0,0 +1,69 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,30 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nostr Relay</title>
|
||||
<link rel="stylesheet" href="tailwind.min.css" />
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
|
||||
<title>ORLY?</title>
|
||||
|
||||
<link rel="icon" type="image/png" href="/orly.png" />
|
||||
<link rel="stylesheet" href="/global.css" />
|
||||
<link rel="stylesheet" href="/build/bundle.css" />
|
||||
|
||||
<script defer src="/build/bundle.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
// Apply system theme preference immediately to avoid flash of wrong theme
|
||||
function applyTheme(isDark) {
|
||||
document.body.classList.remove('bg-white', 'bg-gray-900');
|
||||
document.body.classList.add(isDark ? 'bg-gray-900' : 'bg-white');
|
||||
}
|
||||
|
||||
// Set initial theme
|
||||
applyTheme(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
// Listen for theme changes
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
||||
applyTheme(e.matches);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<body></body>
|
||||
</html>
|
||||
|
||||
BIN
app/web/public/orly.png
Normal file
BIN
app/web/public/orly.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 514 KiB |
112
app/web/public/tailwind.min.css
vendored
112
app/web/public/tailwind.min.css
vendored
@@ -1,112 +0,0 @@
|
||||
/*
|
||||
Local Tailwind CSS (minimal subset for this UI)
|
||||
Note: This file includes just the utilities used by the app to keep size small.
|
||||
You can replace this with a full Tailwind build if desired.
|
||||
*/
|
||||
|
||||
/* Preflight-like resets (very minimal) */
|
||||
*,::before,::after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}
|
||||
html,body,#root{height:100%}
|
||||
html{line-height:1.5;-webkit-text-size-adjust:100%;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,Noto Sans,\"Apple Color Emoji\",\"Segoe UI Emoji\"}
|
||||
body{margin:0}
|
||||
button,input{font:inherit;color:inherit}
|
||||
img{display:block;max-width:100%;height:auto}
|
||||
|
||||
/* Layout */
|
||||
.sticky{position:sticky}.relative{position:relative}.absolute{position:absolute}
|
||||
.top-0{top:0}.left-0{left:0}.inset-0{top:0;right:0;bottom:0;left:0}
|
||||
.z-50{z-index:50}.z-10{z-index:10}
|
||||
.block{display:block}.flex{display:flex}
|
||||
.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}
|
||||
.flex-grow{flex-grow:1}.shrink-0{flex-shrink:0}
|
||||
.overflow-hidden{overflow:hidden}
|
||||
|
||||
/* Sizing */
|
||||
.w-full{width:100%}.w-auto{width:auto}.w-16{width:4rem}
|
||||
.h-full{height:100%}.h-16{height:4rem}
|
||||
.aspect-square{aspect-ratio:1/1}
|
||||
.max-w-3xl{max-width:48rem}
|
||||
|
||||
/* Spacing */
|
||||
.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-6{padding:1.5rem}
|
||||
.px-2{padding-left:.5rem;padding-right:.5rem}
|
||||
.mr-0{margin-right:0}.mr-2{margin-right:.5rem}
|
||||
.mt-2{margin-top:.5rem}.mt-5{margin-top:1.25rem}
|
||||
.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}
|
||||
.mx-auto{margin-left:auto;margin-right:auto}
|
||||
|
||||
/* Borders & Radius */
|
||||
.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}
|
||||
.border-0{border-width:0}.border-2{border-width:2px}
|
||||
.border-white{border-color:#fff}
|
||||
.border{border-width:1px}.border-gray-300{border-color:#d1d5db}.border-gray-600{border-color:#4b5563}
|
||||
.border-red-500{border-color:#ef4444}.border-red-700{border-color:#b91c1c}
|
||||
|
||||
/* Colors / Backgrounds */
|
||||
.bg-white{background-color:#fff}
|
||||
.bg-gray-100{background-color:#f3f4f6}
|
||||
.bg-gray-200{background-color:#e5e7eb}
|
||||
.bg-gray-300{background-color:#d1d5db}
|
||||
.bg-gray-600{background-color:#4b5563}
|
||||
.bg-gray-700{background-color:#374151}
|
||||
.bg-gray-800{background-color:#1f2937}
|
||||
.bg-gray-900{background-color:#111827}
|
||||
.bg-blue-500{background-color:#3b82f6}
|
||||
.bg-blue-600{background-color:#2563eb}.hover\:bg-blue-700:hover{background-color:#1d4ed8}
|
||||
.hover\:bg-blue-600:hover{background-color:#2563eb}
|
||||
.bg-red-600{background-color:#dc2626}.hover\:bg-red-700:hover{background-color:#b91c1c}
|
||||
.bg-cyan-100{background-color:#cffafe}
|
||||
.bg-green-100{background-color:#d1fae5}
|
||||
.bg-red-100{background-color:#fee2e2}
|
||||
.bg-red-50{background-color:#fef2f2}
|
||||
.bg-green-900{background-color:#064e3b}
|
||||
.bg-red-900{background-color:#7f1d1d}
|
||||
.bg-cyan-900{background-color:#164e63}
|
||||
.bg-cover{background-size:cover}.bg-center{background-position:center}
|
||||
.bg-transparent{background-color:transparent}
|
||||
|
||||
/* Text */
|
||||
.text-left{text-align:left}
|
||||
.text-white{color:#fff}
|
||||
.text-gray-300{color:#d1d5db}
|
||||
.text-gray-500{color:#6b7280}.hover\:text-gray-800:hover{color:#1f2937}
|
||||
.hover\:text-gray-100:hover{color:#f3f4f6}
|
||||
.text-gray-700{color:#374151}
|
||||
.text-gray-800{color:#1f2937}
|
||||
.text-gray-900{color:#111827}
|
||||
.text-gray-100{color:#f3f4f6}
|
||||
.text-green-800{color:#065f46}
|
||||
.text-green-100{color:#dcfce7}
|
||||
.text-red-800{color:#991b1b}
|
||||
.text-red-200{color:#fecaca}
|
||||
.text-red-100{color:#fee2e2}
|
||||
.text-cyan-800{color:#155e75}
|
||||
.text-cyan-100{color:#cffafe}
|
||||
.text-base{font-size:1rem;line-height:1.5rem}
|
||||
.text-lg{font-size:1.125rem;line-height:1.75rem}
|
||||
.text-2xl{font-size:1.5rem;line-height:2rem}
|
||||
.font-bold{font-weight:700}
|
||||
|
||||
/* Opacity */
|
||||
.opacity-70{opacity:.7}
|
||||
|
||||
/* Effects */
|
||||
.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,0.1),0 1px 2px -1px rgba(0,0,0,0.1);box-shadow:var(--tw-shadow)}
|
||||
|
||||
/* Cursor */
|
||||
.cursor-pointer{cursor:pointer}
|
||||
|
||||
/* Box model */
|
||||
.box-border{box-sizing:border-box}
|
||||
|
||||
/* Utilities */
|
||||
.hover\:bg-transparent:hover{background-color:transparent}
|
||||
.hover\:bg-gray-200:hover{background-color:#e5e7eb}
|
||||
.hover\:bg-gray-600:hover{background-color:#4b5563}
|
||||
.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}
|
||||
.focus\:ring-blue-200:focus{--tw-ring-color:rgba(191, 219, 254, var(--tw-ring-opacity))}
|
||||
.focus\:ring-blue-500:focus{--tw-ring-color:rgba(59, 130, 246, var(--tw-ring-opacity))}
|
||||
.disabled\:opacity-50:disabled{opacity:.5}
|
||||
.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}
|
||||
|
||||
/* Height for avatar images in header already inherit from container */
|
||||
3
app/web/readme.adoc
Normal file
3
app/web/readme.adoc
Normal file
@@ -0,0 +1,3 @@
|
||||
= nostrly.app
|
||||
|
||||
a simple, material design nostr kind 1 nostr note client
|
||||
78
app/web/rollup.config.js
Normal file
78
app/web/rollup.config.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { spawn } from "child_process";
|
||||
import svelte from "rollup-plugin-svelte";
|
||||
import commonjs from "@rollup/plugin-commonjs";
|
||||
import terser from "@rollup/plugin-terser";
|
||||
import resolve from "@rollup/plugin-node-resolve";
|
||||
import livereload from "rollup-plugin-livereload";
|
||||
import css from "rollup-plugin-css-only";
|
||||
|
||||
const production = !process.env.ROLLUP_WATCH;
|
||||
|
||||
function serve() {
|
||||
let server;
|
||||
|
||||
function toExit() {
|
||||
if (server) server.kill(0);
|
||||
}
|
||||
|
||||
return {
|
||||
writeBundle() {
|
||||
if (server) return;
|
||||
server = spawn("npm", ["run", "start", "--", "--dev"], {
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
shell: true,
|
||||
});
|
||||
|
||||
process.on("SIGTERM", toExit);
|
||||
process.on("exit", toExit);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
input: "src/main.js",
|
||||
output: {
|
||||
sourcemap: true,
|
||||
format: "iife",
|
||||
name: "app",
|
||||
file: "dist/bundle.js",
|
||||
},
|
||||
plugins: [
|
||||
svelte({
|
||||
compilerOptions: {
|
||||
// enable run-time checks when not in production
|
||||
dev: !production,
|
||||
},
|
||||
}),
|
||||
// we'll extract any component CSS out into
|
||||
// a separate file - better for performance
|
||||
css({ output: "bundle.css" }),
|
||||
|
||||
// If you have external dependencies installed from
|
||||
// npm, you'll most likely need these plugins. In
|
||||
// some cases you'll need additional configuration -
|
||||
// consult the documentation for details:
|
||||
// https://github.com/rollup/plugins/tree/master/packages/commonjs
|
||||
resolve({
|
||||
browser: true,
|
||||
dedupe: ["svelte"],
|
||||
exportConditions: ["svelte"],
|
||||
}),
|
||||
commonjs(),
|
||||
|
||||
// In dev mode, call `npm run start` once
|
||||
// the bundle has been generated
|
||||
!production && serve(),
|
||||
|
||||
// Watch the `public` directory and refresh the
|
||||
// browser on changes when not in production
|
||||
!production && livereload("public"),
|
||||
|
||||
// If we're building for production (npm run build
|
||||
// instead of npm run dev), minify
|
||||
production && terser(),
|
||||
],
|
||||
watch: {
|
||||
clearScreen: false,
|
||||
},
|
||||
};
|
||||
147
app/web/scripts/setupTypeScript.js
Normal file
147
app/web/scripts/setupTypeScript.js
Normal file
@@ -0,0 +1,147 @@
|
||||
// @ts-check
|
||||
|
||||
/** This script modifies the project to support TS code in .svelte files like:
|
||||
|
||||
<script lang="ts">
|
||||
export let name: string;
|
||||
</script>
|
||||
|
||||
As well as validating the code for CI.
|
||||
*/
|
||||
|
||||
/** To work on this script:
|
||||
rm -rf test-template template && git clone sveltejs/template test-template && node scripts/setupTypeScript.js test-template
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { argv } from "process";
|
||||
import url from "url";
|
||||
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
|
||||
const projectRoot = argv[2] || path.join(__dirname, "..");
|
||||
|
||||
// Add deps to pkg.json
|
||||
const packageJSON = JSON.parse(
|
||||
fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"),
|
||||
);
|
||||
packageJSON.devDependencies = Object.assign(packageJSON.devDependencies, {
|
||||
"svelte-check": "^3.0.0",
|
||||
"svelte-preprocess": "^5.0.0",
|
||||
"@rollup/plugin-typescript": "^11.0.0",
|
||||
typescript: "^4.9.0",
|
||||
tslib: "^2.5.0",
|
||||
"@tsconfig/svelte": "^3.0.0",
|
||||
});
|
||||
|
||||
// Add script for checking
|
||||
packageJSON.scripts = Object.assign(packageJSON.scripts, {
|
||||
check: "svelte-check",
|
||||
});
|
||||
|
||||
// Write the package JSON
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, "package.json"),
|
||||
JSON.stringify(packageJSON, null, " "),
|
||||
);
|
||||
|
||||
// mv src/main.js to main.ts - note, we need to edit rollup.config.js for this too
|
||||
const beforeMainJSPath = path.join(projectRoot, "src", "main.js");
|
||||
const afterMainTSPath = path.join(projectRoot, "src", "main.ts");
|
||||
fs.renameSync(beforeMainJSPath, afterMainTSPath);
|
||||
|
||||
// Switch the app.svelte file to use TS
|
||||
const appSveltePath = path.join(projectRoot, "src", "App.svelte");
|
||||
let appFile = fs.readFileSync(appSveltePath, "utf8");
|
||||
appFile = appFile.replace("<script>", '<script lang="ts">');
|
||||
appFile = appFile.replace("export let name;", "export let name: string;");
|
||||
fs.writeFileSync(appSveltePath, appFile);
|
||||
|
||||
// Edit rollup config
|
||||
const rollupConfigPath = path.join(projectRoot, "rollup.config.js");
|
||||
let rollupConfig = fs.readFileSync(rollupConfigPath, "utf8");
|
||||
|
||||
// Edit imports
|
||||
rollupConfig = rollupConfig.replace(
|
||||
`'rollup-plugin-css-only';`,
|
||||
`'rollup-plugin-css-only';
|
||||
import sveltePreprocess from 'svelte-preprocess';
|
||||
import typescript from '@rollup/plugin-typescript';`,
|
||||
);
|
||||
|
||||
// Replace name of entry point
|
||||
rollupConfig = rollupConfig.replace(`'src/main.js'`, `'src/main.ts'`);
|
||||
|
||||
// Add preprocessor
|
||||
rollupConfig = rollupConfig.replace(
|
||||
"compilerOptions:",
|
||||
"preprocess: sveltePreprocess({ sourceMap: !production }),\n\t\t\tcompilerOptions:",
|
||||
);
|
||||
|
||||
// Add TypeScript
|
||||
rollupConfig = rollupConfig.replace(
|
||||
"commonjs(),",
|
||||
"commonjs(),\n\t\ttypescript({\n\t\t\tsourceMap: !production,\n\t\t\tinlineSources: !production\n\t\t}),",
|
||||
);
|
||||
fs.writeFileSync(rollupConfigPath, rollupConfig);
|
||||
|
||||
// Add svelte.config.js
|
||||
const tsconfig = `{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules/*", "__sapper__/*", "public/*"]
|
||||
}`;
|
||||
const tsconfigPath = path.join(projectRoot, "tsconfig.json");
|
||||
fs.writeFileSync(tsconfigPath, tsconfig);
|
||||
|
||||
// Add TSConfig
|
||||
const svelteConfig = `import sveltePreprocess from 'svelte-preprocess';
|
||||
|
||||
export default {
|
||||
preprocess: sveltePreprocess()
|
||||
};
|
||||
`;
|
||||
const svelteConfigPath = path.join(projectRoot, "svelte.config.js");
|
||||
fs.writeFileSync(svelteConfigPath, svelteConfig);
|
||||
|
||||
// Add global.d.ts
|
||||
const dtsPath = path.join(projectRoot, "src", "global.d.ts");
|
||||
fs.writeFileSync(dtsPath, `/// <reference types="svelte" />`);
|
||||
|
||||
// Delete this script, but not during testing
|
||||
if (!argv[2]) {
|
||||
// Remove the script
|
||||
fs.unlinkSync(path.join(__filename));
|
||||
|
||||
// Check for Mac's DS_store file, and if it's the only one left remove it
|
||||
const remainingFiles = fs.readdirSync(path.join(__dirname));
|
||||
if (remainingFiles.length === 1 && remainingFiles[0] === ".DS_store") {
|
||||
fs.unlinkSync(path.join(__dirname, ".DS_store"));
|
||||
}
|
||||
|
||||
// Check if the scripts folder is empty
|
||||
if (fs.readdirSync(path.join(__dirname)).length === 0) {
|
||||
// Remove the scripts folder
|
||||
fs.rmdirSync(path.join(__dirname));
|
||||
}
|
||||
}
|
||||
|
||||
// Adds the extension recommendation
|
||||
fs.mkdirSync(path.join(projectRoot, ".vscode"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, ".vscode", "extensions.json"),
|
||||
`{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
console.log("Converted to TypeScript.");
|
||||
|
||||
if (fs.existsSync(path.join(projectRoot, "node_modules"))) {
|
||||
console.log(
|
||||
"\nYou will need to re-run your dependency manager to get started.",
|
||||
);
|
||||
}
|
||||
2299
app/web/src/App.jsx
2299
app/web/src/App.jsx
File diff suppressed because it is too large
Load Diff
2141
app/web/src/App.svelte
Normal file
2141
app/web/src/App.svelte
Normal file
File diff suppressed because it is too large
Load Diff
392
app/web/src/LoginModal.svelte
Normal file
392
app/web/src/LoginModal.svelte
Normal file
@@ -0,0 +1,392 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let showModal = false;
|
||||
export let isDarkTheme = false;
|
||||
|
||||
let activeTab = 'extension';
|
||||
let nsecInput = '';
|
||||
let isLoading = false;
|
||||
let errorMessage = '';
|
||||
let successMessage = '';
|
||||
|
||||
function closeModal() {
|
||||
showModal = false;
|
||||
nsecInput = '';
|
||||
errorMessage = '';
|
||||
successMessage = '';
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
activeTab = tab;
|
||||
errorMessage = '';
|
||||
successMessage = '';
|
||||
}
|
||||
|
||||
async function loginWithExtension() {
|
||||
isLoading = true;
|
||||
errorMessage = '';
|
||||
successMessage = '';
|
||||
|
||||
try {
|
||||
// Check if window.nostr is available
|
||||
if (!window.nostr) {
|
||||
throw new Error('No Nostr extension found. Please install a NIP-07 compatible extension like nos2x or Alby.');
|
||||
}
|
||||
|
||||
// Get public key from extension
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
|
||||
if (pubkey) {
|
||||
// Store authentication info
|
||||
localStorage.setItem('nostr_auth_method', 'extension');
|
||||
localStorage.setItem('nostr_pubkey', pubkey);
|
||||
|
||||
successMessage = 'Successfully logged in with extension!';
|
||||
dispatch('login', {
|
||||
method: 'extension',
|
||||
pubkey: pubkey,
|
||||
signer: window.nostr
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
closeModal();
|
||||
}, 1500);
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage = error.message;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateNsec(nsec) {
|
||||
// Basic validation for nsec format
|
||||
if (!nsec.startsWith('nsec1')) {
|
||||
return false;
|
||||
}
|
||||
// Should be around 63 characters long
|
||||
if (nsec.length < 60 || nsec.length > 70) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function nsecToHex(nsec) {
|
||||
// This is a simplified conversion - in a real app you'd use a proper library
|
||||
// For demo purposes, we'll simulate the conversion
|
||||
try {
|
||||
// Remove 'nsec1' prefix and decode (simplified)
|
||||
const withoutPrefix = nsec.slice(5);
|
||||
// In reality, you'd use bech32 decoding here
|
||||
// For now, we'll generate a mock hex key
|
||||
return 'mock_' + withoutPrefix.slice(0, 32);
|
||||
} catch (error) {
|
||||
throw new Error('Invalid nsec format');
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithNsec() {
|
||||
isLoading = true;
|
||||
errorMessage = '';
|
||||
successMessage = '';
|
||||
|
||||
try {
|
||||
if (!nsecInput.trim()) {
|
||||
throw new Error('Please enter your nsec');
|
||||
}
|
||||
|
||||
if (!validateNsec(nsecInput.trim())) {
|
||||
throw new Error('Invalid nsec format. Must start with "nsec1"');
|
||||
}
|
||||
|
||||
// Convert nsec to hex format (simplified for demo)
|
||||
const privateKey = nsecToHex(nsecInput.trim());
|
||||
|
||||
// In a real implementation, you'd derive the public key from private key
|
||||
const publicKey = 'derived_' + privateKey.slice(5, 37);
|
||||
|
||||
// Store securely (in production, consider more secure storage)
|
||||
localStorage.setItem('nostr_auth_method', 'nsec');
|
||||
localStorage.setItem('nostr_pubkey', publicKey);
|
||||
localStorage.setItem('nostr_privkey', privateKey);
|
||||
|
||||
successMessage = 'Successfully logged in with nsec!';
|
||||
dispatch('login', {
|
||||
method: 'nsec',
|
||||
pubkey: publicKey,
|
||||
privateKey: privateKey
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
closeModal();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
errorMessage = error.message;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
if (event.key === 'Enter' && activeTab === 'nsec') {
|
||||
loginWithNsec();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
{#if showModal}
|
||||
<div class="modal-overlay" on:click={closeModal} on:keydown={(e) => e.key === 'Escape' && closeModal()} role="button" tabindex="0">
|
||||
<div class="modal" class:dark-theme={isDarkTheme} on:click|stopPropagation on:keydown|stopPropagation>
|
||||
<div class="modal-header">
|
||||
<h2>Login to Nostr</h2>
|
||||
<button class="close-btn" on:click={closeModal}>×</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-container">
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab-btn"
|
||||
class:active={activeTab === 'extension'}
|
||||
on:click={() => switchTab('extension')}
|
||||
>
|
||||
Extension
|
||||
</button>
|
||||
<button
|
||||
class="tab-btn"
|
||||
class:active={activeTab === 'nsec'}
|
||||
on:click={() => switchTab('nsec')}
|
||||
>
|
||||
Nsec
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
{#if activeTab === 'extension'}
|
||||
<div class="extension-login">
|
||||
<p>Login using a NIP-07 compatible browser extension like nos2x or Alby.</p>
|
||||
<button
|
||||
class="login-extension-btn"
|
||||
on:click={loginWithExtension}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Connecting...' : 'Log in using extension'}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="nsec-login">
|
||||
<p>Enter your nsec (private key) to login. This will be stored securely in your browser.</p>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="nsec1..."
|
||||
bind:value={nsecInput}
|
||||
disabled={isLoading}
|
||||
class="nsec-input"
|
||||
/>
|
||||
<button
|
||||
class="login-nsec-btn"
|
||||
on:click={loginWithNsec}
|
||||
disabled={isLoading || !nsecInput.trim()}
|
||||
>
|
||||
{isLoading ? 'Logging in...' : 'Log in with nsec'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="message error-message">{errorMessage}</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="message success-message">{successMessage}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background-color: var(--tab-hover-bg);
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
background-color: var(--tab-hover-bg);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
border-bottom-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.extension-login,
|
||||
.nsec-login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.extension-login p,
|
||||
.nsec-login p {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.login-extension-btn,
|
||||
.login-nsec-btn {
|
||||
padding: 12px 24px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.login-extension-btn:hover:not(:disabled),
|
||||
.login-nsec-btn:hover:not(:disabled) {
|
||||
background: #00ACC1;
|
||||
}
|
||||
|
||||
.login-extension-btn:disabled,
|
||||
.login-nsec-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nsec-input {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.nsec-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
border: 1px solid #ffcdd2;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background: #e8f5e8;
|
||||
color: #2e7d32;
|
||||
border: 1px solid #c8e6c9;
|
||||
}
|
||||
|
||||
.modal.dark-theme .error-message {
|
||||
background: #4a2c2a;
|
||||
color: #ffcdd2;
|
||||
border: 1px solid #6d4c41;
|
||||
}
|
||||
|
||||
.modal.dark-theme .success-message {
|
||||
background: #2e4a2e;
|
||||
color: #a5d6a7;
|
||||
border: 1px solid #4caf50;
|
||||
}
|
||||
</style>
|
||||
14
app/web/src/constants.js
Normal file
14
app/web/src/constants.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// Default Nostr relays for searching
|
||||
export const DEFAULT_RELAYS = [
|
||||
// Use the local relay WebSocket endpoint
|
||||
`wss://${window.location.host}/ws`,
|
||||
// Fallback to external relays if local fails
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.nostr.band",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.nostr.net",
|
||||
"wss://relay.minibits.cash",
|
||||
"wss://relay.coinos.io/",
|
||||
"wss://nwc.primal.net",
|
||||
"wss://relay.orly.dev",
|
||||
];
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
const root = createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
11
app/web/src/main.js
Normal file
11
app/web/src/main.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import App from "./App.svelte";
|
||||
import "../public/global.css";
|
||||
|
||||
const app = new App({
|
||||
target: document.body,
|
||||
props: {
|
||||
name: "world",
|
||||
},
|
||||
});
|
||||
|
||||
export default app;
|
||||
599
app/web/src/nostr.js
Normal file
599
app/web/src/nostr.js
Normal file
@@ -0,0 +1,599 @@
|
||||
import { DEFAULT_RELAYS } from "./constants.js";
|
||||
|
||||
// Simple WebSocket relay manager
|
||||
class NostrClient {
|
||||
constructor() {
|
||||
this.relays = new Map();
|
||||
this.subscriptions = new Map();
|
||||
}
|
||||
|
||||
async connect() {
|
||||
console.log("Starting connection to", DEFAULT_RELAYS.length, "relays...");
|
||||
|
||||
const connectionPromises = DEFAULT_RELAYS.map((relayUrl) => {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
console.log(`Attempting to connect to ${relayUrl}`);
|
||||
const ws = new WebSocket(relayUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log(`✓ Successfully connected to ${relayUrl}`);
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error(`✗ Error connecting to ${relayUrl}:`, error);
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.warn(
|
||||
`Connection closed to ${relayUrl}:`,
|
||||
event.code,
|
||||
event.reason,
|
||||
);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
console.log(`Message from ${relayUrl}:`, event.data);
|
||||
try {
|
||||
this.handleMessage(relayUrl, JSON.parse(event.data));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to parse message from ${relayUrl}:`,
|
||||
error,
|
||||
event.data,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
this.relays.set(relayUrl, ws);
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn(`Connection timeout for ${relayUrl}`);
|
||||
resolve(false);
|
||||
}
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
console.error(`Failed to create WebSocket for ${relayUrl}:`, error);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const results = await Promise.all(connectionPromises);
|
||||
const successfulConnections = results.filter(Boolean).length;
|
||||
console.log(
|
||||
`Connected to ${successfulConnections}/${DEFAULT_RELAYS.length} relays`,
|
||||
);
|
||||
|
||||
// Wait a bit more for connections to stabilize
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
handleMessage(relayUrl, message) {
|
||||
console.log(`Processing message from ${relayUrl}:`, message);
|
||||
const [type, subscriptionId, event, ...rest] = message;
|
||||
|
||||
console.log(`Message type: ${type}, subscriptionId: ${subscriptionId}`);
|
||||
|
||||
if (type === "EVENT") {
|
||||
console.log(`Received EVENT for subscription ${subscriptionId}:`, event);
|
||||
if (this.subscriptions.has(subscriptionId)) {
|
||||
console.log(
|
||||
`Found callback for subscription ${subscriptionId}, executing...`,
|
||||
);
|
||||
const callback = this.subscriptions.get(subscriptionId);
|
||||
callback(event);
|
||||
} else {
|
||||
console.warn(`No callback found for subscription ${subscriptionId}`);
|
||||
}
|
||||
} else if (type === "EOSE") {
|
||||
console.log(
|
||||
`End of stored events for subscription ${subscriptionId} from ${relayUrl}`,
|
||||
);
|
||||
// Dispatch EOSE event for fetchEvents function
|
||||
if (this.subscriptions.has(subscriptionId)) {
|
||||
window.dispatchEvent(new CustomEvent('nostr-eose', {
|
||||
detail: { subscriptionId, relayUrl }
|
||||
}));
|
||||
}
|
||||
} else if (type === "NOTICE") {
|
||||
console.warn(`Notice from ${relayUrl}:`, subscriptionId);
|
||||
} else {
|
||||
console.log(`Unknown message type ${type} from ${relayUrl}:`, message);
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(filters, callback) {
|
||||
const subscriptionId = Math.random().toString(36).substring(7);
|
||||
console.log(
|
||||
`Creating subscription ${subscriptionId} with filters:`,
|
||||
filters,
|
||||
);
|
||||
|
||||
this.subscriptions.set(subscriptionId, callback);
|
||||
|
||||
const subscription = ["REQ", subscriptionId, filters];
|
||||
console.log(`Subscription message:`, JSON.stringify(subscription));
|
||||
|
||||
let sentCount = 0;
|
||||
for (const [relayUrl, ws] of this.relays) {
|
||||
console.log(
|
||||
`Checking relay ${relayUrl}, readyState: ${ws.readyState} (${ws.readyState === WebSocket.OPEN ? "OPEN" : "NOT OPEN"})`,
|
||||
);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify(subscription));
|
||||
console.log(`✓ Sent subscription to ${relayUrl}`);
|
||||
sentCount++;
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to send subscription to ${relayUrl}:`, error);
|
||||
}
|
||||
} else {
|
||||
console.warn(`✗ Cannot send to ${relayUrl}, connection not ready`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Subscription ${subscriptionId} sent to ${sentCount}/${this.relays.size} relays`,
|
||||
);
|
||||
return subscriptionId;
|
||||
}
|
||||
|
||||
unsubscribe(subscriptionId) {
|
||||
this.subscriptions.delete(subscriptionId);
|
||||
|
||||
const closeMessage = ["CLOSE", subscriptionId];
|
||||
|
||||
for (const [relayUrl, ws] of this.relays) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(closeMessage));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
for (const [relayUrl, ws] of this.relays) {
|
||||
ws.close();
|
||||
}
|
||||
this.relays.clear();
|
||||
this.subscriptions.clear();
|
||||
}
|
||||
|
||||
// Publish an event to all connected relays
|
||||
async publish(event) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const eventMessage = ["EVENT", event];
|
||||
console.log("Publishing event:", eventMessage);
|
||||
|
||||
let publishedCount = 0;
|
||||
let okCount = 0;
|
||||
let errorCount = 0;
|
||||
const totalRelays = this.relays.size;
|
||||
|
||||
if (totalRelays === 0) {
|
||||
reject(new Error("No relays connected"));
|
||||
return;
|
||||
}
|
||||
|
||||
const handleResponse = (relayUrl, success) => {
|
||||
if (success) {
|
||||
okCount++;
|
||||
} else {
|
||||
errorCount++;
|
||||
}
|
||||
|
||||
if (okCount + errorCount === totalRelays) {
|
||||
if (okCount > 0) {
|
||||
resolve({ success: true, okCount, errorCount });
|
||||
} else {
|
||||
reject(new Error(`All relays rejected the event. Errors: ${errorCount}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set up a temporary listener for OK responses
|
||||
const originalHandleMessage = this.handleMessage.bind(this);
|
||||
this.handleMessage = (relayUrl, message) => {
|
||||
if (message[0] === "OK" && message[1] === event.id) {
|
||||
const success = message[2] === true;
|
||||
console.log(`Relay ${relayUrl} response:`, success ? "OK" : "REJECTED", message[3] || "");
|
||||
handleResponse(relayUrl, success);
|
||||
}
|
||||
// Call original handler for other messages
|
||||
originalHandleMessage(relayUrl, message);
|
||||
};
|
||||
|
||||
// Send to all connected relays
|
||||
for (const [relayUrl, ws] of this.relays) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify(eventMessage));
|
||||
publishedCount++;
|
||||
console.log(`Event sent to ${relayUrl}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to send event to ${relayUrl}:`, error);
|
||||
handleResponse(relayUrl, false);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Relay ${relayUrl} is not open, skipping`);
|
||||
handleResponse(relayUrl, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore original handler after timeout
|
||||
setTimeout(() => {
|
||||
this.handleMessage = originalHandleMessage;
|
||||
if (okCount + errorCount < totalRelays) {
|
||||
reject(new Error("Timeout waiting for relay responses"));
|
||||
}
|
||||
}, 10000); // 10 second timeout
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create a global client instance
|
||||
export const nostrClient = new NostrClient();
|
||||
|
||||
// IndexedDB helpers for caching events (kind 0 profiles)
|
||||
const DB_NAME = "nostrCache";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_EVENTS = "events";
|
||||
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE_EVENTS)) {
|
||||
const store = db.createObjectStore(STORE_EVENTS, { keyPath: "id" });
|
||||
store.createIndex("byKindAuthor", ["kind", "pubkey"], {
|
||||
unique: false,
|
||||
});
|
||||
store.createIndex(
|
||||
"byKindAuthorCreated",
|
||||
["kind", "pubkey", "created_at"],
|
||||
{ unique: false },
|
||||
);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function getLatestProfileEvent(pubkey) {
|
||||
try {
|
||||
const db = await openDB();
|
||||
return await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_EVENTS, "readonly");
|
||||
const idx = tx.objectStore(STORE_EVENTS).index("byKindAuthorCreated");
|
||||
const range = IDBKeyRange.bound(
|
||||
[0, pubkey, -Infinity],
|
||||
[0, pubkey, Infinity],
|
||||
);
|
||||
const req = idx.openCursor(range, "prev"); // newest first
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result;
|
||||
resolve(cursor ? cursor.value : null);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("IDB getLatestProfileEvent failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function putEvent(event) {
|
||||
try {
|
||||
const db = await openDB();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_EVENTS, "readwrite");
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.objectStore(STORE_EVENTS).put(event);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("IDB putEvent failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function parseProfileFromEvent(event) {
|
||||
try {
|
||||
const profile = JSON.parse(event.content || "{}");
|
||||
return {
|
||||
name: profile.name || profile.display_name || "",
|
||||
picture: profile.picture || "",
|
||||
banner: profile.banner || "",
|
||||
about: profile.about || "",
|
||||
nip05: profile.nip05 || "",
|
||||
lud16: profile.lud16 || profile.lud06 || "",
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
name: "",
|
||||
picture: "",
|
||||
banner: "",
|
||||
about: "",
|
||||
nip05: "",
|
||||
lud16: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch user profile metadata (kind 0)
|
||||
export async function fetchUserProfile(pubkey) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
console.log(`Starting profile fetch for pubkey: ${pubkey}`);
|
||||
|
||||
let resolved = false;
|
||||
let newestEvent = null;
|
||||
let debounceTimer = null;
|
||||
let overallTimer = null;
|
||||
let subscriptionId = null;
|
||||
|
||||
function cleanup() {
|
||||
if (subscriptionId) {
|
||||
try {
|
||||
nostrClient.unsubscribe(subscriptionId);
|
||||
} catch {}
|
||||
}
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (overallTimer) clearTimeout(overallTimer);
|
||||
}
|
||||
|
||||
// 1) Try cached profile first and resolve immediately if present
|
||||
try {
|
||||
const cachedEvent = await getLatestProfileEvent(pubkey);
|
||||
if (cachedEvent) {
|
||||
console.log("Using cached profile event");
|
||||
const profile = parseProfileFromEvent(cachedEvent);
|
||||
resolved = true; // resolve immediately with cache
|
||||
resolve(profile);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load cached profile", e);
|
||||
}
|
||||
|
||||
// 2) Set overall timeout
|
||||
overallTimer = setTimeout(() => {
|
||||
if (!newestEvent) {
|
||||
console.log("Profile fetch timeout reached");
|
||||
if (!resolved) reject(new Error("Profile fetch timeout"));
|
||||
} else if (!resolved) {
|
||||
resolve(parseProfileFromEvent(newestEvent));
|
||||
}
|
||||
cleanup();
|
||||
}, 15000);
|
||||
|
||||
// 3) Wait a bit to ensure connections are ready and then subscribe without limit
|
||||
setTimeout(() => {
|
||||
console.log("Starting subscription after connection delay...");
|
||||
subscriptionId = nostrClient.subscribe(
|
||||
{
|
||||
kinds: [0],
|
||||
authors: [pubkey],
|
||||
},
|
||||
(event) => {
|
||||
// Collect all kind 0 events and pick the newest by created_at
|
||||
if (!event || event.kind !== 0) return;
|
||||
console.log("Profile event received:", event);
|
||||
|
||||
if (
|
||||
!newestEvent ||
|
||||
(event.created_at || 0) > (newestEvent.created_at || 0)
|
||||
) {
|
||||
newestEvent = event;
|
||||
}
|
||||
|
||||
// Debounce to wait for more relays; then finalize selection
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
if (newestEvent) {
|
||||
await putEvent(newestEvent); // cache newest only
|
||||
const profile = parseProfileFromEvent(newestEvent);
|
||||
|
||||
// Notify listeners that an updated profile is available
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.dispatchEvent) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("profile-updated", {
|
||||
detail: { pubkey, profile, event: newestEvent },
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to dispatch profile-updated event", e);
|
||||
}
|
||||
|
||||
if (!resolved) {
|
||||
resolve(profile);
|
||||
resolved = true;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}, 800);
|
||||
},
|
||||
);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch events using WebSocket REQ envelopes
|
||||
export async function fetchEvents(filters, options = {}) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
console.log(`Starting event fetch with filters:`, filters);
|
||||
|
||||
let resolved = false;
|
||||
let events = [];
|
||||
let debounceTimer = null;
|
||||
let overallTimer = null;
|
||||
let subscriptionId = null;
|
||||
let eoseReceived = false;
|
||||
|
||||
const {
|
||||
timeout = 30000,
|
||||
debounceDelay = 1000,
|
||||
limit = null
|
||||
} = options;
|
||||
|
||||
function cleanup() {
|
||||
if (subscriptionId) {
|
||||
try {
|
||||
nostrClient.unsubscribe(subscriptionId);
|
||||
} catch {}
|
||||
}
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (overallTimer) clearTimeout(overallTimer);
|
||||
}
|
||||
|
||||
// Set overall timeout
|
||||
overallTimer = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
console.log("Event fetch timeout reached");
|
||||
if (events.length > 0) {
|
||||
resolve(events);
|
||||
} else {
|
||||
reject(new Error("Event fetch timeout"));
|
||||
}
|
||||
resolved = true;
|
||||
}
|
||||
cleanup();
|
||||
}, timeout);
|
||||
|
||||
// Subscribe to events
|
||||
setTimeout(() => {
|
||||
console.log("Starting event subscription...");
|
||||
|
||||
// Add limit to filters if specified
|
||||
const requestFilters = { ...filters };
|
||||
if (limit) {
|
||||
requestFilters.limit = limit;
|
||||
}
|
||||
|
||||
console.log('Sending REQ with filters:', requestFilters);
|
||||
|
||||
subscriptionId = nostrClient.subscribe(
|
||||
requestFilters,
|
||||
(event) => {
|
||||
if (!event) return;
|
||||
console.log("Event received:", event);
|
||||
|
||||
// Check if we already have this event (deduplication)
|
||||
const existingEvent = events.find(e => e.id === event.id);
|
||||
if (!existingEvent) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// If we have a limit and reached it, resolve immediately
|
||||
if (limit && events.length >= limit) {
|
||||
if (!resolved) {
|
||||
resolve(events.slice(0, limit));
|
||||
resolved = true;
|
||||
}
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce to wait for more events
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (eoseReceived && !resolved) {
|
||||
resolve(events);
|
||||
resolved = true;
|
||||
cleanup();
|
||||
}
|
||||
}, debounceDelay);
|
||||
},
|
||||
);
|
||||
|
||||
// Listen for EOSE events
|
||||
const handleEOSE = (event) => {
|
||||
if (event.detail.subscriptionId === subscriptionId) {
|
||||
console.log("EOSE received for subscription", subscriptionId);
|
||||
eoseReceived = true;
|
||||
|
||||
// If we haven't resolved yet and have events, resolve now
|
||||
if (!resolved && events.length > 0) {
|
||||
resolve(events);
|
||||
resolved = true;
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add EOSE listener
|
||||
window.addEventListener('nostr-eose', handleEOSE);
|
||||
|
||||
// Cleanup EOSE listener
|
||||
const originalCleanup = cleanup;
|
||||
cleanup = () => {
|
||||
window.removeEventListener('nostr-eose', handleEOSE);
|
||||
originalCleanup();
|
||||
};
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch all events with timestamp-based pagination
|
||||
export async function fetchAllEvents(options = {}) {
|
||||
const {
|
||||
limit = 100,
|
||||
since = null,
|
||||
until = null,
|
||||
authors = null
|
||||
} = options;
|
||||
|
||||
const filters = {};
|
||||
|
||||
if (since) filters.since = since;
|
||||
if (until) filters.until = until;
|
||||
if (authors) filters.authors = authors;
|
||||
|
||||
const events = await fetchEvents(filters, {
|
||||
limit: limit,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// Fetch user's events with timestamp-based pagination
|
||||
export async function fetchUserEvents(pubkey, options = {}) {
|
||||
const {
|
||||
limit = 100,
|
||||
since = null,
|
||||
until = null
|
||||
} = options;
|
||||
|
||||
const filters = {
|
||||
authors: [pubkey]
|
||||
};
|
||||
|
||||
if (since) filters.since = since;
|
||||
if (until) filters.until = until;
|
||||
|
||||
const events = await fetchEvents(filters, {
|
||||
limit: limit,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
|
||||
// Initialize client connection
|
||||
export async function initializeNostrClient() {
|
||||
await nostrClient.connect();
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: #f9f9f9;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px; /* Reduced space since header is now sticky */
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #007cba;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #005a87;
|
||||
}
|
||||
|
||||
.danger-button {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.danger-button:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.header-panel {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: #f8f9fa;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 0 0 12px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: auto;
|
||||
border-radius: 0;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid white;
|
||||
margin-right: 10px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: bold;
|
||||
font-size: 1em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-banner {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background: transparent;
|
||||
color: #6c757d;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 100%;
|
||||
margin-left: 10px;
|
||||
margin-right: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background: transparent;
|
||||
color: #343a40;
|
||||
}
|
||||
@@ -54,6 +54,7 @@ cd cmd/benchmark
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
- Clone all external relay repositories
|
||||
- Create Docker configurations for each relay
|
||||
- Set up configuration files
|
||||
@@ -68,6 +69,7 @@ docker compose up --build
|
||||
```
|
||||
|
||||
The system will:
|
||||
|
||||
- Build and start all relay containers
|
||||
- Wait for all relays to become healthy
|
||||
- Run benchmarks against each relay sequentially
|
||||
@@ -89,15 +91,15 @@ ls reports/run_YYYYMMDD_HHMMSS/
|
||||
|
||||
### Docker Compose Services
|
||||
|
||||
| Service | Port | Description |
|
||||
|---------|------|-------------|
|
||||
| next-orly | 8001 | This repository's BadgerDB relay |
|
||||
| khatru-sqlite | 8002 | Khatru with SQLite backend |
|
||||
| khatru-badger | 8003 | Khatru with Badger backend |
|
||||
| relayer-basic | 8004 | Basic relayer example |
|
||||
| strfry | 8005 | Strfry C++ LMDB relay |
|
||||
| nostr-rs-relay | 8006 | Rust SQLite relay |
|
||||
| benchmark-runner | - | Orchestrates tests and aggregates results |
|
||||
| Service | Port | Description |
|
||||
| ---------------- | ---- | ----------------------------------------- |
|
||||
| next-orly | 8001 | This repository's BadgerDB relay |
|
||||
| khatru-sqlite | 8002 | Khatru with SQLite backend |
|
||||
| khatru-badger | 8003 | Khatru with Badger backend |
|
||||
| relayer-basic | 8004 | Basic relayer example |
|
||||
| strfry | 8005 | Strfry C++ LMDB relay |
|
||||
| nostr-rs-relay | 8006 | Rust SQLite relay |
|
||||
| benchmark-runner | - | Orchestrates tests and aggregates results |
|
||||
|
||||
### File Structure
|
||||
|
||||
@@ -130,16 +132,16 @@ The benchmark can be configured via environment variables in `docker-compose.yml
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- BENCHMARK_EVENTS=10000 # Number of events per test
|
||||
- BENCHMARK_WORKERS=8 # Concurrent workers
|
||||
- BENCHMARK_DURATION=60s # Test duration
|
||||
- BENCHMARK_TARGETS=... # Relay endpoints to test
|
||||
- BENCHMARK_EVENTS=10000 # Number of events per test
|
||||
- BENCHMARK_WORKERS=8 # Concurrent workers
|
||||
- BENCHMARK_DURATION=60s # Test duration
|
||||
- BENCHMARK_TARGETS=... # Relay endpoints to test
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
1. **Modify test parameters**: Edit environment variables in `docker-compose.yml`
|
||||
2. **Add new relays**:
|
||||
2. **Add new relays**:
|
||||
- Add service to `docker-compose.yml`
|
||||
- Create appropriate Dockerfile
|
||||
- Update `BENCHMARK_TARGETS` environment variable
|
||||
@@ -174,16 +176,19 @@ go build -o benchmark main.go
|
||||
## Benchmark Results Interpretation
|
||||
|
||||
### Peak Throughput Test
|
||||
|
||||
- **High events/sec**: Good write performance
|
||||
- **Low latency**: Efficient event processing
|
||||
- **High success rate**: Stable under load
|
||||
|
||||
### Burst Pattern Test
|
||||
### Burst Pattern Test
|
||||
|
||||
- **Consistent performance**: Good handling of variable loads
|
||||
- **Low P95/P99 latency**: Predictable response times
|
||||
- **No errors during bursts**: Robust queuing/buffering
|
||||
|
||||
### Mixed Read/Write Test
|
||||
|
||||
- **Balanced throughput**: Good concurrent operation handling
|
||||
- **Low read latency**: Efficient query processing
|
||||
- **Stable write performance**: Queries don't significantly impact writes
|
||||
@@ -200,6 +205,7 @@ go build -o benchmark main.go
|
||||
### Modifying Relay Configurations
|
||||
|
||||
Each relay's Dockerfile and configuration can be customized:
|
||||
|
||||
- **Resource limits**: Adjust memory/CPU limits in docker-compose.yml
|
||||
- **Database settings**: Modify configuration files in `configs/`
|
||||
- **Network settings**: Update port mappings and health checks
|
||||
@@ -257,4 +263,4 @@ To add support for new relay implementations:
|
||||
|
||||
## License
|
||||
|
||||
This benchmark suite is part of the next.orly.dev project and follows the same licensing terms.
|
||||
This benchmark suite is part of the next.orly.dev project and follows the same licensing terms.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: '3.8'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# Next.orly.dev relay (this repository)
|
||||
@@ -19,7 +19,11 @@ services:
|
||||
networks:
|
||||
- benchmark-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080 || echo 000); echo $$code | grep -E '^(101|200|400|404|426)$' >/dev/null"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080 || echo 000); echo $$code | grep -E '^(101|200|400|404|426)$' >/dev/null",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -41,7 +45,11 @@ services:
|
||||
networks:
|
||||
- benchmark-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --quiet --server-response --tries=1 http://localhost:3334 2>&1 | grep -E 'HTTP/[0-9.]+ (101|200|400|404)' >/dev/null"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget --quiet --server-response --tries=1 http://localhost:3334 2>&1 | grep -E 'HTTP/[0-9.]+ (101|200|400|404)' >/dev/null",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -63,7 +71,11 @@ services:
|
||||
networks:
|
||||
- benchmark-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --quiet --server-response --tries=1 http://localhost:3334 2>&1 | grep -E 'HTTP/[0-9.]+ (101|200|400|404)' >/dev/null"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget --quiet --server-response --tries=1 http://localhost:3334 2>&1 | grep -E 'HTTP/[0-9.]+ (101|200|400|404)' >/dev/null",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -87,7 +99,11 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --quiet --server-response --tries=1 http://localhost:7447 2>&1 | grep -E 'HTTP/[0-9.]+ (101|200|400|404)' >/dev/null"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget --quiet --server-response --tries=1 http://localhost:7447 2>&1 | grep -E 'HTTP/[0-9.]+ (101|200|400|404)' >/dev/null",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -108,7 +124,11 @@ services:
|
||||
networks:
|
||||
- benchmark-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --quiet --server-response --tries=1 http://127.0.0.1:8080 2>&1 | grep -E 'HTTP/[0-9.]+ (101|200|400|404|426)' >/dev/null"]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget --quiet --server-response --tries=1 http://127.0.0.1:8080 2>&1 | grep -E 'HTTP/[0-9.]+ (101|200|400|404|426)' >/dev/null",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -130,7 +150,15 @@ services:
|
||||
networks:
|
||||
- benchmark-net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--quiet",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://localhost:8080",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -197,4 +225,4 @@ networks:
|
||||
|
||||
volumes:
|
||||
benchmark-data:
|
||||
driver: local
|
||||
driver: local
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Fixes: failed to solve: error from sender: open cmd/benchmark/data/postgres: permission denied
|
||||
|
||||
# Benchmark data and reports (mounted at runtime via volumes)
|
||||
cmd/benchmark/data/
|
||||
../../cmd/benchmark/data/
|
||||
cmd/benchmark/reports/
|
||||
|
||||
# VCS and OS cruft
|
||||
@@ -4,6 +4,7 @@
|
||||
**Updated with real-world troubleshooting solutions and latest Orly relay improvements**
|
||||
|
||||
## 🎯 **What This Solves**
|
||||
|
||||
- WebSocket connection failures (`NS_ERROR_WEBSOCKET_CONNECTION_REFUSED`)
|
||||
- Nostr relay connectivity issues (`HTTP 426` instead of WebSocket upgrade)
|
||||
- Docker container proxy configuration
|
||||
@@ -16,6 +17,7 @@
|
||||
## 🐳 **Step 1: Deploy Your Docker Application**
|
||||
|
||||
### **For Stella's Orly Relay (Latest Version with Proxy Improvements):**
|
||||
|
||||
```bash
|
||||
# Pull and run the relay with enhanced proxy support
|
||||
docker run -d \
|
||||
@@ -39,6 +41,7 @@ curl -I http://127.0.0.1:7777
|
||||
```
|
||||
|
||||
### **For Web Apps (like Jumble):**
|
||||
|
||||
```bash
|
||||
# Run with fixed port for easier proxy setup
|
||||
docker run -d \
|
||||
@@ -61,34 +64,34 @@ curl -I http://127.0.0.1:3000
|
||||
```apache
|
||||
<VirtualHost *:443>
|
||||
ServerName your-domain.com
|
||||
|
||||
|
||||
# SSL Configuration (Let's Encrypt)
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/your-domain.com/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/your-domain.com/privkey.pem
|
||||
|
||||
|
||||
# Enable required modules first:
|
||||
# sudo a2enmod proxy proxy_http proxy_wstunnel rewrite headers ssl
|
||||
|
||||
|
||||
# Proxy settings
|
||||
ProxyPreserveHost On
|
||||
ProxyRequests Off
|
||||
|
||||
|
||||
# WebSocket upgrade handling - CRITICAL for apps with WebSockets
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/?(.*) "ws://127.0.0.1:PORT/$1" [P,L]
|
||||
|
||||
|
||||
# Regular HTTP proxy
|
||||
ProxyPass / http://127.0.0.1:PORT/
|
||||
ProxyPassReverse / http://127.0.0.1:PORT/
|
||||
|
||||
|
||||
# Headers for modern web apps
|
||||
Header always set X-Forwarded-Proto "https"
|
||||
Header always set X-Forwarded-Port "443"
|
||||
Header always set X-Forwarded-For %{REMOTE_ADDR}s
|
||||
|
||||
|
||||
# Security headers
|
||||
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
|
||||
Header always set X-Content-Type-Options nosniff
|
||||
@@ -103,6 +106,7 @@ curl -I http://127.0.0.1:3000
|
||||
```
|
||||
|
||||
**Then enable it:**
|
||||
|
||||
```bash
|
||||
sudo a2ensite domain.conf
|
||||
sudo systemctl reload apache2
|
||||
@@ -121,6 +125,7 @@ sudo systemctl reload apache2
|
||||
5. **In HTTPS section, add:**
|
||||
|
||||
**For Nostr Relay (port 7777):**
|
||||
|
||||
```apache
|
||||
ProxyRequests Off
|
||||
ProxyPreserveHost On
|
||||
@@ -142,23 +147,23 @@ sudo tee /etc/apache2/conf-available/relay-override.conf << 'EOF'
|
||||
ServerName your-domain.com
|
||||
ServerAlias www.your-domain.com
|
||||
ServerAlias ipv4.your-domain.com
|
||||
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/your-domain.com/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/your-domain.com/privkey.pem
|
||||
|
||||
|
||||
DocumentRoot /var/www/relay
|
||||
|
||||
|
||||
# For Nostr relay - proxy everything to WebSocket
|
||||
ProxyRequests Off
|
||||
ProxyPreserveHost On
|
||||
ProxyPass / ws://127.0.0.1:7777/
|
||||
ProxyPassReverse / ws://127.0.0.1:7777/
|
||||
|
||||
|
||||
# CORS headers
|
||||
Header always set Access-Control-Allow-Origin "*"
|
||||
Header always set Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization"
|
||||
|
||||
|
||||
# Logging
|
||||
ErrorLog /var/log/apache2/relay-error.log
|
||||
CustomLog /var/log/apache2/relay-access.log combined
|
||||
@@ -190,6 +195,7 @@ apache2ctl -M | grep -E "(proxy|rewrite)"
|
||||
```
|
||||
|
||||
#### **For Web Apps (port 3000 or 32768):**
|
||||
|
||||
```apache
|
||||
ProxyPreserveHost On
|
||||
ProxyRequests Off
|
||||
@@ -221,22 +227,22 @@ sudo tee /etc/apache2/conf-available/relay-override.conf << 'EOF'
|
||||
ServerName your-domain.com
|
||||
ServerAlias www.your-domain.com
|
||||
ServerAlias ipv4.your-domain.com
|
||||
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/your-domain.com/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/your-domain.com/privkey.pem
|
||||
|
||||
|
||||
DocumentRoot /var/www/relay
|
||||
|
||||
|
||||
# For Nostr relay - proxy everything to WebSocket
|
||||
ProxyRequests Off
|
||||
ProxyPreserveHost On
|
||||
ProxyPass / ws://127.0.0.1:7777/
|
||||
ProxyPassReverse / ws://127.0.0.1:7777/
|
||||
|
||||
|
||||
# CORS headers
|
||||
Header always set Access-Control-Allow-Origin "*"
|
||||
|
||||
|
||||
# Logging
|
||||
ErrorLog /var/log/apache2/relay-error.log
|
||||
CustomLog /var/log/apache2/relay-access.log combined
|
||||
@@ -269,6 +275,7 @@ sudo systemctl restart apache2
|
||||
## 🆕 **Step 4: Latest Orly Relay Improvements**
|
||||
|
||||
### **Enhanced Proxy Support**
|
||||
|
||||
The latest Orly relay includes several proxy improvements:
|
||||
|
||||
1. **Flexible WebSocket Scheme Handling**: Accepts both `ws://` and `wss://` schemes for authentication
|
||||
@@ -277,6 +284,7 @@ The latest Orly relay includes several proxy improvements:
|
||||
4. **Proxy-Aware Logging**: Better debugging information for proxy setups
|
||||
|
||||
### **Key Environment Variables**
|
||||
|
||||
```bash
|
||||
# Essential for proxy setups
|
||||
ORLY_RELAY_URL=wss://your-domain.com # Must match your public URL
|
||||
@@ -286,6 +294,7 @@ ORLY_SUBSCRIPTION_ENABLED=false # Disable payment requirements
|
||||
```
|
||||
|
||||
### **Testing the Enhanced Relay**
|
||||
|
||||
```bash
|
||||
# Test local connectivity
|
||||
curl -I http://127.0.0.1:7777
|
||||
@@ -338,32 +347,38 @@ After making changes:
|
||||
|
||||
## 🚨 **Real-World Troubleshooting Guide**
|
||||
|
||||
*Based on actual deployment experience with Plesk and WebSocket issues*
|
||||
_Based on actual deployment experience with Plesk and WebSocket issues_
|
||||
|
||||
### **Critical Issues & Solutions:**
|
||||
|
||||
#### **🔴 HTTP 503 Service Unavailable**
|
||||
|
||||
- **Cause**: Docker container not running
|
||||
- **Check**: `docker ps | grep relay`
|
||||
- **Fix**: `docker start container-name`
|
||||
|
||||
#### **🔴 HTTP 426 Instead of WebSocket Upgrade**
|
||||
|
||||
- **Cause**: Apache using `http://` proxy instead of `ws://`
|
||||
- **Fix**: Use `ProxyPass / ws://127.0.0.1:7777/` (not `http://`)
|
||||
|
||||
#### **🔴 Plesk Configuration Not Applied**
|
||||
|
||||
- **Symptom**: Config not in `/etc/apache2/plesk.conf.d/vhosts/domain.conf`
|
||||
- **Solution**: Use Direct Apache Override method (bypass Plesk interface)
|
||||
|
||||
#### **🔴 Virtual Host Conflicts**
|
||||
|
||||
- **Check**: `apache2ctl -S | grep domain.com`
|
||||
- **Fix**: Remove Plesk config: `sudo rm /etc/apache2/plesk.conf.d/vhosts/domain.conf`
|
||||
|
||||
#### **🔴 Nginx Intercepting (Plesk)**
|
||||
|
||||
- **Symptom**: Response shows `Server: nginx`
|
||||
- **Fix**: Disable nginx in Plesk settings
|
||||
|
||||
### **Debug Commands:**
|
||||
|
||||
```bash
|
||||
# Essential debugging
|
||||
docker ps | grep relay # Container running?
|
||||
@@ -383,9 +398,11 @@ docker logs relay-name | grep -i "websocket connection"
|
||||
## 🚨 **Latest Troubleshooting Solutions**
|
||||
|
||||
### **WebSocket Scheme Validation Errors**
|
||||
|
||||
**Problem**: `"HTTP Scheme incorrect: expected 'ws' got 'wss'"`
|
||||
|
||||
**Solution**: Use the latest Orly relay image with enhanced proxy support:
|
||||
|
||||
```bash
|
||||
# Pull the latest image with proxy improvements
|
||||
docker pull silberengel/next-orly:latest
|
||||
@@ -396,17 +413,21 @@ docker stop orly-relay && docker rm orly-relay
|
||||
```
|
||||
|
||||
### **Malformed Client Data Errors**
|
||||
|
||||
**Problem**: `"invalid hex array size, got 2 expect 64"`
|
||||
|
||||
**Solution**: These are client-side issues, not server problems. The latest relay handles them gracefully:
|
||||
|
||||
- The relay now sends helpful error messages to clients
|
||||
- Malformed requests are logged but don't crash the relay
|
||||
- Normal operations continue despite client errors
|
||||
|
||||
### **Follows ACL Not Working**
|
||||
|
||||
**Problem**: Only owners can write, admins can't write
|
||||
|
||||
**Solution**: Ensure proper configuration:
|
||||
|
||||
```bash
|
||||
# Check ACL configuration
|
||||
docker exec orly-relay env | grep ACL
|
||||
@@ -416,9 +437,11 @@ docker exec orly-relay env | grep ACL
|
||||
```
|
||||
|
||||
### **Spider Not Syncing Content**
|
||||
|
||||
**Problem**: Spider enabled but not pulling events
|
||||
|
||||
**Solution**: Check for relay lists and follow events:
|
||||
|
||||
```bash
|
||||
# Check spider status
|
||||
docker logs orly-relay | grep -i spider
|
||||
@@ -431,6 +454,7 @@ docker logs orly-relay | grep -i "kind.*3"
|
||||
```
|
||||
|
||||
### **Working Solution (Proven):**
|
||||
|
||||
```apache
|
||||
<VirtualHost SERVER_IP:443>
|
||||
ServerName domain.com
|
||||
@@ -438,20 +462,21 @@ docker logs orly-relay | grep -i "kind.*3"
|
||||
SSLCertificateFile /etc/letsencrypt/live/domain.com/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/domain.com/privkey.pem
|
||||
DocumentRoot /var/www/relay
|
||||
|
||||
|
||||
# Direct WebSocket proxy - this is the key!
|
||||
ProxyRequests Off
|
||||
ProxyPreserveHost On
|
||||
ProxyPass / ws://127.0.0.1:7777/
|
||||
ProxyPassReverse / ws://127.0.0.1:7777/
|
||||
|
||||
|
||||
Header always set Access-Control-Allow-Origin "*"
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Key Lessons**:
|
||||
**Key Lessons**:
|
||||
|
||||
1. Plesk interface often fails to apply Apache directives
|
||||
2. Use `ws://` proxy for Nostr relays, not `http://`
|
||||
3. Direct Apache config files are more reliable than Plesk interface
|
||||
@@ -464,17 +489,20 @@ docker logs orly-relay | grep -i "kind.*3"
|
||||
## 🎉 **Summary of Latest Improvements**
|
||||
|
||||
### **Enhanced Proxy Support**
|
||||
|
||||
- ✅ Flexible WebSocket scheme validation (accepts both `ws://` and `wss://`)
|
||||
- ✅ Enhanced CORS headers for better web app compatibility
|
||||
- ✅ Improved error handling for malformed client data
|
||||
- ✅ Proxy-aware logging for better debugging
|
||||
|
||||
### **Spider and ACL Features**
|
||||
|
||||
- ✅ Follows-based access control (`ORLY_ACL_MODE=follows`)
|
||||
- ✅ Content syncing from other relays (`ORLY_SPIDER_MODE=follows`)
|
||||
- ✅ No payment requirements (`ORLY_SUBSCRIPTION_ENABLED=false`)
|
||||
|
||||
### **Production Ready**
|
||||
|
||||
- ✅ Robust error handling
|
||||
- ✅ Enhanced logging and debugging
|
||||
- ✅ Better client compatibility
|
||||
@@ -37,6 +37,7 @@ cp env.example .env
|
||||
```
|
||||
|
||||
Key settings:
|
||||
|
||||
- `ORLY_OWNERS`: Owner npubs (comma-separated, full control)
|
||||
- `ORLY_ADMINS`: Admin npubs (comma-separated, deletion permissions)
|
||||
- `ORLY_PORT`: Port to listen on (default: 7777)
|
||||
@@ -50,6 +51,7 @@ The relay data is stored in `./data` directory which is mounted as a volume.
|
||||
### Performance Tuning
|
||||
|
||||
Based on the v0.4.8 optimizations:
|
||||
|
||||
- Concurrent event publishing using all CPU cores
|
||||
- Optimized BadgerDB access patterns
|
||||
- Configurable batch sizes and cache settings
|
||||
@@ -105,12 +107,14 @@ go run ./cmd/stresstest -relay ws://localhost:7777
|
||||
### Common Issues (Real-World Experience)
|
||||
|
||||
#### **Container Issues:**
|
||||
|
||||
1. **Port already in use**: Change `ORLY_PORT` in docker-compose.yml
|
||||
2. **Permission denied**: Ensure `./data` directory is writable
|
||||
3. **Container won't start**: Check logs with `docker logs container-name`
|
||||
|
||||
#### **WebSocket Issues:**
|
||||
4. **HTTP 426 instead of WebSocket upgrade**:
|
||||
|
||||
4. **HTTP 426 instead of WebSocket upgrade**:
|
||||
- Use `ws://127.0.0.1:7777` in proxy config, not `http://`
|
||||
- Ensure `proxy_wstunnel` module is enabled
|
||||
5. **Connection refused in browser but works with websocat**:
|
||||
@@ -119,6 +123,7 @@ go run ./cmd/stresstest -relay ws://localhost:7777
|
||||
- Add CORS headers to Apache/nginx config
|
||||
|
||||
#### **Plesk-Specific Issues:**
|
||||
|
||||
6. **Plesk not applying Apache directives**:
|
||||
- Check if config appears in `/etc/apache2/plesk.conf.d/vhosts/domain.conf`
|
||||
- Use direct Apache override if Plesk interface fails
|
||||
@@ -127,6 +132,7 @@ go run ./cmd/stresstest -relay ws://localhost:7777
|
||||
- Remove conflicting Plesk configs if needed
|
||||
|
||||
#### **SSL Certificate Issues:**
|
||||
|
||||
8. **Self-signed certificate after Let's Encrypt**:
|
||||
- Plesk might not be using the correct certificate
|
||||
- Import Let's Encrypt certs into Plesk or use direct Apache config
|
||||
@@ -166,23 +172,24 @@ sudo tail -f /var/log/apache2/domain-error.log
|
||||
### Working Reverse Proxy Config
|
||||
|
||||
**For Apache (direct config file):**
|
||||
|
||||
```apache
|
||||
<VirtualHost SERVER_IP:443>
|
||||
ServerName domain.com
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/domain.com/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/domain.com/privkey.pem
|
||||
|
||||
|
||||
# Direct WebSocket proxy for Nostr relay
|
||||
ProxyRequests Off
|
||||
ProxyPreserveHost On
|
||||
ProxyPass / ws://127.0.0.1:7777/
|
||||
ProxyPassReverse / ws://127.0.0.1:7777/
|
||||
|
||||
|
||||
Header always set Access-Control-Allow-Origin "*"
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Crafted for Stella's digital forest* 🌲
|
||||
_Crafted for Stella's digital forest_ 🌲
|
||||
@@ -19,11 +19,11 @@ RUN apk add --no-cache libsecp256k1-dev
|
||||
WORKDIR /build
|
||||
|
||||
# Copy go modules first (for better caching)
|
||||
COPY go.mod go.sum ./
|
||||
COPY ../../go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
COPY ../.. .
|
||||
|
||||
# Build the relay with optimizations from v0.4.8
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -ldflags "-w -s" -o relay .
|
||||
@@ -1,26 +1,28 @@
|
||||
# Service Worker Certificate Caching Fix
|
||||
|
||||
## 🚨 **Problem**
|
||||
|
||||
When accessing Jumble from the ImWald landing page, the service worker serves a cached self-signed certificate instead of the new Let's Encrypt certificate.
|
||||
|
||||
## ⚡ **Solutions**
|
||||
|
||||
### **Option 1: Force Service Worker Update**
|
||||
|
||||
Add this to your Jumble app's service worker or main JavaScript:
|
||||
|
||||
```javascript
|
||||
// Force service worker update and certificate refresh
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(function(registrations) {
|
||||
for(let registration of registrations) {
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(function (registrations) {
|
||||
for (let registration of registrations) {
|
||||
registration.update(); // Force update
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clear all caches on certificate update
|
||||
if ('caches' in window) {
|
||||
caches.keys().then(function(names) {
|
||||
if ("caches" in window) {
|
||||
caches.keys().then(function (names) {
|
||||
for (let name of names) {
|
||||
caches.delete(name);
|
||||
}
|
||||
@@ -29,49 +31,52 @@ if ('caches' in window) {
|
||||
```
|
||||
|
||||
### **Option 2: Update Service Worker Cache Strategy**
|
||||
|
||||
In your service worker file, add cache busting for SSL-sensitive requests:
|
||||
|
||||
```javascript
|
||||
// In your service worker
|
||||
self.addEventListener('fetch', function(event) {
|
||||
self.addEventListener("fetch", function (event) {
|
||||
// Don't cache HTTPS requests that might have certificate issues
|
||||
if (event.request.url.startsWith('https://') &&
|
||||
event.request.url.includes('imwald.eu')) {
|
||||
event.respondWith(
|
||||
fetch(event.request, { cache: 'no-store' })
|
||||
);
|
||||
if (
|
||||
event.request.url.startsWith("https://") &&
|
||||
event.request.url.includes("imwald.eu")
|
||||
) {
|
||||
event.respondWith(fetch(event.request, { cache: "no-store" }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Your existing fetch handling...
|
||||
});
|
||||
```
|
||||
|
||||
### **Option 3: Version Your Service Worker**
|
||||
|
||||
Update your service worker with a new version number:
|
||||
|
||||
```javascript
|
||||
// At the top of your service worker
|
||||
const CACHE_VERSION = 'v2.0.1'; // Increment this when certificates change
|
||||
const CACHE_VERSION = "v2.0.1"; // Increment this when certificates change
|
||||
const CACHE_NAME = `jumble-cache-${CACHE_VERSION}`;
|
||||
|
||||
// Clear old caches
|
||||
self.addEventListener('activate', function(event) {
|
||||
self.addEventListener("activate", function (event) {
|
||||
event.waitUntil(
|
||||
caches.keys().then(function(cacheNames) {
|
||||
caches.keys().then(function (cacheNames) {
|
||||
return Promise.all(
|
||||
cacheNames.map(function(cacheName) {
|
||||
cacheNames.map(function (cacheName) {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### **Option 4: Add Cache Headers**
|
||||
|
||||
In your Plesk Apache config for Jumble, add:
|
||||
|
||||
```apache
|
||||
@@ -1,11 +1,13 @@
|
||||
# WebSocket Connection Debug Guide
|
||||
|
||||
## 🚨 **Current Issue**
|
||||
|
||||
`wss://orly-relay.imwald.eu/` returns `NS_ERROR_WEBSOCKET_CONNECTION_REFUSED`
|
||||
|
||||
## 🔍 **Debug Steps**
|
||||
|
||||
### **Step 1: Verify Relay is Running**
|
||||
|
||||
```bash
|
||||
# On your server
|
||||
curl -I http://127.0.0.1:7777
|
||||
@@ -16,6 +18,7 @@ docker ps | grep stella
|
||||
```
|
||||
|
||||
### **Step 2: Test Apache Modules**
|
||||
|
||||
```bash
|
||||
# Check if WebSocket modules are enabled
|
||||
apache2ctl -M | grep -E "(proxy|rewrite)"
|
||||
@@ -30,6 +33,7 @@ sudo systemctl restart apache2
|
||||
```
|
||||
|
||||
### **Step 3: Check Apache Configuration**
|
||||
|
||||
```bash
|
||||
# Check what Plesk generated
|
||||
sudo cat /etc/apache2/plesk.conf.d/vhosts/orly-relay.imwald.eu.conf
|
||||
@@ -39,6 +43,7 @@ grep -E "(Proxy|Rewrite)" /etc/apache2/plesk.conf.d/vhosts/orly-relay.imwald.eu.
|
||||
```
|
||||
|
||||
### **Step 4: Test Direct WebSocket Connection**
|
||||
|
||||
```bash
|
||||
# Test if the issue is Apache or the relay itself
|
||||
echo '["REQ","test",{}]' | websocat ws://127.0.0.1:7777/
|
||||
@@ -48,6 +53,7 @@ echo '["REQ","test",{}]' | websocat ws://127.0.0.1:7777/
|
||||
```
|
||||
|
||||
### **Step 5: Check Apache Error Logs**
|
||||
|
||||
```bash
|
||||
# Watch Apache errors in real-time
|
||||
sudo tail -f /var/log/apache2/error.log
|
||||
@@ -83,6 +89,7 @@ ProxyAddHeaders On
|
||||
```
|
||||
|
||||
### **Alternative Simpler Version:**
|
||||
|
||||
If the above doesn't work, try just:
|
||||
|
||||
```apache
|
||||
@@ -4,9 +4,9 @@
|
||||
services:
|
||||
orly-relay:
|
||||
build:
|
||||
context: .
|
||||
context: ../..
|
||||
dockerfile: Dockerfile
|
||||
image: silberengel/next-orly:latest
|
||||
image: silberengel/next-orly:latest
|
||||
container_name: orly-relay
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -23,40 +23,40 @@ services:
|
||||
- ORLY_DB_LOG_LEVEL=error
|
||||
- ORLY_OWNERS=npub1v30tsz9vw6ylpz63g0a702nj3xa26t3m7p5us8f2y2sd8v6cnsvq465zjx
|
||||
- ORLY_ADMINS=npub1v30tsz9vw6ylpz63g0a702nj3xa26t3m7p5us8f2y2sd8v6cnsvq465zjx,npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl,npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z
|
||||
|
||||
|
||||
# ACL and Spider Configuration
|
||||
- ORLY_ACL_MODE=follows
|
||||
- ORLY_SPIDER_MODE=follows
|
||||
|
||||
|
||||
# Bootstrap relay URLs for initial sync
|
||||
- ORLY_BOOTSTRAP_RELAYS=wss://profiles.nostr1.com,wss://purplepag.es,wss://relay.nostr.band,wss://relay.damus.io
|
||||
|
||||
|
||||
# Subscription Settings (optional)
|
||||
- ORLY_SUBSCRIPTION_ENABLED=false
|
||||
- ORLY_MONTHLY_PRICE_SATS=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: 10s
|
||||
|
||||
|
||||
# Resource limits
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
cpus: '1.0'
|
||||
cpus: "1.0"
|
||||
reservations:
|
||||
memory: 256M
|
||||
cpus: '0.25'
|
||||
|
||||
cpus: "0.25"
|
||||
|
||||
# Logging configuration
|
||||
logging:
|
||||
driver: "json-file"
|
||||
@@ -79,7 +79,7 @@ services:
|
||||
depends_on:
|
||||
- orly-relay
|
||||
profiles:
|
||||
- proxy # Only start with: docker-compose --profile proxy up
|
||||
- proxy # Only start with: docker-compose --profile proxy up
|
||||
|
||||
volumes:
|
||||
relay_data:
|
||||
@@ -10,12 +10,14 @@ This document compares how two Nostr relay implementations handle WebSocket conn
|
||||
## Architecture Comparison
|
||||
|
||||
### Khatru Architecture
|
||||
|
||||
- **Monolithic approach**: Single large `HandleWebsocket` method (~380 lines) processes all message types
|
||||
- **Inline processing**: REQ handling is embedded within the main websocket handler
|
||||
- **Hook-based extensibility**: Uses function slices for customizable behavior
|
||||
- **Simple structure**: WebSocket struct with basic fields and mutex for thread safety
|
||||
|
||||
### Next.orly.dev Architecture
|
||||
### Next.orly.dev Architecture
|
||||
|
||||
- **Modular approach**: Separate methods for each message type (`HandleReq`, `HandleEvent`, etc.)
|
||||
- **Layered processing**: Message identification → envelope parsing → type-specific handling
|
||||
- **Publisher-subscriber system**: Dedicated infrastructure for subscription management
|
||||
@@ -24,6 +26,7 @@ This document compares how two Nostr relay implementations handle WebSocket conn
|
||||
## Connection Establishment
|
||||
|
||||
### Khatru
|
||||
|
||||
```go
|
||||
// Simple websocket upgrade
|
||||
conn, err := rl.upgrader.Upgrade(w, r, nil)
|
||||
@@ -36,6 +39,7 @@ ws := &WebSocket{
|
||||
```
|
||||
|
||||
### Next.orly.dev
|
||||
|
||||
```go
|
||||
// More sophisticated setup with IP whitelisting
|
||||
conn, err = websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
|
||||
@@ -50,6 +54,7 @@ listener := &Listener{
|
||||
```
|
||||
|
||||
**Key Differences:**
|
||||
|
||||
- Next.orly.dev includes IP whitelisting and immediate authentication challenges
|
||||
- Khatru uses fasthttp/websocket library vs next.orly.dev using coder/websocket
|
||||
- Next.orly.dev has more detailed connection state tracking
|
||||
@@ -57,11 +62,13 @@ listener := &Listener{
|
||||
## Message Processing
|
||||
|
||||
### Khatru
|
||||
|
||||
- Uses `nostr.MessageParser` for sequential parsing
|
||||
- Switch statement on envelope type within goroutine
|
||||
- Direct processing without intermediate validation layers
|
||||
|
||||
### Next.orly.dev
|
||||
|
||||
- Custom envelope identification system (`envelopes.Identify`)
|
||||
- Separate validation and processing phases
|
||||
- Extensive logging and error handling at each step
|
||||
@@ -69,11 +76,12 @@ listener := &Listener{
|
||||
## REQ Message Handling
|
||||
|
||||
### Khatru REQ Processing
|
||||
|
||||
```go
|
||||
case *nostr.ReqEnvelope:
|
||||
eose := sync.WaitGroup{}
|
||||
eose.Add(len(env.Filters))
|
||||
|
||||
|
||||
// Handle each filter separately
|
||||
for _, filter := range env.Filters {
|
||||
err := srl.handleRequest(reqCtx, env.SubscriptionID, &eose, ws, filter)
|
||||
@@ -85,7 +93,7 @@ case *nostr.ReqEnvelope:
|
||||
rl.addListener(ws, env.SubscriptionID, srl, filter, cancelReqCtx)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
go func() {
|
||||
eose.Wait()
|
||||
ws.WriteJSON(nostr.EOSEEnvelope(env.SubscriptionID))
|
||||
@@ -93,6 +101,7 @@ case *nostr.ReqEnvelope:
|
||||
```
|
||||
|
||||
### Next.orly.dev REQ Processing
|
||||
|
||||
```go
|
||||
// Comprehensive ACL and authentication checks first
|
||||
accessLevel := acl.Registry.GetAccessLevel(l.authedPubkey.Load(), l.remote)
|
||||
@@ -117,12 +126,14 @@ for _, f := range *env.Filters {
|
||||
### 1. **Filter Processing Strategy**
|
||||
|
||||
**Khatru:**
|
||||
|
||||
- Processes each filter independently and concurrently
|
||||
- Uses WaitGroup to coordinate EOSE across all filters
|
||||
- Immediately sets up listeners for ongoing subscriptions
|
||||
- Fails entire subscription if any filter is rejected
|
||||
|
||||
**Next.orly.dev:**
|
||||
|
||||
- Processes all filters sequentially in a single context
|
||||
- Collects all events before applying access control
|
||||
- Only sets up subscriptions for filters that need ongoing updates
|
||||
@@ -131,11 +142,13 @@ for _, f := range *env.Filters {
|
||||
### 2. **Access Control Integration**
|
||||
|
||||
**Khatru:**
|
||||
|
||||
- Basic NIP-42 authentication support
|
||||
- Hook-based authorization via `RejectFilter` functions
|
||||
- Limited built-in access control features
|
||||
|
||||
**Next.orly.dev:**
|
||||
|
||||
- Comprehensive ACL system with multiple access levels
|
||||
- Built-in support for private events with npub authorization
|
||||
- Privileged event filtering based on pubkey and p-tags
|
||||
@@ -144,6 +157,7 @@ for _, f := range *env.Filters {
|
||||
### 3. **Subscription Management**
|
||||
|
||||
**Khatru:**
|
||||
|
||||
```go
|
||||
// Simple listener registration
|
||||
type listenerSpec struct {
|
||||
@@ -155,6 +169,7 @@ rl.addListener(ws, subscriptionID, relay, filter, cancel)
|
||||
```
|
||||
|
||||
**Next.orly.dev:**
|
||||
|
||||
```go
|
||||
// Publisher-subscriber system with rich metadata
|
||||
type W struct {
|
||||
@@ -171,11 +186,13 @@ l.publishers.Receive(&W{...})
|
||||
### 4. **Performance Optimizations**
|
||||
|
||||
**Khatru:**
|
||||
|
||||
- Concurrent filter processing
|
||||
- Immediate streaming of events as they're found
|
||||
- Memory-efficient with direct event streaming
|
||||
|
||||
**Next.orly.dev:**
|
||||
|
||||
- Batch processing with deduplication
|
||||
- Memory management with explicit `ev.Free()` calls
|
||||
- Smart subscription cancellation for ID-only queries
|
||||
@@ -184,11 +201,13 @@ l.publishers.Receive(&W{...})
|
||||
### 5. **Error Handling & Observability**
|
||||
|
||||
**Khatru:**
|
||||
|
||||
- Basic error logging
|
||||
- Simple connection state management
|
||||
- Limited metrics and observability
|
||||
|
||||
**Next.orly.dev:**
|
||||
|
||||
- Comprehensive error handling with context preservation
|
||||
- Detailed logging at each processing stage
|
||||
- Built-in metrics (message count, REQ count, event count)
|
||||
@@ -197,11 +216,13 @@ l.publishers.Receive(&W{...})
|
||||
## Memory Management
|
||||
|
||||
### Khatru
|
||||
|
||||
- Relies on Go's garbage collector
|
||||
- Simple WebSocket struct with minimal state
|
||||
- Uses sync.Map for thread-safe operations
|
||||
|
||||
### Next.orly.dev
|
||||
|
||||
- Explicit memory management with `ev.Free()` calls
|
||||
- Resource pooling and reuse patterns
|
||||
- Detailed tracking of connection resources
|
||||
@@ -209,11 +230,13 @@ l.publishers.Receive(&W{...})
|
||||
## Concurrency Models
|
||||
|
||||
### Khatru
|
||||
|
||||
- Per-connection goroutine for message reading
|
||||
- Additional goroutines for each message processing
|
||||
- WaitGroup coordination for multi-filter EOSE
|
||||
|
||||
### Next.orly.dev
|
||||
|
||||
- Per-connection goroutine with single-threaded message processing
|
||||
- Publisher-subscriber system handles concurrent event distribution
|
||||
- Context-based cancellation throughout
|
||||
@@ -221,18 +244,21 @@ l.publishers.Receive(&W{...})
|
||||
## Trade-offs Analysis
|
||||
|
||||
### Khatru Advantages
|
||||
|
||||
- **Simplicity**: Easier to understand and modify
|
||||
- **Performance**: Lower latency due to concurrent processing
|
||||
- **Flexibility**: Hook-based architecture allows extensive customization
|
||||
- **Streaming**: Events sent as soon as they're found
|
||||
|
||||
### Khatru Disadvantages
|
||||
|
||||
- **Monolithic**: Large methods harder to maintain
|
||||
- **Limited ACL**: Basic authentication and authorization
|
||||
- **Error handling**: Less graceful failure recovery
|
||||
- **Resource usage**: No explicit memory management
|
||||
|
||||
### Next.orly.dev Advantages
|
||||
|
||||
- **Security**: Comprehensive ACL and privacy features
|
||||
- **Observability**: Extensive logging and metrics
|
||||
- **Resource management**: Explicit memory and connection lifecycle management
|
||||
@@ -240,6 +266,7 @@ l.publishers.Receive(&W{...})
|
||||
- **Robustness**: Graceful handling of edge cases and failures
|
||||
|
||||
### Next.orly.dev Disadvantages
|
||||
|
||||
- **Complexity**: Higher cognitive overhead and learning curve
|
||||
- **Latency**: Sequential processing may be slower for some use cases
|
||||
- **Resource overhead**: More memory usage due to batching and state tracking
|
||||
@@ -253,7 +280,8 @@ Both implementations represent different philosophies:
|
||||
- **Next.orly.dev** prioritizes security, observability, and robustness through comprehensive built-in features
|
||||
|
||||
The choice between them depends on specific requirements:
|
||||
|
||||
- Choose **Khatru** for high-performance relays with custom business logic
|
||||
- Choose **Next.orly.dev** for production relays requiring comprehensive access control and monitoring
|
||||
|
||||
Both approaches demonstrate mature understanding of Nostr protocol requirements while making different trade-offs in complexity vs. features.
|
||||
Both approaches demonstrate mature understanding of Nostr protocol requirements while making different trade-offs in complexity vs. features.
|
||||
|
||||
@@ -40,6 +40,7 @@ type Follows struct {
|
||||
pubs *publish.S
|
||||
followsMx sync.RWMutex
|
||||
admins [][]byte
|
||||
owners [][]byte
|
||||
follows [][]byte
|
||||
updated chan struct{}
|
||||
subsCancel context.CancelFunc
|
||||
@@ -69,6 +70,16 @@ func (f *Follows) Configure(cfg ...any) (err error) {
|
||||
err = errorf.E("both config and database must be set")
|
||||
return
|
||||
}
|
||||
// add owners list
|
||||
for _, owner := range f.cfg.Owners {
|
||||
var own []byte
|
||||
if o, e := bech32encoding.NpubOrHexToPublicKeyBinary(owner); chk.E(e) {
|
||||
continue
|
||||
} else {
|
||||
own = o
|
||||
}
|
||||
f.owners = append(f.owners, own)
|
||||
}
|
||||
// find admin follow lists
|
||||
f.followsMx.Lock()
|
||||
defer f.followsMx.Unlock()
|
||||
@@ -129,11 +140,13 @@ func (f *Follows) Configure(cfg ...any) (err error) {
|
||||
}
|
||||
|
||||
func (f *Follows) GetAccessLevel(pub []byte, address string) (level string) {
|
||||
if f.cfg == nil {
|
||||
return "write"
|
||||
}
|
||||
f.followsMx.RLock()
|
||||
defer f.followsMx.RUnlock()
|
||||
for _, v := range f.owners {
|
||||
if utils.FastEqual(v, pub) {
|
||||
return "owner"
|
||||
}
|
||||
}
|
||||
for _, v := range f.admins {
|
||||
if utils.FastEqual(v, pub) {
|
||||
return "admin"
|
||||
@@ -144,6 +157,9 @@ func (f *Follows) GetAccessLevel(pub []byte, address string) (level string) {
|
||||
return "write"
|
||||
}
|
||||
}
|
||||
if f.cfg == nil {
|
||||
return "write"
|
||||
}
|
||||
return "read"
|
||||
}
|
||||
|
||||
@@ -236,7 +252,7 @@ func (f *Follows) startSubscriptions(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
urls := f.adminRelays()
|
||||
log.I.S(urls)
|
||||
// log.I.S(urls)
|
||||
if len(urls) == 0 {
|
||||
log.W.F("follows syncer: no admin relays found in DB (kind 10002) and no bootstrap relays configured")
|
||||
return
|
||||
@@ -274,11 +290,16 @@ func (f *Follows) startSubscriptions(ctx context.Context) {
|
||||
log.W.F("follows syncer: dial %s failed: %v", u, err)
|
||||
|
||||
// Handle different types of errors
|
||||
if strings.Contains(err.Error(), "response status code 101 but got 403") {
|
||||
if strings.Contains(
|
||||
err.Error(), "response status code 101 but got 403",
|
||||
) {
|
||||
// 403 means the relay is not accepting connections from us
|
||||
// Forbidden is the meaning, usually used to indicate either the IP or user is blocked
|
||||
// But we should still retry after a longer delay
|
||||
log.W.F("follows syncer: relay %s returned 403, will retry after longer delay", u)
|
||||
log.W.F(
|
||||
"follows syncer: relay %s returned 403, will retry after longer delay",
|
||||
u,
|
||||
)
|
||||
timer := time.NewTimer(5 * time.Minute) // Wait 5 minutes before retrying 403 errors
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -286,12 +307,20 @@ func (f *Follows) startSubscriptions(ctx context.Context) {
|
||||
case <-timer.C:
|
||||
}
|
||||
continue
|
||||
} else if strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "connection refused") {
|
||||
} else if strings.Contains(
|
||||
err.Error(), "timeout",
|
||||
) || strings.Contains(err.Error(), "connection refused") {
|
||||
// Network issues, retry with normal backoff
|
||||
log.W.F("follows syncer: network issue with %s, retrying in %v", u, backoff)
|
||||
log.W.F(
|
||||
"follows syncer: network issue with %s, retrying in %v",
|
||||
u, backoff,
|
||||
)
|
||||
} else {
|
||||
// Other errors, retry with normal backoff
|
||||
log.W.F("follows syncer: connection error with %s, retrying in %v", u, backoff)
|
||||
log.W.F(
|
||||
"follows syncer: connection error with %s, retrying in %v",
|
||||
u, backoff,
|
||||
)
|
||||
}
|
||||
|
||||
timer := time.NewTimer(backoff)
|
||||
@@ -306,7 +335,7 @@ func (f *Follows) startSubscriptions(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
backoff = time.Second
|
||||
log.I.F("follows syncer: successfully connected to %s", u)
|
||||
log.T.F("follows syncer: successfully connected to %s", u)
|
||||
|
||||
// send REQ for kind 3 (follow lists), kind 10002 (relay lists), and all events from follows
|
||||
ff := &filter.S{}
|
||||
@@ -332,11 +361,16 @@ func (f *Follows) startSubscriptions(ctx context.Context) {
|
||||
if err = c.Write(
|
||||
ctx, websocket.MessageText, req.Marshal(nil),
|
||||
); chk.E(err) {
|
||||
log.W.F("follows syncer: failed to send REQ to %s: %v", u, err)
|
||||
log.W.F(
|
||||
"follows syncer: failed to send REQ to %s: %v", u, err,
|
||||
)
|
||||
_ = c.Close(websocket.StatusInternalError, "write failed")
|
||||
continue
|
||||
}
|
||||
log.I.F("follows syncer: sent REQ to %s for kind 3, 10002, and all events (last 30 days) from followed users", u)
|
||||
log.T.F(
|
||||
"follows syncer: sent REQ to %s for kind 3, 10002, and all events (last 30 days) from followed users",
|
||||
u,
|
||||
)
|
||||
// read loop
|
||||
for {
|
||||
select {
|
||||
@@ -368,17 +402,24 @@ func (f *Follows) startSubscriptions(ctx context.Context) {
|
||||
// Process events based on kind
|
||||
switch res.Event.Kind {
|
||||
case kind.FollowList.K:
|
||||
log.I.F("follows syncer: received kind 3 (follow list) event from %s on relay %s",
|
||||
hex.EncodeToString(res.Event.Pubkey), u)
|
||||
log.T.F(
|
||||
"follows syncer: received kind 3 (follow list) event from %s on relay %s",
|
||||
hex.EncodeToString(res.Event.Pubkey), u,
|
||||
)
|
||||
// Extract followed pubkeys from 'p' tags in kind 3 events
|
||||
f.extractFollowedPubkeys(res.Event)
|
||||
case kind.RelayListMetadata.K:
|
||||
log.I.F("follows syncer: received kind 10002 (relay list) event from %s on relay %s",
|
||||
hex.EncodeToString(res.Event.Pubkey), u)
|
||||
log.T.F(
|
||||
"follows syncer: received kind 10002 (relay list) event from %s on relay %s",
|
||||
hex.EncodeToString(res.Event.Pubkey), u,
|
||||
)
|
||||
default:
|
||||
// Log all other events from followed users
|
||||
log.I.F("follows syncer: received kind %d event from %s on relay %s",
|
||||
res.Event.Kind, hex.EncodeToString(res.Event.Pubkey), u)
|
||||
log.T.F(
|
||||
"follows syncer: received kind %d event from %s on relay %s",
|
||||
res.Event.Kind,
|
||||
hex.EncodeToString(res.Event.Pubkey), u,
|
||||
)
|
||||
}
|
||||
|
||||
if _, _, err = f.D.SaveEvent(
|
||||
@@ -488,7 +529,10 @@ func (f *Follows) AddFollow(pub []byte) {
|
||||
b := make([]byte, len(pub))
|
||||
copy(b, pub)
|
||||
f.follows = append(f.follows, b)
|
||||
log.I.F("follows syncer: added new followed pubkey: %s", hex.EncodeToString(pub))
|
||||
log.I.F(
|
||||
"follows syncer: added new followed pubkey: %s",
|
||||
hex.EncodeToString(pub),
|
||||
)
|
||||
// notify syncer if initialized
|
||||
if f.updated != nil {
|
||||
select {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
realy.lol/pkg/ec
|
||||
=====
|
||||
# realy.lol/pkg/ec
|
||||
|
||||
This is a full drop-in replacement for
|
||||
[github.com/btcsuite/btcd/btcec](https://github.com/btcsuite/btcd/tree/master/btcec)
|
||||
@@ -20,7 +19,7 @@ message signing with the extra test vectors present and passing.
|
||||
|
||||
The remainder of this document is from the original README.md.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
---
|
||||
|
||||
Package `ec` implements elliptic curve cryptography needed for working with
|
||||
Bitcoin. It is designed so that it may be used with the standard
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
chainhash
|
||||
=========
|
||||
# chainhash
|
||||
|
||||
[](http://copyfree.org)
|
||||
=======
|
||||
# [](http://copyfree.org)
|
||||
|
||||
chainhash provides a generic hash type and associated functions that allows the
|
||||
specific hash algorithm to be abstracted.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
ecdsa
|
||||
=====
|
||||
# ecdsa
|
||||
|
||||
[](http://copyfree.org)
|
||||
[](https://pkg.go.dev/mleku.online/git/ec/secp/ecdsa)
|
||||
|
||||
@@ -14,45 +14,25 @@
|
||||
],
|
||||
"valid_test_cases": [
|
||||
{
|
||||
"key_indices": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"key_indices": [0, 1, 2],
|
||||
"expected": "90539EEDE565F5D054F32CC0C220126889ED1E5D193BAF15AEF344FE59D4610C"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
2,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"key_indices": [2, 1, 0],
|
||||
"expected": "6204DE8B083426DC6EAF9502D27024D53FC826BF7D2012148A0575435DF54B2B"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"key_indices": [0, 0, 0],
|
||||
"expected": "B436E3BAD62B8CD409969A224731C193D051162D8C5AE8B109306127DA3AA935"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"key_indices": [0, 0, 1, 1],
|
||||
"expected": "69BC22BFA5D106306E48A20679DE1D7389386124D07571D0D872686028C26A3E"
|
||||
}
|
||||
],
|
||||
"error_test_cases": [
|
||||
{
|
||||
"key_indices": [
|
||||
0,
|
||||
3
|
||||
],
|
||||
"key_indices": [0, 3],
|
||||
"tweak_indices": [],
|
||||
"is_xonly": [],
|
||||
"error": {
|
||||
@@ -63,10 +43,7 @@
|
||||
"comment": "Invalid public key"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
0,
|
||||
4
|
||||
],
|
||||
"key_indices": [0, 4],
|
||||
"tweak_indices": [],
|
||||
"is_xonly": [],
|
||||
"error": {
|
||||
@@ -77,10 +54,7 @@
|
||||
"comment": "Public key exceeds field size"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
5,
|
||||
0
|
||||
],
|
||||
"key_indices": [5, 0],
|
||||
"tweak_indices": [],
|
||||
"is_xonly": [],
|
||||
"error": {
|
||||
@@ -91,16 +65,9 @@
|
||||
"comment": "First byte of public key is not 2 or 3"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"tweak_indices": [
|
||||
0
|
||||
],
|
||||
"is_xonly": [
|
||||
true
|
||||
],
|
||||
"key_indices": [0, 1],
|
||||
"tweak_indices": [0],
|
||||
"is_xonly": [true],
|
||||
"error": {
|
||||
"type": "value",
|
||||
"message": "The tweak must be less than n."
|
||||
@@ -108,15 +75,9 @@
|
||||
"comment": "Tweak is out of range"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
6
|
||||
],
|
||||
"tweak_indices": [
|
||||
1
|
||||
],
|
||||
"is_xonly": [
|
||||
false
|
||||
],
|
||||
"key_indices": [6],
|
||||
"tweak_indices": [1],
|
||||
"is_xonly": [false],
|
||||
"error": {
|
||||
"type": "value",
|
||||
"message": "The result of tweaking cannot be infinity."
|
||||
|
||||
@@ -10,27 +10,18 @@
|
||||
],
|
||||
"valid_test_cases": [
|
||||
{
|
||||
"pnonce_indices": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"pnonce_indices": [0, 1],
|
||||
"expected": "035FE1873B4F2967F52FEA4A06AD5A8ECCBE9D0FD73068012C894E2E87CCB5804B024725377345BDE0E9C33AF3C43C0A29A9249F2F2956FA8CFEB55C8573D0262DC8"
|
||||
},
|
||||
{
|
||||
"pnonce_indices": [
|
||||
2,
|
||||
3
|
||||
],
|
||||
"pnonce_indices": [2, 3],
|
||||
"expected": "035FE1873B4F2967F52FEA4A06AD5A8ECCBE9D0FD73068012C894E2E87CCB5804B000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"comment": "Sum of second points encoded in the nonces is point at infinity which is serialized as 33 zero bytes"
|
||||
}
|
||||
],
|
||||
"error_test_cases": [
|
||||
{
|
||||
"pnonce_indices": [
|
||||
0,
|
||||
4
|
||||
],
|
||||
"pnonce_indices": [0, 4],
|
||||
"error": {
|
||||
"type": "invalid_contribution",
|
||||
"signer": 1,
|
||||
@@ -40,10 +31,7 @@
|
||||
"btcec_err": "invalid public key: unsupported format: 4"
|
||||
},
|
||||
{
|
||||
"pnonce_indices": [
|
||||
5,
|
||||
1
|
||||
],
|
||||
"pnonce_indices": [5, 1],
|
||||
"error": {
|
||||
"type": "invalid_contribution",
|
||||
"signer": 0,
|
||||
@@ -53,10 +41,7 @@
|
||||
"btcec_err": "invalid public key: x coordinate 48c264cdd57d3c24d79990b0f865674eb62a0f9018277a95011b41bfc193b831 is not on the secp256k1 curve"
|
||||
},
|
||||
{
|
||||
"pnonce_indices": [
|
||||
6,
|
||||
1
|
||||
],
|
||||
"pnonce_indices": [6, 1],
|
||||
"error": {
|
||||
"type": "invalid_contribution",
|
||||
"signer": 0,
|
||||
|
||||
@@ -37,4 +37,4 @@
|
||||
"expected": "890E83616A3BC4640AB9B6374F21C81FF89CDDDBAFAA7475AE2A102A92E3EDB29FD7E874E23342813A60D9646948242646B7951CA046B4B36D7D6078506D3C9402F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,114 +33,49 @@
|
||||
"valid_test_cases": [
|
||||
{
|
||||
"aggnonce": "0341432722C5CD0268D829C702CF0D1CBCE57033EED201FD335191385227C3210C03D377F2D258B64AADC0E16F26462323D701D286046A2EA93365656AFD9875982B",
|
||||
"nonce_indices": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"key_indices": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"nonce_indices": [0, 1],
|
||||
"key_indices": [0, 1],
|
||||
"tweak_indices": [],
|
||||
"is_xonly": [],
|
||||
"psig_indices": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"psig_indices": [0, 1],
|
||||
"expected": "041DA22223CE65C92C9A0D6C2CAC828AAF1EEE56304FEC371DDF91EBB2B9EF0912F1038025857FEDEB3FF696F8B99FA4BB2C5812F6095A2E0004EC99CE18DE1E"
|
||||
},
|
||||
{
|
||||
"aggnonce": "0224AFD36C902084058B51B5D36676BBA4DC97C775873768E58822F87FE437D792028CB15929099EEE2F5DAE404CD39357591BA32E9AF4E162B8D3E7CB5EFE31CB20",
|
||||
"nonce_indices": [
|
||||
0,
|
||||
2
|
||||
],
|
||||
"key_indices": [
|
||||
0,
|
||||
2
|
||||
],
|
||||
"nonce_indices": [0, 2],
|
||||
"key_indices": [0, 2],
|
||||
"tweak_indices": [],
|
||||
"is_xonly": [],
|
||||
"psig_indices": [
|
||||
2,
|
||||
3
|
||||
],
|
||||
"psig_indices": [2, 3],
|
||||
"expected": "1069B67EC3D2F3C7C08291ACCB17A9C9B8F2819A52EB5DF8726E17E7D6B52E9F01800260A7E9DAC450F4BE522DE4CE12BA91AEAF2B4279219EF74BE1D286ADD9"
|
||||
},
|
||||
{
|
||||
"aggnonce": "0208C5C438C710F4F96A61E9FF3C37758814B8C3AE12BFEA0ED2C87FF6954FF186020B1816EA104B4FCA2D304D733E0E19CEAD51303FF6420BFD222335CAA402916D",
|
||||
"nonce_indices": [
|
||||
0,
|
||||
3
|
||||
],
|
||||
"key_indices": [
|
||||
0,
|
||||
2
|
||||
],
|
||||
"tweak_indices": [
|
||||
0
|
||||
],
|
||||
"is_xonly": [
|
||||
false
|
||||
],
|
||||
"psig_indices": [
|
||||
4,
|
||||
5
|
||||
],
|
||||
"nonce_indices": [0, 3],
|
||||
"key_indices": [0, 2],
|
||||
"tweak_indices": [0],
|
||||
"is_xonly": [false],
|
||||
"psig_indices": [4, 5],
|
||||
"expected": "5C558E1DCADE86DA0B2F02626A512E30A22CF5255CAEA7EE32C38E9A71A0E9148BA6C0E6EC7683B64220F0298696F1B878CD47B107B81F7188812D593971E0CC"
|
||||
},
|
||||
{
|
||||
"aggnonce": "02B5AD07AFCD99B6D92CB433FBD2A28FDEB98EAE2EB09B6014EF0F8197CD58403302E8616910F9293CF692C49F351DB86B25E352901F0E237BAFDA11F1C1CEF29FFD",
|
||||
"nonce_indices": [
|
||||
0,
|
||||
4
|
||||
],
|
||||
"key_indices": [
|
||||
0,
|
||||
3
|
||||
],
|
||||
"tweak_indices": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"is_xonly": [
|
||||
true,
|
||||
false,
|
||||
true
|
||||
],
|
||||
"psig_indices": [
|
||||
6,
|
||||
7
|
||||
],
|
||||
"nonce_indices": [0, 4],
|
||||
"key_indices": [0, 3],
|
||||
"tweak_indices": [0, 1, 2],
|
||||
"is_xonly": [true, false, true],
|
||||
"psig_indices": [6, 7],
|
||||
"expected": "839B08820B681DBA8DAF4CC7B104E8F2638F9388F8D7A555DC17B6E6971D7426CE07BF6AB01F1DB50E4E33719295F4094572B79868E440FB3DEFD3FAC1DB589E"
|
||||
}
|
||||
],
|
||||
"error_test_cases": [
|
||||
{
|
||||
"aggnonce": "02B5AD07AFCD99B6D92CB433FBD2A28FDEB98EAE2EB09B6014EF0F8197CD58403302E8616910F9293CF692C49F351DB86B25E352901F0E237BAFDA11F1C1CEF29FFD",
|
||||
"nonce_indices": [
|
||||
0,
|
||||
4
|
||||
],
|
||||
"key_indices": [
|
||||
0,
|
||||
3
|
||||
],
|
||||
"tweak_indices": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"is_xonly": [
|
||||
true,
|
||||
false,
|
||||
true
|
||||
],
|
||||
"psig_indices": [
|
||||
7,
|
||||
8
|
||||
],
|
||||
"nonce_indices": [0, 4],
|
||||
"key_indices": [0, 3],
|
||||
"tweak_indices": [0, 1, 2],
|
||||
"is_xonly": [true, false, true],
|
||||
"psig_indices": [7, 8],
|
||||
"error": {
|
||||
"type": "invalid_contribution",
|
||||
"signer": 1
|
||||
@@ -148,4 +83,4 @@
|
||||
"comment": "Partial signature is invalid because it exceeds group size"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,62 +31,32 @@
|
||||
],
|
||||
"valid_test_cases": [
|
||||
{
|
||||
"key_indices": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"nonce_indices": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"key_indices": [0, 1, 2],
|
||||
"nonce_indices": [0, 1, 2],
|
||||
"aggnonce_index": 0,
|
||||
"msg_index": 0,
|
||||
"signer_index": 0,
|
||||
"expected": "012ABBCB52B3016AC03AD82395A1A415C48B93DEF78718E62A7A90052FE224FB"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
1,
|
||||
0,
|
||||
2
|
||||
],
|
||||
"nonce_indices": [
|
||||
1,
|
||||
0,
|
||||
2
|
||||
],
|
||||
"key_indices": [1, 0, 2],
|
||||
"nonce_indices": [1, 0, 2],
|
||||
"aggnonce_index": 0,
|
||||
"msg_index": 0,
|
||||
"signer_index": 1,
|
||||
"expected": "9FF2F7AAA856150CC8819254218D3ADEEB0535269051897724F9DB3789513A52"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"nonce_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"key_indices": [1, 2, 0],
|
||||
"nonce_indices": [1, 2, 0],
|
||||
"aggnonce_index": 0,
|
||||
"msg_index": 0,
|
||||
"signer_index": 2,
|
||||
"expected": "FA23C359F6FAC4E7796BB93BC9F0532A95468C539BA20FF86D7C76ED92227900"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"nonce_indices": [
|
||||
0,
|
||||
3
|
||||
],
|
||||
"key_indices": [0, 1],
|
||||
"nonce_indices": [0, 3],
|
||||
"aggnonce_index": 1,
|
||||
"msg_index": 0,
|
||||
"signer_index": 0,
|
||||
@@ -96,10 +66,7 @@
|
||||
],
|
||||
"sign_error_test_cases": [
|
||||
{
|
||||
"key_indices": [
|
||||
1,
|
||||
2
|
||||
],
|
||||
"key_indices": [1, 2],
|
||||
"aggnonce_index": 0,
|
||||
"msg_index": 0,
|
||||
"secnonce_index": 0,
|
||||
@@ -110,11 +77,7 @@
|
||||
"comment": "The signers pubkey is not in the list of pubkeys"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
1,
|
||||
0,
|
||||
3
|
||||
],
|
||||
"key_indices": [1, 0, 3],
|
||||
"aggnonce_index": 0,
|
||||
"msg_index": 0,
|
||||
"secnonce_index": 0,
|
||||
@@ -126,11 +89,7 @@
|
||||
"comment": "Signer 2 provided an invalid public key"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"key_indices": [1, 2, 0],
|
||||
"aggnonce_index": 2,
|
||||
"msg_index": 0,
|
||||
"secnonce_index": 0,
|
||||
@@ -142,11 +101,7 @@
|
||||
"comment": "Aggregate nonce is invalid due wrong tag, 0x04, in the first half"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"key_indices": [1, 2, 0],
|
||||
"aggnonce_index": 3,
|
||||
"msg_index": 0,
|
||||
"secnonce_index": 0,
|
||||
@@ -158,11 +113,7 @@
|
||||
"comment": "Aggregate nonce is invalid because the second half does not correspond to an X coordinate"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"key_indices": [1, 2, 0],
|
||||
"aggnonce_index": 4,
|
||||
"msg_index": 0,
|
||||
"secnonce_index": 0,
|
||||
@@ -174,11 +125,7 @@
|
||||
"comment": "Aggregate nonce is invalid because second half exceeds field size"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"key_indices": [0, 1, 2],
|
||||
"aggnonce_index": 0,
|
||||
"msg_index": 0,
|
||||
"signer_index": 0,
|
||||
@@ -193,48 +140,24 @@
|
||||
"verify_fail_test_cases": [
|
||||
{
|
||||
"sig": "97AC833ADCB1AFA42EBF9E0725616F3C9A0D5B614F6FE283CEAAA37A8FFAF406",
|
||||
"key_indices": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"nonce_indices": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"key_indices": [0, 1, 2],
|
||||
"nonce_indices": [0, 1, 2],
|
||||
"msg_index": 0,
|
||||
"signer_index": 0,
|
||||
"comment": "Wrong signature (which is equal to the negation of valid signature)"
|
||||
},
|
||||
{
|
||||
"sig": "68537CC5234E505BD14061F8DA9E90C220A181855FD8BDB7F127BB12403B4D3B",
|
||||
"key_indices": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"nonce_indices": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"key_indices": [0, 1, 2],
|
||||
"nonce_indices": [0, 1, 2],
|
||||
"msg_index": 0,
|
||||
"signer_index": 1,
|
||||
"comment": "Wrong signer"
|
||||
},
|
||||
{
|
||||
"sig": "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141",
|
||||
"key_indices": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"nonce_indices": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"key_indices": [0, 1, 2],
|
||||
"nonce_indices": [0, 1, 2],
|
||||
"msg_index": 0,
|
||||
"signer_index": 0,
|
||||
"comment": "Signature exceeds group size"
|
||||
@@ -243,16 +166,8 @@
|
||||
"verify_error_test_cases": [
|
||||
{
|
||||
"sig": "68537CC5234E505BD14061F8DA9E90C220A181855FD8BDB7F127BB12403B4D3B",
|
||||
"key_indices": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"nonce_indices": [
|
||||
4,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"key_indices": [0, 1, 2],
|
||||
"nonce_indices": [4, 1, 2],
|
||||
"msg_index": 0,
|
||||
"signer_index": 0,
|
||||
"error": {
|
||||
@@ -264,16 +179,8 @@
|
||||
},
|
||||
{
|
||||
"sig": "68537CC5234E505BD14061F8DA9E90C220A181855FD8BDB7F127BB12403B4D3B",
|
||||
"key_indices": [
|
||||
3,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"nonce_indices": [
|
||||
0,
|
||||
1,
|
||||
2
|
||||
],
|
||||
"key_indices": [3, 1, 2],
|
||||
"nonce_indices": [0, 1, 2],
|
||||
"msg_index": 0,
|
||||
"signer_index": 0,
|
||||
"error": {
|
||||
|
||||
@@ -22,120 +22,46 @@
|
||||
"msg": "F95466D086770E689964664219266FE5ED215C92AE20BAB5C9D79ADDDDF3C0CF",
|
||||
"valid_test_cases": [
|
||||
{
|
||||
"key_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"nonce_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"tweak_indices": [
|
||||
0
|
||||
],
|
||||
"is_xonly": [
|
||||
true
|
||||
],
|
||||
"key_indices": [1, 2, 0],
|
||||
"nonce_indices": [1, 2, 0],
|
||||
"tweak_indices": [0],
|
||||
"is_xonly": [true],
|
||||
"signer_index": 2,
|
||||
"expected": "E28A5C66E61E178C2BA19DB77B6CF9F7E2F0F56C17918CD13135E60CC848FE91",
|
||||
"comment": "A single x-only tweak"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"nonce_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"tweak_indices": [
|
||||
0
|
||||
],
|
||||
"is_xonly": [
|
||||
false
|
||||
],
|
||||
"key_indices": [1, 2, 0],
|
||||
"nonce_indices": [1, 2, 0],
|
||||
"tweak_indices": [0],
|
||||
"is_xonly": [false],
|
||||
"signer_index": 2,
|
||||
"expected": "38B0767798252F21BF5702C48028B095428320F73A4B14DB1E25DE58543D2D2D",
|
||||
"comment": "A single plain tweak"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"nonce_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"tweak_indices": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"is_xonly": [
|
||||
false,
|
||||
true
|
||||
],
|
||||
"key_indices": [1, 2, 0],
|
||||
"nonce_indices": [1, 2, 0],
|
||||
"tweak_indices": [0, 1],
|
||||
"is_xonly": [false, true],
|
||||
"signer_index": 2,
|
||||
"expected": "408A0A21C4A0F5DACAF9646AD6EB6FECD7F7A11F03ED1F48DFFF2185BC2C2408",
|
||||
"comment": "A plain tweak followed by an x-only tweak"
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"nonce_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"tweak_indices": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3
|
||||
],
|
||||
"is_xonly": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
],
|
||||
"key_indices": [1, 2, 0],
|
||||
"nonce_indices": [1, 2, 0],
|
||||
"tweak_indices": [0, 1, 2, 3],
|
||||
"is_xonly": [false, false, true, true],
|
||||
"signer_index": 2,
|
||||
"expected": "45ABD206E61E3DF2EC9E264A6FEC8292141A633C28586388235541F9ADE75435",
|
||||
"comment": "Four tweaks: plain, plain, x-only, x-only."
|
||||
},
|
||||
{
|
||||
"key_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"nonce_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"tweak_indices": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3
|
||||
],
|
||||
"is_xonly": [
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
],
|
||||
"key_indices": [1, 2, 0],
|
||||
"nonce_indices": [1, 2, 0],
|
||||
"tweak_indices": [0, 1, 2, 3],
|
||||
"is_xonly": [true, false, true, false],
|
||||
"signer_index": 2,
|
||||
"expected": "B255FDCAC27B40C7CE7848E2D3B7BF5EA0ED756DA81565AC804CCCA3E1D5D239",
|
||||
"comment": "Four tweaks: x-only, plain, x-only, plain. If an implementation prohibits applying plain tweaks after x-only tweaks, it can skip this test vector or return an error."
|
||||
@@ -143,22 +69,10 @@
|
||||
],
|
||||
"error_test_cases": [
|
||||
{
|
||||
"key_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"nonce_indices": [
|
||||
1,
|
||||
2,
|
||||
0
|
||||
],
|
||||
"tweak_indices": [
|
||||
4
|
||||
],
|
||||
"is_xonly": [
|
||||
false
|
||||
],
|
||||
"key_indices": [1, 2, 0],
|
||||
"nonce_indices": [1, 2, 0],
|
||||
"tweak_indices": [4],
|
||||
"is_xonly": [false],
|
||||
"signer_index": 2,
|
||||
"error": {
|
||||
"type": "value",
|
||||
|
||||
@@ -25,16 +25,16 @@ An overview of the features provided by this package are as follows:
|
||||
|
||||
- Secret key generation, serialization, and parsing
|
||||
- Public key generation, serialization and parsing per ANSI X9.62-1998
|
||||
- Parses uncompressed, compressed, and hybrid public keys
|
||||
- Serializes uncompressed and compressed public keys
|
||||
- Parses uncompressed, compressed, and hybrid public keys
|
||||
- Serializes uncompressed and compressed public keys
|
||||
- Specialized types for performing optimized and constant time field operations
|
||||
- `FieldVal` type for working modulo the secp256k1 field prime
|
||||
- `ModNScalar` type for working modulo the secp256k1 group order
|
||||
- `FieldVal` type for working modulo the secp256k1 field prime
|
||||
- `ModNScalar` type for working modulo the secp256k1 group order
|
||||
- Elliptic curve operations in Jacobian projective coordinates
|
||||
- Point addition
|
||||
- Point doubling
|
||||
- Scalar multiplication with an arbitrary point
|
||||
- Scalar multiplication with the base point (group generator)
|
||||
- Point addition
|
||||
- Point doubling
|
||||
- Scalar multiplication with an arbitrary point
|
||||
- Scalar multiplication with the base point (group generator)
|
||||
- Point decompression from a given x coordinate
|
||||
- Nonce generation via RFC6979 with support for extra data and version
|
||||
information that can be used to prevent nonce reuse between signing algorithms
|
||||
|
||||
@@ -25,7 +25,7 @@ it
|
||||
|
||||
For ubuntu, you need these:
|
||||
|
||||
sudo apt -y install build-essential autoconf libtool
|
||||
sudo apt -y install build-essential autoconf libtool
|
||||
|
||||
For other linux distributions, the process is the same but the dependencies are
|
||||
likely different. The main thing is it requires make, gcc/++, autoconf and
|
||||
@@ -65,4 +65,4 @@ coordinate and this is incorrect for nostr. It will be enabled soon... for now
|
||||
it is done with the `btcec` fallback version. This is slower, however previous
|
||||
tests have shown that this ECDH library is fast enough to enable 8mb/s
|
||||
throughput per CPU thread when used to generate a distinct secret for TCP
|
||||
packets. The C library will likely raise this to 20mb/s or more.
|
||||
packets. The C library will likely raise this to 20mb/s or more.
|
||||
|
||||
@@ -95,9 +95,9 @@ Note that, because of the scheduling overhead, for small messages (< 1 MB) you
|
||||
will be better off using the regular SHA256 hashing (but those are typically not
|
||||
performance critical anyway). Some other tips to get the best performance:
|
||||
|
||||
* Have many go routines doing SHA256 calculations in parallel.
|
||||
* Try to Write() messages in multiples of 64 bytes.
|
||||
* Try to keep the overall length of messages to a roughly similar size ie. 5
|
||||
- Have many go routines doing SHA256 calculations in parallel.
|
||||
- Try to Write() messages in multiples of 64 bytes.
|
||||
- Try to keep the overall length of messages to a roughly similar size ie. 5
|
||||
MB (this way all 16 ‘lanes’ in the AVX512 computations are contributing as
|
||||
much as possible).
|
||||
|
||||
@@ -128,7 +128,7 @@ Below is the speed in MB/s for a single core (ranked fast to slow) for blocks
|
||||
larger than 1 MB.
|
||||
|
||||
| Processor | SIMD | Speed (MB/s) |
|
||||
|-----------------------------------|---------|-------------:|
|
||||
| --------------------------------- | ------- | -----------: |
|
||||
| 3.0 GHz Intel Xeon Platinum 8124M | AVX512 | 3498 |
|
||||
| 3.7 GHz AMD Ryzen 7 2700X | SHA Ext | 1979 |
|
||||
| 1.2 GHz ARM Cortex-A53 | ARM64 | 638 |
|
||||
@@ -160,18 +160,18 @@ Below you can see a small excerpt highlighting one of the rounds as is done for
|
||||
the SHA256 calculation process (for full code
|
||||
see [sha256block_arm64.s](https://github.com/minio/sha256-simd/blob/master/sha256block_arm64.s)).
|
||||
|
||||
```
|
||||
sha256h q2, q3, v9.4s
|
||||
sha256h2 q3, q4, v9.4s
|
||||
sha256su0 v5.4s, v6.4s
|
||||
rev32 v8.16b, v8.16b
|
||||
add v9.4s, v7.4s, v18.4s
|
||||
mov v4.16b, v2.16b
|
||||
sha256h q2, q3, v10.4s
|
||||
sha256h2 q3, q4, v10.4s
|
||||
sha256su0 v6.4s, v7.4s
|
||||
sha256su1 v5.4s, v7.4s, v8.4s
|
||||
```
|
||||
```
|
||||
sha256h q2, q3, v9.4s
|
||||
sha256h2 q3, q4, v9.4s
|
||||
sha256su0 v5.4s, v6.4s
|
||||
rev32 v8.16b, v8.16b
|
||||
add v9.4s, v7.4s, v18.4s
|
||||
mov v4.16b, v2.16b
|
||||
sha256h q2, q3, v10.4s
|
||||
sha256h2 q3, q4, v10.4s
|
||||
sha256su0 v6.4s, v7.4s
|
||||
sha256su1 v5.4s, v7.4s, v8.4s
|
||||
```
|
||||
|
||||
### Detailed benchmarks
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@ import (
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrOlderThanExisting is returned when a candidate event is older than an existing replaceable/addressable event.
|
||||
ErrOlderThanExisting = errors.New("older than existing event")
|
||||
// ErrMissingDTag is returned when a parameterized replaceable event lacks the required 'd' tag.
|
||||
ErrMissingDTag = errors.New("event is missing a d tag identifier")
|
||||
)
|
||||
|
||||
func (d *D) GetSerialsFromFilter(f *filter.F) (
|
||||
sers types.Uint40s, err error,
|
||||
) {
|
||||
@@ -36,6 +43,65 @@ func (d *D) GetSerialsFromFilter(f *filter.F) (
|
||||
return
|
||||
}
|
||||
|
||||
// WouldReplaceEvent checks if the provided event would replace existing events
|
||||
// based on Nostr's replaceable or parameterized replaceable semantics. It
|
||||
// returns true along with the serials of events that should be replaced if the
|
||||
// candidate is newer-or-equal. If an existing event is newer, it returns
|
||||
// (false, serials, ErrOlderThanExisting). If no conflicts exist, it returns
|
||||
// (false, nil, nil).
|
||||
func (d *D) WouldReplaceEvent(ev *event.E) (bool, types.Uint40s, error) {
|
||||
// Only relevant for replaceable or parameterized replaceable kinds
|
||||
if !(kind.IsReplaceable(ev.Kind) || kind.IsParameterizedReplaceable(ev.Kind)) {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
var f *filter.F
|
||||
if kind.IsReplaceable(ev.Kind) {
|
||||
f = &filter.F{
|
||||
Authors: tag.NewFromBytesSlice(ev.Pubkey),
|
||||
Kinds: kind.NewS(kind.New(ev.Kind)),
|
||||
}
|
||||
} else {
|
||||
// parameterized replaceable requires 'd' tag
|
||||
dTag := ev.Tags.GetFirst([]byte("d"))
|
||||
if dTag == nil {
|
||||
return false, nil, ErrMissingDTag
|
||||
}
|
||||
f = &filter.F{
|
||||
Authors: tag.NewFromBytesSlice(ev.Pubkey),
|
||||
Kinds: kind.NewS(kind.New(ev.Kind)),
|
||||
Tags: tag.NewS(
|
||||
tag.NewFromAny("d", dTag.Value()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
sers, err := d.GetSerialsFromFilter(f)
|
||||
if chk.E(err) {
|
||||
return false, nil, err
|
||||
}
|
||||
if len(sers) == 0 {
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// Determine if any existing event is newer than the candidate
|
||||
shouldReplace := true
|
||||
for _, s := range sers {
|
||||
oldEv, ferr := d.FetchEventBySerial(s)
|
||||
if chk.E(ferr) {
|
||||
continue
|
||||
}
|
||||
if ev.CreatedAt < oldEv.CreatedAt {
|
||||
shouldReplace = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if shouldReplace {
|
||||
return true, sers, nil
|
||||
}
|
||||
return false, sers, ErrOlderThanExisting
|
||||
}
|
||||
|
||||
// SaveEvent saves an event to the database, generating all the necessary indexes.
|
||||
func (d *D) SaveEvent(c context.Context, ev *event.E) (kc, vc int, err error) {
|
||||
if ev == nil {
|
||||
@@ -68,117 +134,37 @@ func (d *D) SaveEvent(c context.Context, ev *event.E) (kc, vc int, err error) {
|
||||
err = fmt.Errorf("blocked: %s", err.Error())
|
||||
return
|
||||
}
|
||||
// check for replacement
|
||||
if kind.IsReplaceable(ev.Kind) {
|
||||
// find the events and check timestamps before deleting
|
||||
f := &filter.F{
|
||||
Authors: tag.NewFromBytesSlice(ev.Pubkey),
|
||||
Kinds: kind.NewS(kind.New(ev.Kind)),
|
||||
}
|
||||
// check for replacement (separated check vs deletion)
|
||||
if kind.IsReplaceable(ev.Kind) || kind.IsParameterizedReplaceable(ev.Kind) {
|
||||
var wouldReplace bool
|
||||
var sers types.Uint40s
|
||||
if sers, err = d.GetSerialsFromFilter(f); chk.E(err) {
|
||||
var werr error
|
||||
if wouldReplace, sers, werr = d.WouldReplaceEvent(ev); werr != nil {
|
||||
if errors.Is(werr, ErrOlderThanExisting) {
|
||||
if kind.IsReplaceable(ev.Kind) {
|
||||
err = errors.New("blocked: event is older than existing replaceable event")
|
||||
} else {
|
||||
err = errors.New("blocked: event is older than existing addressable event")
|
||||
}
|
||||
return
|
||||
}
|
||||
if errors.Is(werr, ErrMissingDTag) {
|
||||
// keep behavior consistent with previous implementation
|
||||
err = ErrMissingDTag
|
||||
return
|
||||
}
|
||||
// any other error
|
||||
return
|
||||
}
|
||||
// if found, check timestamps before deleting
|
||||
if len(sers) > 0 {
|
||||
var shouldReplace bool = true
|
||||
if wouldReplace {
|
||||
for _, s := range sers {
|
||||
var oldEv *event.E
|
||||
if oldEv, err = d.FetchEventBySerial(s); chk.E(err) {
|
||||
continue
|
||||
}
|
||||
// Only replace if the new event is newer or same timestamp
|
||||
if ev.CreatedAt < oldEv.CreatedAt {
|
||||
// log.I.F(
|
||||
// "SaveEvent: rejecting older replaceable event ID=%s (created_at=%d) - existing event ID=%s (created_at=%d)",
|
||||
// hex.Enc(ev.ID), ev.CreatedAt, hex.Enc(oldEv.ID),
|
||||
// oldEv.CreatedAt,
|
||||
// )
|
||||
shouldReplace = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if shouldReplace {
|
||||
for _, s := range sers {
|
||||
var oldEv *event.E
|
||||
if oldEv, err = d.FetchEventBySerial(s); chk.E(err) {
|
||||
continue
|
||||
}
|
||||
// log.I.F(
|
||||
// "SaveEvent: replacing older replaceable event ID=%s (created_at=%d) with newer event ID=%s (created_at=%d)",
|
||||
// hex.Enc(oldEv.ID), oldEv.CreatedAt, hex.Enc(ev.ID),
|
||||
// ev.CreatedAt,
|
||||
// )
|
||||
if err = d.DeleteEventBySerial(
|
||||
c, s, oldEv,
|
||||
); chk.E(err) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Don't save the older event - return an error
|
||||
err = errors.New("blocked: event is older than existing replaceable event")
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if kind.IsParameterizedReplaceable(ev.Kind) {
|
||||
// find the events and check timestamps before deleting
|
||||
dTag := ev.Tags.GetFirst([]byte("d"))
|
||||
if dTag == nil {
|
||||
err = errors.New("event is missing a d tag identifier")
|
||||
return
|
||||
}
|
||||
f := &filter.F{
|
||||
Authors: tag.NewFromBytesSlice(ev.Pubkey),
|
||||
Kinds: kind.NewS(kind.New(ev.Kind)),
|
||||
Tags: tag.NewS(
|
||||
tag.NewFromAny("d", dTag.Value()),
|
||||
),
|
||||
}
|
||||
var sers types.Uint40s
|
||||
if sers, err = d.GetSerialsFromFilter(f); chk.E(err) {
|
||||
return
|
||||
}
|
||||
// if found, check timestamps before deleting
|
||||
if len(sers) > 0 {
|
||||
var shouldReplace bool = true
|
||||
for _, s := range sers {
|
||||
var oldEv *event.E
|
||||
if oldEv, err = d.FetchEventBySerial(s); chk.E(err) {
|
||||
if err = d.DeleteEventBySerial(c, s, oldEv); chk.E(err) {
|
||||
continue
|
||||
}
|
||||
// Only replace if the new event is newer or same timestamp
|
||||
if ev.CreatedAt < oldEv.CreatedAt {
|
||||
// log.I.F(
|
||||
// "SaveEvent: rejecting older addressable event ID=%s (created_at=%d) - existing event ID=%s (created_at=%d)",
|
||||
// hex.Enc(ev.ID), ev.CreatedAt, hex.Enc(oldEv.ID),
|
||||
// oldEv.CreatedAt,
|
||||
// )
|
||||
shouldReplace = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if shouldReplace {
|
||||
for _, s := range sers {
|
||||
var oldEv *event.E
|
||||
if oldEv, err = d.FetchEventBySerial(s); chk.E(err) {
|
||||
continue
|
||||
}
|
||||
// log.I.F(
|
||||
// "SaveEvent: replacing older addressable event ID=%s (created_at=%d) with newer event ID=%s (created_at=%d)",
|
||||
// hex.Enc(oldEv.ID), oldEv.CreatedAt, hex.Enc(ev.ID),
|
||||
// ev.CreatedAt,
|
||||
// )
|
||||
if err = d.DeleteEventBySerial(
|
||||
c, s, oldEv,
|
||||
); chk.E(err) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Don't save the older event - return an error
|
||||
err = errors.New("blocked: event is older than existing addressable event")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,17 @@ func (t *T) Relay() (key []byte) {
|
||||
return
|
||||
}
|
||||
|
||||
// ToSliceOfStrings returns the tag's bytes slices as a slice of strings. This
|
||||
// method provides a convenient way to access the tag's contents in string format.
|
||||
//
|
||||
// # Return Values
|
||||
//
|
||||
// - s ([]string): A slice containing all tag elements converted to strings.
|
||||
//
|
||||
// # Expected Behaviour
|
||||
//
|
||||
// Returns an empty slice if the tag is empty, otherwise returns a new slice with
|
||||
// each byte slice element converted to a string.
|
||||
func (t *T) ToSliceOfStrings() (s []string) {
|
||||
for _, v := range t.T {
|
||||
s = append(s, string(v))
|
||||
|
||||
@@ -188,3 +188,24 @@ func (s *S) GetTagElement(i int) (t *T) {
|
||||
t = (*s)[i]
|
||||
return
|
||||
}
|
||||
|
||||
// ToSliceOfSliceOfStrings converts the tag collection into a two-dimensional
|
||||
// slice of strings, maintaining the structure of tags and their elements.
|
||||
//
|
||||
// # Return Values
|
||||
//
|
||||
// - ss ([][]string): A slice of string slices where each inner slice represents
|
||||
// a tag's elements converted from bytes to strings.
|
||||
//
|
||||
// - err (error): Currently unused but maintained for interface consistency.
|
||||
//
|
||||
// # Expected Behaviour
|
||||
//
|
||||
// Iterates through each tag in the collection and converts its byte elements
|
||||
// to strings, preserving the tag structure in the resulting nested slice.
|
||||
func (s *S) ToSliceOfSliceOfStrings() (ss [][]string) {
|
||||
for _, v := range *s {
|
||||
ss = append(ss, v.ToSliceOfStrings())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
None = "none"
|
||||
// Read means read only
|
||||
Read = "read"
|
||||
// Write means read and write
|
||||
@@ -14,9 +15,6 @@ const (
|
||||
Admin = "admin"
|
||||
// Owner means read, write, import/export, arbitrary delete and wipe
|
||||
Owner = "owner"
|
||||
// Group applies to communities and other groups; the content afterwards a
|
||||
// set of comma separated <permission>:<pubkey> pairs designating permissions to groups.
|
||||
Group = "group:"
|
||||
)
|
||||
|
||||
type I interface {
|
||||
|
||||
5
pkg/protocol/httpauth/doc.go
Normal file
5
pkg/protocol/httpauth/doc.go
Normal file
@@ -0,0 +1,5 @@
|
||||
// Package httpauth provides helpers and encoders for nostr NIP-98 HTTP
|
||||
// authentication header messages and a new JWT authentication message and
|
||||
// delegation event kind 13004 that enables time limited expiring delegations of
|
||||
// authentication (as with NIP-42 auth) for the HTTP API.
|
||||
package httpauth
|
||||
75
pkg/protocol/httpauth/nip98auth.go
Normal file
75
pkg/protocol/httpauth/nip98auth.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package httpauth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/kind"
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
"next.orly.dev/pkg/encoders/timestamp"
|
||||
"next.orly.dev/pkg/interfaces/signer"
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderKey = "Authorization"
|
||||
NIP98Prefix = "Nostr"
|
||||
)
|
||||
|
||||
// MakeNIP98Event creates a new NIP-98 event. If expiry is given, method is
|
||||
// ignored; otherwise either option is the same.
|
||||
func MakeNIP98Event(u, method, hash string, expiry int64) (ev *event.E) {
|
||||
var t []*tag.T
|
||||
t = append(t, tag.NewFromAny("u", u))
|
||||
if expiry > 0 {
|
||||
t = append(
|
||||
t,
|
||||
tag.NewFromAny("expiration", timestamp.FromUnix(expiry).String()),
|
||||
)
|
||||
} else {
|
||||
t = append(
|
||||
t,
|
||||
tag.NewFromAny("method", strings.ToUpper(method)),
|
||||
)
|
||||
}
|
||||
if hash != "" {
|
||||
t = append(t, tag.NewFromAny("payload", hash))
|
||||
}
|
||||
ev = &event.E{
|
||||
CreatedAt: timestamp.Now().V,
|
||||
Kind: kind.HTTPAuth.K,
|
||||
Tags: tag.NewS(t...),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func CreateNIP98Blob(
|
||||
ur, method, hash string, expiry int64, sign signer.I,
|
||||
) (blob string, err error) {
|
||||
ev := MakeNIP98Event(ur, method, hash, expiry)
|
||||
if err = ev.Sign(sign); chk.E(err) {
|
||||
return
|
||||
}
|
||||
// log.T.F("nip-98 http auth event:\n%s\n", ev.SerializeIndented())
|
||||
blob = base64.URLEncoding.EncodeToString(ev.Serialize())
|
||||
return
|
||||
}
|
||||
|
||||
// AddNIP98Header creates a NIP-98 http auth event and adds the standard header to a provided
|
||||
// http.Request.
|
||||
func AddNIP98Header(
|
||||
r *http.Request, ur *url.URL, method, hash string,
|
||||
sign signer.I, expiry int64,
|
||||
) (err error) {
|
||||
var b64 string
|
||||
if b64, err = CreateNIP98Blob(
|
||||
ur.String(), method, hash, expiry, sign,
|
||||
); chk.E(err) {
|
||||
return
|
||||
}
|
||||
r.Header.Add(HeaderKey, "Nostr "+b64)
|
||||
return
|
||||
}
|
||||
191
pkg/protocol/httpauth/validate.go
Normal file
191
pkg/protocol/httpauth/validate.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package httpauth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/errorf"
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/ints"
|
||||
"next.orly.dev/pkg/encoders/kind"
|
||||
)
|
||||
|
||||
var ErrMissingKey = fmt.Errorf(
|
||||
"'%s' key missing from request header", HeaderKey,
|
||||
)
|
||||
|
||||
// CheckAuth verifies a received http.Request has got a valid authentication
|
||||
// event in it, with an optional specification for tolerance of before and
|
||||
// after, and provides the public key that should be verified to be authorized
|
||||
// to access the resource associated with the request.
|
||||
func CheckAuth(r *http.Request, tolerance ...time.Duration) (
|
||||
valid bool,
|
||||
pubkey []byte, err error,
|
||||
) {
|
||||
val := r.Header.Get(HeaderKey)
|
||||
if val == "" {
|
||||
err = ErrMissingKey
|
||||
valid = true
|
||||
return
|
||||
}
|
||||
if len(tolerance) == 0 {
|
||||
tolerance = append(tolerance, time.Minute)
|
||||
}
|
||||
// log.I.S(tolerance)
|
||||
if tolerance[0] == 0 {
|
||||
tolerance[0] = time.Minute
|
||||
}
|
||||
tolerate := int64(tolerance[0] / time.Second)
|
||||
log.T.C(func() string { return fmt.Sprintf("validating auth '%s'", val) })
|
||||
switch {
|
||||
case strings.HasPrefix(val, NIP98Prefix):
|
||||
split := strings.Split(val, " ")
|
||||
if len(split) == 1 {
|
||||
err = errorf.E(
|
||||
"missing nip-98 auth event from '%s' http header key: '%s'",
|
||||
HeaderKey, val,
|
||||
)
|
||||
}
|
||||
if len(split) > 2 {
|
||||
err = errorf.E(
|
||||
"extraneous content after second field space separated: %s",
|
||||
val,
|
||||
)
|
||||
return
|
||||
}
|
||||
var evb []byte
|
||||
if evb, err = base64.URLEncoding.DecodeString(split[1]); chk.E(err) {
|
||||
return
|
||||
}
|
||||
ev := event.New()
|
||||
var rem []byte
|
||||
if rem, err = ev.Unmarshal(evb); chk.E(err) {
|
||||
return
|
||||
}
|
||||
if len(rem) > 0 {
|
||||
err = errorf.E("rem", rem)
|
||||
return
|
||||
}
|
||||
// log.T.F("received http auth event:\n%s\n", ev.SerializeIndented())
|
||||
// The kind MUST be 27235.
|
||||
if ev.Kind != kind.HTTPAuth.K {
|
||||
err = errorf.E(
|
||||
"invalid kind %d %s in nip-98 http auth event, require %d %s",
|
||||
ev.Kind, kind.GetString(ev.Kind), kind.HTTPAuth.K,
|
||||
kind.HTTPAuth.Name(),
|
||||
)
|
||||
return
|
||||
}
|
||||
// if there is an expiration timestamp, check it supersedes the
|
||||
// created_at for validity.
|
||||
exp := ev.Tags.GetAll([]byte("expiration"))
|
||||
if len(exp) > 1 {
|
||||
err = errorf.E(
|
||||
"more than one \"expiration\" tag found",
|
||||
)
|
||||
return
|
||||
}
|
||||
var expiring bool
|
||||
if len(exp) == 1 {
|
||||
ex := ints.New(0)
|
||||
exp1 := exp[0]
|
||||
if rem, err = ex.Unmarshal(exp1.Value()); chk.E(err) {
|
||||
return
|
||||
}
|
||||
tn := time.Now().Unix()
|
||||
if tn > ex.Int64()+tolerate {
|
||||
err = errorf.E(
|
||||
"HTTP auth event is expired %d time now %d",
|
||||
tn, ex.Int64()+tolerate,
|
||||
)
|
||||
return
|
||||
}
|
||||
expiring = true
|
||||
} else {
|
||||
// The created_at timestamp MUST be within a reasonable time window
|
||||
// (suggestion 60 seconds)
|
||||
ts := ev.CreatedAt
|
||||
tn := time.Now().Unix()
|
||||
if ts < tn-tolerate || ts > tn+tolerate {
|
||||
err = errorf.E(
|
||||
"timestamp %d is more than %d seconds divergent from now %d",
|
||||
ts, tolerate, tn,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
ut := ev.Tags.GetAll([]byte("u"))
|
||||
if len(ut) > 1 {
|
||||
err = errorf.E(
|
||||
"more than one \"u\" tag found",
|
||||
)
|
||||
return
|
||||
}
|
||||
// The u tag MUST be exactly the same as the absolute request URL
|
||||
// (including query parameters).
|
||||
proto := r.URL.Scheme
|
||||
// if this came through a proxy, we need to get the protocol to match
|
||||
// the event
|
||||
if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
|
||||
proto = p
|
||||
}
|
||||
if proto == "" {
|
||||
proto = "http"
|
||||
}
|
||||
fullUrl := proto + "://" + r.Host + r.URL.RequestURI()
|
||||
evUrl := string(ut[0].Value())
|
||||
log.T.F("full URL: %s event u tag value: %s", fullUrl, evUrl)
|
||||
if expiring {
|
||||
// if it is expiring, the URL only needs to be the same prefix to
|
||||
// allow its use with multiple endpoints.
|
||||
if !strings.HasPrefix(fullUrl, evUrl) {
|
||||
err = errorf.E(
|
||||
"request URL %s is not prefixed with the u tag URL %s",
|
||||
fullUrl, evUrl,
|
||||
)
|
||||
return
|
||||
}
|
||||
} else if fullUrl != evUrl {
|
||||
err = errorf.E(
|
||||
"request has URL %s but signed nip-98 event has url %s",
|
||||
fullUrl, string(ut[0].Value()),
|
||||
)
|
||||
return
|
||||
}
|
||||
if !expiring {
|
||||
// The method tag MUST be the same HTTP method used for the
|
||||
// requested resource.
|
||||
mt := ev.Tags.GetAll([]byte("method"))
|
||||
if len(mt) != 1 {
|
||||
err = errorf.E(
|
||||
"more than one \"method\" tag found",
|
||||
)
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(string(mt[0].Value()), r.Method) {
|
||||
err = errorf.E(
|
||||
"request has method %s but event has method %s",
|
||||
string(mt[0].Value()), r.Method,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
if valid, err = ev.Verify(); chk.E(err) {
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
pubkey = ev.Pubkey
|
||||
default:
|
||||
err = errorf.E("invalid '%s' value: '%s'", HeaderKey, val)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -28,7 +28,7 @@ err = client.Request(ctx, "make_invoice", params, &invoice)
|
||||
## Methods
|
||||
|
||||
- `get_info` - Get wallet info
|
||||
- `get_balance` - Get wallet balance
|
||||
- `get_balance` - Get wallet balance
|
||||
- `make_invoice` - Create invoice
|
||||
- `lookup_invoice` - Check invoice status
|
||||
- `pay_invoice` - Pay invoice
|
||||
@@ -53,4 +53,4 @@ err = client.SubscribeNotifications(ctx, func(notificationType string, notificat
|
||||
- Event signing
|
||||
- Relay communication
|
||||
- Payment notifications
|
||||
- Error handling
|
||||
- Error handling
|
||||
|
||||
@@ -23,6 +23,10 @@ import (
|
||||
const (
|
||||
OneTimeSpiderSyncMarker = "spider_one_time_sync_completed"
|
||||
SpiderLastScanMarker = "spider_last_scan_time"
|
||||
// MaxWebSocketMessageSize is the maximum size for WebSocket messages to avoid 32KB limit
|
||||
MaxWebSocketMessageSize = 30 * 1024 // 30KB to be safe
|
||||
// PubkeyHexSize is the size of a hex-encoded pubkey (32 bytes = 64 hex chars)
|
||||
PubkeyHexSize = 64
|
||||
)
|
||||
|
||||
type Spider struct {
|
||||
@@ -271,11 +275,33 @@ func (s *Spider) discoverRelays(followedPubkeys [][]byte) ([]string, error) {
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
// calculateOptimalChunkSize calculates the optimal chunk size for pubkeys to stay under message size limit
|
||||
func (s *Spider) calculateOptimalChunkSize() int {
|
||||
// Estimate the size of a filter with timestamps and other fields
|
||||
// Base filter overhead: ~200 bytes for timestamps, limits, etc.
|
||||
baseFilterSize := 200
|
||||
|
||||
// Calculate how many pubkeys we can fit in the remaining space
|
||||
availableSpace := MaxWebSocketMessageSize - baseFilterSize
|
||||
maxPubkeys := availableSpace / PubkeyHexSize
|
||||
|
||||
// Use a conservative chunk size (80% of max to be safe)
|
||||
chunkSize := int(float64(maxPubkeys) * 0.8)
|
||||
|
||||
// Ensure minimum chunk size of 10
|
||||
if chunkSize < 10 {
|
||||
chunkSize = 10
|
||||
}
|
||||
|
||||
log.D.F("Spider: calculated optimal chunk size: %d pubkeys (max would be %d)", chunkSize, maxPubkeys)
|
||||
return chunkSize
|
||||
}
|
||||
|
||||
// queryRelayForEvents connects to a relay and queries for events from followed pubkeys
|
||||
func (s *Spider) queryRelayForEvents(
|
||||
relayURL string, followedPubkeys [][]byte, startTime, endTime time.Time,
|
||||
) (int, error) {
|
||||
log.T.F("Spider sync: querying relay %s", relayURL)
|
||||
log.T.F("Spider sync: querying relay %s with %d pubkeys", relayURL, len(followedPubkeys))
|
||||
|
||||
// Connect to the relay with a timeout context
|
||||
ctx, cancel := context.WithTimeout(s.ctx, 30*time.Second)
|
||||
@@ -287,82 +313,110 @@ func (s *Spider) queryRelayForEvents(
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Create filter for the time range and followed pubkeys
|
||||
f := &filter.F{
|
||||
Authors: tag.NewFromBytesSlice(followedPubkeys...),
|
||||
Since: timestamp.FromUnix(startTime.Unix()),
|
||||
Until: timestamp.FromUnix(endTime.Unix()),
|
||||
Limit: func() *uint { l := uint(1000); return &l }(), // Limit to avoid overwhelming
|
||||
}
|
||||
// Break pubkeys into chunks to avoid 32KB message limit
|
||||
chunkSize := s.calculateOptimalChunkSize()
|
||||
totalEventsSaved := 0
|
||||
|
||||
// Subscribe to get events
|
||||
sub, err := client.Subscribe(ctx, filter.NewS(f))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer sub.Unsub()
|
||||
|
||||
eventsCount := 0
|
||||
eventsSaved := 0
|
||||
timeout := time.After(10 * time.Second) // Timeout for receiving events
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.T.F(
|
||||
"Spider sync: context done for relay %s, saved %d/%d events",
|
||||
relayURL, eventsSaved, eventsCount,
|
||||
)
|
||||
return eventsSaved, nil
|
||||
case <-timeout:
|
||||
log.T.F(
|
||||
"Spider sync: timeout for relay %s, saved %d/%d events",
|
||||
relayURL, eventsSaved, eventsCount,
|
||||
)
|
||||
return eventsSaved, nil
|
||||
case <-sub.EndOfStoredEvents:
|
||||
log.T.F(
|
||||
"Spider sync: end of stored events for relay %s, saved %d/%d events",
|
||||
relayURL, eventsSaved, eventsCount,
|
||||
)
|
||||
return eventsSaved, nil
|
||||
case ev := <-sub.Events:
|
||||
if ev == nil {
|
||||
continue
|
||||
}
|
||||
eventsCount++
|
||||
|
||||
// Verify the event signature
|
||||
if ok, err := ev.Verify(); !ok || err != nil {
|
||||
log.T.F(
|
||||
"Spider sync: invalid event signature from relay %s",
|
||||
relayURL,
|
||||
)
|
||||
ev.Free()
|
||||
continue
|
||||
}
|
||||
|
||||
// Save the event to the database
|
||||
if _, _, err := s.db.SaveEvent(s.ctx, ev); err != nil {
|
||||
if !strings.HasPrefix(err.Error(), "blocked:") {
|
||||
log.T.F(
|
||||
"Spider sync: error saving event from relay %s: %v",
|
||||
relayURL, err,
|
||||
)
|
||||
}
|
||||
// Event might already exist, which is fine for deduplication
|
||||
} else {
|
||||
eventsSaved++
|
||||
if eventsSaved%10 == 0 {
|
||||
log.T.F(
|
||||
"Spider sync: saved %d events from relay %s",
|
||||
eventsSaved, relayURL,
|
||||
)
|
||||
}
|
||||
}
|
||||
ev.Free()
|
||||
for i := 0; i < len(followedPubkeys); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(followedPubkeys) {
|
||||
end = len(followedPubkeys)
|
||||
}
|
||||
|
||||
chunk := followedPubkeys[i:end]
|
||||
log.T.F("Spider sync: processing chunk %d-%d (%d pubkeys) for relay %s",
|
||||
i, end-1, len(chunk), relayURL)
|
||||
|
||||
// Create filter for this chunk of pubkeys
|
||||
f := &filter.F{
|
||||
Authors: tag.NewFromBytesSlice(chunk...),
|
||||
Since: timestamp.FromUnix(startTime.Unix()),
|
||||
Until: timestamp.FromUnix(endTime.Unix()),
|
||||
Limit: func() *uint { l := uint(1000); return &l }(), // Limit to avoid overwhelming
|
||||
}
|
||||
|
||||
// Subscribe to get events for this chunk
|
||||
sub, err := client.Subscribe(ctx, filter.NewS(f))
|
||||
if err != nil {
|
||||
log.E.F("Spider sync: failed to subscribe to chunk %d-%d for relay %s: %v",
|
||||
i, end-1, relayURL, err)
|
||||
continue
|
||||
}
|
||||
|
||||
chunkEventsSaved := 0
|
||||
chunkEventsCount := 0
|
||||
timeout := time.After(10 * time.Second) // Timeout for receiving events
|
||||
|
||||
chunkDone := false
|
||||
for !chunkDone {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.T.F(
|
||||
"Spider sync: context done for relay %s chunk %d-%d, saved %d/%d events",
|
||||
relayURL, i, end-1, chunkEventsSaved, chunkEventsCount,
|
||||
)
|
||||
chunkDone = true
|
||||
case <-timeout:
|
||||
log.T.F(
|
||||
"Spider sync: timeout for relay %s chunk %d-%d, saved %d/%d events",
|
||||
relayURL, i, end-1, chunkEventsSaved, chunkEventsCount,
|
||||
)
|
||||
chunkDone = true
|
||||
case <-sub.EndOfStoredEvents:
|
||||
log.T.F(
|
||||
"Spider sync: end of stored events for relay %s chunk %d-%d, saved %d/%d events",
|
||||
relayURL, i, end-1, chunkEventsSaved, chunkEventsCount,
|
||||
)
|
||||
chunkDone = true
|
||||
case ev := <-sub.Events:
|
||||
if ev == nil {
|
||||
continue
|
||||
}
|
||||
chunkEventsCount++
|
||||
|
||||
// Verify the event signature
|
||||
if ok, err := ev.Verify(); !ok || err != nil {
|
||||
log.T.F(
|
||||
"Spider sync: invalid event signature from relay %s",
|
||||
relayURL,
|
||||
)
|
||||
ev.Free()
|
||||
continue
|
||||
}
|
||||
|
||||
// Save the event to the database
|
||||
if _, _, err := s.db.SaveEvent(s.ctx, ev); err != nil {
|
||||
if !strings.HasPrefix(err.Error(), "blocked:") {
|
||||
log.T.F(
|
||||
"Spider sync: error saving event from relay %s: %v",
|
||||
relayURL, err,
|
||||
)
|
||||
}
|
||||
// Event might already exist, which is fine for deduplication
|
||||
} else {
|
||||
chunkEventsSaved++
|
||||
if chunkEventsSaved%10 == 0 {
|
||||
log.T.F(
|
||||
"Spider sync: saved %d events from relay %s chunk %d-%d",
|
||||
chunkEventsSaved, relayURL, i, end-1,
|
||||
)
|
||||
}
|
||||
}
|
||||
ev.Free()
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up subscription
|
||||
sub.Unsub()
|
||||
totalEventsSaved += chunkEventsSaved
|
||||
|
||||
log.T.F("Spider sync: completed chunk %d-%d for relay %s, saved %d events",
|
||||
i, end-1, relayURL, chunkEventsSaved)
|
||||
}
|
||||
|
||||
log.T.F("Spider sync: completed all chunks for relay %s, total saved %d events",
|
||||
relayURL, totalEventsSaved)
|
||||
return totalEventsSaved, nil
|
||||
}
|
||||
|
||||
// Stop stops the spider functionality
|
||||
|
||||
@@ -4,14 +4,15 @@ coverage:
|
||||
precision: 2
|
||||
|
||||
status:
|
||||
project: # measuring the overall project coverage
|
||||
default: # context, you can create multiple ones with custom titles
|
||||
enabled: yes # must be yes|true to enable this status
|
||||
target: 100 # specify the target coverage for each commit status
|
||||
# option: "auto" (must increase from parent commit or pull request base)
|
||||
# option: "X%" a static target percentage to hit
|
||||
if_not_found: success # if parent is not found report status as success, error, or failure
|
||||
if_ci_failed: error # if ci fails report status as success, error, or failure
|
||||
project: # measuring the overall project coverage
|
||||
default: # context, you can create multiple ones with custom titles
|
||||
enabled: yes # must be yes|true to enable this status
|
||||
target:
|
||||
100 # specify the target coverage for each commit status
|
||||
# option: "auto" (must increase from parent commit or pull request base)
|
||||
# option: "X%" a static target percentage to hit
|
||||
if_not_found: success # if parent is not found report status as success, error, or failure
|
||||
if_ci_failed: error # if ci fails report status as success, error, or failure
|
||||
|
||||
# Also update COVER_IGNORE_PKGS in the Makefile.
|
||||
ignore:
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## Unreleased
|
||||
|
||||
- No changes yet.
|
||||
|
||||
## [1.11.0] - 2023-05-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix `Swap` and `CompareAndSwap` for `Value` wrappers without initialization.
|
||||
|
||||
### Added
|
||||
|
||||
- Add `String` method to `atomic.Pointer[T]` type allowing users to safely print
|
||||
underlying values of pointers.
|
||||
underlying values of pointers.
|
||||
|
||||
[1.11.0]: https://github.com/uber-go/atomic/compare/v1.10.0...v1.11.0
|
||||
|
||||
## [1.10.0] - 2022-08-11
|
||||
|
||||
### Added
|
||||
|
||||
- Add `atomic.Float32` type for atomic operations on `float32`.
|
||||
- Add `CompareAndSwap` and `Swap` methods to `atomic.String`, `atomic.Error`,
|
||||
and `atomic.Value`.
|
||||
@@ -27,6 +34,7 @@ underlying values of pointers.
|
||||
replacement for the standard library's `sync/atomic.Pointer` type.
|
||||
|
||||
### Changed
|
||||
|
||||
- Deprecate `CAS` methods on all types in favor of corresponding
|
||||
`CompareAndSwap` methods.
|
||||
|
||||
@@ -35,46 +43,59 @@ Thanks to @eNV25 and @icpd for their contributions to this release.
|
||||
[1.10.0]: https://github.com/uber-go/atomic/compare/v1.9.0...v1.10.0
|
||||
|
||||
## [1.9.0] - 2021-07-15
|
||||
|
||||
### Added
|
||||
|
||||
- Add `Float64.Swap` to match int atomic operations.
|
||||
- Add `atomic.Time` type for atomic operations on `time.Time` values.
|
||||
|
||||
[1.9.0]: https://github.com/uber-go/atomic/compare/v1.8.0...v1.9.0
|
||||
|
||||
## [1.8.0] - 2021-06-09
|
||||
|
||||
### Added
|
||||
|
||||
- Add `atomic.Uintptr` type for atomic operations on `uintptr` values.
|
||||
- Add `atomic.UnsafePointer` type for atomic operations on `unsafe.Pointer` values.
|
||||
|
||||
[1.8.0]: https://github.com/uber-go/atomic/compare/v1.7.0...v1.8.0
|
||||
|
||||
## [1.7.0] - 2020-09-14
|
||||
|
||||
### Added
|
||||
|
||||
- Support JSON serialization and deserialization of primitive atomic types.
|
||||
- Support Text marshalling and unmarshalling for string atomics.
|
||||
|
||||
### Changed
|
||||
|
||||
- Disallow incorrect comparison of atomic values in a non-atomic way.
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove dependency on `golang.org/x/{lint, tools}`.
|
||||
|
||||
[1.7.0]: https://github.com/uber-go/atomic/compare/v1.6.0...v1.7.0
|
||||
|
||||
## [1.6.0] - 2020-02-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Drop library dependency on `golang.org/x/{lint, tools}`.
|
||||
|
||||
[1.6.0]: https://github.com/uber-go/atomic/compare/v1.5.1...v1.6.0
|
||||
|
||||
## [1.5.1] - 2019-11-19
|
||||
|
||||
- Fix bug where `Bool.CAS` and `Bool.Toggle` do work correctly together
|
||||
causing `CAS` to fail even though the old value matches.
|
||||
|
||||
[1.5.1]: https://github.com/uber-go/atomic/compare/v1.5.0...v1.5.1
|
||||
|
||||
## [1.5.0] - 2019-10-29
|
||||
|
||||
### Changed
|
||||
|
||||
- With Go modules, only the `go.uber.org/atomic` import path is supported now.
|
||||
If you need to use the old import path, please add a `replace` directive to
|
||||
your `go.mod`.
|
||||
@@ -82,43 +103,57 @@ Thanks to @eNV25 and @icpd for their contributions to this release.
|
||||
[1.5.0]: https://github.com/uber-go/atomic/compare/v1.4.0...v1.5.0
|
||||
|
||||
## [1.4.0] - 2019-05-01
|
||||
|
||||
### Added
|
||||
- Add `atomic.Error` type for atomic operations on `error` values.
|
||||
|
||||
- Add `atomic.Error` type for atomic operations on `error` values.
|
||||
|
||||
[1.4.0]: https://github.com/uber-go/atomic/compare/v1.3.2...v1.4.0
|
||||
|
||||
## [1.3.2] - 2018-05-02
|
||||
|
||||
### Added
|
||||
|
||||
- Add `atomic.Duration` type for atomic operations on `time.Duration` values.
|
||||
|
||||
[1.3.2]: https://github.com/uber-go/atomic/compare/v1.3.1...v1.3.2
|
||||
|
||||
## [1.3.1] - 2017-11-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- Revert optimization for `atomic.String.Store("")` which caused data races.
|
||||
|
||||
[1.3.1]: https://github.com/uber-go/atomic/compare/v1.3.0...v1.3.1
|
||||
|
||||
## [1.3.0] - 2017-11-13
|
||||
|
||||
### Added
|
||||
|
||||
- Add `atomic.Bool.CAS` for compare-and-swap semantics on bools.
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimize `atomic.String.Store("")` by avoiding an allocation.
|
||||
|
||||
[1.3.0]: https://github.com/uber-go/atomic/compare/v1.2.0...v1.3.0
|
||||
|
||||
## [1.2.0] - 2017-04-12
|
||||
|
||||
### Added
|
||||
|
||||
- Shadow `atomic.Value` from `sync/atomic`.
|
||||
|
||||
[1.2.0]: https://github.com/uber-go/atomic/compare/v1.1.0...v1.2.0
|
||||
|
||||
## [1.1.0] - 2017-03-10
|
||||
|
||||
### Added
|
||||
|
||||
- Add atomic `Float64` type.
|
||||
|
||||
### Changed
|
||||
|
||||
- Support new `go.uber.org/atomic` import path.
|
||||
|
||||
[1.1.0]: https://github.com/uber-go/atomic/compare/v1.0.0...v1.1.0
|
||||
|
||||
@@ -30,4 +30,4 @@ Stable.
|
||||
|
||||
---
|
||||
|
||||
Released under the [MIT License](LICENSE.txt).
|
||||
Released under the [MIT License](LICENSE.txt).
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# interrupt
|
||||
|
||||
Handle shutdowns cleanly and enable hot reload
|
||||
|
||||
@@ -1 +1 @@
|
||||
v0.10.2
|
||||
v0.11.1
|
||||
Reference in New Issue
Block a user