Compare commits

...

32 Commits

Author SHA1 Message Date
34a3b1ba69 Add dynamic relay dashboard URL support and version increment to v0.8.2.
Some checks failed
Go / build (push) Has been cancelled
- Introduced configuration option `RelayURL` for relay dashboard base URL.
- Added dynamic dashboard URL functionality in `PaymentProcessor`.
- Updated payment notifications to include dashboard access link.
- Incremented version to v0.8.2.
2025-09-23 14:49:08 +01:00
093a19db29 Expand relay features and update version to v0.8.1.
Some checks failed
Go / build (push) Has been cancelled
- Enabled support for additional relay NIPs: Authentication, GenericTagQueries, ParameterizedReplaceableEvents, ExpirationTimestamp.
- Added `PaymentRequired` limitation based on configuration.
- Incremented version to v0.8.1.
2025-09-23 14:26:50 +01:00
2ba361c915 Add relay identity management and subscription enhancements.
Some checks failed
Go / build (push) Has been cancelled
- Introduced relay identity management for subscriptions and follow-list sync.
- Added `IdentityRequested` function to handle the `identity` subcommand.
- Implemented periodic follow-list synchronization for active subscribers.
- Enhanced payment handling to include payer pubkey and subscription updates.
- Added trial expiry and subscription expiry notifications.
2025-09-23 14:22:24 +01:00
7736bb7640 Add payment processing with NWC and subscription-based access control.
- Implemented `PaymentProcessor` to handle NWC payments and extend user subscriptions.
- Added configuration options for NWC URI, subscription pricing, and enablement.
- Updated server to initialize and manage the payment processor.
2025-09-22 17:36:05 +01:00
804e1c9649 Add NWC protocol handling and NIP-44 encryption and decryption functions. 2025-09-22 17:18:47 +01:00
81a6aade4e Bump version to v0.7.1; update relay icon URL.
Some checks failed
Go / build (push) Has been cancelled
2025-09-22 09:38:01 +01:00
fc9600f99d Bump version to v0.7.0; update docs image. 2025-09-22 09:33:04 +01:00
199f922208 Refactor deletion checks and error handling; bump version to v0.6.4.
Some checks failed
Go / build (push) Has been cancelled
2025-09-21 18:15:27 +01:00
405e223aa6 implement delete events 2025-09-21 18:06:11 +01:00
fc3a89a309 Remove unused JavaScript file index-tha189jf.js from dist directory.
- Cleaned up the `app/web/dist/` directory by deleting an unreferenced and outdated build artifact.
- Maintained a lean and organized repository structure.
2025-09-21 17:17:31 +01:00
ba8166da07 Remove unused JavaScript file index-wnwvj11w.js from dist directory.
- Cleaned up the `app/web/dist/` directory by deleting an unreferenced and outdated build artifact.
- Maintained a lean and organized repository structure.
2025-09-21 17:17:15 +01:00
3e3af08644 Remove unused JavaScript file index-wnwvj11w.js from dist directory.
Some checks failed
Go / build (push) Has been cancelled
- Cleaned up the `app/web/dist/` directory by deleting an unreferenced and outdated build artifact.
- Maintained a lean and organized repository structure.
2025-09-21 16:39:45 +01:00
fbdf565bf7 Remove unused JavaScript file index-sskmjaqz.js from dist directory.
- Cleaned up the `app/web/dist/` directory by deleting an unreferenced and outdated build artifact.
- Maintained a lean and organized repository structure.
2025-09-21 16:33:23 +01:00
14b6960070 Add admin-only "All Events Log" feature with WebSocket integration.
Some checks failed
Go / build (push) Has been cancelled
- Implemented an "All Events Log" section accessible only to admin users.
- Added WebSocket-based data fetching to retrieve all events from the relay.
- Included profile caching and metadata fetching for event authors.
- Updated UI components to display events with expandable raw JSON details.
- Adjusted CSS for avatar sizes and improved layout.
- Refactored logout logic to reset all event states.
2025-09-21 16:31:06 +01:00
f9896e52ea use websockets for events log 2025-09-21 16:12:10 +01:00
ad7ca69964 Bump version to v0.6.1 for patch release.
Some checks failed
Go / build (push) Has been cancelled
2025-09-21 14:39:23 +01:00
facf03783f Remove outdated CSS and JavaScript files from dist directory.
- Deleted `index-zhtd763e.css` and `index-zqddcpy5.js` to streamline the build artifacts.
- Simplified repository by removing unused generated files to maintain a clean structure.
2025-09-21 14:36:49 +01:00
a5b6943320 Bump version to v0.6.0 for upcoming release.
Some checks failed
Go / build (push) Has been cancelled
2025-09-21 11:56:32 +01:00
1fe0a395be Add minimal local build outputs for streamlined dist integration.
- Introduced `index-zhtd763e.css` with a tailored CSS rule set for performance optimization.
- Added `index-zqddcpy5.js` containing essential JavaScript for React app functionality and improved compatibility.
2025-09-21 11:51:20 +01:00
92b3716a61 Remove dist directory and streamline build artifacts.
- Deleted `index.css`, `index.js`, and `index.html` from `app/web/dist/`.
- Cleared unused build artifacts to maintain a lean repository structure.
2025-09-21 11:46:59 +01:00
5c05d741d9 Replace remote Tailwind CSS with a local minimal subset; refine .gitignore and dist structure.
- Added a minimal `tailwind.min.css` with utilities tailored to app needs (`app/web/dist/`).
- Updated `.gitignore` to include specific `dist/` paths while maintaining clean build artifacts.
- Added local `dist` files (`index.css`, `index.js`) for better control over UI styling and build outputs.
2025-09-21 11:34:08 +01:00
9a1bbbafce Refine login view styling and update authentication text.
- Updated `App.jsx` to improve layout with centered flexbox and dynamic width.
- Adjusted login text for better clarity: "Authenticate" replaces "Connect".
2025-09-21 11:28:35 +01:00
2fd3828010 Refine login view styling and update authentication text.
- Updated `App.jsx` to improve layout with centered flexbox and dynamic width.
- Adjusted login text for better clarity: "Authenticate" replaces "Connect".
2025-09-21 10:38:25 +01:00
24b742bd20 Enable dev mode for React app with proxy support; refine build, styles, and UI.
- Adjusted `package.json` scripts for Bun dev server and build flow.
- Added `dev.html` for standalone web development with hot-reload enabled.
- Introduced `WebDisableEmbedded` and `WebDevProxyURL` configurations to support proxying non-API paths.
- Refactored server logic to handle reverse proxy for development mode.
- Updated `App.jsx` structure, styles, and layout for responsiveness and dynamic padding.
- Improved login interface with logo support and cleaner design.
- Enhanced development flow documentation in `README.md`.
2025-09-21 10:29:17 +01:00
6f71b95734 Handle EOF case in text encoder helper loop.
- Added check for `len(rem) == 0` to return `io.EOF` when no remaining input is available.
2025-09-21 03:00:29 +01:00
82665444f4 Add /api/auth/logout endpoint and improve auth flow.
- Implemented `handleAuthLogout` to support user logout by clearing session cookies.
- Improved `/api/auth/status` with authentication cookie validation for persistent login state.
- Enhanced `App.jsx` to prevent UI flash during auth status checks and streamline logout flow.
- Refined user profile handling and permission fetch logic for better reliability.
2025-09-20 20:30:14 +01:00
effeae4495 Replace remote Tailwind CSS with a minimal local build; refine build script and UI styling.
- Added `tailwind.min.css` tailored to current app requirements to reduce external dependencies.
- Updated `index.html` to use the local Tailwind CSS file.
- Improved `package.json` `build` script to ensure `dist` directory creation and inclusion of all `public/` assets.
- Refined CSS and layout in `App.jsx` for better consistency and responsiveness.
2025-09-20 20:24:04 +01:00
6b38291bf9 Add CORS headers and update UI for enhanced user profile handling.
- Added CORS support in server responses for cross-origin requests (`Access-Control-Allow-Origin`, etc.).
- Improved header panel behavior with a sticky position and refined CSS styling.
- Integrated profile data fetching (Kind 0 metadata) for user personalization.
- Enhanced login functionality to support dynamic profile display based on fetched metadata.
- Updated `index.html` to include Tailwind CSS for better design consistency.
2025-09-20 19:54:27 +01:00
0b69ea6d80 Embed React app and add new user authentication interface.
- Integrated a React-based web frontend into the Go server using the `embed` package, serving it from `/`.
- Added build and development scripts utilizing Bun for the React app (`package.json`, `README.md`).
- Enhanced auth interface to support better user experience and permissions (`App.jsx`, CSS updates).
- Refactored `/api/auth/login` to serve React UI, removing hardcoded HTML template.
- Implemented `/api/permissions/` with ACL support for user access management.
2025-09-20 19:03:25 +01:00
9c85dca598 Add graceful termination logging on signal triggers.
- Added explicit `log.I.F("exiting")` calls on signal handling for better visibility during shutdown.
- Ensured immediate return after logging to prevent further processing.
2025-09-20 17:59:06 +01:00
0d8c518896 Add user authentication interface with Nostr relay support.
- Implemented basic UI for login with NIP-07 extensions or private keys.
- Added `/api/auth/` endpoints for challenge generation, login handling, and status checking.
- Introduced challenge storage with thread-safe management.
- Enhanced `Server` structure to support authentication and user interface workflows.
- Improved HTML/CSS for a responsive and user-friendly experience.
2025-09-20 14:17:41 +01:00
20fbce9263 Add spider functionality for relay crawling, marker management, and new SpiderMode config.
- Introduced the `spider` package for relay crawling, including periodic tasks and one-time sync capabilities.
- Added `SetMarker`, `GetMarker`, `HasMarker`, and `DeleteMarker` methods in the database for marker management.
- Updated configuration with `SpiderMode` and `SpiderFrequency` options to enable and customize spider behavior.
- Integrated `spider` initialization into the main application flow.
- Improved tag handling, NIP-70 compliance, and protected tag validation in event processing.
- Removed unnecessary logging and replaced `errorf` with `fmt.Errorf` for better error handling.
- Incremented version to `v0.5.0`.
2025-09-20 13:46:22 +01:00
57 changed files with 8346 additions and 184 deletions

7
.gitignore vendored
View File

@@ -92,6 +92,13 @@ cmd/benchmark/data
!strfry.conf
!config.toml
!.dockerignore
!*.jsx
!*.tsx
!app/web/dist
!/app/web/dist
!/app/web/dist/*
!/app/web/dist/**
!bun.lock
# ...even if they are in subdirectories
!*/
/blocklist.json

7
.idea/jsLibraryMappings.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<file url="file://$PROJECT_DIR$/../github.com/jumble" libraries="{jumble/node_modules}" />
<file url="file://$PROJECT_DIR$/../github.com/mleku/jumble" libraries="{jumble/node_modules}" />
</component>
</project>

View File

@@ -23,23 +23,33 @@ import (
// and default values. It defines parameters for app behaviour, storage
// locations, logging, and network settings used across the relay service.
type C struct {
AppName string `env:"ORLY_APP_NAME" usage:"set a name to display on information about the relay" default:"ORLY"`
DataDir string `env:"ORLY_DATA_DIR" usage:"storage location for the event store" default:"~/.local/share/ORLY"`
Listen string `env:"ORLY_LISTEN" default:"0.0.0.0" usage:"network listen address"`
Port int `env:"ORLY_PORT" default:"3334" usage:"port to listen on"`
HealthPort int `env:"ORLY_HEALTH_PORT" default:"0" usage:"optional health check HTTP port; 0 disables"`
EnableShutdown bool `env:"ORLY_ENABLE_SHUTDOWN" default:"false" usage:"if true, expose /shutdown on the health port to gracefully stop the process (for profiling)"`
LogLevel string `env:"ORLY_LOG_LEVEL" default:"info" usage:"relay log level: fatal error warn info debug trace"`
DBLogLevel string `env:"ORLY_DB_LOG_LEVEL" default:"info" usage:"database log level: fatal error warn info debug trace"`
LogToStdout bool `env:"ORLY_LOG_TO_STDOUT" default:"false" usage:"log to stdout instead of stderr"`
Pprof string `env:"ORLY_PPROF" usage:"enable pprof in modes: cpu,memory,allocation,heap,block,goroutine,threadcreate,mutex"`
PprofPath string `env:"ORLY_PPROF_PATH" usage:"optional directory to write pprof profiles into (inside container); default is temporary dir"`
PprofHTTP bool `env:"ORLY_PPROF_HTTP" default:"false" usage:"if true, expose net/http/pprof on port 6060"`
OpenPprofWeb bool `env:"ORLY_OPEN_PPROF_WEB" default:"false" usage:"if true, automatically open the pprof web viewer when profiling is enabled"`
IPWhitelist []string `env:"ORLY_IP_WHITELIST" usage:"comma-separated list of IP addresses to allow access from, matches on prefixes to allow private subnets, eg 10.0.0 = 10.0.0.0/8"`
Admins []string `env:"ORLY_ADMINS" usage:"comma-separated list of admin npubs"`
Owners []string `env:"ORLY_OWNERS" usage:"comma-separated list of owner npubs, who have full control of the relay for wipe and restart and other functions"`
ACLMode string `env:"ORLY_ACL_MODE" usage:"ACL mode: follows,none" default:"none"`
AppName string `env:"ORLY_APP_NAME" usage:"set a name to display on information about the relay" default:"ORLY"`
DataDir string `env:"ORLY_DATA_DIR" usage:"storage location for the event store" default:"~/.local/share/ORLY"`
Listen string `env:"ORLY_LISTEN" default:"0.0.0.0" usage:"network listen address"`
Port int `env:"ORLY_PORT" default:"3334" usage:"port to listen on"`
HealthPort int `env:"ORLY_HEALTH_PORT" default:"0" usage:"optional health check HTTP port; 0 disables"`
EnableShutdown bool `env:"ORLY_ENABLE_SHUTDOWN" default:"false" usage:"if true, expose /shutdown on the health port to gracefully stop the process (for profiling)"`
LogLevel string `env:"ORLY_LOG_LEVEL" default:"info" usage:"relay log level: fatal error warn info debug trace"`
DBLogLevel string `env:"ORLY_DB_LOG_LEVEL" default:"info" usage:"database log level: fatal error warn info debug trace"`
LogToStdout bool `env:"ORLY_LOG_TO_STDOUT" default:"false" usage:"log to stdout instead of stderr"`
Pprof string `env:"ORLY_PPROF" usage:"enable pprof in modes: cpu,memory,allocation,heap,block,goroutine,threadcreate,mutex"`
PprofPath string `env:"ORLY_PPROF_PATH" usage:"optional directory to write pprof profiles into (inside container); default is temporary dir"`
PprofHTTP bool `env:"ORLY_PPROF_HTTP" default:"false" usage:"if true, expose net/http/pprof on port 6060"`
OpenPprofWeb bool `env:"ORLY_OPEN_PPROF_WEB" default:"false" usage:"if true, automatically open the pprof web viewer when profiling is enabled"`
IPWhitelist []string `env:"ORLY_IP_WHITELIST" usage:"comma-separated list of IP addresses to allow access from, matches on prefixes to allow private subnets, eg 10.0.0 = 10.0.0.0/8"`
Admins []string `env:"ORLY_ADMINS" usage:"comma-separated list of admin npubs"`
Owners []string `env:"ORLY_OWNERS" usage:"comma-separated list of owner npubs, who have full control of the relay for wipe and restart and other functions"`
ACLMode string `env:"ORLY_ACL_MODE" usage:"ACL mode: follows,none" default:"none"`
SpiderMode string `env:"ORLY_SPIDER_MODE" usage:"spider mode: none,follow" default:"none"`
SpiderFrequency time.Duration `env:"ORLY_SPIDER_FREQUENCY" usage:"spider frequency in seconds" default:"1h"`
NWCUri string `env:"ORLY_NWC_URI" usage:"NWC (Nostr Wallet Connect) connection string for Lightning payments"`
SubscriptionEnabled bool `env:"ORLY_SUBSCRIPTION_ENABLED" default:"false" usage:"enable subscription-based access control requiring payment for non-directory events"`
MonthlyPriceSats int64 `env:"ORLY_MONTHLY_PRICE_SATS" default:"6000" usage:"price in satoshis for one month subscription (default ~$2 USD)"`
RelayURL string `env:"ORLY_RELAY_URL" usage:"base URL for the relay dashboard (e.g., https://relay.example.com)"`
// Web UI and dev mode settings
WebDisableEmbedded bool `env:"ORLY_WEB_DISABLE" default:"false" usage:"disable serving the embedded web UI; useful for hot-reload during development"`
WebDevProxyURL string `env:"ORLY_WEB_DEV_PROXY_URL" usage:"when ORLY_WEB_DISABLE is true, reverse-proxy non-API paths to this dev server URL (e.g. http://localhost:5173)"`
}
// New creates and initializes a new configuration object for the relay
@@ -130,6 +140,21 @@ func GetEnv() (requested bool) {
return
}
// IdentityRequested checks if the first command line argument is "identity" and returns
// whether the relay identity should be printed and the program should exit.
//
// Return Values
// - requested: true if the 'identity' subcommand was provided, false otherwise.
func IdentityRequested() (requested bool) {
if len(os.Args) > 1 {
switch strings.ToLower(os.Args[1]) {
case "identity":
requested = true
}
}
return
}
// KV is a key/value pair.
type KV struct{ Key, Value string }

View File

@@ -50,6 +50,34 @@ 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)
}
return
}
// handleFirstTimeUser checks if user is logging in for first time and creates welcome note
func (l *Listener) handleFirstTimeUser(pubkey []byte) {
// Check if this is a first-time user
isFirstTime, err := l.Server.D.IsFirstTimeUser(pubkey)
if err != nil {
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)
}
}
}

View File

@@ -145,12 +145,10 @@ func (l *Listener) HandleDelete(env *eventenvelope.Submission) (err error) {
if ev, err = l.FetchEventBySerial(s); chk.E(err) {
continue
}
// check that the author is the same as the signer of the
// delete, for the e tag case the author is the signer of
// the event.
if !utils.FastEqual(env.E.Pubkey, ev.Pubkey) {
// allow deletion if the signer is the author OR an admin/owner
if !(ownerDelete || utils.FastEqual(env.E.Pubkey, ev.Pubkey)) {
log.W.F(
"HandleDelete: attempted deletion of event %s by different user - delete pubkey=%s, event pubkey=%s",
"HandleDelete: attempted deletion of event %s by unauthorized user - delete pubkey=%s, event pubkey=%s",
hex.Enc(ev.ID), hex.Enc(env.E.Pubkey),
hex.Enc(ev.Pubkey),
)

View File

@@ -103,6 +103,20 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
// user has write access or better, continue
// log.D.F("user has %s access", accessLevel)
}
// check for protected tag (NIP-70)
protectedTag := env.E.Tags.GetFirst([]byte("-"))
if protectedTag != nil && acl.Registry.Active.Load() != "none" {
// check that the pubkey of the event matches the authed pubkey
if !utils.FastEqual(l.authedPubkey.Load(), env.E.Pubkey) {
if err = Ok.Blocked(
l, env,
"protected tag may only be published by user authed to the same pubkey",
); chk.E(err) {
return
}
return
}
}
// if the event is a delete, process the delete
if env.E.Kind == kind.EventDeletion.K {
if err = l.HandleDelete(env); err != nil {

View File

@@ -1,9 +1,9 @@
package app
import (
"fmt"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"lol.mleku.dev/log"
"next.orly.dev/pkg/encoders/envelopes"
"next.orly.dev/pkg/encoders/envelopes/authenvelope"
"next.orly.dev/pkg/encoders/envelopes/closeenvelope"
@@ -13,7 +13,7 @@ import (
)
func (l *Listener) HandleMessage(msg []byte, remote string) {
log.D.F("%s received message:\n%s", remote, msg)
// log.D.F("%s received message:\n%s", remote, msg)
var err error
var t string
var rem []byte
@@ -32,7 +32,7 @@ func (l *Listener) HandleMessage(msg []byte, remote string) {
// log.D.F("authenvelope: %s %s", remote, rem)
err = l.HandleAuth(rem)
default:
err = errorf.E("unknown envelope type %s\n%s", t, rem)
err = fmt.Errorf("unknown envelope type %s\n%s", t, rem)
}
}
if err != nil {
@@ -43,7 +43,7 @@ func (l *Listener) HandleMessage(msg []byte, remote string) {
// )
// },
// )
if err = noticeenvelope.NewFrom(err.Error()).Write(l); chk.E(err) {
if err = noticeenvelope.NewFrom(err.Error()).Write(l); err != nil {
return
}
}

View File

@@ -31,11 +31,11 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
var info *relayinfo.T
supportedNIPs := relayinfo.GetList(
relayinfo.BasicProtocol,
// relayinfo.Authentication,
relayinfo.Authentication,
// relayinfo.EncryptedDirectMessage,
relayinfo.EventDeletion,
relayinfo.RelayInformationDocument,
// relayinfo.GenericTagQueries,
relayinfo.GenericTagQueries,
// relayinfo.NostrMarketplace,
relayinfo.EventTreatment,
// relayinfo.CommandResults,
@@ -51,12 +51,12 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
// relayinfo.EncryptedDirectMessage,
relayinfo.EventDeletion,
relayinfo.RelayInformationDocument,
// relayinfo.GenericTagQueries,
relayinfo.GenericTagQueries,
// relayinfo.NostrMarketplace,
relayinfo.EventTreatment,
// relayinfo.CommandResults,
// relayinfo.ParameterizedReplaceableEvents,
// relayinfo.ExpirationTimestamp,
relayinfo.ParameterizedReplaceableEvents,
relayinfo.ExpirationTimestamp,
relayinfo.ProtectedEvents,
relayinfo.RelayListMetadata,
)
@@ -72,8 +72,9 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
Limitation: relayinfo.Limits{
AuthRequired: s.Config.ACLMode != "none",
RestrictedWrites: s.Config.ACLMode != "none",
PaymentRequired: s.Config.MonthlyPriceSats > 0,
},
Icon: "https://cdn.satellite.earth/ac9778868fbf23b63c47c769a74e163377e6ea94d3f0f31711931663d035c4f6.png",
Icon: "https://i.nostr.build/6wGXAn7Zaw9mHxFg.png",
}
if err := json.NewEncoder(w).Encode(info); chk.E(err) {
}

View File

@@ -4,12 +4,14 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
acl "next.orly.dev/pkg/acl"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/encoders/bech32encoding"
"next.orly.dev/pkg/encoders/envelopes/authenvelope"
"next.orly.dev/pkg/encoders/envelopes/closedenvelope"
"next.orly.dev/pkg/encoders/envelopes/eoseenvelope"
@@ -22,21 +24,21 @@ import (
"next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/reason"
"next.orly.dev/pkg/encoders/tag"
utils "next.orly.dev/pkg/utils"
"next.orly.dev/pkg/utils"
"next.orly.dev/pkg/utils/normalize"
"next.orly.dev/pkg/utils/pointers"
)
func (l *Listener) HandleReq(msg []byte) (err error) {
log.T.F("HandleReq: START processing from %s\n%s\n", l.remote, msg)
var rem []byte
// log.T.F("HandleReq: START processing from %s\n%s\n", l.remote, msg)
// var rem []byte
env := reqenvelope.New()
if rem, err = env.Unmarshal(msg); chk.E(err) {
if _, err = env.Unmarshal(msg); chk.E(err) {
return normalize.Error.Errorf(err.Error())
}
if len(rem) > 0 {
log.I.F("REQ extra bytes: '%s'", rem)
}
// if len(rem) > 0 {
// log.I.F("REQ extra bytes: '%s'", rem)
// }
// 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()).
@@ -57,59 +59,59 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
return
default:
// user has read access or better, continue
log.D.F("user has %s access", accessLevel)
// log.D.F("user has %s access", accessLevel)
}
var events event.S
for _, f := range *env.Filters {
idsLen := 0
kindsLen := 0
authorsLen := 0
tagsLen := 0
if f != nil {
if f.Ids != nil {
idsLen = f.Ids.Len()
}
if f.Kinds != nil {
kindsLen = f.Kinds.Len()
}
if f.Authors != nil {
authorsLen = f.Authors.Len()
}
if f.Tags != nil {
tagsLen = f.Tags.Len()
}
}
log.T.F(
"REQ %s: filter summary ids=%d kinds=%d authors=%d tags=%d",
env.Subscription, idsLen, kindsLen, authorsLen, tagsLen,
)
// idsLen := 0
// kindsLen := 0
// authorsLen := 0
// tagsLen := 0
// if f != nil {
// if f.Ids != nil {
// idsLen = f.Ids.Len()
// }
// if f.Kinds != nil {
// kindsLen = f.Kinds.Len()
// }
// if f.Authors != nil {
// authorsLen = f.Authors.Len()
// }
// if f.Tags != nil {
// tagsLen = f.Tags.Len()
// }
// }
// log.T.F(
// "REQ %s: filter summary ids=%d kinds=%d authors=%d tags=%d",
// env.Subscription, idsLen, kindsLen, authorsLen, tagsLen,
// )
if f != nil && f.Authors != nil && f.Authors.Len() > 0 {
var authors []string
for _, a := range f.Authors.T {
authors = append(authors, hex.Enc(a))
}
log.T.F("REQ %s: authors=%v", env.Subscription, authors)
// log.T.F("REQ %s: authors=%v", env.Subscription, authors)
}
if f != nil && f.Kinds != nil && f.Kinds.Len() > 0 {
log.T.F("REQ %s: kinds=%v", env.Subscription, f.Kinds.ToUint16())
}
if f != nil && f.Ids != nil && f.Ids.Len() > 0 {
var ids []string
for _, id := range f.Ids.T {
ids = append(ids, hex.Enc(id))
}
var lim any
if pointers.Present(f.Limit) {
lim = *f.Limit
} else {
lim = nil
}
log.T.F(
"REQ %s: ids filter count=%d ids=%v limit=%v", env.Subscription,
f.Ids.Len(), ids, lim,
)
}
if pointers.Present(f.Limit) {
// if f != nil && f.Kinds != nil && f.Kinds.Len() > 0 {
// log.T.F("REQ %s: kinds=%v", env.Subscription, f.Kinds.ToUint16())
// }
// if f != nil && f.Ids != nil && f.Ids.Len() > 0 {
// var ids []string
// for _, id := range f.Ids.T {
// ids = append(ids, hex.Enc(id))
// }
// // var lim any
// // if pointers.Present(f.Limit) {
// // lim = *f.Limit
// // } else {
// // lim = nil
// // }
// // log.T.F(
// // "REQ %s: ids filter count=%d ids=%v limit=%v", env.Subscription,
// // f.Ids.Len(), ids, lim,
// // )
// }
if f != nil && pointers.Present(f.Limit) {
if *f.Limit == 0 {
continue
}
@@ -119,15 +121,15 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
context.Background(), 30*time.Second,
)
defer cancel()
log.T.F(
"HandleReq: About to QueryEvents for %s, main context done: %v",
l.remote, l.ctx.Err() != nil,
)
// log.T.F(
// "HandleReq: About to QueryEvents for %s, main context done: %v",
// l.remote, l.ctx.Err() != nil,
// )
if events, err = l.QueryEvents(queryCtx, f); chk.E(err) {
if errors.Is(err, badger.ErrDBClosed) {
return
}
log.T.F("HandleReq: QueryEvents error for %s: %v", l.remote, err)
// log.T.F("HandleReq: QueryEvents error for %s: %v", l.remote, err)
err = nil
}
defer func() {
@@ -135,23 +137,60 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
ev.Free()
}
}()
log.T.F(
"HandleReq: QueryEvents completed for %s, found %d events",
l.remote, len(events),
)
// log.T.F(
// "HandleReq: QueryEvents completed for %s, found %d events",
// l.remote, len(events),
// )
}
var tmp event.S
privCheck:
for _, ev := range events {
// Check for private tag first
privateTags := ev.Tags.GetAll([]byte("private"))
if len(privateTags) > 0 && accessLevel != "admin" {
pk := l.authedPubkey.Load()
if pk == nil {
continue // no auth, can't access private events
}
// Convert authenticated pubkey to npub for comparison
authedNpub, err := bech32encoding.BinToNpub(pk)
if err != nil {
continue // couldn't convert pubkey, skip
}
// Check if authenticated npub is in any private tag
authorized := false
for _, privateTag := range privateTags {
authorizedNpubs := strings.Split(string(privateTag.Value()), ",")
for _, npub := range authorizedNpubs {
if strings.TrimSpace(npub) == string(authedNpub) {
authorized = true
break
}
}
if authorized {
break
}
}
if !authorized {
continue // not authorized to see this private event
}
tmp = append(tmp, ev)
continue
}
if kind.IsPrivileged(ev.Kind) &&
accessLevel != "admin" { // admins can see all events
log.T.C(
func() string {
return fmt.Sprintf(
"checking privileged event %0x", ev.ID,
)
},
)
// log.T.C(
// func() string {
// return fmt.Sprintf(
// "checking privileged event %0x", ev.ID,
// )
// },
// )
pk := l.authedPubkey.Load()
if pk == nil {
continue
@@ -175,26 +214,26 @@ privCheck:
continue
}
if utils.FastEqual(pt, pk) {
log.T.C(
func() string {
return fmt.Sprintf(
"privileged event %s is for logged in pubkey %0x",
ev.ID, pk,
)
},
)
// log.T.C(
// func() string {
// return fmt.Sprintf(
// "privileged event %s is for logged in pubkey %0x",
// ev.ID, pk,
// )
// },
// )
tmp = append(tmp, ev)
continue privCheck
}
}
log.T.C(
func() string {
return fmt.Sprintf(
"privileged event %s does not contain the logged in pubkey %0x",
ev.ID, pk,
)
},
)
// log.T.C(
// func() string {
// return fmt.Sprintf(
// "privileged event %s does not contain the logged in pubkey %0x",
// ev.ID, pk,
// )
// },
// )
} else {
tmp = append(tmp, ev)
}
@@ -202,19 +241,19 @@ privCheck:
events = tmp
seen := make(map[string]struct{})
for _, ev := range events {
log.D.C(
func() string {
return fmt.Sprintf(
"REQ %s: sending EVENT id=%s kind=%d", env.Subscription,
hex.Enc(ev.ID), ev.Kind,
)
},
)
log.T.C(
func() string {
return fmt.Sprintf("event:\n%s\n", ev.Serialize())
},
)
// log.D.C(
// func() string {
// return fmt.Sprintf(
// "REQ %s: sending EVENT id=%s kind=%d", env.Subscription,
// hex.Enc(ev.ID), ev.Kind,
// )
// },
// )
// log.T.C(
// func() string {
// return fmt.Sprintf("event:\n%s\n", ev.Serialize())
// },
// )
var res *eventenvelope.Result
if res, err = eventenvelope.NewResultWith(
env.Subscription, ev,
@@ -229,7 +268,7 @@ privCheck:
}
// write the EOSE to signal to the client that all events found have been
// sent.
log.T.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
@@ -237,10 +276,10 @@ privCheck:
// if the query was for just Ids, we know there can't be any more results,
// so cancel the subscription.
cancel := true
log.T.F(
"REQ %s: computing cancel/subscription; events_sent=%d",
env.Subscription, len(events),
)
// log.T.F(
// "REQ %s: computing cancel/subscription; events_sent=%d",
// env.Subscription, len(events),
// )
var subbedFilters filter.S
for _, f := range *env.Filters {
if f.Ids.Len() < 1 {
@@ -255,10 +294,10 @@ privCheck:
}
notFounds = append(notFounds, id)
}
log.T.F(
"REQ %s: ids outstanding=%d of %d", env.Subscription,
len(notFounds), f.Ids.Len(),
)
// log.T.F(
// "REQ %s: ids outstanding=%d of %d", env.Subscription,
// len(notFounds), f.Ids.Len(),
// )
// if all were found, don't add to subbedFilters
if len(notFounds) == 0 {
continue
@@ -295,6 +334,6 @@ privCheck:
return
}
}
log.T.F("HandleReq: COMPLETED processing from %s", l.remote)
// log.T.F("HandleReq: COMPLETED processing from %s", l.remote)
return
}

View File

@@ -97,7 +97,7 @@ whitelist:
}
var typ websocket.MessageType
var msg []byte
log.T.F("waiting for message from %s", remote)
// log.T.F("waiting for message from %s", remote)
// Create a read context with timeout to prevent indefinite blocking
readCtx, readCancel := context.WithTimeout(ctx, DefaultReadTimeout)
@@ -152,7 +152,7 @@ whitelist:
writeCancel()
continue
}
log.T.F("received message from %s: %s", remote, string(msg))
// log.T.F("received message from %s: %s", remote, string(msg))
go listener.HandleMessage(msg, remote)
}
}

View File

@@ -8,7 +8,8 @@ import (
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/app/config"
database "next.orly.dev/pkg/database"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/bech32encoding"
"next.orly.dev/pkg/protocol/publish"
)
@@ -45,6 +46,40 @@ func Run(
publishers: publish.New(NewPublisher(ctx)),
Admins: adminKeys,
}
// Initialize the user interface
l.UserInterface()
// Ensure a relay identity secret key exists when subscriptions and NWC are enabled
if cfg.SubscriptionEnabled && cfg.NWCUri != "" {
if skb, e := db.GetOrCreateRelayIdentitySecret(); e != nil {
log.E.F("failed to ensure relay identity key: %v", e)
} else if pk, e2 := keys.SecretBytesToPubKeyHex(skb); e2 == nil {
log.I.F("relay identity loaded (pub=%s)", pk)
// ensure relay identity pubkey is considered an admin for ACL follows mode
found := false
for _, a := range cfg.Admins {
if a == pk {
found = true
break
}
}
if !found {
cfg.Admins = append(cfg.Admins, pk)
log.I.F("added relay identity to admins for follow-list whitelisting")
}
}
}
if l.paymentProcessor, err = NewPaymentProcessor(ctx, cfg, db); err != nil {
log.E.F("failed to create payment processor: %v", err)
// Continue without payment processor
} else {
if err = l.paymentProcessor.Start(); err != nil {
log.E.F("failed to start payment processor: %v", err)
} else {
log.I.F("payment processor started successfully")
}
}
addr := fmt.Sprintf("%s:%d", cfg.Listen, cfg.Port)
log.I.F("starting listener on http://%s", addr)
go func() {

842
app/payment_processor.go Normal file
View File

@@ -0,0 +1,842 @@
package app
import (
"context"
// std hex not used; use project hex encoder instead
"fmt"
"strings"
"sync"
"time"
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/app/config"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/crypto/p256k"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/bech32encoding"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/json"
"next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/encoders/timestamp"
"next.orly.dev/pkg/protocol/nwc"
)
// PaymentProcessor handles NWC payment notifications and updates subscriptions
type PaymentProcessor struct {
nwcClient *nwc.Client
db *database.D
config *config.C
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
dashboardURL string
}
// NewPaymentProcessor creates a new payment processor
func NewPaymentProcessor(
ctx context.Context, cfg *config.C, db *database.D,
) (pp *PaymentProcessor, err error) {
if cfg.NWCUri == "" {
return nil, fmt.Errorf("NWC URI not configured")
}
var nwcClient *nwc.Client
if nwcClient, err = nwc.NewClient(cfg.NWCUri); chk.E(err) {
return nil, fmt.Errorf("failed to create NWC client: %w", err)
}
c, cancel := context.WithCancel(ctx)
pp = &PaymentProcessor{
nwcClient: nwcClient,
db: db,
config: cfg,
ctx: c,
cancel: cancel,
}
return pp, nil
}
// Start begins listening for payment notifications
func (pp *PaymentProcessor) Start() error {
// start NWC notifications listener
pp.wg.Add(1)
go func() {
defer pp.wg.Done()
if err := pp.listenForPayments(); err != nil {
log.E.F("payment processor error: %v", err)
}
}()
// start periodic follow-list sync if subscriptions are enabled
if pp.config != nil && pp.config.SubscriptionEnabled {
pp.wg.Add(1)
go func() {
defer pp.wg.Done()
pp.runFollowSyncLoop()
}()
// start daily subscription checker
pp.wg.Add(1)
go func() {
defer pp.wg.Done()
pp.runDailySubscriptionChecker()
}()
}
return nil
}
// Stop gracefully stops the payment processor
func (pp *PaymentProcessor) Stop() {
if pp.cancel != nil {
pp.cancel()
}
pp.wg.Wait()
}
// listenForPayments subscribes to NWC notifications and processes payments
func (pp *PaymentProcessor) listenForPayments() error {
return pp.nwcClient.SubscribeNotifications(pp.ctx, pp.handleNotification)
}
// runFollowSyncLoop periodically syncs the relay identity follow list with active subscribers
func (pp *PaymentProcessor) runFollowSyncLoop() {
t := time.NewTicker(10 * time.Minute)
defer t.Stop()
// do an initial sync shortly after start
_ = pp.syncFollowList()
for {
select {
case <-pp.ctx.Done():
return
case <-t.C:
if err := pp.syncFollowList(); err != nil {
log.W.F("follow list sync failed: %v", err)
}
}
}
}
// runDailySubscriptionChecker checks once daily for subscription expiry warnings and trial reminders
func (pp *PaymentProcessor) runDailySubscriptionChecker() {
t := time.NewTicker(24 * time.Hour)
defer t.Stop()
// do an initial check shortly after start
_ = pp.checkSubscriptionStatus()
for {
select {
case <-pp.ctx.Done():
return
case <-t.C:
if err := pp.checkSubscriptionStatus(); err != nil {
log.W.F("subscription status check failed: %v", err)
}
}
}
}
// syncFollowList builds a kind-3 event from the relay identity containing only active subscribers
func (pp *PaymentProcessor) syncFollowList() error {
// ensure we have a relay identity secret
skb, err := pp.db.GetRelayIdentitySecret()
if err != nil || len(skb) != 32 {
return nil // nothing to do if no identity
}
// collect active subscribers
actives, err := pp.getActiveSubscriberPubkeys()
if err != nil {
return err
}
// signer
sign := new(p256k.Signer)
if err := sign.InitSec(skb); err != nil {
return err
}
// build follow list event
ev := event.New()
ev.Kind = kind.FollowList.K
ev.Pubkey = sign.Pub()
ev.CreatedAt = timestamp.Now().V
ev.Tags = tag.NewS()
for _, pk := range actives {
*ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(pk)))
}
// sign and save
ev.Sign(sign)
if _, _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil {
return err
}
log.I.F(
"updated relay follow list with %d active subscribers", len(actives),
)
return nil
}
// getActiveSubscriberPubkeys scans the subscription records and returns active ones
func (pp *PaymentProcessor) getActiveSubscriberPubkeys() ([][]byte, error) {
prefix := []byte("sub:")
now := time.Now()
var out [][]byte
err := pp.db.DB.View(
func(txn *badger.Txn) error {
it := txn.NewIterator(badger.DefaultIteratorOptions)
defer it.Close()
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
item := it.Item()
key := item.KeyCopy(nil)
// key format: sub:<hexpub>
hexpub := string(key[len(prefix):])
var sub database.Subscription
if err := item.Value(
func(val []byte) error {
return json.Unmarshal(val, &sub)
},
); err != nil {
return err
}
if now.Before(sub.TrialEnd) || (!sub.PaidUntil.IsZero() && now.Before(sub.PaidUntil)) {
if b, err := hex.Dec(hexpub); err == nil {
out = append(out, b)
}
}
}
return nil
},
)
return out, err
}
// checkSubscriptionStatus scans all subscriptions and creates warning/reminder notes
func (pp *PaymentProcessor) checkSubscriptionStatus() error {
prefix := []byte("sub:")
now := time.Now()
sevenDaysFromNow := now.AddDate(0, 0, 7)
return pp.db.DB.View(
func(txn *badger.Txn) error {
it := txn.NewIterator(badger.DefaultIteratorOptions)
defer it.Close()
for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
item := it.Item()
key := item.KeyCopy(nil)
// key format: sub:<hexpub>
hexpub := string(key[len(prefix):])
var sub database.Subscription
if err := item.Value(
func(val []byte) error {
return json.Unmarshal(val, &sub)
},
); err != nil {
continue // skip invalid subscription records
}
pubkey, err := hex.Dec(hexpub)
if err != nil {
continue // skip invalid pubkey
}
// Check if paid subscription is expiring in 7 days
if !sub.PaidUntil.IsZero() {
// Format dates for comparison (ignore time component)
paidUntilDate := sub.PaidUntil.Truncate(24 * time.Hour)
sevenDaysDate := sevenDaysFromNow.Truncate(24 * time.Hour)
if paidUntilDate.Equal(sevenDaysDate) {
go pp.createExpiryWarningNote(pubkey, sub.PaidUntil)
}
}
// Check if user is on trial (no paid subscription, trial not expired)
if sub.PaidUntil.IsZero() && now.Before(sub.TrialEnd) {
go pp.createTrialReminderNote(pubkey, sub.TrialEnd)
}
}
return nil
},
)
}
// createExpiryWarningNote creates a warning note for users whose paid subscription expires in 7 days
func (pp *PaymentProcessor) createExpiryWarningNote(userPubkey []byte, expiryTime time.Time) error {
// Get relay identity secret to sign the note
skb, err := pp.db.GetRelayIdentitySecret()
if err != nil || len(skb) != 32 {
return fmt.Errorf("no relay identity configured")
}
// Initialize signer
sign := new(p256k.Signer)
if err := sign.InitSec(skb); err != nil {
return fmt.Errorf("failed to initialize signer: %w", err)
}
monthlyPrice := pp.config.MonthlyPriceSats
if monthlyPrice <= 0 {
monthlyPrice = 6000
}
// Get relay npub for content link
relayNpubForContent, err := bech32encoding.BinToNpub(sign.Pub())
if err != nil {
return fmt.Errorf("failed to encode relay npub: %w", err)
}
// Create the warning note content
content := fmt.Sprintf(`⚠️ Subscription Expiring Soon ⚠️
Your paid subscription to this relay will expire in 7 days on %s.
💰 To extend your subscription:
- Monthly price: %d sats
- Zap this note with your payment amount
- Each %d sats = 30 days of access
⚡ Payment Instructions:
1. Use any Lightning wallet that supports zaps
2. Zap this note with your payment
3. Your subscription will be automatically extended
Don't lose access to your private relay! Extend your subscription today.
Relay: nostr:%s
Log in to the relay dashboard to access your configuration at: %s`,
expiryTime.Format("2006-01-02 15:04:05 UTC"), monthlyPrice, monthlyPrice, string(relayNpubForContent), pp.getDashboardURL())
// Build the event
ev := event.New()
ev.Kind = kind.TextNote.K // Kind 1 for text note
ev.Pubkey = sign.Pub()
ev.CreatedAt = timestamp.Now().V
ev.Content = []byte(content)
ev.Tags = tag.NewS()
// Add "p" tag for the user
*ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(userPubkey)))
// Add expiration tag (5 days from creation)
noteExpiry := time.Now().AddDate(0, 0, 5)
*ev.Tags = append(*ev.Tags, tag.NewFromAny("expiration", fmt.Sprintf("%d", noteExpiry.Unix())))
// Add "private" tag with authorized npubs (user and relay)
var authorizedNpubs []string
// Add user npub
userNpub, err := bech32encoding.BinToNpub(userPubkey)
if err == nil {
authorizedNpubs = append(authorizedNpubs, string(userNpub))
}
// Add relay npub
relayNpub, err := bech32encoding.BinToNpub(sign.Pub())
if err == nil {
authorizedNpubs = append(authorizedNpubs, string(relayNpub))
}
// Create the private tag with comma-separated npubs
if len(authorizedNpubs) > 0 {
privateTagValue := strings.Join(authorizedNpubs, ",")
*ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue))
}
// Add a special tag to mark this as an expiry warning
*ev.Tags = append(*ev.Tags, tag.NewFromAny("warning", "subscription-expiry"))
// Sign and save the event
ev.Sign(sign)
if _, _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil {
return fmt.Errorf("failed to save expiry warning note: %w", err)
}
log.I.F("created expiry warning note for user %s (expires %s)", hex.Enc(userPubkey), expiryTime.Format("2006-01-02"))
return nil
}
// createTrialReminderNote creates a reminder note for users on trial to support the relay
func (pp *PaymentProcessor) createTrialReminderNote(userPubkey []byte, trialEnd time.Time) error {
// Get relay identity secret to sign the note
skb, err := pp.db.GetRelayIdentitySecret()
if err != nil || len(skb) != 32 {
return fmt.Errorf("no relay identity configured")
}
// Initialize signer
sign := new(p256k.Signer)
if err := sign.InitSec(skb); err != nil {
return fmt.Errorf("failed to initialize signer: %w", err)
}
monthlyPrice := pp.config.MonthlyPriceSats
if monthlyPrice <= 0 {
monthlyPrice = 6000
}
// Calculate daily rate
dailyRate := monthlyPrice / 30
// Get relay npub for content link
relayNpubForContent, err := bech32encoding.BinToNpub(sign.Pub())
if err != nil {
return fmt.Errorf("failed to encode relay npub: %w", err)
}
// Create the reminder note content
content := fmt.Sprintf(`🆓 Free Trial Reminder 🆓
You're currently using this relay for FREE! Your trial expires on %s.
🙏 Support Relay Operations:
This relay provides you with private, censorship-resistant communication. Please consider supporting its continued operation.
💰 Subscription Details:
- Monthly price: %d sats (%d sats/day)
- Fair pricing for premium service
- Helps keep the relay running 24/7
⚡ How to Subscribe:
Simply zap this note with your payment amount:
- Each %d sats = 30 days of access
- Payment is processed automatically
- No account setup required
Thank you for considering supporting decentralized communication!
Relay: nostr:%s
Log in to the relay dashboard to access your configuration at: %s`,
trialEnd.Format("2006-01-02 15:04:05 UTC"), monthlyPrice, dailyRate, monthlyPrice, string(relayNpubForContent), pp.getDashboardURL())
// Build the event
ev := event.New()
ev.Kind = kind.TextNote.K // Kind 1 for text note
ev.Pubkey = sign.Pub()
ev.CreatedAt = timestamp.Now().V
ev.Content = []byte(content)
ev.Tags = tag.NewS()
// Add "p" tag for the user
*ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(userPubkey)))
// Add expiration tag (5 days from creation)
noteExpiry := time.Now().AddDate(0, 0, 5)
*ev.Tags = append(*ev.Tags, tag.NewFromAny("expiration", fmt.Sprintf("%d", noteExpiry.Unix())))
// Add "private" tag with authorized npubs (user and relay)
var authorizedNpubs []string
// Add user npub
userNpub, err := bech32encoding.BinToNpub(userPubkey)
if err == nil {
authorizedNpubs = append(authorizedNpubs, string(userNpub))
}
// Add relay npub
relayNpub, err := bech32encoding.BinToNpub(sign.Pub())
if err == nil {
authorizedNpubs = append(authorizedNpubs, string(relayNpub))
}
// Create the private tag with comma-separated npubs
if len(authorizedNpubs) > 0 {
privateTagValue := strings.Join(authorizedNpubs, ",")
*ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue))
}
// Add a special tag to mark this as a trial reminder
*ev.Tags = append(*ev.Tags, tag.NewFromAny("reminder", "trial-support"))
// Sign and save the event
ev.Sign(sign)
if _, _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil {
return fmt.Errorf("failed to save trial reminder note: %w", err)
}
log.I.F("created trial reminder note for user %s (trial ends %s)", hex.Enc(userPubkey), trialEnd.Format("2006-01-02"))
return nil
}
// handleNotification processes incoming payment notifications
func (pp *PaymentProcessor) handleNotification(
notificationType string, notification map[string]any,
) error {
// Only process payment_received notifications
if notificationType != "payment_received" {
return nil
}
amount, ok := notification["amount"].(float64)
if !ok {
return fmt.Errorf("invalid amount")
}
// Prefer explicit payer/relay pubkeys if provided in metadata
var payerPubkey []byte
var userNpub string
if metadata, ok := notification["metadata"].(map[string]any); ok {
if s, ok := metadata["payer_pubkey"].(string); ok && s != "" {
if pk, err := decodeAnyPubkey(s); err == nil {
payerPubkey = pk
}
}
if payerPubkey == nil {
if s, ok := metadata["sender_pubkey"].(string); ok && s != "" { // alias
if pk, err := decodeAnyPubkey(s); err == nil {
payerPubkey = pk
}
}
}
// Optional: the intended subscriber npub (for backwards compat)
if userNpub == "" {
if npubField, ok := metadata["npub"].(string); ok {
userNpub = npubField
}
}
// If relay identity pubkey is provided, verify it matches ours
if s, ok := metadata["relay_pubkey"].(string); ok && s != "" {
if rpk, err := decodeAnyPubkey(s); err == nil {
if skb, err := pp.db.GetRelayIdentitySecret(); err == nil && len(skb) == 32 {
var signer p256k.Signer
if err := signer.InitSec(skb); err == nil {
if !strings.EqualFold(hex.Enc(rpk), hex.Enc(signer.Pub())) {
log.W.F("relay_pubkey in payment metadata does not match this relay identity: got %s want %s", hex.Enc(rpk), hex.Enc(signer.Pub()))
}
}
}
}
}
}
// Fallback: extract npub from description or metadata
description, _ := notification["description"].(string)
if userNpub == "" {
userNpub = pp.extractNpubFromDescription(description)
}
var pubkey []byte
var err error
if payerPubkey != nil {
pubkey = payerPubkey
} else {
if userNpub == "" {
return fmt.Errorf("no payer_pubkey or npub provided in payment notification")
}
pubkey, err = pp.npubToPubkey(userNpub)
if err != nil {
return fmt.Errorf("invalid npub: %w", err)
}
}
satsReceived := int64(amount / 1000)
monthlyPrice := pp.config.MonthlyPriceSats
if monthlyPrice <= 0 {
monthlyPrice = 6000
}
days := int((float64(satsReceived) / float64(monthlyPrice)) * 30)
if days < 1 {
return fmt.Errorf("payment amount too small")
}
if err := pp.db.ExtendSubscription(pubkey, days); err != nil {
return fmt.Errorf("failed to extend subscription: %w", err)
}
// Record payment history
invoice, _ := notification["invoice"].(string)
preimage, _ := notification["preimage"].(string)
if err := pp.db.RecordPayment(
pubkey, satsReceived, invoice, preimage,
); err != nil {
log.E.F("failed to record payment: %v", err)
}
// Log helpful identifiers
var payerHex = hex.Enc(pubkey)
if userNpub == "" {
log.I.F("payment processed: payer %s %d sats -> %d days", payerHex, satsReceived, days)
} else {
log.I.F("payment processed: %s (%s) %d sats -> %d days", userNpub, payerHex, satsReceived, days)
}
// Update ACL follows cache and relay follow list immediately
if pp.config != nil && pp.config.ACLMode == "follows" {
acl.Registry.AddFollow(pubkey)
}
// Trigger an immediate follow-list sync in background (best-effort)
go func() { _ = pp.syncFollowList() }()
// Create a note with payment confirmation and private tag
if err := pp.createPaymentNote(pubkey, satsReceived, days); err != nil {
log.E.F("failed to create payment note: %v", err)
}
return nil
}
// createPaymentNote creates a note recording the payment with private tag for authorization
func (pp *PaymentProcessor) createPaymentNote(payerPubkey []byte, satsReceived int64, days int) error {
// Get relay identity secret to sign the note
skb, err := pp.db.GetRelayIdentitySecret()
if err != nil || len(skb) != 32 {
return fmt.Errorf("no relay identity configured")
}
// Initialize signer
sign := new(p256k.Signer)
if err := sign.InitSec(skb); err != nil {
return fmt.Errorf("failed to initialize signer: %w", err)
}
// Get subscription info to determine expiry
sub, err := pp.db.GetSubscription(payerPubkey)
if err != nil {
return fmt.Errorf("failed to get subscription: %w", err)
}
var expiryTime time.Time
if sub != nil && !sub.PaidUntil.IsZero() {
expiryTime = sub.PaidUntil
} else {
expiryTime = time.Now().AddDate(0, 0, days)
}
// Get relay npub for content link
relayNpubForContent, err := bech32encoding.BinToNpub(sign.Pub())
if err != nil {
return fmt.Errorf("failed to encode relay npub: %w", err)
}
// Create the note content with nostr:npub link and dashboard link
content := fmt.Sprintf("Payment received: %d sats for %d days. Subscription expires: %s\n\nRelay: nostr:%s\n\nLog in to the relay dashboard to access your configuration at: %s",
satsReceived, days, expiryTime.Format("2006-01-02 15:04:05 UTC"), string(relayNpubForContent), pp.getDashboardURL())
// Build the event
ev := event.New()
ev.Kind = kind.TextNote.K // Kind 1 for text note
ev.Pubkey = sign.Pub()
ev.CreatedAt = timestamp.Now().V
ev.Content = []byte(content)
ev.Tags = tag.NewS()
// Add "p" tag for the payer
*ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(payerPubkey)))
// Add expiration tag (5 days from creation)
noteExpiry := time.Now().AddDate(0, 0, 5)
*ev.Tags = append(*ev.Tags, tag.NewFromAny("expiration", fmt.Sprintf("%d", noteExpiry.Unix())))
// Add "private" tag with authorized npubs (payer and relay)
var authorizedNpubs []string
// Add payer npub
payerNpub, err := bech32encoding.BinToNpub(payerPubkey)
if err == nil {
authorizedNpubs = append(authorizedNpubs, string(payerNpub))
}
// Add relay npub
relayNpub, err := bech32encoding.BinToNpub(sign.Pub())
if err == nil {
authorizedNpubs = append(authorizedNpubs, string(relayNpub))
}
// Create the private tag with comma-separated npubs
if len(authorizedNpubs) > 0 {
privateTagValue := strings.Join(authorizedNpubs, ",")
*ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue))
}
// Sign and save the event
ev.Sign(sign)
if _, _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil {
return fmt.Errorf("failed to save payment note: %w", err)
}
log.I.F("created payment note for %s with private authorization", hex.Enc(payerPubkey))
return nil
}
// CreateWelcomeNote creates a welcome note for first-time users with private tag for authorization
func (pp *PaymentProcessor) CreateWelcomeNote(userPubkey []byte) error {
// Get relay identity secret to sign the note
skb, err := pp.db.GetRelayIdentitySecret()
if err != nil || len(skb) != 32 {
return fmt.Errorf("no relay identity configured")
}
// Initialize signer
sign := new(p256k.Signer)
if err := sign.InitSec(skb); err != nil {
return fmt.Errorf("failed to initialize signer: %w", err)
}
monthlyPrice := pp.config.MonthlyPriceSats
if monthlyPrice <= 0 {
monthlyPrice = 6000
}
// Get relay npub for content link
relayNpubForContent, err := bech32encoding.BinToNpub(sign.Pub())
if err != nil {
return fmt.Errorf("failed to encode relay npub: %w", err)
}
// Create the welcome note content with nostr:npub link
content := fmt.Sprintf(`Welcome to the relay! 🎉
You have a FREE 30-day trial that started when you first logged in.
💰 Subscription Details:
- Monthly price: %d sats
- Trial period: 30 days from first login
💡 How to Subscribe:
To extend your subscription after the trial ends, simply zap this note with the amount you want to pay. Each %d sats = 30 days of access.
⚡ Payment Instructions:
1. Use any Lightning wallet that supports zaps
2. Zap this note with your payment
3. Your subscription will be automatically extended
Relay: nostr:%s
Log in to the relay dashboard to access your configuration at: %s
Enjoy your time on the relay!`, monthlyPrice, monthlyPrice, string(relayNpubForContent), pp.getDashboardURL())
// Build the event
ev := event.New()
ev.Kind = kind.TextNote.K // Kind 1 for text note
ev.Pubkey = sign.Pub()
ev.CreatedAt = timestamp.Now().V
ev.Content = []byte(content)
ev.Tags = tag.NewS()
// Add "p" tag for the user
*ev.Tags = append(*ev.Tags, tag.NewFromAny("p", hex.Enc(userPubkey)))
// Add expiration tag (5 days from creation)
noteExpiry := time.Now().AddDate(0, 0, 5)
*ev.Tags = append(*ev.Tags, tag.NewFromAny("expiration", fmt.Sprintf("%d", noteExpiry.Unix())))
// Add "private" tag with authorized npubs (user and relay)
var authorizedNpubs []string
// Add user npub
userNpub, err := bech32encoding.BinToNpub(userPubkey)
if err == nil {
authorizedNpubs = append(authorizedNpubs, string(userNpub))
}
// Add relay npub
relayNpub, err := bech32encoding.BinToNpub(sign.Pub())
if err == nil {
authorizedNpubs = append(authorizedNpubs, string(relayNpub))
}
// Create the private tag with comma-separated npubs
if len(authorizedNpubs) > 0 {
privateTagValue := strings.Join(authorizedNpubs, ",")
*ev.Tags = append(*ev.Tags, tag.NewFromAny("private", privateTagValue))
}
// Add a special tag to mark this as a welcome note
*ev.Tags = append(*ev.Tags, tag.NewFromAny("welcome", "first-time-user"))
// Sign and save the event
ev.Sign(sign)
if _, _, err := pp.db.SaveEvent(pp.ctx, ev); err != nil {
return fmt.Errorf("failed to save welcome note: %w", err)
}
log.I.F("created welcome note for first-time user %s", hex.Enc(userPubkey))
return nil
}
// SetDashboardURL sets the dynamic dashboard URL based on HTTP request
func (pp *PaymentProcessor) SetDashboardURL(url string) {
pp.dashboardURL = url
}
// getDashboardURL returns the dashboard URL for the relay
func (pp *PaymentProcessor) getDashboardURL() string {
// Use dynamic URL if available
if pp.dashboardURL != "" {
return pp.dashboardURL
}
// Fallback to static config
if pp.config.RelayURL != "" {
return pp.config.RelayURL
}
// Default fallback if no URL is configured
return "https://your-relay.example.com"
}
// extractNpubFromDescription extracts an npub from the payment description
func (pp *PaymentProcessor) extractNpubFromDescription(description string) string {
// check if the entire description is just an npub
description = strings.TrimSpace(description)
if strings.HasPrefix(description, "npub1") && len(description) == 63 {
return description
}
// Look for npub1... pattern in the description
parts := strings.Fields(description)
for _, part := range parts {
if strings.HasPrefix(part, "npub1") && len(part) == 63 {
return part
}
}
return ""
}
// npubToPubkey converts an npub string to pubkey bytes
func (pp *PaymentProcessor) npubToPubkey(npubStr string) ([]byte, error) {
// Validate npub format
if !strings.HasPrefix(npubStr, "npub1") || len(npubStr) != 63 {
return nil, fmt.Errorf("invalid npub format")
}
// Decode using bech32encoding
prefix, value, err := bech32encoding.Decode([]byte(npubStr))
if err != nil {
return nil, fmt.Errorf("failed to decode npub: %w", err)
}
if !strings.EqualFold(string(prefix), "npub") {
return nil, fmt.Errorf("invalid prefix: %s", string(prefix))
}
pubkey, ok := value.([]byte)
if !ok {
return nil, fmt.Errorf("decoded value is not []byte")
}
return pubkey, nil
}
// decodeAnyPubkey decodes a public key from either hex string or npub format
func decodeAnyPubkey(s string) ([]byte, error) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "npub1") {
prefix, value, err := bech32encoding.Decode([]byte(s))
if err != nil {
return nil, fmt.Errorf("failed to decode npub: %w", err)
}
if !strings.EqualFold(string(prefix), "npub") {
return nil, fmt.Errorf("invalid prefix: %s", string(prefix))
}
b, ok := value.([]byte)
if !ok {
return nil, fmt.Errorf("decoded value is not []byte")
}
return b, nil
}
// assume hex-encoded public key
return hex.Dec(s)
}

View File

@@ -2,13 +2,26 @@ package app
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"sync"
"time"
"lol.mleku.dev/chk"
"next.orly.dev/app/config"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/filter"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/protocol/auth"
"next.orly.dev/pkg/protocol/publish"
)
@@ -20,26 +33,55 @@ type Server struct {
publishers *publish.S
Admins [][]byte
*database.D
// optional reverse proxy for dev web server
devProxy *httputil.ReverseProxy
// Challenge storage for HTTP UI authentication
challengeMutex sync.RWMutex
challenges map[string][]byte
paymentProcessor *PaymentProcessor
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// log.T.C(
// func() string {
// return fmt.Sprintf("path %v header %v", r.URL, r.Header)
// },
// )
if r.Header.Get("Upgrade") == "websocket" {
s.HandleWebsocket(w, r)
} else if r.Header.Get("Accept") == "application/nostr+json" {
s.HandleRelayInfo(w, r)
} else {
if s.mux == nil {
http.Error(w, "Upgrade required", http.StatusUpgradeRequired)
} else {
s.mux.ServeHTTP(w, r)
}
// Set CORS headers for all responses
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set(
"Access-Control-Allow-Headers", "Content-Type, Authorization",
)
// Handle preflight OPTIONS requests
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// If this is a websocket request, only intercept the relay root path.
// This allows other websocket paths (e.g., Vite HMR) to be handled by the dev proxy when enabled.
if r.Header.Get("Upgrade") == "websocket" {
if s.mux != nil && s.Config != nil && s.Config.WebDisableEmbedded && s.Config.WebDevProxyURL != "" && r.URL.Path != "/" {
// forward to mux (which will proxy to dev server)
s.mux.ServeHTTP(w, r)
return
}
s.HandleWebsocket(w, r)
return
}
if r.Header.Get("Accept") == "application/nostr+json" {
s.HandleRelayInfo(w, r)
return
}
if s.mux == nil {
http.Error(w, "Upgrade required", http.StatusUpgradeRequired)
return
}
s.mux.ServeHTTP(w, r)
}
func (s *Server) ServiceURL(req *http.Request) (st string) {
host := req.Header.Get("X-Forwarded-Host")
if host == "" {
@@ -70,3 +112,505 @@ func (s *Server) ServiceURL(req *http.Request) (st string) {
}
return proto + "://" + host
}
// DashboardURL constructs HTTPS URL for the dashboard based on the HTTP request
func (s *Server) DashboardURL(req *http.Request) string {
host := req.Header.Get("X-Forwarded-Host")
if host == "" {
host = req.Host
}
return "https://" + host
}
// UserInterface sets up a basic Nostr NDK interface that allows users to log into the relay user interface
func (s *Server) UserInterface() {
if s.mux == nil {
s.mux = http.NewServeMux()
}
// If dev proxy is configured, initialize it
if s.Config != nil && s.Config.WebDisableEmbedded && s.Config.WebDevProxyURL != "" {
proxyURL := s.Config.WebDevProxyURL
// Add default scheme if missing to avoid: proxy error: unsupported protocol scheme ""
if !strings.Contains(proxyURL, "://") {
proxyURL = "http://" + proxyURL
}
if target, err := url.Parse(proxyURL); !chk.E(err) {
if target.Scheme == "" || target.Host == "" {
// invalid URL, disable proxy
log.Printf(
"invalid ORLY_WEB_DEV_PROXY_URL: %q — disabling dev proxy\n",
s.Config.WebDevProxyURL,
)
} else {
s.devProxy = httputil.NewSingleHostReverseProxy(target)
// Ensure Host header points to upstream for dev servers that care
origDirector := s.devProxy.Director
s.devProxy.Director = func(req *http.Request) {
origDirector(req)
req.Host = target.Host
}
}
}
}
// Initialize challenge storage if not already done
if s.challenges == nil {
s.challengeMutex.Lock()
s.challenges = make(map[string][]byte)
s.challengeMutex.Unlock()
}
// Serve the main login interface (and static assets) or proxy in dev mode
s.mux.HandleFunc("/", s.handleLoginInterface)
// API endpoints for authentication
s.mux.HandleFunc("/api/auth/challenge", s.handleAuthChallenge)
s.mux.HandleFunc("/api/auth/login", s.handleAuthLogin)
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
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)
s.mux.HandleFunc("/api/import", s.handleImport)
}
// 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 {
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)
}
// handleAuthChallenge generates and returns an 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
challenge := auth.GenerateChallenge()
challengeHex := hex.Enc(challenge)
// Store the challenge using the hex value as the key for easy lookup
s.challengeMutex.Lock()
s.challenges[challengeHex] = challenge
s.challengeMutex.Unlock()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"challenge": "` + challengeHex + `"}`))
}
// handleAuthLogin processes authentication requests
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
// Read the request body
body, err := io.ReadAll(r.Body)
if chk.E(err) {
w.Write([]byte(`{"success": false, "error": "Failed to read request body"}`))
return
}
// Parse the signed event
var evt event.E
if err = json.Unmarshal(body, &evt); chk.E(err) {
w.Write([]byte(`{"success": false, "error": "Invalid event format"}`))
return
}
// Extract the challenge from the event to look up the stored challenge
challengeTag := evt.Tags.GetFirst([]byte("challenge"))
if challengeTag == nil {
w.Write([]byte(`{"success": false, "error": "Challenge tag missing from event"}`))
return
}
challengeHex := string(challengeTag.Value())
// Retrieve the stored challenge
s.challengeMutex.RLock()
_, exists := s.challenges[challengeHex]
s.challengeMutex.RUnlock()
if !exists {
w.Write([]byte(`{"success": false, "error": "Invalid or expired challenge"}`))
return
}
// Clean up the used challenge
s.challengeMutex.Lock()
delete(s.challenges, challengeHex)
s.challengeMutex.Unlock()
relayURL := s.ServiceURL(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
ok, err := auth.Validate(&evt, []byte(challengeHex), relayURL)
if chk.E(err) || !ok {
errorMsg := "Authentication validation failed"
if err != nil {
errorMsg = err.Error()
}
w.Write([]byte(`{"success": false, "error": "` + errorMsg + `"}`))
return
}
// Authentication successful: set a simple session cookie with the pubkey
cookie := &http.Cookie{
Name: "orly_auth",
Value: hex.Enc(evt.Pubkey),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 60 * 60 * 24 * 30, // 30 days
}
http.SetCookie(w, cookie)
w.Write([]byte(`{"success": true, "pubkey": "` + hex.Enc(evt.Pubkey) + `", "message": "Authentication successful"}`))
}
// handleAuthStatus returns the current authentication status
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
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
}
}
w.Write([]byte(`{"authenticated": false}`))
}
// handleAuthLogout clears the auth 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")
w.Write([]byte(`{"success": true}`))
}
// handlePermissions returns the permission level for a given pubkey
func (s *Server) handlePermissions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract pubkey from URL path
pubkeyHex := strings.TrimPrefix(r.URL.Path, "/api/permissions/")
if pubkeyHex == "" || pubkeyHex == "/" {
http.Error(w, "Invalid pubkey", http.StatusBadRequest)
return
}
// Convert hex to binary pubkey
pubkey, err := hex.Dec(pubkeyHex)
if chk.E(err) {
http.Error(w, "Invalid pubkey format", http.StatusBadRequest)
return
}
// Get access level using acl registry
permission := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
// Set content type and write JSON response
w.Header().Set("Content-Type", "application/json")
// Format response as proper JSON
response := struct {
Permission string `json:"permission"`
}{
Permission: permission,
}
// 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)
}
// handleExport streams all events as JSONL (NDJSON). Admins only.
func (s *Server) handleExport(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
}
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)
return
}
// Optional filtering by pubkey(s)
var pks [][]byte
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)
}
}
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+"\"",
)
// 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
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)
return
}
// Parse pagination parameters
query := r.URL.Query()
limit := 50 // default limit
if l := query.Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
limit = parsed
}
}
offset := 0
if o := query.Get("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
offset = parsed
}
}
// Use QueryEvents with filter for this user's events
f := &filter.F{
Authors: tag.NewFromBytesSlice(pubkey),
}
log.Printf("DEBUG: Querying events for pubkey: %s", hex.Enc(pubkey))
events, err := s.D.QueryEvents(s.Ctx, f)
if chk.E(err) {
log.Printf("DEBUG: QueryEvents failed: %v", err)
http.Error(w, "Failed to query events", http.StatusInternalServerError)
return
}
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
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()),
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

19
app/web.go Normal file
View File

@@ -0,0 +1,19 @@
package app
import (
"embed"
"io/fs"
"net/http"
)
//go:embed web/dist
var reactAppFS embed.FS
// GetReactAppFS returns a http.FileSystem from the embedded React app
func GetReactAppFS() http.FileSystem {
webDist, err := fs.Sub(reactAppFS, "web/dist")
if err != nil {
panic("Failed to load embedded web app: " + err.Error())
}
return http.FS(webDist)
}

30
app/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# 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

89
app/web/README.md Normal file
View File

@@ -0,0 +1,89 @@
# 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`.

36
app/web/bun.lock Normal file
View File

@@ -0,0 +1,36 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "orly-web",
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
},
"devDependencies": {
"bun-types": "latest",
},
},
},
"packages": {
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
"@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="],
"bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"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=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"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=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="],
}
}

1
app/web/dist/index-q4cwd1fy.css vendored Normal file

File diff suppressed because one or more lines are too long

160
app/web/dist/index-w8zpqk4w.js vendored Normal file

File diff suppressed because one or more lines are too long

30
app/web/dist/index.html vendored Normal file
View File

@@ -0,0 +1,30 @@
<!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-w8zpqk4w.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>
</body>
</html>

112
app/web/dist/tailwind.min.css vendored Normal file
View File

@@ -0,0 +1,112 @@
/*
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 */

18
app/web/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "orly-web",
"version": "0.1.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"
},
"devDependencies": {
"bun-types": "latest"
}
}

13
app/web/public/dev.html Normal file
View File

@@ -0,0 +1,13 @@
<!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>

30
app/web/public/index.html Normal file
View File

@@ -0,0 +1,30 @@
<!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" />
</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>

112
app/web/public/tailwind.min.css vendored Normal file
View File

@@ -0,0 +1,112 @@
/*
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 */

1960
app/web/src/App.jsx Normal file

File diff suppressed because it is too large Load Diff

11
app/web/src/index.jsx Normal file
View File

@@ -0,0 +1,11 @@
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>
);

191
app/web/src/styles.css Normal file
View File

@@ -0,0 +1,191 @@
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;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 485 KiB

1
go.mod
View File

@@ -15,6 +15,7 @@ require (
github.com/templexxx/xhex v0.0.0-20200614015412-aed53437177b
go-simpler.org/env v0.12.0
go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.41.0
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b
golang.org/x/lint v0.0.0-20241112194109-818c5a804067
golang.org/x/net v0.43.0

2
go.sum
View File

@@ -76,6 +76,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=

42
main.go
View File

@@ -17,7 +17,10 @@ import (
"next.orly.dev/app"
"next.orly.dev/app/config"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/spider"
"next.orly.dev/pkg/version"
)
@@ -50,11 +53,32 @@ func main() {
runtime.GOMAXPROCS(runtime.NumCPU() * 4)
var err error
var cfg *config.C
if cfg, err = config.New(); chk.T(err) {
}
log.I.F("starting %s %s", cfg.AppName, version.V)
if cfg, err = config.New(); chk.T(err) {
}
log.I.F("starting %s %s", cfg.AppName, version.V)
// If OpenPprofWeb is true and profiling is enabled, we need to ensure HTTP profiling is also enabled
// Handle 'identity' subcommand: print relay identity secret and pubkey and exit
if config.IdentityRequested() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var db *database.D
if db, err = database.New(ctx, cancel, cfg.DataDir, cfg.DBLogLevel); chk.E(err) {
os.Exit(1)
}
defer db.Close()
skb, err := db.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
os.Exit(1)
}
pk, err := keys.SecretBytesToPubKeyHex(skb)
if chk.E(err) {
os.Exit(1)
}
fmt.Printf("identity secret: %s\nidentity pubkey: %s\n", hex.Enc(skb), pk)
os.Exit(0)
}
// If OpenPprofWeb is true and profiling is enabled, we need to ensure HTTP profiling is also enabled
if cfg.OpenPprofWeb && cfg.Pprof != "" && !cfg.PprofHTTP {
log.I.F("enabling HTTP pprof server to support web viewer")
cfg.PprofHTTP = true
@@ -158,6 +182,12 @@ func main() {
}
acl.Registry.Syncer()
// Initialize and start spider functionality if enabled
spiderCtx, spiderCancel := context.WithCancel(ctx)
spiderInstance := spider.New(db, cfg, spiderCtx, spiderCancel)
spiderInstance.Start()
defer spiderInstance.Stop()
// Start HTTP pprof server if enabled
if cfg.PprofHTTP {
pprofAddr := fmt.Sprintf("%s:%d", cfg.Listen, 6060)
@@ -254,9 +284,13 @@ func main() {
fmt.Printf("\r")
cancel()
chk.E(db.Close())
log.I.F("exiting")
return
case <-quit:
cancel()
chk.E(db.Close())
log.I.F("exiting")
return
}
}
log.I.F("exiting")

View File

@@ -66,3 +66,15 @@ func (s *S) Type() (typ string) {
}
return
}
// AddFollow forwards a pubkey to the active ACL if it supports dynamic follows
func (s *S) AddFollow(pub []byte) {
for _, i := range s.ACL {
if i.Type() == s.Active.Load() {
if f, ok := i.(*Follows); ok {
f.AddFollow(pub)
}
break
}
}
}

View File

@@ -1,6 +1,7 @@
package acl
import (
"bytes"
"context"
"reflect"
"strings"
@@ -360,6 +361,42 @@ func (f *Follows) Syncer() {
f.updated <- struct{}{}
}
// GetFollowedPubkeys returns a copy of the followed pubkeys list
func (f *Follows) GetFollowedPubkeys() [][]byte {
f.followsMx.RLock()
defer f.followsMx.RUnlock()
followedPubkeys := make([][]byte, len(f.follows))
copy(followedPubkeys, f.follows)
return followedPubkeys
}
// AddFollow appends a pubkey to the in-memory follows list if not already present
// and signals the syncer to refresh subscriptions.
func (f *Follows) AddFollow(pub []byte) {
if len(pub) == 0 {
return
}
f.followsMx.Lock()
defer f.followsMx.Unlock()
for _, p := range f.follows {
if bytes.Equal(p, pub) {
return
}
}
b := make([]byte, len(pub))
copy(b, pub)
f.follows = append(f.follows, b)
// notify syncer if initialized
if f.updated != nil {
select {
case f.updated <- struct{}{}:
default:
// if channel is full or not yet listened to, ignore
}
}
}
func init() {
log.T.F("registering follows ACL")
Registry.Register(new(Follows))

View File

@@ -0,0 +1 @@
Code copied from https://github.com/paulmillr/nip44/tree/e7aed61aaf77240ac10c325683eed14b22e7950f/go.

View File

@@ -0,0 +1,3 @@
// Package encryption contains the message encryption schemes defined in NIP-04
// and NIP-44, used for encrypting the content of nostr messages.
package encryption

View File

@@ -0,0 +1,88 @@
package encryption
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"lukechampine.com/frand"
)
// EncryptNip4 encrypts message with key using aes-256-cbc. key should be the shared secret generated by
// ComputeSharedSecret.
//
// Returns: base64(encrypted_bytes) + "?iv=" + base64(initialization_vector).
func EncryptNip4(msg, key []byte) (ct []byte, err error) {
// block size is 16 bytes
iv := make([]byte, 16)
if _, err = frand.Read(iv); chk.E(err) {
err = errorf.E("error creating initialization vector: %w", err)
return
}
// automatically picks aes-256 based on key length (32 bytes)
var block cipher.Block
if block, err = aes.NewCipher(key); chk.E(err) {
err = errorf.E("error creating block cipher: %w", err)
return
}
mode := cipher.NewCBCEncrypter(block, iv)
plaintext := []byte(msg)
// add padding
base := len(plaintext)
// this will be a number between 1 and 16 (inclusive), never 0
bs := block.BlockSize()
padding := bs - base%bs
// encode the padding in all the padding bytes themselves
padText := bytes.Repeat([]byte{byte(padding)}, padding)
paddedMsgBytes := append(plaintext, padText...)
ciphertext := make([]byte, len(paddedMsgBytes))
mode.CryptBlocks(ciphertext, paddedMsgBytes)
return []byte(base64.StdEncoding.EncodeToString(ciphertext) + "?iv=" +
base64.StdEncoding.EncodeToString(iv)), nil
}
// DecryptNip4 decrypts a content string using the shared secret key. The inverse operation to message ->
// EncryptNip4(message, key).
func DecryptNip4(content, key []byte) (msg []byte, err error) {
parts := bytes.Split(content, []byte("?iv="))
if len(parts) < 2 {
return nil, errorf.E(
"error parsing encrypted message: no initialization vector",
)
}
ciphertext := make([]byte, base64.StdEncoding.EncodedLen(len(parts[0])))
if _, err = base64.StdEncoding.Decode(ciphertext, parts[0]); chk.E(err) {
err = errorf.E("error decoding ciphertext from base64: %w", err)
return
}
iv := make([]byte, base64.StdEncoding.EncodedLen(len(parts[1])))
if _, err = base64.StdEncoding.Decode(iv, parts[1]); chk.E(err) {
err = errorf.E("error decoding iv from base64: %w", err)
return
}
var block cipher.Block
if block, err = aes.NewCipher(key); chk.E(err) {
err = errorf.E("error creating block cipher: %w", err)
return
}
mode := cipher.NewCBCDecrypter(block, iv)
msg = make([]byte, len(ciphertext))
mode.CryptBlocks(msg, ciphertext)
// remove padding
var (
plaintextLen = len(msg)
)
if plaintextLen > 0 {
// the padding amount is encoded in the padding bytes themselves
padding := int(msg[plaintextLen-1])
if padding > plaintextLen {
err = errorf.E("invalid padding amount: %d", padding)
return
}
msg = msg[0 : plaintextLen-padding]
}
return msg, nil
}

View File

@@ -0,0 +1,260 @@
package encryption
import (
"crypto/hmac"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"io"
"math"
"golang.org/x/crypto/chacha20"
"golang.org/x/crypto/hkdf"
"lol.mleku.dev/chk"
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/crypto/p256k"
"next.orly.dev/pkg/crypto/sha256"
"next.orly.dev/pkg/interfaces/signer"
"next.orly.dev/pkg/utils"
)
const (
version byte = 2
MinPlaintextSize = 0x0001 // 1b msg => padded to 32b
MaxPlaintextSize = 0xffff // 65535 (64kb-1) => padded to 64kb
)
type Opts struct {
err error
nonce []byte
}
// Deprecated: use WithCustomNonce instead of WithCustomSalt, so the naming is less confusing
var WithCustomSalt = WithCustomNonce
// WithCustomNonce enables using a custom nonce (salt) instead of using the
// system crypto/rand entropy source.
func WithCustomNonce(salt []byte) func(opts *Opts) {
return func(opts *Opts) {
if len(salt) != 32 {
opts.err = errorf.E("salt must be 32 bytes, got %d", len(salt))
}
opts.nonce = salt
}
}
// Encrypt data using a provided symmetric conversation key using NIP-44
// encryption (chacha20 cipher stream and sha256 HMAC).
func Encrypt(
plaintext, conversationKey []byte, applyOptions ...func(opts *Opts),
) (
cipherString []byte, err error,
) {
var o Opts
for _, apply := range applyOptions {
apply(&o)
}
if chk.E(o.err) {
err = o.err
return
}
if o.nonce == nil {
o.nonce = make([]byte, 32)
if _, err = rand.Read(o.nonce); chk.E(err) {
return
}
}
var enc, cc20nonce, auth []byte
if enc, cc20nonce, auth, err = getKeys(
conversationKey, o.nonce,
); chk.E(err) {
return
}
plain := plaintext
size := len(plain)
if size < MinPlaintextSize || size > MaxPlaintextSize {
err = errorf.E("plaintext should be between 1b and 64kB")
return
}
padding := CalcPadding(size)
padded := make([]byte, 2+padding)
binary.BigEndian.PutUint16(padded, uint16(size))
copy(padded[2:], plain)
var cipher []byte
if cipher, err = encrypt(enc, cc20nonce, padded); chk.E(err) {
return
}
var mac []byte
if mac, err = sha256Hmac(auth, cipher, o.nonce); chk.E(err) {
return
}
ct := make([]byte, 0, 1+32+len(cipher)+32)
ct = append(ct, version)
ct = append(ct, o.nonce...)
ct = append(ct, cipher...)
ct = append(ct, mac...)
cipherString = make([]byte, base64.StdEncoding.EncodedLen(len(ct)))
base64.StdEncoding.Encode(cipherString, ct)
return
}
// Decrypt data that has been encoded using a provided symmetric conversation
// key using NIP-44 encryption (chacha20 cipher stream and sha256 HMAC).
func Decrypt(b64ciphertextWrapped, conversationKey []byte) (
plaintext []byte,
err error,
) {
cLen := len(b64ciphertextWrapped)
if cLen < 132 || cLen > 87472 {
err = errorf.E("invalid payload length: %d", cLen)
return
}
if len(b64ciphertextWrapped) > 0 && b64ciphertextWrapped[0] == '#' {
err = errorf.E("unknown version")
return
}
var decoded []byte
if decoded, err = base64.StdEncoding.DecodeString(string(b64ciphertextWrapped)); chk.E(err) {
return
}
if decoded[0] != version {
err = errorf.E("unknown version %d", decoded[0])
return
}
dLen := len(decoded)
if dLen < 99 || dLen > 65603 {
err = errorf.E("invalid data length: %d", dLen)
return
}
nonce, ciphertext, givenMac := decoded[1:33], decoded[33:dLen-32], decoded[dLen-32:]
var enc, cc20nonce, auth []byte
if enc, cc20nonce, auth, err = getKeys(conversationKey, nonce); chk.E(err) {
return
}
var expectedMac []byte
if expectedMac, err = sha256Hmac(auth, ciphertext, nonce); chk.E(err) {
return
}
if !utils.FastEqual(givenMac, expectedMac) {
err = errorf.E("invalid hmac")
return
}
var padded []byte
if padded, err = encrypt(enc, cc20nonce, ciphertext); chk.E(err) {
return
}
unpaddedLen := binary.BigEndian.Uint16(padded[0:2])
if unpaddedLen < uint16(MinPlaintextSize) || unpaddedLen > uint16(MaxPlaintextSize) ||
len(padded) != 2+CalcPadding(int(unpaddedLen)) {
err = errorf.E("invalid padding")
return
}
unpadded := padded[2:][:unpaddedLen]
if len(unpadded) == 0 || len(unpadded) != int(unpaddedLen) {
err = errorf.E("invalid padding")
return
}
plaintext = unpadded
return
}
// GenerateConversationKeyFromHex performs an ECDH key generation hashed with the nip-44-v2 using hkdf.
func GenerateConversationKeyFromHex(pkh, skh string) (ck []byte, err error) {
if skh >= "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141" ||
skh == "0000000000000000000000000000000000000000000000000000000000000000" {
err = errorf.E(
"invalid private key: x coordinate %s is not on the secp256k1 curve",
skh,
)
return
}
var sign signer.I
if sign, err = p256k.NewSecFromHex(skh); chk.E(err) {
return
}
var pk []byte
if pk, err = p256k.HexToBin(pkh); chk.E(err) {
return
}
var shared []byte
if shared, err = sign.ECDH(pk); chk.E(err) {
return
}
ck = hkdf.Extract(sha256.New, shared, []byte("nip44-v2"))
return
}
func GenerateConversationKeyWithSigner(sign signer.I, pk []byte) (
ck []byte, err error,
) {
var shared []byte
if shared, err = sign.ECDH(pk); chk.E(err) {
return
}
ck = hkdf.Extract(sha256.New, shared, []byte("nip44-v2"))
return
}
func encrypt(key, nonce, message []byte) (dst []byte, err error) {
var cipher *chacha20.Cipher
if cipher, err = chacha20.NewUnauthenticatedCipher(key, nonce); chk.E(err) {
return
}
dst = make([]byte, len(message))
cipher.XORKeyStream(dst, message)
return
}
func sha256Hmac(key, ciphertext, nonce []byte) (h []byte, err error) {
if len(nonce) != sha256.Size {
err = errorf.E("nonce aad must be 32 bytes")
return
}
hm := hmac.New(sha256.New, key)
hm.Write(nonce)
hm.Write(ciphertext)
h = hm.Sum(nil)
return
}
func getKeys(conversationKey, nonce []byte) (
enc, cc20nonce, auth []byte, err error,
) {
if len(conversationKey) != 32 {
err = errorf.E("conversation key must be 32 bytes")
return
}
if len(nonce) != 32 {
err = errorf.E("nonce must be 32 bytes")
return
}
r := hkdf.Expand(sha256.New, conversationKey, nonce)
enc = make([]byte, 32)
if _, err = io.ReadFull(r, enc); chk.E(err) {
return
}
cc20nonce = make([]byte, 12)
if _, err = io.ReadFull(r, cc20nonce); chk.E(err) {
return
}
auth = make([]byte, 32)
if _, err = io.ReadFull(r, auth); chk.E(err) {
return
}
return
}
// CalcPadding creates padding for the message payload that is precisely a power
// of two in order to reduce the chances of plaintext attack. This is plainly
// retarded because it could blow out the message size a lot when just a random few
// dozen bytes and a length prefix would achieve the same result.
func CalcPadding(sLen int) (l int) {
if sLen <= 32 {
return 32
}
nextPower := 1 << int(math.Floor(math.Log2(float64(sLen-1)))+1)
chunk := int(math.Max(32, float64(nextPower/8)))
l = chunk * int(math.Floor(float64((sLen-1)/chunk))+1)
return
}

File diff suppressed because it is too large Load Diff

83
pkg/crypto/keys/keys.go Normal file
View File

@@ -0,0 +1,83 @@
// Package keys is a set of helpers for generating and converting public/secret
// keys to hex and back to binary.
package keys
import (
"bytes"
"lol.mleku.dev/chk"
"next.orly.dev/pkg/crypto/ec/schnorr"
"next.orly.dev/pkg/crypto/p256k"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/utils"
)
// GeneratePrivateKey - deprecated, use GenerateSecretKeyHex
var GeneratePrivateKey = func() string { return GenerateSecretKeyHex() }
// GenerateSecretKey creates a new secret key and returns the bytes of the secret.
func GenerateSecretKey() (skb []byte, err error) {
signer := &p256k.Signer{}
if err = signer.Generate(); chk.E(err) {
return
}
skb = signer.Sec()
return
}
// GenerateSecretKeyHex generates a secret key and encodes the bytes as hex.
func GenerateSecretKeyHex() (sks string) {
skb, err := GenerateSecretKey()
if chk.E(err) {
return
}
return hex.Enc(skb)
}
// GetPublicKeyHex generates a public key from a hex encoded secret key.
func GetPublicKeyHex(sk string) (pk string, err error) {
var b []byte
if b, err = hex.Dec(sk); chk.E(err) {
return
}
signer := &p256k.Signer{}
if err = signer.InitSec(b); chk.E(err) {
return
}
return hex.Enc(signer.Pub()), nil
}
// SecretBytesToPubKeyHex generates a public key from secret key bytes.
func SecretBytesToPubKeyHex(skb []byte) (pk string, err error) {
signer := &p256k.Signer{}
if err = signer.InitSec(skb); chk.E(err) {
return
}
return hex.Enc(signer.Pub()), nil
}
// IsValid32ByteHex checks that a hex string is a valid 32 bytes lower case hex encoded value as
// per nostr NIP-01 spec.
func IsValid32ByteHex[V []byte | string](pk V) bool {
if utils.FastEqual(bytes.ToLower([]byte(pk)), []byte(pk)) {
return false
}
var err error
dec := make([]byte, 32)
if _, err = hex.DecBytes(dec, []byte(pk)); chk.E(err) {
}
return len(dec) == 32
}
// IsValidPublicKey checks that a hex encoded public key is a valid BIP-340 public key.
func IsValidPublicKey[V []byte | string](pk V) bool {
v, _ := hex.Dec(string(pk))
_, err := schnorr.ParsePubKey(v)
return err == nil
}
// HexPubkeyToBytes decodes a pubkey from hex encoded string/bytes.
func HexPubkeyToBytes[V []byte | string](hpk V) (pkb []byte, err error) {
return hex.DecAppend(nil, []byte(hpk))
}

81
pkg/database/identity.go Normal file
View File

@@ -0,0 +1,81 @@
package database
import (
"errors"
"fmt"
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/encoders/hex"
)
const relayIdentitySecretKey = "relay:identity:sk"
// GetRelayIdentitySecret returns the relay identity secret key bytes if present.
// If the key is not found, returns (nil, badger.ErrKeyNotFound).
func (d *D) GetRelayIdentitySecret() (skb []byte, err error) {
err = d.DB.View(func(txn *badger.Txn) error {
item, err := txn.Get([]byte(relayIdentitySecretKey))
if errors.Is(err, badger.ErrKeyNotFound) {
return err
}
if err != nil {
return err
}
return item.Value(func(val []byte) error {
// value stored as hex string
b, err := hex.Dec(string(val))
if err != nil {
return err
}
skb = make([]byte, len(b))
copy(skb, b)
return nil
})
})
return
}
// SetRelayIdentitySecret stores the relay identity secret key bytes (expects 32 bytes).
func (d *D) SetRelayIdentitySecret(skb []byte) (err error) {
if len(skb) != 32 {
return fmt.Errorf("invalid secret key length: %d", len(skb))
}
val := []byte(hex.Enc(skb))
return d.DB.Update(func(txn *badger.Txn) error {
return txn.Set([]byte(relayIdentitySecretKey), val)
})
}
// GetOrCreateRelayIdentitySecret retrieves the existing relay identity secret
// key or creates and stores a new one if none exists.
func (d *D) GetOrCreateRelayIdentitySecret() (skb []byte, err error) {
// Try get fast path
if skb, err = d.GetRelayIdentitySecret(); err == nil && len(skb) == 32 {
return skb, nil
}
if err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return nil, err
}
// Create new key and store atomically
var gen []byte
if gen, err = keys.GenerateSecretKey(); chk.E(err) {
return nil, err
}
if err = d.SetRelayIdentitySecret(gen); chk.E(err) {
return nil, err
}
log.I.F("generated new relay identity key (pub=%s)", mustPub(gen))
return gen, nil
}
func mustPub(skb []byte) string {
pk, err := keys.SecretBytesToPubKeyHex(skb)
if err != nil {
return ""
}
return pk
}

62
pkg/database/markers.go Normal file
View File

@@ -0,0 +1,62 @@
package database
import (
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
)
const (
markerPrefix = "MARKER:"
)
// SetMarker stores an arbitrary marker in the database
func (d *D) SetMarker(key string, value []byte) (err error) {
markerKey := []byte(markerPrefix + key)
err = d.Update(func(txn *badger.Txn) error {
return txn.Set(markerKey, value)
})
return
}
// GetMarker retrieves an arbitrary marker from the database
func (d *D) GetMarker(key string) (value []byte, err error) {
markerKey := []byte(markerPrefix + key)
err = d.View(func(txn *badger.Txn) error {
item, err := txn.Get(markerKey)
if err != nil {
return err
}
value, err = item.ValueCopy(nil)
return err
})
return
}
// HasMarker checks if a marker exists in the database
func (d *D) HasMarker(key string) (exists bool) {
markerKey := []byte(markerPrefix + key)
err := d.View(func(txn *badger.Txn) error {
_, err := txn.Get(markerKey)
return err
})
exists = !chk.E(err)
return
}
// DeleteMarker removes a marker from the database
func (d *D) DeleteMarker(key string) (err error) {
markerKey := []byte(markerPrefix + key)
err = d.Update(func(txn *badger.Txn) error {
return txn.Delete(markerKey)
})
return
}

View File

@@ -70,11 +70,14 @@ func (d *D) QueryEvents(c context.Context, f *filter.F) (
// Process each successfully fetched event and apply filters
for serialValue, ev := range fetchedEvents {
idHex := idHexToSerial[serialValue]
// Convert serial value back to Uint40 for expiration handling
ser := new(types.Uint40)
if err = ser.Set(serialValue); err != nil {
log.T.F("QueryEvents: error converting serial %d: %v", serialValue, err)
log.T.F(
"QueryEvents: error converting serial %d: %v", serialValue,
err,
)
continue
}
@@ -241,7 +244,7 @@ func (d *D) QueryEvents(c context.Context, f *filter.F) (
// For replaceable events, we need to check if there are any
// e-tags that reference events with the same kind and pubkey
for _, eTag := range eTags {
if eTag.Len() < 2 {
if eTag.Len() != 64 {
continue
}
// Get the event ID from the e-tag

View File

@@ -173,10 +173,10 @@ func (d *D) CheckForDeleted(ev *event.E, admins [][]byte) (err error) {
}
}
if ev.CreatedAt < maxTs {
// err = fmt.Errorf(
// "blocked: was deleted by address %s: event is older than the delete: event: %d delete: %d",
// at, ev.CreatedAt, maxTs,
// )
err = errorf.E(
"blocked: %0x was deleted by address %s because it is older than the delete: event: %d delete: %d",
ev.ID, at, ev.CreatedAt, maxTs,
)
return
}
return
@@ -203,22 +203,14 @@ func (d *D) CheckForDeleted(ev *event.E, admins [][]byte) (err error) {
return
}
if len(s) > 0 {
// For e-tag deletions (delete by ID), any deletion event means the event cannot be resubmitted
// regardless of timestamp, since it's a specific deletion of this exact event
// err = errorf.E(
// "blocked: was deleted by ID and cannot be resubmitted",
// // ev.ID,
// )
// Any e-tag deletion found means the exact event was deleted and cannot be resubmitted
err = errorf.E("blocked: %0x has been deleted", ev.ID)
return
}
}
if len(sers) > 0 {
// For e-tag deletions (delete by ID), any deletion event means the event cannot be resubmitted
// regardless of timestamp, since it's a specific deletion of this exact event
// err = errorf.E(
// "blocked: was deleted by ID and cannot be resubmitted",
// // ev.ID,
// )
// Any e-tag deletion found means the exact event was deleted and cannot be resubmitted
err = errorf.E("blocked: %0x has been deleted", ev.ID)
return
}

View File

@@ -188,3 +188,30 @@ func (d *D) GetPaymentHistory(pubkey []byte) ([]Payment, error) {
return payments, err
}
// IsFirstTimeUser checks if a user is logging in for the first time and marks them as seen
func (d *D) IsFirstTimeUser(pubkey []byte) (bool, error) {
key := fmt.Sprintf("firstlogin:%s", hex.EncodeToString(pubkey))
isFirstTime := false
err := d.DB.Update(
func(txn *badger.Txn) error {
_, err := txn.Get([]byte(key))
if errors.Is(err, badger.ErrKeyNotFound) {
// First time - record the login
isFirstTime = true
now := time.Now()
data, err := json.Marshal(map[string]interface{}{
"first_login": now,
})
if err != nil {
return err
}
return txn.Set([]byte(key), data)
}
return err // Return any other error as-is
},
)
return isFirstTime, err
}

View File

@@ -8,7 +8,7 @@ import (
"lol.mleku.dev/errorf"
"next.orly.dev/pkg/encoders/text"
utils "next.orly.dev/pkg/utils"
"next.orly.dev/pkg/utils"
)
// The tag position meanings, so they are clear when reading.

View File

@@ -24,7 +24,8 @@ func NewSWithCap(c int) (s *S) {
func (s *S) Len() int {
if s == nil {
panic("tags cannot be used without initialization")
return 0
// panic("tags cannot be used without initialization")
}
return len(*s)
}
@@ -165,3 +166,11 @@ func (s *S) GetAll(t []byte) (all []*T) {
}
return
}
func (s *S) GetTagElement(i int) (t *T) {
if s == nil || len(*s) < i {
return
}
t = (*s)[i]
return
}

View File

@@ -66,6 +66,10 @@ func UnmarshalQuoted(b []byte) (content, rem []byte, err error) {
}
rem = b[:]
for ; len(rem) >= 0; rem = rem[1:] {
if len(rem) == 0 {
err = io.EOF
return
}
// advance to open quotes
if rem[0] == '"' {
rem = rem[1:]

View File

@@ -0,0 +1,56 @@
# NWC Client
Nostr Wallet Connect (NIP-47) client implementation.
## Usage
```go
import "orly.dev/pkg/protocol/nwc"
// Create client from NWC connection URI
client, err := nwc.NewClient("nostr+walletconnect://...")
if err != nil {
log.Fatal(err)
}
// Make requests
var info map[string]any
err = client.Request(ctx, "get_info", nil, &info)
var balance map[string]any
err = client.Request(ctx, "get_balance", nil, &balance)
var invoice map[string]any
params := map[string]any{"amount": 1000, "description": "test"}
err = client.Request(ctx, "make_invoice", params, &invoice)
```
## Methods
- `get_info` - Get wallet info
- `get_balance` - Get wallet balance
- `make_invoice` - Create invoice
- `lookup_invoice` - Check invoice status
- `pay_invoice` - Pay invoice
## Payment Notifications
```go
// Subscribe to payment notifications
err = client.SubscribeNotifications(ctx, func(notificationType string, notification map[string]any) error {
if notificationType == "payment_received" {
amount := notification["amount"].(float64)
description := notification["description"].(string)
// Process payment...
}
return nil
})
```
## Features
- NIP-44 encryption
- Event signing
- Relay communication
- Payment notifications
- Error handling

265
pkg/protocol/nwc/client.go Normal file
View File

@@ -0,0 +1,265 @@
package nwc
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/crypto/encryption"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/filter"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/encoders/timestamp"
"next.orly.dev/pkg/interfaces/signer"
"next.orly.dev/pkg/protocol/ws"
"next.orly.dev/pkg/utils/values"
)
type Client struct {
relay string
clientSecretKey signer.I
walletPublicKey []byte
conversationKey []byte
}
func NewClient(connectionURI string) (cl *Client, err error) {
var parts *ConnectionParams
if parts, err = ParseConnectionURI(connectionURI); chk.E(err) {
return
}
cl = &Client{
relay: parts.relay,
clientSecretKey: parts.clientSecretKey,
walletPublicKey: parts.walletPublicKey,
conversationKey: parts.conversationKey,
}
return
}
func (cl *Client) Request(
c context.Context, method string, params, result any,
) (err error) {
ctx, cancel := context.WithTimeout(c, 10*time.Second)
defer cancel()
request := map[string]any{"method": method}
if params != nil {
request["params"] = params
}
var req []byte
if req, err = json.Marshal(request); chk.E(err) {
return
}
var content []byte
if content, err = encryption.Encrypt(req, cl.conversationKey); chk.E(err) {
return
}
ev := &event.E{
Content: content,
CreatedAt: time.Now().Unix(),
Kind: 23194,
Tags: tag.NewS(
tag.NewFromAny("encryption", "nip44_v2"),
tag.NewFromAny("p", hex.Enc(cl.walletPublicKey)),
),
}
if err = ev.Sign(cl.clientSecretKey); chk.E(err) {
return
}
var rc *ws.Client
if rc, err = ws.RelayConnect(ctx, cl.relay); chk.E(err) {
return
}
defer rc.Close()
var sub *ws.Subscription
if sub, err = rc.Subscribe(
ctx, filter.NewS(
&filter.F{
Limit: values.ToUintPointer(1),
Kinds: kind.NewS(kind.New(23195)),
Since: &timestamp.T{V: time.Now().Unix()},
},
),
); chk.E(err) {
return
}
defer sub.Unsub()
if err = rc.Publish(ctx, ev); chk.E(err) {
return fmt.Errorf("publish failed: %w", err)
}
select {
case <-ctx.Done():
return fmt.Errorf("no response from wallet (connection may be inactive)")
case e := <-sub.Events:
if e == nil {
return fmt.Errorf("subscription closed (wallet connection inactive)")
}
if len(e.Content) == 0 {
return fmt.Errorf("empty response content")
}
var raw []byte
if raw, err = encryption.Decrypt(
e.Content, cl.conversationKey,
); chk.E(err) {
return fmt.Errorf(
"decryption failed (invalid conversation key): %w", err,
)
}
var resp map[string]any
if err = json.Unmarshal(raw, &resp); chk.E(err) {
return
}
if errData, ok := resp["error"].(map[string]any); ok {
code, _ := errData["code"].(string)
msg, _ := errData["message"].(string)
return fmt.Errorf("%s: %s", code, msg)
}
if result != nil && resp["result"] != nil {
var resultBytes []byte
if resultBytes, err = json.Marshal(resp["result"]); chk.E(err) {
return
}
if err = json.Unmarshal(resultBytes, result); chk.E(err) {
return
}
}
}
return
}
// NotificationHandler is a callback for handling NWC notifications
type NotificationHandler func(
notificationType string, notification map[string]any,
) error
// SubscribeNotifications subscribes to NWC notification events (kinds 23197/23196)
// and handles them with the provided callback. It maintains a persistent connection
// with auto-reconnection on disconnect.
func (cl *Client) SubscribeNotifications(
c context.Context, handler NotificationHandler,
) (err error) {
delay := time.Second
for {
if err = cl.subscribeNotificationsOnce(c, handler); err != nil {
if errors.Is(err, context.Canceled) {
return err
}
select {
case <-time.After(delay):
if delay < 30*time.Second {
delay *= 2
}
case <-c.Done():
return context.Canceled
}
continue
}
delay = time.Second
}
}
// subscribeNotificationsOnce performs a single subscription attempt
func (cl *Client) subscribeNotificationsOnce(
c context.Context, handler NotificationHandler,
) (err error) {
// Connect to relay
var rc *ws.Client
if rc, err = ws.RelayConnect(c, cl.relay); chk.E(err) {
return fmt.Errorf("relay connection failed: %w", err)
}
defer rc.Close()
// Subscribe to notification events filtered by "p" tag
// Support both NIP-44 (kind 23197) and legacy NIP-04 (kind 23196)
var sub *ws.Subscription
if sub, err = rc.Subscribe(
c, filter.NewS(
&filter.F{
Kinds: kind.NewS(kind.New(23197), kind.New(23196)),
Tags: tag.NewS(
tag.NewFromAny("p", hex.Enc(cl.clientSecretKey.Pub())),
),
Since: &timestamp.T{V: time.Now().Unix()},
},
),
); chk.E(err) {
return fmt.Errorf("subscription failed: %w", err)
}
defer sub.Unsub()
log.I.F(
"subscribed to NWC notifications from wallet %s",
hex.Enc(cl.walletPublicKey),
)
// Process notification events
for {
select {
case <-c.Done():
return context.Canceled
case ev := <-sub.Events:
if ev == nil {
// Channel closed, subscription ended
return fmt.Errorf("subscription closed")
}
// Process the notification event
if err := cl.processNotificationEvent(ev, handler); err != nil {
log.E.F("error processing notification: %v", err)
// Continue processing other notifications even if one fails
}
}
}
}
// processNotificationEvent decrypts and processes a single notification event
func (cl *Client) processNotificationEvent(
ev *event.E, handler NotificationHandler,
) (err error) {
// Decrypt the notification content
var decrypted []byte
if decrypted, err = encryption.Decrypt(
ev.Content, cl.conversationKey,
); err != nil {
return fmt.Errorf("failed to decrypt notification: %w", err)
}
// Parse the notification JSON
var notification map[string]any
if err = json.Unmarshal(decrypted, &notification); err != nil {
return fmt.Errorf("failed to parse notification JSON: %w", err)
}
// Extract notification type
notificationType, ok := notification["notification_type"].(string)
if !ok {
return fmt.Errorf("missing or invalid notification_type")
}
// Extract notification data
notificationData, ok := notification["notification"].(map[string]any)
if !ok {
return fmt.Errorf("missing or invalid notification data")
}
// Route to type-specific handler
return handler(notificationType, notificationData)
}

View File

@@ -0,0 +1,188 @@
package nwc_test
import (
"encoding/json"
"testing"
"time"
"next.orly.dev/pkg/crypto/encryption"
"next.orly.dev/pkg/crypto/p256k"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/protocol/nwc"
"next.orly.dev/pkg/utils"
)
func TestNWCConversationKey(t *testing.T) {
secret := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
walletPubkey := "816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b"
uri := "nostr+walletconnect://" + walletPubkey + "?relay=wss://relay.getalby.com/v1&secret=" + secret
parts, err := nwc.ParseConnectionURI(uri)
if err != nil {
t.Fatal(err)
}
// Validate conversation key was generated
convKey := parts.GetConversationKey()
if len(convKey) == 0 {
t.Fatal("conversation key should not be empty")
}
// Validate wallet public key
walletKey := parts.GetWalletPublicKey()
if len(walletKey) == 0 {
t.Fatal("wallet public key should not be empty")
}
expected, err := hex.Dec(walletPubkey)
if err != nil {
t.Fatal(err)
}
if len(walletKey) != len(expected) {
t.Fatal("wallet public key length mismatch")
}
for i := range walletKey {
if walletKey[i] != expected[i] {
t.Fatal("wallet public key mismatch")
}
}
// Test passed
}
func TestNWCEncryptionDecryption(t *testing.T) {
secret := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
walletPubkey := "816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b"
uri := "nostr+walletconnect://" + walletPubkey + "?relay=wss://relay.getalby.com/v1&secret=" + secret
parts, err := nwc.ParseConnectionURI(uri)
if err != nil {
t.Fatal(err)
}
convKey := parts.GetConversationKey()
testMessage := `{"method":"get_info","params":null}`
// Test encryption
encrypted, err := encryption.Encrypt([]byte(testMessage), convKey)
if err != nil {
t.Fatalf("encryption failed: %v", err)
}
if len(encrypted) == 0 {
t.Fatal("encrypted message should not be empty")
}
// Test decryption
decrypted, err := encryption.Decrypt(encrypted, convKey)
if err != nil {
t.Fatalf("decryption failed: %v", err)
}
if string(decrypted) != testMessage {
t.Fatalf(
"decrypted message mismatch: got %s, want %s", string(decrypted),
testMessage,
)
}
// Test passed
}
func TestNWCEventCreation(t *testing.T) {
secretBytes, err := hex.Dec("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
if err != nil {
t.Fatal(err)
}
clientKey := &p256k.Signer{}
if err := clientKey.InitSec(secretBytes); err != nil {
t.Fatal(err)
}
walletPubkey, err := hex.Dec("816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b")
if err != nil {
t.Fatal(err)
}
convKey, err := encryption.GenerateConversationKeyWithSigner(
clientKey, walletPubkey,
)
if err != nil {
t.Fatal(err)
}
request := map[string]any{"method": "get_info"}
reqBytes, err := json.Marshal(request)
if err != nil {
t.Fatal(err)
}
encrypted, err := encryption.Encrypt(reqBytes, convKey)
if err != nil {
t.Fatal(err)
}
// Create NWC event
ev := &event.E{
Content: encrypted,
CreatedAt: time.Now().Unix(),
Kind: 23194,
Tags: tag.NewS(
tag.NewFromAny("encryption", "nip44_v2"),
tag.NewFromAny("p", hex.Enc(walletPubkey)),
),
}
if err := ev.Sign(clientKey); err != nil {
t.Fatalf("event signing failed: %v", err)
}
// Validate event structure
if len(ev.Content) == 0 {
t.Fatal("event content should not be empty")
}
if len(ev.ID) == 0 {
t.Fatal("event should have ID after signing")
}
if len(ev.Sig) == 0 {
t.Fatal("event should have signature after signing")
}
// Validate tags
hasEncryption := false
hasP := false
for i := 0; i < ev.Tags.Len(); i++ {
tag := ev.Tags.GetTagElement(i)
if tag.Len() >= 2 {
if utils.FastEqual(
tag.T[0], "encryption",
) && utils.FastEqual(tag.T[1], "nip44_v2") {
hasEncryption = true
}
if utils.FastEqual(
tag.T[0], "p",
) && utils.FastEqual(tag.T[1], hex.Enc(walletPubkey)) {
hasP = true
}
}
}
if !hasEncryption {
t.Fatal("event missing encryption tag")
}
if !hasP {
t.Fatal("event missing p tag")
}
// Test passed
}

View File

@@ -0,0 +1,495 @@
package nwc
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"sync"
"time"
"lol.mleku.dev/chk"
"next.orly.dev/pkg/crypto/encryption"
"next.orly.dev/pkg/crypto/p256k"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/filter"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/encoders/timestamp"
"next.orly.dev/pkg/interfaces/signer"
"next.orly.dev/pkg/protocol/ws"
)
// MockWalletService implements a mock NIP-47 wallet service for testing
type MockWalletService struct {
relay string
walletSecretKey signer.I
walletPublicKey []byte
client *ws.Client
ctx context.Context
cancel context.CancelFunc
balance int64 // in satoshis
balanceMutex sync.RWMutex
connectedClients map[string][]byte // pubkey -> conversation key
clientsMutex sync.RWMutex
}
// NewMockWalletService creates a new mock wallet service
func NewMockWalletService(
relay string, initialBalance int64,
) (service *MockWalletService, err error) {
// Generate wallet keypair
walletKey := &p256k.Signer{}
if err = walletKey.Generate(); chk.E(err) {
return
}
ctx, cancel := context.WithCancel(context.Background())
service = &MockWalletService{
relay: relay,
walletSecretKey: walletKey,
walletPublicKey: walletKey.Pub(),
ctx: ctx,
cancel: cancel,
balance: initialBalance,
connectedClients: make(map[string][]byte),
}
return
}
// Start begins the mock wallet service
func (m *MockWalletService) Start() (err error) {
// Connect to relay
if m.client, err = ws.RelayConnect(m.ctx, m.relay); chk.E(err) {
return fmt.Errorf("failed to connect to relay: %w", err)
}
// Publish wallet info event
if err = m.publishWalletInfo(); chk.E(err) {
return fmt.Errorf("failed to publish wallet info: %w", err)
}
// Subscribe to request events
if err = m.subscribeToRequests(); chk.E(err) {
return fmt.Errorf("failed to subscribe to requests: %w", err)
}
return
}
// Stop stops the mock wallet service
func (m *MockWalletService) Stop() {
if m.cancel != nil {
m.cancel()
}
if m.client != nil {
m.client.Close()
}
}
// GetWalletPublicKey returns the wallet's public key
func (m *MockWalletService) GetWalletPublicKey() []byte {
return m.walletPublicKey
}
// publishWalletInfo publishes the NIP-47 info event (kind 13194)
func (m *MockWalletService) publishWalletInfo() (err error) {
capabilities := []string{
"get_info",
"get_balance",
"make_invoice",
"pay_invoice",
}
info := map[string]any{
"capabilities": capabilities,
"notifications": []string{"payment_received", "payment_sent"},
}
var content []byte
if content, err = json.Marshal(info); chk.E(err) {
return
}
ev := &event.E{
Content: content,
CreatedAt: time.Now().Unix(),
Kind: 13194,
Tags: tag.NewS(),
}
if err = ev.Sign(m.walletSecretKey); chk.E(err) {
return
}
return m.client.Publish(m.ctx, ev)
}
// subscribeToRequests subscribes to NWC request events (kind 23194)
func (m *MockWalletService) subscribeToRequests() (err error) {
var sub *ws.Subscription
if sub, err = m.client.Subscribe(
m.ctx, filter.NewS(
&filter.F{
Kinds: kind.NewS(kind.New(23194)),
Tags: tag.NewS(
tag.NewFromAny("p", hex.Enc(m.walletPublicKey)),
),
Since: &timestamp.T{V: time.Now().Unix()},
},
),
); chk.E(err) {
return
}
// Handle incoming request events
go m.handleRequestEvents(sub)
return
}
// handleRequestEvents processes incoming NWC request events
func (m *MockWalletService) handleRequestEvents(sub *ws.Subscription) {
for {
select {
case <-m.ctx.Done():
return
case ev := <-sub.Events:
if ev == nil {
continue
}
if err := m.processRequestEvent(ev); chk.E(err) {
fmt.Printf("Error processing request event: %v\n", err)
}
}
}
}
// processRequestEvent processes a single NWC request event
func (m *MockWalletService) processRequestEvent(ev *event.E) (err error) {
// Get client pubkey from event
clientPubkey := ev.Pubkey
clientPubkeyHex := hex.Enc(clientPubkey)
// Generate or get conversation key
var conversationKey []byte
m.clientsMutex.Lock()
if existingKey, exists := m.connectedClients[clientPubkeyHex]; exists {
conversationKey = existingKey
} else {
if conversationKey, err = encryption.GenerateConversationKeyWithSigner(
m.walletSecretKey, clientPubkey,
); chk.E(err) {
m.clientsMutex.Unlock()
return
}
m.connectedClients[clientPubkeyHex] = conversationKey
}
m.clientsMutex.Unlock()
// Decrypt request content
var decrypted []byte
if decrypted, err = encryption.Decrypt(
ev.Content, conversationKey,
); chk.E(err) {
return
}
var request map[string]any
if err = json.Unmarshal(decrypted, &request); chk.E(err) {
return
}
method, ok := request["method"].(string)
if !ok {
return fmt.Errorf("invalid method")
}
params := request["params"]
// Process the method
var result any
if result, err = m.processMethod(method, params); chk.E(err) {
// Send error response
return m.sendErrorResponse(
clientPubkey, conversationKey, "INTERNAL", err.Error(),
)
}
// Send success response
return m.sendSuccessResponse(clientPubkey, conversationKey, result)
}
// processMethod handles the actual NWC method execution
func (m *MockWalletService) processMethod(
method string, params any,
) (result any, err error) {
switch method {
case "get_info":
return m.getInfo()
case "get_balance":
return m.getBalance()
case "make_invoice":
return m.makeInvoice(params)
case "pay_invoice":
return m.payInvoice(params)
default:
err = fmt.Errorf("unsupported method: %s", method)
return
}
}
// getInfo returns wallet information
func (m *MockWalletService) getInfo() (result map[string]any, err error) {
result = map[string]any{
"alias": "Mock Wallet",
"color": "#3399FF",
"pubkey": hex.Enc(m.walletPublicKey),
"network": "mainnet",
"block_height": 850000,
"block_hash": "0000000000000000000123456789abcdef",
"methods": []string{
"get_info", "get_balance", "make_invoice", "pay_invoice",
},
}
return
}
// getBalance returns the current wallet balance
func (m *MockWalletService) getBalance() (result map[string]any, err error) {
m.balanceMutex.RLock()
balance := m.balance
m.balanceMutex.RUnlock()
result = map[string]any{
"balance": balance * 1000, // convert to msats
}
return
}
// makeInvoice creates a Lightning invoice
func (m *MockWalletService) makeInvoice(params any) (
result map[string]any, err error,
) {
paramsMap, ok := params.(map[string]any)
if !ok {
err = fmt.Errorf("invalid params")
return
}
amount, ok := paramsMap["amount"].(float64)
if !ok {
err = fmt.Errorf("missing or invalid amount")
return
}
description := ""
if desc, ok := paramsMap["description"].(string); ok {
description = desc
}
paymentHash := make([]byte, 32)
rand.Read(paymentHash)
// Generate a fake bolt11 invoice
bolt11 := fmt.Sprintf("lnbc%dm1pwxxxxxxx", int64(amount/1000))
result = map[string]any{
"type": "incoming",
"invoice": bolt11,
"description": description,
"payment_hash": hex.Enc(paymentHash),
"amount": int64(amount),
"created_at": time.Now().Unix(),
"expires_at": time.Now().Add(24 * time.Hour).Unix(),
}
return
}
// payInvoice pays a Lightning invoice
func (m *MockWalletService) payInvoice(params any) (
result map[string]any, err error,
) {
paramsMap, ok := params.(map[string]any)
if !ok {
err = fmt.Errorf("invalid params")
return
}
invoice, ok := paramsMap["invoice"].(string)
if !ok {
err = fmt.Errorf("missing or invalid invoice")
return
}
// Mock payment amount (would parse from invoice in real implementation)
amount := int64(1000) // 1000 msats
// Check balance
m.balanceMutex.Lock()
if m.balance*1000 < amount {
m.balanceMutex.Unlock()
err = fmt.Errorf("insufficient balance")
return
}
m.balance -= amount / 1000
m.balanceMutex.Unlock()
preimage := make([]byte, 32)
rand.Read(preimage)
result = map[string]any{
"type": "outgoing",
"invoice": invoice,
"amount": amount,
"preimage": hex.Enc(preimage),
"created_at": time.Now().Unix(),
}
// Emit payment_sent notification
go m.emitPaymentNotification("payment_sent", result)
return
}
// sendSuccessResponse sends a successful NWC response
func (m *MockWalletService) sendSuccessResponse(
clientPubkey []byte, conversationKey []byte, result any,
) (err error) {
response := map[string]any{
"result": result,
}
var responseBytes []byte
if responseBytes, err = json.Marshal(response); chk.E(err) {
return
}
return m.sendEncryptedResponse(clientPubkey, conversationKey, responseBytes)
}
// sendErrorResponse sends an error NWC response
func (m *MockWalletService) sendErrorResponse(
clientPubkey []byte, conversationKey []byte, code, message string,
) (err error) {
response := map[string]any{
"error": map[string]any{
"code": code,
"message": message,
},
}
var responseBytes []byte
if responseBytes, err = json.Marshal(response); chk.E(err) {
return
}
return m.sendEncryptedResponse(clientPubkey, conversationKey, responseBytes)
}
// sendEncryptedResponse sends an encrypted response event (kind 23195)
func (m *MockWalletService) sendEncryptedResponse(
clientPubkey []byte, conversationKey []byte, content []byte,
) (err error) {
var encrypted []byte
if encrypted, err = encryption.Encrypt(
content, conversationKey,
); chk.E(err) {
return
}
ev := &event.E{
Content: encrypted,
CreatedAt: time.Now().Unix(),
Kind: 23195,
Tags: tag.NewS(
tag.NewFromAny("encryption", "nip44_v2"),
tag.NewFromAny("p", hex.Enc(clientPubkey)),
),
}
if err = ev.Sign(m.walletSecretKey); chk.E(err) {
return
}
return m.client.Publish(m.ctx, ev)
}
// emitPaymentNotification emits a payment notification (kind 23197)
func (m *MockWalletService) emitPaymentNotification(
notificationType string, paymentData map[string]any,
) (err error) {
notification := map[string]any{
"notification_type": notificationType,
"notification": paymentData,
}
var content []byte
if content, err = json.Marshal(notification); chk.E(err) {
return
}
// Send notification to all connected clients
m.clientsMutex.RLock()
defer m.clientsMutex.RUnlock()
for clientPubkeyHex, conversationKey := range m.connectedClients {
var clientPubkey []byte
if clientPubkey, err = hex.Dec(clientPubkeyHex); chk.E(err) {
continue
}
var encrypted []byte
if encrypted, err = encryption.Encrypt(
content, conversationKey,
); chk.E(err) {
continue
}
ev := &event.E{
Content: encrypted,
CreatedAt: time.Now().Unix(),
Kind: 23197,
Tags: tag.NewS(
tag.NewFromAny("encryption", "nip44_v2"),
tag.NewFromAny("p", hex.Enc(clientPubkey)),
),
}
if err = ev.Sign(m.walletSecretKey); chk.E(err) {
continue
}
m.client.Publish(m.ctx, ev)
}
return
}
// SimulateIncomingPayment simulates an incoming payment for testing
func (m *MockWalletService) SimulateIncomingPayment(
pubkey []byte, amount int64, description string,
) (err error) {
// Add to balance
m.balanceMutex.Lock()
m.balance += amount / 1000 // convert msats to sats
m.balanceMutex.Unlock()
paymentHash := make([]byte, 32)
rand.Read(paymentHash)
preimage := make([]byte, 32)
rand.Read(preimage)
paymentData := map[string]any{
"type": "incoming",
"invoice": fmt.Sprintf("lnbc%dm1pwxxxxxxx", amount/1000),
"description": description,
"amount": amount,
"payment_hash": hex.Enc(paymentHash),
"preimage": hex.Enc(preimage),
"created_at": time.Now().Unix(),
}
// Emit payment_received notification
return m.emitPaymentNotification("payment_received", paymentData)
}

View File

@@ -0,0 +1,176 @@
package nwc_test
import (
"context"
"testing"
"time"
"next.orly.dev/pkg/protocol/nwc"
"next.orly.dev/pkg/protocol/ws"
)
func TestNWCClientCreation(t *testing.T) {
uri := "nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b?relay=wss://relay.getalby.com/v1&secret=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
c, err := nwc.NewClient(uri)
if err != nil {
t.Fatal(err)
}
if c == nil {
t.Fatal("client should not be nil")
}
}
func TestNWCInvalidURI(t *testing.T) {
invalidURIs := []string{
"invalid://test",
"nostr+walletconnect://",
"nostr+walletconnect://invalid",
"nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b",
"nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b?relay=invalid",
}
for _, uri := range invalidURIs {
_, err := nwc.NewClient(uri)
if err == nil {
t.Fatalf("expected error for invalid URI: %s", uri)
}
}
}
func TestNWCRelayConnection(t *testing.T) {
ctx, cancel := context.WithTimeout(context.TODO(), 5*time.Second)
defer cancel()
rc, err := ws.RelayConnect(ctx, "wss://relay.getalby.com/v1")
if err != nil {
t.Fatalf("relay connection failed: %v", err)
}
defer rc.Close()
t.Log("relay connection successful")
}
func TestNWCRequestTimeout(t *testing.T) {
uri := "nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b?relay=wss://relay.getalby.com/v1&secret=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
c, err := nwc.NewClient(uri)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.TODO(), 2*time.Second)
defer cancel()
var r map[string]any
err = c.Request(ctx, "get_info", nil, &r)
if err == nil {
t.Log("wallet responded")
return
}
expectedErrors := []string{
"no response from wallet",
"subscription closed",
"timeout waiting for response",
"context deadline exceeded",
}
errorFound := false
for _, expected := range expectedErrors {
if contains(err.Error(), expected) {
errorFound = true
break
}
}
if !errorFound {
t.Fatalf("unexpected error: %v", err)
}
t.Logf("proper timeout handling: %v", err)
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || (len(s) > len(substr) &&
(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
findInString(s, substr))))
}
func findInString(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func TestNWCEncryption(t *testing.T) {
uri := "nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b?relay=wss://relay.getalby.com/v1&secret=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
c, err := nwc.NewClient(uri)
if err != nil {
t.Fatal(err)
}
// We can't directly access private fields, but we can test the client creation
// check conversation key generation
if c == nil {
t.Fatal("client creation should succeed with valid URI")
}
// Test passed
}
func TestNWCEventFormat(t *testing.T) {
uri := "nostr+walletconnect://816fd7f1d000ae81a3da251c91866fc47f4bcd6ce36921e6d46773c32f1d548b?relay=wss://relay.getalby.com/v1&secret=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
c, err := nwc.NewClient(uri)
if err != nil {
t.Fatal(err)
}
// Test client creation
// The Request method will create proper NWC events with:
// - Kind 23194 for requests
// - Proper encryption tag
// - Signed with client key
ctx, cancel := context.WithTimeout(context.TODO(), 1*time.Second)
defer cancel()
var r map[string]any
err = c.Request(ctx, "get_info", nil, &r)
// We expect this to fail due to inactive connection, but it should fail
// after creating and sending NWC event
if err == nil {
t.Log("wallet responded")
return
}
// Verify it failed for the right reason (connection/response issue, not formatting)
validFailures := []string{
"subscription closed",
"no response from wallet",
"context deadline exceeded",
"timeout waiting for response",
}
validFailure := false
for _, failure := range validFailures {
if contains(err.Error(), failure) {
validFailure = true
break
}
}
if !validFailure {
t.Fatalf("unexpected error type (suggests formatting issue): %v", err)
}
// Test passed
}

81
pkg/protocol/nwc/uri.go Normal file
View File

@@ -0,0 +1,81 @@
package nwc
import (
"errors"
"net/url"
"lol.mleku.dev/chk"
"next.orly.dev/pkg/crypto/encryption"
"next.orly.dev/pkg/crypto/p256k"
"next.orly.dev/pkg/interfaces/signer"
)
type ConnectionParams struct {
clientSecretKey signer.I
walletPublicKey []byte
conversationKey []byte
relay string
}
// GetWalletPublicKey returns the wallet public key from the ConnectionParams.
func (c *ConnectionParams) GetWalletPublicKey() []byte {
return c.walletPublicKey
}
// GetConversationKey returns the conversation key from the ConnectionParams.
func (c *ConnectionParams) GetConversationKey() []byte {
return c.conversationKey
}
func ParseConnectionURI(nwcUri string) (parts *ConnectionParams, err error) {
var p *url.URL
if p, err = url.Parse(nwcUri); chk.E(err) {
return
}
if p == nil {
err = errors.New("invalid uri")
return
}
parts = &ConnectionParams{}
if p.Scheme != "nostr+walletconnect" {
err = errors.New("incorrect scheme")
return
}
if parts.walletPublicKey, err = p256k.HexToBin(p.Host); chk.E(err) {
err = errors.New("invalid public key")
return
}
query := p.Query()
var ok bool
var relay []string
if relay, ok = query["relay"]; !ok {
err = errors.New("missing relay parameter")
return
}
if len(relay) == 0 {
return nil, errors.New("no relays")
}
parts.relay = relay[0]
var secret string
if secret = query.Get("secret"); secret == "" {
err = errors.New("missing secret parameter")
return
}
var secretBytes []byte
if secretBytes, err = p256k.HexToBin(secret); chk.E(err) {
err = errors.New("invalid secret")
return
}
clientKey := &p256k.Signer{}
if err = clientKey.InitSec(secretBytes); chk.E(err) {
return
}
parts.clientSecretKey = clientKey
if parts.conversationKey, err = encryption.GenerateConversationKeyWithSigner(
clientKey,
parts.walletPublicKey,
); chk.E(err) {
return
}
return
}

372
pkg/spider/spider.go Normal file
View File

@@ -0,0 +1,372 @@
package spider
import (
"context"
"strconv"
"strings"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/app/config"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/database/indexes/types"
"next.orly.dev/pkg/encoders/filter"
"next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/encoders/timestamp"
"next.orly.dev/pkg/protocol/ws"
"next.orly.dev/pkg/utils/normalize"
)
const (
OneTimeSpiderSyncMarker = "spider_one_time_sync_completed"
SpiderLastScanMarker = "spider_last_scan_time"
)
type Spider struct {
db *database.D
cfg *config.C
ctx context.Context
cancel context.CancelFunc
}
func New(
db *database.D, cfg *config.C, ctx context.Context,
cancel context.CancelFunc,
) *Spider {
return &Spider{
db: db,
cfg: cfg,
ctx: ctx,
cancel: cancel,
}
}
// Start initializes the spider functionality based on configuration
func (s *Spider) Start() {
if s.cfg.SpiderMode != "follows" {
log.D.Ln("Spider mode is not set to 'follows', skipping spider functionality")
return
}
log.I.Ln("Starting spider in follow mode")
// Check if one-time sync has been completed
if !s.db.HasMarker(OneTimeSpiderSyncMarker) {
log.I.Ln("Performing one-time spider sync back one month")
go s.performOneTimeSync()
} else {
log.D.Ln("One-time spider sync already completed, skipping")
}
// Start periodic scanning
go s.startPeriodicScanning()
}
// performOneTimeSync performs the initial sync going back one month
func (s *Spider) performOneTimeSync() {
defer func() {
// Mark the one-time sync as completed
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
if err := s.db.SetMarker(
OneTimeSpiderSyncMarker, []byte(timestamp),
); err != nil {
log.E.F("Failed to set one-time sync marker: %v", err)
} else {
log.I.Ln("One-time spider sync completed and marked")
}
}()
// Calculate the time one month ago
oneMonthAgo := time.Now().AddDate(0, -1, 0)
log.I.F("Starting one-time spider sync from %v", oneMonthAgo)
// Perform the sync (placeholder - would need actual implementation based on follows)
if err := s.performSync(oneMonthAgo, time.Now()); err != nil {
log.E.F("One-time spider sync failed: %v", err)
return
}
log.I.Ln("One-time spider sync completed successfully")
}
// startPeriodicScanning starts the regular scanning process
func (s *Spider) startPeriodicScanning() {
ticker := time.NewTicker(s.cfg.SpiderFrequency)
defer ticker.Stop()
log.I.F("Starting periodic spider scanning every %v", s.cfg.SpiderFrequency)
for {
select {
case <-s.ctx.Done():
log.D.Ln("Spider periodic scanning stopped due to context cancellation")
return
case <-ticker.C:
s.performPeriodicScan()
}
}
}
// performPeriodicScan performs the regular scan of the last two hours (double the frequency window)
func (s *Spider) performPeriodicScan() {
// Calculate the scanning window (double the frequency period)
scanWindow := s.cfg.SpiderFrequency * 2
scanStart := time.Now().Add(-scanWindow)
scanEnd := time.Now()
log.D.F(
"Performing periodic spider scan from %v to %v (window: %v)", scanStart,
scanEnd, scanWindow,
)
if err := s.performSync(scanStart, scanEnd); err != nil {
log.E.F("Periodic spider scan failed: %v", err)
return
}
// Update the last scan marker
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
if err := s.db.SetMarker(
SpiderLastScanMarker, []byte(timestamp),
); err != nil {
log.E.F("Failed to update last scan marker: %v", err)
}
log.D.F("Periodic spider scan completed successfully")
}
// performSync performs the actual sync operation for the given time range
func (s *Spider) performSync(startTime, endTime time.Time) error {
log.D.F(
"Spider sync from %v to %v - starting implementation", startTime,
endTime,
)
// 1. Check ACL mode is set to "follows"
if s.cfg.ACLMode != "follows" {
log.D.F(
"Spider sync skipped - ACL mode is not 'follows' (current: %s)",
s.cfg.ACLMode,
)
return nil
}
// 2. Get the list of followed users from the ACL system
followedPubkeys, err := s.getFollowedPubkeys()
if err != nil {
return err
}
if len(followedPubkeys) == 0 {
log.D.Ln("Spider sync: no followed pubkeys found")
return nil
}
log.D.F("Spider sync: found %d followed pubkeys", len(followedPubkeys))
// 3. Discover relay lists from followed users
relayURLs, err := s.discoverRelays(followedPubkeys)
if err != nil {
return err
}
if len(relayURLs) == 0 {
log.W.Ln("Spider sync: no relays discovered from followed users")
return nil
}
log.I.F("Spider sync: discovered %d relay URLs", len(relayURLs))
// 4. Query each relay for events from followed pubkeys in the time range
eventsFound := 0
for _, relayURL := range relayURLs {
count, err := s.queryRelayForEvents(
relayURL, followedPubkeys, startTime, endTime,
)
if err != nil {
log.E.F("Spider sync: error querying relay %s: %v", relayURL, err)
continue
}
eventsFound += count
}
log.I.F(
"Spider sync completed: found %d new events from %d relays",
eventsFound, len(relayURLs),
)
return nil
}
// getFollowedPubkeys retrieves the list of followed pubkeys from the ACL system
func (s *Spider) getFollowedPubkeys() ([][]byte, error) {
// Access the ACL registry to get the current ACL instance
var followedPubkeys [][]byte
// Get all ACL instances and find the active one
for _, aclInstance := range acl.Registry.ACL {
if aclInstance.Type() == acl.Registry.Active.Load() {
// Cast to *Follows to access the follows field
if followsACL, ok := aclInstance.(*acl.Follows); ok {
followedPubkeys = followsACL.GetFollowedPubkeys()
break
}
}
}
return followedPubkeys, nil
}
// discoverRelays discovers relay URLs from kind 10002 events of followed users
func (s *Spider) discoverRelays(followedPubkeys [][]byte) ([]string, error) {
seen := make(map[string]struct{})
var urls []string
for _, pubkey := range followedPubkeys {
// Query for kind 10002 (RelayListMetadata) events from this pubkey
fl := &filter.F{
Authors: tag.NewFromAny(pubkey),
Kinds: kind.NewS(kind.New(kind.RelayListMetadata.K)),
}
idxs, err := database.GetIndexesFromFilter(fl)
if chk.E(err) {
continue
}
var sers types.Uint40s
for _, idx := range idxs {
s, err := s.db.GetSerialsByRange(idx)
if chk.E(err) {
continue
}
sers = append(sers, s...)
}
for _, ser := range sers {
ev, err := s.db.FetchEventBySerial(ser)
if chk.E(err) || ev == nil {
continue
}
// Extract relay URLs from 'r' tags
for _, v := range ev.Tags.GetAll([]byte("r")) {
u := string(v.Value())
n := string(normalize.URL(u))
if n == "" {
continue
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
urls = append(urls, n)
}
}
}
return urls, nil
}
// 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)
// Connect to the relay with a timeout context
ctx, cancel := context.WithTimeout(s.ctx, 30*time.Second)
defer cancel()
client, err := ws.RelayConnect(ctx, relayURL)
if err != nil {
return 0, err
}
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
}
// 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()
}
}
}
// Stop stops the spider functionality
func (s *Spider) Stop() {
log.D.Ln("Stopping spider")
s.cancel()
}

View File

@@ -1 +1 @@
v0.4.9
v0.8.2

97
scripts/update-embedded-web.sh Executable file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# scripts/update-embedded-web.sh
# Build the embedded web UI and then install the Go binary.
#
# This script will:
# - Build the React app in app/web to app/web/dist using Bun (preferred),
# or fall back to npm/yarn/pnpm if Bun isn't available.
# - Run `go install` from the repository root so the binary picks up the new
# embedded assets.
#
# Usage:
# ./scripts/update-embedded-web.sh
#
# Requirements:
# - Go 1.18+ installed (for `go install` and go:embed support)
# - Bun (https://bun.sh) recommended; alternatively Node.js with npm/yarn/pnpm
#
set -euo pipefail
# Resolve repo root to allow running from anywhere
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
WEB_DIR="${REPO_ROOT}/app/web"
log() { printf "[update-embedded-web] %s\n" "$*"; }
err() { printf "[update-embedded-web][ERROR] %s\n" "$*" >&2; }
if [[ ! -d "${WEB_DIR}" ]]; then
err "Expected web directory at ${WEB_DIR} not found."
exit 1
fi
# Choose a JS package runner
JS_RUNNER=""
if command -v bun >/dev/null 2>&1; then
JS_RUNNER="bun"
elif command -v npm >/dev/null 2>&1; then
JS_RUNNER="npm"
elif command -v yarn >/dev/null 2>&1; then
JS_RUNNER="yarn"
elif command -v pnpm >/dev/null 2>&1; then
JS_RUNNER="pnpm"
else
err "No JavaScript package manager found. Install Bun (recommended) or npm/yarn/pnpm."
exit 1
fi
log "Using JavaScript runner: ${JS_RUNNER}"
# Install dependencies and build the web app
log "Installing frontend dependencies..."
pushd "${WEB_DIR}" >/dev/null
case "${JS_RUNNER}" in
bun)
bun install
log "Building web app with Bun..."
bun run build
;;
npm)
npm ci || npm install
log "Building web app with npm..."
npm run build
;;
yarn)
yarn install --frozen-lockfile || yarn install
log "Building web app with yarn..."
yarn build
;;
pnpm)
pnpm install --frozen-lockfile || pnpm install
log "Building web app with pnpm..."
pnpm build
;;
*)
err "Unsupported JS runner: ${JS_RUNNER}"
exit 1
;;
esac
popd >/dev/null
# Verify the output directory expected by go:embed exists
DIST_DIR="${WEB_DIR}/dist"
if [[ ! -d "${DIST_DIR}" ]]; then
err "Build did not produce ${DIST_DIR}. Check your frontend build configuration."
exit 1
fi
log "Frontend build complete at ${DIST_DIR}."
# Install the Go binary so it embeds the latest files
log "Running 'go install' from repo root..."
pushd "${REPO_ROOT}" >/dev/null
GO111MODULE=on go install ./...
popd >/dev/null
log "Done. Your installed binary now includes the updated embedded web UI."