Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
e161d0e4be
|
|||
|
ed412dcb7e
|
|||
|
2614b51068
|
|||
|
edcdec9c7e
|
|||
|
3567bb26a4
|
|||
|
9082481129
|
18
.github/workflows/go.yml
vendored
18
.github/workflows/go.yml
vendored
@@ -29,15 +29,6 @@ jobs:
|
||||
with:
|
||||
go-version: "1.25"
|
||||
|
||||
- name: Install libsecp256k1
|
||||
run: ./scripts/ubuntu_install_libsecp256k1.sh
|
||||
|
||||
- name: Build with cgo
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Test with cgo
|
||||
run: go test -v $(go list ./... | xargs -n1 sh -c 'ls $0/*_test.go 1>/dev/null 2>&1 && echo $0' | grep .)
|
||||
|
||||
- name: Set CGO off
|
||||
run: echo "CGO_ENABLED=0" >> $GITHUB_ENV
|
||||
|
||||
@@ -61,9 +52,6 @@ jobs:
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Install libsecp256k1
|
||||
run: ./scripts/ubuntu_install_libsecp256k1.sh
|
||||
|
||||
- name: Build Release Binaries
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
@@ -75,11 +63,7 @@ jobs:
|
||||
mkdir -p release-binaries
|
||||
|
||||
# Build for different platforms
|
||||
GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -ldflags "-s -w" -o release-binaries/orly-${VERSION}-linux-amd64 .
|
||||
# GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o release-binaries/orly-${VERSION}-linux-arm64 .
|
||||
# GOEXPERIMENT=greenteagc,jsonv2 GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -o release-binaries/orly-${VERSION}-darwin-amd64 .
|
||||
# GOEXPERIMENT=greenteagc,jsonv2 GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o release-binaries/orly-${VERSION}-darwin-arm64 .
|
||||
# GOEXPERIMENT=greenteagc,jsonv2 GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o release-binaries/orly-${VERSION}-windows-amd64.exe .
|
||||
GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-s -w" -o release-binaries/orly-${VERSION}-linux-amd64 .
|
||||
|
||||
# Note: Only building orly binary as requested
|
||||
# Other cmd utilities (aggregator, benchmark, convert, policytest, stresstest) are development tools
|
||||
|
||||
53
app/blossom.go
Normal file
53
app/blossom.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/app/config"
|
||||
"next.orly.dev/pkg/acl"
|
||||
"next.orly.dev/pkg/database"
|
||||
blossom "next.orly.dev/pkg/blossom"
|
||||
)
|
||||
|
||||
// initializeBlossomServer creates and configures the Blossom blob storage server
|
||||
func initializeBlossomServer(
|
||||
ctx context.Context, cfg *config.C, db *database.D,
|
||||
) (*blossom.Server, error) {
|
||||
// Create blossom server configuration
|
||||
blossomCfg := &blossom.Config{
|
||||
BaseURL: "", // Will be set dynamically per request
|
||||
MaxBlobSize: 100 * 1024 * 1024, // 100MB default
|
||||
AllowedMimeTypes: nil, // Allow all MIME types by default
|
||||
RequireAuth: cfg.AuthRequired || cfg.AuthToWrite,
|
||||
}
|
||||
|
||||
// Create blossom server with relay's ACL registry
|
||||
bs := blossom.NewServer(db, acl.Registry, blossomCfg)
|
||||
|
||||
// Override baseURL getter to use request-based URL
|
||||
// We'll need to modify the handler to inject the baseURL per request
|
||||
// For now, we'll use a middleware approach
|
||||
|
||||
log.I.F("blossom server initialized with ACL mode: %s", cfg.ACLMode)
|
||||
return bs, nil
|
||||
}
|
||||
|
||||
// blossomHandler wraps the blossom server handler to inject baseURL per request
|
||||
func (s *Server) blossomHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Strip /blossom prefix and pass to blossom handler
|
||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/blossom")
|
||||
if !strings.HasPrefix(r.URL.Path, "/") {
|
||||
r.URL.Path = "/" + r.URL.Path
|
||||
}
|
||||
|
||||
// Set baseURL in request context for blossom server to use
|
||||
baseURL := s.ServiceURL(r) + "/blossom"
|
||||
type baseURLKey struct{}
|
||||
r = r.WithContext(context.WithValue(r.Context(), baseURLKey{}, baseURL))
|
||||
|
||||
s.blossomServer.Handler().ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@@ -50,8 +50,12 @@ type C struct {
|
||||
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)"`
|
||||
RelayAddresses []string `env:"ORLY_RELAY_ADDRESSES" usage:"comma-separated list of websocket addresses for this relay (e.g., wss://relay.example.com,wss://backup.example.com)"`
|
||||
RelayPeers []string `env:"ORLY_RELAY_PEERS" usage:"comma-separated list of peer relay URLs for distributed synchronization (e.g., https://peer1.example.com,https://peer2.example.com)"`
|
||||
FollowListFrequency time.Duration `env:"ORLY_FOLLOW_LIST_FREQUENCY" usage:"how often to fetch admin follow lists (default: 1h)" default:"1h"`
|
||||
|
||||
// Blossom blob storage service level settings
|
||||
BlossomServiceLevels string `env:"ORLY_BLOSSOM_SERVICE_LEVELS" usage:"comma-separated list of service levels in format: name:storage_mb_per_sat_per_month (e.g., basic:1,premium:10)"`
|
||||
|
||||
// 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)"`
|
||||
|
||||
@@ -455,6 +455,12 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
|
||||
chk.E(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Update serial for distributed synchronization
|
||||
if l.syncManager != nil {
|
||||
l.syncManager.UpdateSerial()
|
||||
log.D.F("updated serial for event %s", hex.Enc(env.E.ID))
|
||||
}
|
||||
// Send a success response storing
|
||||
if err = Ok.Ok(l, env, ""); chk.E(err) {
|
||||
return
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/acl"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/protocol/relayinfo"
|
||||
"next.orly.dev/pkg/version"
|
||||
@@ -74,7 +74,7 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
|
||||
// Get relay identity pubkey as hex
|
||||
var relayPubkey string
|
||||
if skb, err := s.D.GetRelayIdentitySecret(); err == nil && len(skb) == 32 {
|
||||
sign := new(p256k.Signer)
|
||||
sign := p256k1signer.NewP256K1Signer()
|
||||
if err := sign.InitSec(skb); err == nil {
|
||||
relayPubkey = hex.Enc(sign.Pub())
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/encoders/envelopes/authenvelope"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/protocol/publish"
|
||||
"next.orly.dev/pkg/utils/units"
|
||||
)
|
||||
|
||||
@@ -20,7 +21,7 @@ const (
|
||||
DefaultPongWait = 60 * time.Second
|
||||
DefaultPingWait = DefaultPongWait / 2
|
||||
DefaultWriteTimeout = 3 * time.Second
|
||||
DefaultMaxMessageSize = 100 * units.Mb
|
||||
DefaultMaxMessageSize = 512000 // Match khatru's MaxMessageSize
|
||||
// ClientMessageSizeLimit is the maximum message size that clients can handle
|
||||
// This is set to 100MB to allow large messages
|
||||
ClientMessageSizeLimit = 100 * 1024 * 1024 // 100MB
|
||||
@@ -83,7 +84,7 @@ whitelist:
|
||||
remote: remote,
|
||||
req: r,
|
||||
startTime: time.Now(),
|
||||
writeChan: make(chan WriteRequest, 100), // Buffered channel for writes
|
||||
writeChan: make(chan publish.WriteRequest, 100), // Buffered channel for writes
|
||||
writeDone: make(chan struct{}),
|
||||
}
|
||||
|
||||
@@ -119,13 +120,6 @@ whitelist:
|
||||
conn.SetReadDeadline(time.Now().Add(DefaultPongWait))
|
||||
return nil
|
||||
})
|
||||
// Set ping handler - extends read deadline when pings are received
|
||||
// Send pong through write channel
|
||||
conn.SetPingHandler(func(msg string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(DefaultPongWait))
|
||||
deadline := time.Now().Add(DefaultWriteTimeout)
|
||||
return listener.WriteControl(websocket.PongMessage, []byte{}, deadline)
|
||||
})
|
||||
// Don't pass cancel to Pinger - it should not be able to cancel the connection context
|
||||
go s.Pinger(ctx, listener, ticker)
|
||||
defer func() {
|
||||
@@ -135,11 +129,6 @@ whitelist:
|
||||
cancel()
|
||||
ticker.Stop()
|
||||
|
||||
// Close write channel to signal worker to exit
|
||||
close(listener.writeChan)
|
||||
// Wait for write worker to finish
|
||||
<-listener.writeDone
|
||||
|
||||
// Cancel all subscriptions for this connection
|
||||
log.D.F("cancelling subscriptions for %s", remote)
|
||||
listener.publishers.Receive(&W{
|
||||
@@ -162,6 +151,11 @@ whitelist:
|
||||
} else {
|
||||
log.D.F("ws connection %s was not authenticated", remote)
|
||||
}
|
||||
|
||||
// Close write channel to signal worker to exit
|
||||
close(listener.writeChan)
|
||||
// Wait for write worker to finish
|
||||
<-listener.writeDone
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
@@ -191,97 +185,25 @@ whitelist:
|
||||
typ, msg, err = conn.ReadMessage()
|
||||
|
||||
if err != nil {
|
||||
// Check if the error is due to context cancellation
|
||||
if err == context.Canceled || strings.Contains(err.Error(), "context canceled") {
|
||||
log.T.F("connection from %s cancelled (context done): %v", remote, err)
|
||||
return
|
||||
}
|
||||
if strings.Contains(
|
||||
err.Error(), "use of closed network connection",
|
||||
if websocket.IsUnexpectedCloseError(
|
||||
err,
|
||||
websocket.CloseNormalClosure, // 1000
|
||||
websocket.CloseGoingAway, // 1001
|
||||
websocket.CloseNoStatusReceived, // 1005
|
||||
websocket.CloseAbnormalClosure, // 1006
|
||||
4537, // some client seems to send many of these
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Handle EOF errors gracefully - these occur when client closes connection
|
||||
// or sends incomplete/malformed WebSocket frames
|
||||
if strings.Contains(err.Error(), "EOF") ||
|
||||
strings.Contains(err.Error(), "failed to read frame header") {
|
||||
log.T.F("connection from %s closed: %v", remote, err)
|
||||
return
|
||||
}
|
||||
// Handle timeout errors specifically - these can occur on idle connections
|
||||
// but pongs should extend the deadline, so a timeout usually means dead connection
|
||||
if strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "deadline exceeded") {
|
||||
log.T.F("connection from %s read timeout (likely dead connection): %v", remote, err)
|
||||
return
|
||||
}
|
||||
// Handle message too big errors specifically
|
||||
if strings.Contains(err.Error(), "message too large") ||
|
||||
strings.Contains(err.Error(), "read limited at") {
|
||||
log.D.F("client %s hit message size limit: %v", remote, err)
|
||||
// Don't log this as an error since it's a client-side limit
|
||||
// Just close the connection gracefully
|
||||
return
|
||||
}
|
||||
// Check for websocket close errors
|
||||
if websocket.IsCloseError(err, websocket.CloseNormalClosure,
|
||||
websocket.CloseGoingAway,
|
||||
websocket.CloseNoStatusReceived,
|
||||
websocket.CloseAbnormalClosure,
|
||||
websocket.CloseUnsupportedData,
|
||||
websocket.CloseInvalidFramePayloadData) {
|
||||
log.T.F("connection from %s closed: %v", remote, err)
|
||||
} else if websocket.IsCloseError(err, websocket.CloseMessageTooBig) {
|
||||
log.D.F("client %s sent message too big: %v", remote, err)
|
||||
} else {
|
||||
log.E.F("unexpected close error from %s: %v", remote, err)
|
||||
log.I.F("websocket connection closed from %s: %v", remote, err)
|
||||
}
|
||||
cancel() // Cancel context like khatru does
|
||||
return
|
||||
}
|
||||
if typ == websocket.PingMessage {
|
||||
log.D.F("received PING from %s, sending PONG", remote)
|
||||
// Send pong through write channel
|
||||
deadline := time.Now().Add(DefaultWriteTimeout)
|
||||
pongStart := time.Now()
|
||||
if err = listener.WriteControl(websocket.PongMessage, msg, deadline); err != nil {
|
||||
pongDuration := time.Since(pongStart)
|
||||
|
||||
// Check if this is a timeout vs a connection error
|
||||
isTimeout := strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "deadline exceeded")
|
||||
isConnectionError := strings.Contains(err.Error(), "use of closed network connection") ||
|
||||
strings.Contains(err.Error(), "broken pipe") ||
|
||||
strings.Contains(err.Error(), "connection reset") ||
|
||||
websocket.IsCloseError(err, websocket.CloseAbnormalClosure,
|
||||
websocket.CloseGoingAway,
|
||||
websocket.CloseNoStatusReceived)
|
||||
|
||||
if isConnectionError {
|
||||
log.E.F(
|
||||
"failed to send PONG to %s after %v (connection error): %v", remote,
|
||||
pongDuration, err,
|
||||
)
|
||||
return
|
||||
} else if isTimeout {
|
||||
// Timeout on pong - log but don't close immediately
|
||||
// The read deadline will catch dead connections
|
||||
log.W.F(
|
||||
"failed to send PONG to %s after %v (timeout, but connection may still be alive): %v", remote,
|
||||
pongDuration, err,
|
||||
)
|
||||
// Continue - don't close connection on pong timeout
|
||||
} else {
|
||||
// Unknown error - log and continue
|
||||
log.E.F(
|
||||
"failed to send PONG to %s after %v (unknown error): %v", remote,
|
||||
pongDuration, err,
|
||||
)
|
||||
// Continue - don't close on unknown errors
|
||||
}
|
||||
continue
|
||||
}
|
||||
pongDuration := time.Since(pongStart)
|
||||
log.D.F("sent PONG to %s successfully in %v", remote, pongDuration)
|
||||
if pongDuration > time.Millisecond*50 {
|
||||
log.D.F("SLOW PONG to %s: %v (>50ms)", remote, pongDuration)
|
||||
// Send pong directly (like khatru does)
|
||||
if err = conn.WriteMessage(websocket.PongMessage, nil); err != nil {
|
||||
log.E.F("failed to send PONG to %s: %v", remote, err)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -300,68 +222,25 @@ func (s *Server) Pinger(
|
||||
defer func() {
|
||||
log.D.F("pinger shutting down")
|
||||
ticker.Stop()
|
||||
// DO NOT call cancel here - the pinger should not be able to cancel the connection context
|
||||
// The connection handler will cancel the context when the connection is actually closing
|
||||
}()
|
||||
var err error
|
||||
pingCount := 0
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
pingCount++
|
||||
log.D.F("sending PING #%d", pingCount)
|
||||
|
||||
// Send ping through write channel
|
||||
deadline := time.Now().Add(DefaultWriteTimeout)
|
||||
pingStart := time.Now()
|
||||
|
||||
if err = listener.WriteControl(websocket.PingMessage, []byte{}, deadline); err != nil {
|
||||
pingDuration := time.Since(pingStart)
|
||||
|
||||
// Check if this is a timeout vs a connection error
|
||||
isTimeout := strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "deadline exceeded")
|
||||
isConnectionError := strings.Contains(err.Error(), "use of closed network connection") ||
|
||||
strings.Contains(err.Error(), "broken pipe") ||
|
||||
strings.Contains(err.Error(), "connection reset") ||
|
||||
websocket.IsCloseError(err, websocket.CloseAbnormalClosure,
|
||||
websocket.CloseGoingAway,
|
||||
websocket.CloseNoStatusReceived)
|
||||
|
||||
if isConnectionError {
|
||||
log.E.F(
|
||||
"PING #%d FAILED after %v (connection error): %v", pingCount, pingDuration,
|
||||
err,
|
||||
)
|
||||
chk.E(err)
|
||||
return
|
||||
} else if isTimeout {
|
||||
// Timeout on ping - log but don't stop pinger immediately
|
||||
// The read deadline will catch dead connections
|
||||
log.W.F(
|
||||
"PING #%d timeout after %v (connection may still be alive): %v", pingCount, pingDuration,
|
||||
err,
|
||||
)
|
||||
// Continue - don't stop pinger on timeout
|
||||
} else {
|
||||
// Unknown error - log and continue
|
||||
log.E.F(
|
||||
"PING #%d FAILED after %v (unknown error): %v", pingCount, pingDuration,
|
||||
err,
|
||||
)
|
||||
// Continue - don't stop pinger on unknown errors
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
pingDuration := time.Since(pingStart)
|
||||
log.D.F("PING #%d sent successfully in %v", pingCount, pingDuration)
|
||||
|
||||
if pingDuration > time.Millisecond*100 {
|
||||
log.D.F("SLOW PING #%d: %v (>100ms)", pingCount, pingDuration)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
log.T.F("pinger context cancelled after %d pings", pingCount)
|
||||
return
|
||||
case <-ticker.C:
|
||||
pingCount++
|
||||
// Send ping request through write channel - this allows pings to interrupt other writes
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case listener.writeChan <- publish.WriteRequest{IsPing: true, MsgType: pingCount}:
|
||||
// Ping request queued successfully
|
||||
case <-time.After(DefaultWriteTimeout):
|
||||
log.E.F("ping #%d channel timeout - connection may be overloaded", pingCount)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
124
app/listener.go
124
app/listener.go
@@ -18,9 +18,6 @@ import (
|
||||
"next.orly.dev/pkg/utils/atomic"
|
||||
)
|
||||
|
||||
// WriteRequest represents a write operation to be performed by the write worker
|
||||
type WriteRequest = publish.WriteRequest
|
||||
|
||||
type Listener struct {
|
||||
*Server
|
||||
conn *websocket.Conn
|
||||
@@ -32,7 +29,7 @@ type Listener struct {
|
||||
startTime time.Time
|
||||
isBlacklisted bool // Marker to identify blacklisted IPs
|
||||
blacklistTimeout time.Time // When to timeout blacklisted connections
|
||||
writeChan chan WriteRequest // Channel for write requests
|
||||
writeChan chan publish.WriteRequest // Channel for write requests (back to queued approach)
|
||||
writeDone chan struct{} // Closed when write worker exits
|
||||
// Diagnostics: per-connection counters
|
||||
msgCount int
|
||||
@@ -46,62 +43,13 @@ func (l *Listener) Ctx() context.Context {
|
||||
return l.ctx
|
||||
}
|
||||
|
||||
// writeWorker is the single goroutine that handles all writes to the websocket connection.
|
||||
// This serializes all writes to prevent concurrent write panics.
|
||||
func (l *Listener) writeWorker() {
|
||||
defer close(l.writeDone)
|
||||
for {
|
||||
select {
|
||||
case <-l.ctx.Done():
|
||||
return
|
||||
case req, ok := <-l.writeChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
deadline := req.Deadline
|
||||
if deadline.IsZero() {
|
||||
deadline = time.Now().Add(DefaultWriteTimeout)
|
||||
}
|
||||
l.conn.SetWriteDeadline(deadline)
|
||||
writeStart := time.Now()
|
||||
var err error
|
||||
if req.IsControl {
|
||||
err = l.conn.WriteControl(req.MsgType, req.Data, deadline)
|
||||
} else {
|
||||
err = l.conn.WriteMessage(req.MsgType, req.Data)
|
||||
}
|
||||
if err != nil {
|
||||
writeDuration := time.Since(writeStart)
|
||||
log.E.F("ws->%s write worker FAILED: len=%d duration=%v error=%v",
|
||||
l.remote, len(req.Data), writeDuration, err)
|
||||
// Check for connection errors - if so, stop the worker
|
||||
isConnectionError := strings.Contains(err.Error(), "use of closed network connection") ||
|
||||
strings.Contains(err.Error(), "broken pipe") ||
|
||||
strings.Contains(err.Error(), "connection reset") ||
|
||||
websocket.IsCloseError(err, websocket.CloseAbnormalClosure,
|
||||
websocket.CloseGoingAway,
|
||||
websocket.CloseNoStatusReceived)
|
||||
if isConnectionError {
|
||||
return
|
||||
}
|
||||
// Continue for other errors (timeouts, etc.)
|
||||
} else {
|
||||
writeDuration := time.Since(writeStart)
|
||||
if writeDuration > time.Millisecond*100 {
|
||||
log.D.F("ws->%s write worker SLOW: len=%d duration=%v",
|
||||
l.remote, len(req.Data), writeDuration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Listener) Write(p []byte) (n int, err error) {
|
||||
// Send write request to channel - non-blocking with timeout
|
||||
select {
|
||||
case <-l.ctx.Done():
|
||||
return 0, l.ctx.Err()
|
||||
case l.writeChan <- WriteRequest{Data: p, MsgType: websocket.TextMessage, IsControl: false}:
|
||||
case l.writeChan <- publish.WriteRequest{Data: p, MsgType: websocket.TextMessage, IsControl: false}:
|
||||
return len(p), nil
|
||||
case <-time.After(DefaultWriteTimeout):
|
||||
log.E.F("ws->%s write channel timeout", l.remote)
|
||||
@@ -114,7 +62,7 @@ func (l *Listener) WriteControl(messageType int, data []byte, deadline time.Time
|
||||
select {
|
||||
case <-l.ctx.Done():
|
||||
return l.ctx.Err()
|
||||
case l.writeChan <- WriteRequest{Data: data, MsgType: messageType, IsControl: true, Deadline: deadline}:
|
||||
case l.writeChan <- publish.WriteRequest{Data: data, MsgType: messageType, IsControl: true, Deadline: deadline}:
|
||||
return nil
|
||||
case <-time.After(DefaultWriteTimeout):
|
||||
log.E.F("ws->%s writeControl channel timeout", l.remote)
|
||||
@@ -122,6 +70,72 @@ func (l *Listener) WriteControl(messageType int, data []byte, deadline time.Time
|
||||
}
|
||||
}
|
||||
|
||||
// writeWorker is the single goroutine that handles all writes to the websocket connection.
|
||||
// This serializes all writes to prevent concurrent write panics and allows pings to interrupt writes.
|
||||
func (l *Listener) writeWorker() {
|
||||
defer func() {
|
||||
// Only unregister write channel if connection is actually dead/closing
|
||||
// Unregister if:
|
||||
// 1. Context is cancelled (connection closing)
|
||||
// 2. Channel was closed (connection closing)
|
||||
// 3. Connection error occurred (already handled inline)
|
||||
if l.ctx.Err() != nil {
|
||||
// Connection is closing - safe to unregister
|
||||
if socketPub := l.publishers.GetSocketPublisher(); socketPub != nil {
|
||||
log.D.F("ws->%s write worker: unregistering write channel (connection closing)", l.remote)
|
||||
socketPub.SetWriteChan(l.conn, nil)
|
||||
}
|
||||
} else {
|
||||
// Exiting for other reasons (timeout, etc.) but connection may still be valid
|
||||
log.D.F("ws->%s write worker exiting unexpectedly", l.remote)
|
||||
}
|
||||
close(l.writeDone)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-l.ctx.Done():
|
||||
log.D.F("ws->%s write worker context cancelled", l.remote)
|
||||
return
|
||||
case req, ok := <-l.writeChan:
|
||||
if !ok {
|
||||
log.D.F("ws->%s write channel closed", l.remote)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle the write request
|
||||
var err error
|
||||
if req.IsPing {
|
||||
// Special handling for ping messages
|
||||
log.D.F("sending PING #%d", req.MsgType)
|
||||
deadline := time.Now().Add(DefaultWriteTimeout)
|
||||
err = l.conn.WriteControl(websocket.PingMessage, nil, deadline)
|
||||
if err != nil {
|
||||
if !strings.HasSuffix(err.Error(), "use of closed network connection") {
|
||||
log.E.F("error writing ping: %v; closing websocket", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
} else if req.IsControl {
|
||||
// Control message
|
||||
err = l.conn.WriteControl(req.MsgType, req.Data, req.Deadline)
|
||||
if err != nil {
|
||||
log.E.F("ws->%s control write failed: %v", l.remote, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Regular message
|
||||
l.conn.SetWriteDeadline(time.Now().Add(DefaultWriteTimeout))
|
||||
err = l.conn.WriteMessage(req.MsgType, req.Data)
|
||||
if err != nil {
|
||||
log.E.F("ws->%s write failed: %v", l.remote, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getManagedACL returns the managed ACL instance if available
|
||||
func (l *Listener) getManagedACL() *database.ManagedACL {
|
||||
// Get the managed ACL instance from the ACL registry
|
||||
|
||||
32
app/main.go
32
app/main.go
@@ -20,6 +20,7 @@ import (
|
||||
"next.orly.dev/pkg/policy"
|
||||
"next.orly.dev/pkg/protocol/publish"
|
||||
"next.orly.dev/pkg/spider"
|
||||
dsync "next.orly.dev/pkg/sync"
|
||||
)
|
||||
|
||||
func Run(
|
||||
@@ -116,9 +117,38 @@ func Run(
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize sync manager if relay peers are configured
|
||||
if len(cfg.RelayPeers) > 0 {
|
||||
// Get relay identity for node ID
|
||||
sk, err := db.GetOrCreateRelayIdentitySecret()
|
||||
if err != nil {
|
||||
log.E.F("failed to get relay identity for sync: %v", err)
|
||||
} else {
|
||||
nodeID, err := keys.SecretBytesToPubKeyHex(sk)
|
||||
if err != nil {
|
||||
log.E.F("failed to derive pubkey for sync node ID: %v", err)
|
||||
} else {
|
||||
relayURL := cfg.RelayURL
|
||||
if relayURL == "" {
|
||||
relayURL = fmt.Sprintf("http://localhost:%d", cfg.Port)
|
||||
}
|
||||
l.syncManager = dsync.NewManager(ctx, db, nodeID, relayURL, cfg.RelayPeers)
|
||||
log.I.F("distributed sync manager initialized with %d peers", len(cfg.RelayPeers))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the user interface
|
||||
l.UserInterface()
|
||||
|
||||
// Initialize Blossom blob storage server
|
||||
if l.blossomServer, err = initializeBlossomServer(ctx, cfg, db); err != nil {
|
||||
log.E.F("failed to initialize blossom server: %v", err)
|
||||
// Continue without blossom server
|
||||
} else if l.blossomServer != nil {
|
||||
log.I.F("blossom blob storage server initialized")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -153,7 +183,7 @@ func Run(
|
||||
}
|
||||
|
||||
if l.paymentProcessor, err = NewPaymentProcessor(ctx, cfg, db); err != nil {
|
||||
log.E.F("failed to create payment processor: %v", err)
|
||||
// log.E.F("failed to create payment processor: %v", err)
|
||||
// Continue without payment processor
|
||||
} else {
|
||||
if err = l.paymentProcessor.Start(); err != nil {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/app/config"
|
||||
"next.orly.dev/pkg/acl"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/database"
|
||||
"next.orly.dev/pkg/encoders/bech32encoding"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
@@ -152,7 +152,7 @@ func (pp *PaymentProcessor) syncFollowList() error {
|
||||
return err
|
||||
}
|
||||
// signer
|
||||
sign := new(p256k.Signer)
|
||||
sign := p256k1signer.NewP256K1Signer()
|
||||
if err := sign.InitSec(skb); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -272,7 +272,7 @@ func (pp *PaymentProcessor) createExpiryWarningNote(
|
||||
}
|
||||
|
||||
// Initialize signer
|
||||
sign := new(p256k.Signer)
|
||||
sign := p256k1signer.NewP256K1Signer()
|
||||
if err := sign.InitSec(skb); err != nil {
|
||||
return fmt.Errorf("failed to initialize signer: %w", err)
|
||||
}
|
||||
@@ -383,7 +383,7 @@ func (pp *PaymentProcessor) createTrialReminderNote(
|
||||
}
|
||||
|
||||
// Initialize signer
|
||||
sign := new(p256k.Signer)
|
||||
sign := p256k1signer.NewP256K1Signer()
|
||||
if err := sign.InitSec(skb); err != nil {
|
||||
return fmt.Errorf("failed to initialize signer: %w", err)
|
||||
}
|
||||
@@ -505,7 +505,9 @@ func (pp *PaymentProcessor) handleNotification(
|
||||
// Prefer explicit payer/relay pubkeys if provided in metadata
|
||||
var payerPubkey []byte
|
||||
var userNpub string
|
||||
if metadata, ok := notification["metadata"].(map[string]any); ok {
|
||||
var metadata map[string]any
|
||||
if md, ok := notification["metadata"].(map[string]any); ok {
|
||||
metadata = md
|
||||
if s, ok := metadata["payer_pubkey"].(string); ok && s != "" {
|
||||
if pk, err := decodeAnyPubkey(s); err == nil {
|
||||
payerPubkey = pk
|
||||
@@ -528,7 +530,7 @@ func (pp *PaymentProcessor) handleNotification(
|
||||
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
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err := signer.InitSec(skb); err == nil {
|
||||
if !strings.EqualFold(
|
||||
hex.Enc(rpk), hex.Enc(signer.Pub()),
|
||||
@@ -565,6 +567,11 @@ func (pp *PaymentProcessor) handleNotification(
|
||||
}
|
||||
|
||||
satsReceived := int64(amount / 1000)
|
||||
|
||||
// Parse zap memo for blossom service level
|
||||
blossomLevel := pp.parseBlossomServiceLevel(description, metadata)
|
||||
|
||||
// Calculate subscription days (for relay access)
|
||||
monthlyPrice := pp.config.MonthlyPriceSats
|
||||
if monthlyPrice <= 0 {
|
||||
monthlyPrice = 6000
|
||||
@@ -575,10 +582,19 @@ func (pp *PaymentProcessor) handleNotification(
|
||||
return fmt.Errorf("payment amount too small")
|
||||
}
|
||||
|
||||
// Extend relay subscription
|
||||
if err := pp.db.ExtendSubscription(pubkey, days); err != nil {
|
||||
return fmt.Errorf("failed to extend subscription: %w", err)
|
||||
}
|
||||
|
||||
// If blossom service level specified, extend blossom subscription
|
||||
if blossomLevel != "" {
|
||||
if err := pp.extendBlossomSubscription(pubkey, satsReceived, blossomLevel, days); err != nil {
|
||||
log.W.F("failed to extend blossom subscription: %v", err)
|
||||
// Don't fail the payment if blossom subscription fails
|
||||
}
|
||||
}
|
||||
|
||||
// Record payment history
|
||||
invoice, _ := notification["invoice"].(string)
|
||||
preimage, _ := notification["preimage"].(string)
|
||||
@@ -628,7 +644,7 @@ func (pp *PaymentProcessor) createPaymentNote(
|
||||
}
|
||||
|
||||
// Initialize signer
|
||||
sign := new(p256k.Signer)
|
||||
sign := p256k1signer.NewP256K1Signer()
|
||||
if err := sign.InitSec(skb); err != nil {
|
||||
return fmt.Errorf("failed to initialize signer: %w", err)
|
||||
}
|
||||
@@ -722,7 +738,7 @@ func (pp *PaymentProcessor) CreateWelcomeNote(userPubkey []byte) error {
|
||||
}
|
||||
|
||||
// Initialize signer
|
||||
sign := new(p256k.Signer)
|
||||
sign := p256k1signer.NewP256K1Signer()
|
||||
if err := sign.InitSec(skb); err != nil {
|
||||
return fmt.Errorf("failed to initialize signer: %w", err)
|
||||
}
|
||||
@@ -888,6 +904,118 @@ func (pp *PaymentProcessor) npubToPubkey(npubStr string) ([]byte, error) {
|
||||
return pubkey, nil
|
||||
}
|
||||
|
||||
// parseBlossomServiceLevel parses the zap memo for a blossom service level specification
|
||||
// Format: "blossom:level" or "blossom:level:storage_mb" in description or metadata memo field
|
||||
func (pp *PaymentProcessor) parseBlossomServiceLevel(
|
||||
description string, metadata map[string]any,
|
||||
) string {
|
||||
// Check metadata memo field first
|
||||
if metadata != nil {
|
||||
if memo, ok := metadata["memo"].(string); ok && memo != "" {
|
||||
if level := pp.extractBlossomLevelFromMemo(memo); level != "" {
|
||||
return level
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check description
|
||||
if description != "" {
|
||||
if level := pp.extractBlossomLevelFromMemo(description); level != "" {
|
||||
return level
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractBlossomLevelFromMemo extracts blossom service level from memo text
|
||||
// Supports formats: "blossom:basic", "blossom:premium", "blossom:basic:100"
|
||||
func (pp *PaymentProcessor) extractBlossomLevelFromMemo(memo string) string {
|
||||
// Look for "blossom:" prefix
|
||||
parts := strings.Fields(memo)
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "blossom:") {
|
||||
// Extract level name (e.g., "basic", "premium")
|
||||
levelPart := strings.TrimPrefix(part, "blossom:")
|
||||
// Remove any storage specification (e.g., ":100")
|
||||
if colonIdx := strings.Index(levelPart, ":"); colonIdx > 0 {
|
||||
levelPart = levelPart[:colonIdx]
|
||||
}
|
||||
// Validate level exists in config
|
||||
if pp.isValidBlossomLevel(levelPart) {
|
||||
return levelPart
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isValidBlossomLevel checks if a service level is configured
|
||||
func (pp *PaymentProcessor) isValidBlossomLevel(level string) bool {
|
||||
if pp.config == nil || pp.config.BlossomServiceLevels == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse service levels from config
|
||||
levels := strings.Split(pp.config.BlossomServiceLevels, ",")
|
||||
for _, l := range levels {
|
||||
l = strings.TrimSpace(l)
|
||||
if strings.HasPrefix(l, level+":") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseServiceLevelStorage parses storage quota in MB per sat per month for a service level
|
||||
func (pp *PaymentProcessor) parseServiceLevelStorage(level string) (int64, error) {
|
||||
if pp.config == nil || pp.config.BlossomServiceLevels == "" {
|
||||
return 0, fmt.Errorf("blossom service levels not configured")
|
||||
}
|
||||
|
||||
levels := strings.Split(pp.config.BlossomServiceLevels, ",")
|
||||
for _, l := range levels {
|
||||
l = strings.TrimSpace(l)
|
||||
if strings.HasPrefix(l, level+":") {
|
||||
parts := strings.Split(l, ":")
|
||||
if len(parts) >= 2 {
|
||||
var storageMB float64
|
||||
if _, err := fmt.Sscanf(parts[1], "%f", &storageMB); err != nil {
|
||||
return 0, fmt.Errorf("invalid storage format: %w", err)
|
||||
}
|
||||
return int64(storageMB), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("service level %s not found", level)
|
||||
}
|
||||
|
||||
// extendBlossomSubscription extends or creates a blossom subscription with service level
|
||||
func (pp *PaymentProcessor) extendBlossomSubscription(
|
||||
pubkey []byte, satsReceived int64, level string, days int,
|
||||
) error {
|
||||
// Get storage quota per sat per month for this level
|
||||
storageMBPerSatPerMonth, err := pp.parseServiceLevelStorage(level)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse service level storage: %w", err)
|
||||
}
|
||||
|
||||
// Calculate storage quota: sats * storage_mb_per_sat_per_month * (days / 30)
|
||||
storageMB := int64(float64(satsReceived) * float64(storageMBPerSatPerMonth) * (float64(days) / 30.0))
|
||||
|
||||
// Extend blossom subscription
|
||||
if err := pp.db.ExtendBlossomSubscription(pubkey, level, storageMB, days); err != nil {
|
||||
return fmt.Errorf("failed to extend blossom subscription: %w", err)
|
||||
}
|
||||
|
||||
log.I.F(
|
||||
"extended blossom subscription: level=%s, storage=%d MB, days=%d",
|
||||
level, storageMB, days,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateRelayProfile creates or updates the relay's kind 0 profile with subscription information
|
||||
func (pp *PaymentProcessor) UpdateRelayProfile() error {
|
||||
// Get relay identity secret to sign the profile
|
||||
@@ -897,7 +1025,7 @@ func (pp *PaymentProcessor) UpdateRelayProfile() error {
|
||||
}
|
||||
|
||||
// Initialize signer
|
||||
sign := new(p256k.Signer)
|
||||
sign := p256k1signer.NewP256K1Signer()
|
||||
if err := sign.InitSec(skb); err != nil {
|
||||
return fmt.Errorf("failed to initialize signer: %w", err)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ import (
|
||||
|
||||
const Type = "socketapi"
|
||||
|
||||
// WriteChanMap maps websocket connections to their write channels
|
||||
type WriteChanMap map[*websocket.Conn]chan publish.WriteRequest
|
||||
|
||||
type Subscription struct {
|
||||
remote string
|
||||
AuthedPubkey []byte
|
||||
@@ -33,9 +36,6 @@ type Subscription struct {
|
||||
// connections.
|
||||
type Map map[*websocket.Conn]map[string]Subscription
|
||||
|
||||
// WriteChanMap maps websocket connections to their write channels
|
||||
type WriteChanMap map[*websocket.Conn]chan<- publish.WriteRequest
|
||||
|
||||
type W struct {
|
||||
*websocket.Conn
|
||||
|
||||
@@ -88,20 +88,6 @@ func NewPublisher(c context.Context) (publisher *P) {
|
||||
|
||||
func (p *P) Type() (typeName string) { return Type }
|
||||
|
||||
// SetWriteChan stores the write channel for a websocket connection
|
||||
func (p *P) SetWriteChan(conn *websocket.Conn, writeChan chan<- publish.WriteRequest) {
|
||||
p.Mx.Lock()
|
||||
defer p.Mx.Unlock()
|
||||
p.WriteChans[conn] = writeChan
|
||||
}
|
||||
|
||||
// GetWriteChan returns the write channel for a websocket connection
|
||||
func (p *P) GetWriteChan(conn *websocket.Conn) (chan<- publish.WriteRequest, bool) {
|
||||
p.Mx.RLock()
|
||||
defer p.Mx.RUnlock()
|
||||
ch, ok := p.WriteChans[conn]
|
||||
return ch, ok
|
||||
}
|
||||
|
||||
// Receive handles incoming messages to manage websocket listener subscriptions
|
||||
// and associated filters.
|
||||
@@ -314,14 +300,14 @@ func (p *P) Deliver(ev *event.E) {
|
||||
log.D.F("subscription delivery QUEUED: event=%s to=%s sub=%s len=%d",
|
||||
hex.Enc(ev.ID), d.sub.remote, d.id, len(msgData))
|
||||
case <-time.After(DefaultWriteTimeout):
|
||||
log.E.F("subscription delivery TIMEOUT: event=%s to=%s sub=%s (write channel full)",
|
||||
log.E.F("subscription delivery TIMEOUT: event=%s to=%s sub=%s",
|
||||
hex.Enc(ev.ID), d.sub.remote, d.id)
|
||||
// Check if connection is still valid
|
||||
p.Mx.RLock()
|
||||
stillSubscribed = p.Map[d.w] != nil
|
||||
p.Mx.RUnlock()
|
||||
if !stillSubscribed {
|
||||
log.D.F("removing failed subscriber connection due to channel timeout: %s", d.sub.remote)
|
||||
log.D.F("removing failed subscriber connection: %s", d.sub.remote)
|
||||
p.removeSubscriber(d.w)
|
||||
}
|
||||
}
|
||||
@@ -340,11 +326,33 @@ func (p *P) removeSubscriberId(ws *websocket.Conn, id string) {
|
||||
// Check the actual map after deletion, not the original reference
|
||||
if len(p.Map[ws]) == 0 {
|
||||
delete(p.Map, ws)
|
||||
delete(p.WriteChans, ws)
|
||||
// Don't remove write channel here - it's tied to the connection, not subscriptions
|
||||
// The write channel will be removed when the connection closes (in handle-websocket.go defer)
|
||||
// This allows new subscriptions to be created on the same connection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetWriteChan stores the write channel for a websocket connection
|
||||
// If writeChan is nil, the entry is removed from the map
|
||||
func (p *P) SetWriteChan(conn *websocket.Conn, writeChan chan publish.WriteRequest) {
|
||||
p.Mx.Lock()
|
||||
defer p.Mx.Unlock()
|
||||
if writeChan == nil {
|
||||
delete(p.WriteChans, conn)
|
||||
} else {
|
||||
p.WriteChans[conn] = writeChan
|
||||
}
|
||||
}
|
||||
|
||||
// GetWriteChan returns the write channel for a websocket connection
|
||||
func (p *P) GetWriteChan(conn *websocket.Conn) (chan publish.WriteRequest, bool) {
|
||||
p.Mx.RLock()
|
||||
defer p.Mx.RUnlock()
|
||||
ch, ok := p.WriteChans[conn]
|
||||
return ch, ok
|
||||
}
|
||||
|
||||
// removeSubscriber removes a websocket from the P collection.
|
||||
func (p *P) removeSubscriber(ws *websocket.Conn) {
|
||||
p.Mx.Lock()
|
||||
|
||||
@@ -27,6 +27,8 @@ import (
|
||||
"next.orly.dev/pkg/protocol/httpauth"
|
||||
"next.orly.dev/pkg/protocol/publish"
|
||||
"next.orly.dev/pkg/spider"
|
||||
dsync "next.orly.dev/pkg/sync"
|
||||
blossom "next.orly.dev/pkg/blossom"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
@@ -49,6 +51,8 @@ type Server struct {
|
||||
sprocketManager *SprocketManager
|
||||
policyManager *policy.P
|
||||
spiderManager *spider.Spider
|
||||
syncManager *dsync.Manager
|
||||
blossomServer *blossom.Server
|
||||
}
|
||||
|
||||
// isIPBlacklisted checks if an IP address is blacklisted using the managed ACL system
|
||||
@@ -241,6 +245,19 @@ func (s *Server) UserInterface() {
|
||||
s.mux.HandleFunc("/api/nip86", s.handleNIP86Management)
|
||||
// ACL mode endpoint
|
||||
s.mux.HandleFunc("/api/acl-mode", s.handleACLMode)
|
||||
|
||||
// Sync endpoints for distributed synchronization
|
||||
if s.syncManager != nil {
|
||||
s.mux.HandleFunc("/api/sync/current", s.handleSyncCurrent)
|
||||
s.mux.HandleFunc("/api/sync/fetch", s.handleSyncFetch)
|
||||
log.Printf("Distributed sync API enabled at /api/sync")
|
||||
}
|
||||
|
||||
// Blossom blob storage API endpoint
|
||||
if s.blossomServer != nil {
|
||||
s.mux.HandleFunc("/blossom/", s.blossomHandler)
|
||||
log.Printf("Blossom blob storage API enabled at /blossom")
|
||||
}
|
||||
}
|
||||
|
||||
// handleFavicon serves orly-favicon.png as favicon.ico
|
||||
@@ -982,3 +999,70 @@ func (s *Server) handleACLMode(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Write(jsonData)
|
||||
}
|
||||
|
||||
// handleSyncCurrent handles requests for the current serial number
|
||||
func (s *Server) handleSyncCurrent(w http.ResponseWriter, r *http.Request) {
|
||||
if s.syncManager == nil {
|
||||
http.Error(w, "Sync manager not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate NIP-98 authentication and check peer authorization
|
||||
if !s.validatePeerRequest(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
s.syncManager.HandleCurrentRequest(w, r)
|
||||
}
|
||||
|
||||
// handleSyncFetch handles requests for events in a serial range
|
||||
func (s *Server) handleSyncFetch(w http.ResponseWriter, r *http.Request) {
|
||||
if s.syncManager == nil {
|
||||
http.Error(w, "Sync manager not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate NIP-98 authentication and check peer authorization
|
||||
if !s.validatePeerRequest(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
s.syncManager.HandleFetchRequest(w, r)
|
||||
}
|
||||
|
||||
// validatePeerRequest validates NIP-98 authentication and checks if the requesting peer is authorized
|
||||
func (s *Server) validatePeerRequest(w http.ResponseWriter, r *http.Request) bool {
|
||||
// Validate NIP-98 authentication
|
||||
valid, pubkey, err := httpauth.CheckAuth(r)
|
||||
if err != nil {
|
||||
log.Printf("NIP-98 auth validation error: %v", err)
|
||||
http.Error(w, "Authentication validation failed", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
if !valid {
|
||||
http.Error(w, "NIP-98 authentication required", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if this pubkey corresponds to a configured peer relay
|
||||
peerPubkeyHex := hex.Enc(pubkey)
|
||||
for range s.Config.RelayPeers {
|
||||
// Extract pubkey from peer URL (assuming format: https://relay.example.com@pubkey)
|
||||
// For now, check if the pubkey matches any configured admin/owner
|
||||
// TODO: Implement proper peer identity mapping
|
||||
for _, admin := range s.Admins {
|
||||
if hex.Enc(admin) == peerPubkeyHex {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, owner := range s.Owners {
|
||||
if hex.Enc(owner) == peerPubkeyHex {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Unauthorized sync request from pubkey: %s", peerPubkeyHex)
|
||||
http.Error(w, "Unauthorized peer", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/crypto/sha256"
|
||||
"next.orly.dev/pkg/encoders/bech32encoding"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
@@ -335,7 +335,7 @@ func NewAggregator(keyInput string, since, until *timestamp.T, bloomFilterFile s
|
||||
}
|
||||
|
||||
// Create signer from private key
|
||||
signer = &p256k.Signer{}
|
||||
signer = p256k1signer.NewP256K1Signer()
|
||||
if err = signer.InitSec(secretBytes); chk.E(err) {
|
||||
return nil, fmt.Errorf("failed to initialize signer: %w", err)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
"next.orly.dev/pkg/database"
|
||||
"next.orly.dev/pkg/encoders/envelopes/eventenvelope"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
@@ -22,6 +21,7 @@ import (
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
"next.orly.dev/pkg/encoders/timestamp"
|
||||
"next.orly.dev/pkg/protocol/ws"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
)
|
||||
|
||||
type BenchmarkConfig struct {
|
||||
@@ -167,7 +167,7 @@ func runNetworkLoad(cfg *BenchmarkConfig) {
|
||||
fmt.Printf("worker %d: connected to %s\n", workerID, cfg.RelayURL)
|
||||
|
||||
// Signer for this worker
|
||||
var keys p256k.Signer
|
||||
keys := p256k1signer.NewP256K1Signer()
|
||||
if err := keys.Generate(); err != nil {
|
||||
fmt.Printf("worker %d: keygen failed: %v\n", workerID, err)
|
||||
return
|
||||
@@ -244,7 +244,7 @@ func runNetworkLoad(cfg *BenchmarkConfig) {
|
||||
ev.Content = []byte(fmt.Sprintf(
|
||||
"bench worker=%d n=%d", workerID, count,
|
||||
))
|
||||
if err := ev.Sign(&keys); err != nil {
|
||||
if err := ev.Sign(keys); err != nil {
|
||||
fmt.Printf("worker %d: sign error: %v\n", workerID, err)
|
||||
ev.Free()
|
||||
continue
|
||||
@@ -960,7 +960,7 @@ func (b *Benchmark) generateEvents(count int) []*event.E {
|
||||
now := timestamp.Now()
|
||||
|
||||
// Generate a keypair for signing all events
|
||||
var keys p256k.Signer
|
||||
keys := p256k1signer.NewP256K1Signer()
|
||||
if err := keys.Generate(); err != nil {
|
||||
log.Fatalf("Failed to generate keys for benchmark events: %v", err)
|
||||
}
|
||||
@@ -983,7 +983,7 @@ func (b *Benchmark) generateEvents(count int) []*event.E {
|
||||
)
|
||||
|
||||
// Properly sign the event instead of generating fake signatures
|
||||
if err := ev.Sign(&keys); err != nil {
|
||||
if err := ev.Sign(keys); err != nil {
|
||||
log.Fatalf("Failed to sign event %d: %v", i, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/filter"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
@@ -44,7 +44,7 @@ func main() {
|
||||
log.E.F("failed to decode allowed secret key: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
allowedSigner := &p256k.Signer{}
|
||||
allowedSigner := p256k1signer.NewP256K1Signer()
|
||||
if err = allowedSigner.InitSec(allowedSecBytes); chk.E(err) {
|
||||
log.E.F("failed to initialize allowed signer: %v", err)
|
||||
os.Exit(1)
|
||||
@@ -55,7 +55,7 @@ func main() {
|
||||
log.E.F("failed to decode unauthorized secret key: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
unauthorizedSigner := &p256k.Signer{}
|
||||
unauthorizedSigner := p256k1signer.NewP256K1Signer()
|
||||
if err = unauthorizedSigner.InitSec(unauthorizedSecBytes); chk.E(err) {
|
||||
log.E.F("failed to initialize unauthorized signer: %v", err)
|
||||
os.Exit(1)
|
||||
@@ -136,7 +136,7 @@ func main() {
|
||||
fmt.Println("\n✅ All tests passed!")
|
||||
}
|
||||
|
||||
func testWriteEvent(ctx context.Context, url string, kindNum uint16, eventSigner, authSigner *p256k.Signer) error {
|
||||
func testWriteEvent(ctx context.Context, url string, kindNum uint16, eventSigner, authSigner *p256k1signer.P256K1Signer) error {
|
||||
rl, err := ws.RelayConnect(ctx, url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect error: %w", err)
|
||||
@@ -192,7 +192,7 @@ func testWriteEvent(ctx context.Context, url string, kindNum uint16, eventSigner
|
||||
return nil
|
||||
}
|
||||
|
||||
func testWriteEventUnauthenticated(ctx context.Context, url string, kindNum uint16, eventSigner *p256k.Signer) error {
|
||||
func testWriteEventUnauthenticated(ctx context.Context, url string, kindNum uint16, eventSigner *p256k1signer.P256K1Signer) error {
|
||||
rl, err := ws.RelayConnect(ctx, url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect error: %w", err)
|
||||
@@ -227,7 +227,7 @@ func testWriteEventUnauthenticated(ctx context.Context, url string, kindNum uint
|
||||
return nil
|
||||
}
|
||||
|
||||
func testReadEvent(ctx context.Context, url string, kindNum uint16, authSigner *p256k.Signer) error {
|
||||
func testReadEvent(ctx context.Context, url string, kindNum uint16, authSigner *p256k1signer.P256K1Signer) error {
|
||||
rl, err := ws.RelayConnect(ctx, url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect error: %w", err)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/kind"
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
@@ -29,7 +29,7 @@ func main() {
|
||||
}
|
||||
defer rl.Close()
|
||||
|
||||
signer := &p256k.Signer{}
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err = signer.Generate(); chk.E(err) {
|
||||
log.E.F("signer generate error: %v", err)
|
||||
return
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/envelopes/eventenvelope"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/event/examples"
|
||||
@@ -35,7 +35,7 @@ func randomHex(n int) string {
|
||||
return hex.Enc(b)
|
||||
}
|
||||
|
||||
func makeEvent(rng *rand.Rand, signer *p256k.Signer) (*event.E, error) {
|
||||
func makeEvent(rng *rand.Rand, signer *p256k1signer.P256K1Signer) (*event.E, error) {
|
||||
ev := &event.E{
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Kind: kind.TextNote.K,
|
||||
@@ -293,7 +293,7 @@ func publisherWorker(
|
||||
src := rand.NewSource(time.Now().UnixNano() ^ int64(id<<16))
|
||||
rng := rand.New(src)
|
||||
// Generate and reuse signing key per worker
|
||||
signer := &p256k.Signer{}
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err := signer.Generate(); err != nil {
|
||||
log.E.F("worker %d: signer generate error: %v", id, err)
|
||||
return
|
||||
|
||||
294
pkg/blossom/auth.go
Normal file
294
pkg/blossom/auth.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/errorf"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/encoders/ints"
|
||||
)
|
||||
|
||||
const (
|
||||
// BlossomAuthKind is the Nostr event kind for Blossom authorization events (BUD-01)
|
||||
BlossomAuthKind = 24242
|
||||
// AuthorizationHeader is the HTTP header name for authorization
|
||||
AuthorizationHeader = "Authorization"
|
||||
// NostrAuthPrefix is the prefix for Nostr authorization scheme
|
||||
NostrAuthPrefix = "Nostr"
|
||||
)
|
||||
|
||||
// AuthEvent represents a validated authorization event
|
||||
type AuthEvent struct {
|
||||
Event *event.E
|
||||
Pubkey []byte
|
||||
Verb string
|
||||
Expires int64
|
||||
}
|
||||
|
||||
// ExtractAuthEvent extracts and parses a kind 24242 authorization event from the Authorization header
|
||||
func ExtractAuthEvent(r *http.Request) (ev *event.E, err error) {
|
||||
authHeader := r.Header.Get(AuthorizationHeader)
|
||||
if authHeader == "" {
|
||||
err = errorf.E("missing Authorization header")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse "Nostr <base64>" format
|
||||
if !strings.HasPrefix(authHeader, NostrAuthPrefix+" ") {
|
||||
err = errorf.E("invalid Authorization scheme, expected 'Nostr'")
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
err = errorf.E("invalid Authorization header format")
|
||||
return
|
||||
}
|
||||
|
||||
var evb []byte
|
||||
if evb, err = base64.StdEncoding.DecodeString(parts[1]); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
ev = event.New()
|
||||
var rem []byte
|
||||
if rem, err = ev.Unmarshal(evb); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(rem) > 0 {
|
||||
err = errorf.E("unexpected trailing data in auth event")
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ValidateAuthEvent validates a kind 24242 authorization event according to BUD-01
|
||||
func ValidateAuthEvent(
|
||||
r *http.Request, verb string, sha256Hash []byte,
|
||||
) (authEv *AuthEvent, err error) {
|
||||
var ev *event.E
|
||||
if ev, err = ExtractAuthEvent(r); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. The kind must be 24242
|
||||
if ev.Kind != BlossomAuthKind {
|
||||
err = errorf.E(
|
||||
"invalid kind %d in authorization event, require %d",
|
||||
ev.Kind, BlossomAuthKind,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. created_at must be in the past
|
||||
now := time.Now().Unix()
|
||||
if ev.CreatedAt > now {
|
||||
err = errorf.E(
|
||||
"authorization event created_at %d is in the future (now: %d)",
|
||||
ev.CreatedAt, now,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Check expiration tag (must be set and in the future)
|
||||
expTags := ev.Tags.GetAll([]byte("expiration"))
|
||||
if len(expTags) == 0 {
|
||||
err = errorf.E("authorization event missing expiration tag")
|
||||
return
|
||||
}
|
||||
if len(expTags) > 1 {
|
||||
err = errorf.E("authorization event has multiple expiration tags")
|
||||
return
|
||||
}
|
||||
|
||||
expInt := ints.New(0)
|
||||
var rem []byte
|
||||
if rem, err = expInt.Unmarshal(expTags[0].Value()); chk.E(err) {
|
||||
return
|
||||
}
|
||||
if len(rem) > 0 {
|
||||
err = errorf.E("unexpected trailing data in expiration tag")
|
||||
return
|
||||
}
|
||||
|
||||
expiration := expInt.Int64()
|
||||
if expiration <= now {
|
||||
err = errorf.E(
|
||||
"authorization event expired: expiration %d <= now %d",
|
||||
expiration, now,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 4. The t tag must have a verb matching the intended action
|
||||
tTags := ev.Tags.GetAll([]byte("t"))
|
||||
if len(tTags) == 0 {
|
||||
err = errorf.E("authorization event missing 't' tag")
|
||||
return
|
||||
}
|
||||
if len(tTags) > 1 {
|
||||
err = errorf.E("authorization event has multiple 't' tags")
|
||||
return
|
||||
}
|
||||
|
||||
eventVerb := string(tTags[0].Value())
|
||||
if eventVerb != verb {
|
||||
err = errorf.E(
|
||||
"authorization event verb '%s' does not match required verb '%s'",
|
||||
eventVerb, verb,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 5. If sha256Hash is provided, verify at least one x tag matches
|
||||
if sha256Hash != nil && len(sha256Hash) > 0 {
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
xTags := ev.Tags.GetAll([]byte("x"))
|
||||
if len(xTags) == 0 {
|
||||
err = errorf.E(
|
||||
"authorization event missing 'x' tag for SHA256 hash %s",
|
||||
sha256Hex,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, xTag := range xTags {
|
||||
if string(xTag.Value()) == sha256Hex {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
err = errorf.E(
|
||||
"authorization event has no 'x' tag matching SHA256 hash %s",
|
||||
sha256Hex,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Verify event signature
|
||||
var valid bool
|
||||
if valid, err = ev.Verify(); chk.E(err) {
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
err = errorf.E("authorization event signature verification failed")
|
||||
return
|
||||
}
|
||||
|
||||
authEv = &AuthEvent{
|
||||
Event: ev,
|
||||
Pubkey: ev.Pubkey,
|
||||
Verb: eventVerb,
|
||||
Expires: expiration,
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ValidateAuthEventOptional validates authorization but returns nil if no auth header is present
|
||||
// This is used for endpoints where authorization is optional
|
||||
func ValidateAuthEventOptional(
|
||||
r *http.Request, verb string, sha256Hash []byte,
|
||||
) (authEv *AuthEvent, err error) {
|
||||
authHeader := r.Header.Get(AuthorizationHeader)
|
||||
if authHeader == "" {
|
||||
// No authorization provided, but that's OK for optional endpoints
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return ValidateAuthEvent(r, verb, sha256Hash)
|
||||
}
|
||||
|
||||
// ValidateAuthEventForGet validates authorization for GET requests (BUD-01)
|
||||
// GET requests may have either:
|
||||
// - A server tag matching the server URL
|
||||
// - At least one x tag matching the blob hash
|
||||
func ValidateAuthEventForGet(
|
||||
r *http.Request, serverURL string, sha256Hash []byte,
|
||||
) (authEv *AuthEvent, err error) {
|
||||
var ev *event.E
|
||||
if ev, err = ExtractAuthEvent(r); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if authEv, err = ValidateAuthEvent(r, "get", sha256Hash); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// For GET requests, check server tag or x tag
|
||||
serverTags := ev.Tags.GetAll([]byte("server"))
|
||||
xTags := ev.Tags.GetAll([]byte("x"))
|
||||
|
||||
// If server tag exists, verify it matches
|
||||
if len(serverTags) > 0 {
|
||||
serverTagValue := string(serverTags[0].Value())
|
||||
if !strings.HasPrefix(serverURL, serverTagValue) {
|
||||
err = errorf.E(
|
||||
"server tag '%s' does not match server URL '%s'",
|
||||
serverTagValue, serverURL,
|
||||
)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, verify at least one x tag matches the hash
|
||||
if sha256Hash != nil && len(sha256Hash) > 0 {
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
found := false
|
||||
for _, xTag := range xTags {
|
||||
if string(xTag.Value()) == sha256Hex {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
err = errorf.E(
|
||||
"no 'x' tag matching SHA256 hash %s",
|
||||
sha256Hex,
|
||||
)
|
||||
return
|
||||
}
|
||||
} else if len(xTags) == 0 {
|
||||
err = errorf.E(
|
||||
"authorization event must have either 'server' tag or 'x' tag",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// GetPubkeyFromRequest extracts pubkey from Authorization header if present
|
||||
func GetPubkeyFromRequest(r *http.Request) (pubkey []byte, err error) {
|
||||
authHeader := r.Header.Get(AuthorizationHeader)
|
||||
if authHeader == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
authEv, err := ValidateAuthEventOptional(r, "", nil)
|
||||
if err != nil {
|
||||
// If validation fails, return empty pubkey but no error
|
||||
// This allows endpoints to work without auth
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if authEv != nil {
|
||||
return authEv.Pubkey, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
67
pkg/blossom/blob.go
Normal file
67
pkg/blossom/blob.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BlobDescriptor represents a blob descriptor as defined in BUD-02
|
||||
type BlobDescriptor struct {
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int64 `json:"size"`
|
||||
Type string `json:"type"`
|
||||
Uploaded int64 `json:"uploaded"`
|
||||
NIP94 [][]string `json:"nip94,omitempty"`
|
||||
}
|
||||
|
||||
// BlobMetadata stores metadata about a blob in the database
|
||||
type BlobMetadata struct {
|
||||
Pubkey []byte `json:"pubkey"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Uploaded int64 `json:"uploaded"`
|
||||
Size int64 `json:"size"`
|
||||
Extension string `json:"extension"` // File extension (e.g., ".png", ".pdf")
|
||||
}
|
||||
|
||||
// NewBlobDescriptor creates a new blob descriptor
|
||||
func NewBlobDescriptor(
|
||||
url, sha256 string, size int64, mimeType string, uploaded int64,
|
||||
) *BlobDescriptor {
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
return &BlobDescriptor{
|
||||
URL: url,
|
||||
SHA256: sha256,
|
||||
Size: size,
|
||||
Type: mimeType,
|
||||
Uploaded: uploaded,
|
||||
}
|
||||
}
|
||||
|
||||
// NewBlobMetadata creates a new blob metadata struct
|
||||
func NewBlobMetadata(pubkey []byte, mimeType string, size int64) *BlobMetadata {
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
return &BlobMetadata{
|
||||
Pubkey: pubkey,
|
||||
MimeType: mimeType,
|
||||
Uploaded: time.Now().Unix(),
|
||||
Size: size,
|
||||
Extension: "", // Will be set by SaveBlob
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize serializes blob metadata to JSON
|
||||
func (bm *BlobMetadata) Serialize() (data []byte, err error) {
|
||||
return json.Marshal(bm)
|
||||
}
|
||||
|
||||
// DeserializeBlobMetadata deserializes blob metadata from JSON
|
||||
func DeserializeBlobMetadata(data []byte) (bm *BlobMetadata, err error) {
|
||||
bm = &BlobMetadata{}
|
||||
err = json.Unmarshal(data, bm)
|
||||
return
|
||||
}
|
||||
845
pkg/blossom/handlers.go
Normal file
845
pkg/blossom/handlers.go
Normal file
@@ -0,0 +1,845 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/utils"
|
||||
)
|
||||
|
||||
// handleGetBlob handles GET /<sha256> requests (BUD-01)
|
||||
func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
|
||||
// Extract SHA256 and extension
|
||||
sha256Hex, ext, err := ExtractSHA256FromPath(path)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Convert hex to bytes
|
||||
sha256Hash, err := hex.Dec(sha256Hex)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid SHA256 format")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if blob exists
|
||||
exists, err := s.storage.HasBlob(sha256Hash)
|
||||
if err != nil {
|
||||
log.E.F("error checking blob existence: %v", err)
|
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
if !exists {
|
||||
s.setErrorResponse(w, http.StatusNotFound, "blob not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Get blob metadata
|
||||
metadata, err := s.storage.GetBlobMetadata(sha256Hash)
|
||||
if err != nil {
|
||||
log.E.F("error getting blob metadata: %v", err)
|
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
// Optional authorization check (BUD-01)
|
||||
if s.requireAuth {
|
||||
authEv, err := ValidateAuthEventForGet(r, s.getBaseURL(r), sha256Hash)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
|
||||
return
|
||||
}
|
||||
if authEv == nil {
|
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get blob data
|
||||
blobData, _, err := s.storage.GetBlob(sha256Hash)
|
||||
if err != nil {
|
||||
log.E.F("error getting blob: %v", err)
|
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
// Set headers
|
||||
mimeType := DetectMimeType(metadata.MimeType, ext)
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(blobData)), 10))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
|
||||
// Handle range requests (RFC 7233)
|
||||
rangeHeader := r.Header.Get("Range")
|
||||
if rangeHeader != "" {
|
||||
start, end, valid, err := ParseRangeHeader(rangeHeader, int64(len(blobData)))
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusRequestedRangeNotSatisfiable, err.Error())
|
||||
return
|
||||
}
|
||||
if valid {
|
||||
WriteRangeResponse(w, blobData, start, end, int64(len(blobData)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Send full blob
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(blobData)
|
||||
}
|
||||
|
||||
// handleHeadBlob handles HEAD /<sha256> requests (BUD-01)
|
||||
func (s *Server) handleHeadBlob(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
|
||||
// Extract SHA256 and extension
|
||||
sha256Hex, ext, err := ExtractSHA256FromPath(path)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Convert hex to bytes
|
||||
sha256Hash, err := hex.Dec(sha256Hex)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid SHA256 format")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if blob exists
|
||||
exists, err := s.storage.HasBlob(sha256Hash)
|
||||
if err != nil {
|
||||
log.E.F("error checking blob existence: %v", err)
|
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
if !exists {
|
||||
s.setErrorResponse(w, http.StatusNotFound, "blob not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Get blob metadata
|
||||
metadata, err := s.storage.GetBlobMetadata(sha256Hash)
|
||||
if err != nil {
|
||||
log.E.F("error getting blob metadata: %v", err)
|
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
// Optional authorization check
|
||||
if s.requireAuth {
|
||||
authEv, err := ValidateAuthEventForGet(r, s.getBaseURL(r), sha256Hash)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
|
||||
return
|
||||
}
|
||||
if authEv == nil {
|
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Set headers (same as GET but no body)
|
||||
mimeType := DetectMimeType(metadata.MimeType, ext)
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(metadata.Size, 10))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleUpload handles PUT /upload requests (BUD-02)
|
||||
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
// Check ACL
|
||||
pubkey, _ := GetPubkeyFromRequest(r)
|
||||
remoteAddr := s.getRemoteAddr(r)
|
||||
|
||||
if !s.checkACL(pubkey, remoteAddr, "write") {
|
||||
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
// Read request body
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, s.maxBlobSize+1))
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "error reading request body")
|
||||
return
|
||||
}
|
||||
|
||||
if int64(len(body)) > s.maxBlobSize {
|
||||
s.setErrorResponse(w, http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("blob too large: max %d bytes", s.maxBlobSize))
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate SHA256
|
||||
sha256Hash := CalculateSHA256(body)
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
// Check if blob already exists
|
||||
exists, err := s.storage.HasBlob(sha256Hash)
|
||||
if err != nil {
|
||||
log.E.F("error checking blob existence: %v", err)
|
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
// Optional authorization validation
|
||||
if r.Header.Get(AuthorizationHeader) != "" {
|
||||
authEv, err := ValidateAuthEvent(r, "upload", sha256Hash)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
if authEv != nil {
|
||||
pubkey = authEv.Pubkey
|
||||
}
|
||||
}
|
||||
|
||||
if len(pubkey) == 0 {
|
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
|
||||
return
|
||||
}
|
||||
|
||||
// Detect MIME type
|
||||
mimeType := DetectMimeType(
|
||||
r.Header.Get("Content-Type"),
|
||||
GetFileExtensionFromPath(r.URL.Path),
|
||||
)
|
||||
|
||||
// Extract extension from path or infer from MIME type
|
||||
ext := GetFileExtensionFromPath(r.URL.Path)
|
||||
if ext == "" {
|
||||
ext = GetExtensionFromMimeType(mimeType)
|
||||
}
|
||||
|
||||
// Check allowed MIME types
|
||||
if len(s.allowedMimeTypes) > 0 && !s.allowedMimeTypes[mimeType] {
|
||||
s.setErrorResponse(w, http.StatusUnsupportedMediaType,
|
||||
fmt.Sprintf("MIME type %s not allowed", mimeType))
|
||||
return
|
||||
}
|
||||
|
||||
// Check storage quota if blob doesn't exist (new upload)
|
||||
if !exists {
|
||||
blobSizeMB := int64(len(body)) / (1024 * 1024)
|
||||
if blobSizeMB == 0 && len(body) > 0 {
|
||||
blobSizeMB = 1 // At least 1 MB for any non-zero blob
|
||||
}
|
||||
|
||||
// Get storage quota from database
|
||||
quotaMB, err := s.db.GetBlossomStorageQuota(pubkey)
|
||||
if err != nil {
|
||||
log.W.F("failed to get storage quota: %v", err)
|
||||
} else if quotaMB > 0 {
|
||||
// Get current storage used
|
||||
usedMB, err := s.storage.GetTotalStorageUsed(pubkey)
|
||||
if err != nil {
|
||||
log.W.F("failed to calculate storage used: %v", err)
|
||||
} else {
|
||||
// Check if upload would exceed quota
|
||||
if usedMB+blobSizeMB > quotaMB {
|
||||
s.setErrorResponse(w, http.StatusPaymentRequired,
|
||||
fmt.Sprintf("storage quota exceeded: %d/%d MB used, %d MB needed",
|
||||
usedMB, quotaMB, blobSizeMB))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save blob if it doesn't exist
|
||||
if !exists {
|
||||
if err = s.storage.SaveBlob(sha256Hash, body, pubkey, mimeType, ext); err != nil {
|
||||
log.E.F("error saving blob: %v", err)
|
||||
s.setErrorResponse(w, http.StatusInternalServerError, "error saving blob")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Verify ownership
|
||||
metadata, err := s.storage.GetBlobMetadata(sha256Hash)
|
||||
if err != nil {
|
||||
log.E.F("error getting blob metadata: %v", err)
|
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
// Allow if same pubkey or if ACL allows
|
||||
if !utils.FastEqual(metadata.Pubkey, pubkey) && !s.checkACL(pubkey, remoteAddr, "admin") {
|
||||
s.setErrorResponse(w, http.StatusConflict, "blob already exists")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Build URL with extension
|
||||
blobURL := BuildBlobURL(s.getBaseURL(r), sha256Hex, ext)
|
||||
|
||||
// Create descriptor
|
||||
descriptor := NewBlobDescriptor(
|
||||
blobURL,
|
||||
sha256Hex,
|
||||
int64(len(body)),
|
||||
mimeType,
|
||||
time.Now().Unix(),
|
||||
)
|
||||
|
||||
// Return descriptor
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err = json.NewEncoder(w).Encode(descriptor); err != nil {
|
||||
log.E.F("error encoding response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUploadRequirements handles HEAD /upload requests (BUD-06)
|
||||
func (s *Server) handleUploadRequirements(w http.ResponseWriter, r *http.Request) {
|
||||
// Get headers
|
||||
sha256Hex := r.Header.Get("X-SHA-256")
|
||||
contentLengthStr := r.Header.Get("X-Content-Length")
|
||||
contentType := r.Header.Get("X-Content-Type")
|
||||
|
||||
// Validate SHA256 header
|
||||
if sha256Hex == "" {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "missing X-SHA-256 header")
|
||||
return
|
||||
}
|
||||
|
||||
if !ValidateSHA256Hex(sha256Hex) {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid X-SHA-256 header format")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate Content-Length header
|
||||
if contentLengthStr == "" {
|
||||
s.setErrorResponse(w, http.StatusLengthRequired, "missing X-Content-Length header")
|
||||
return
|
||||
}
|
||||
|
||||
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid X-Content-Length header")
|
||||
return
|
||||
}
|
||||
|
||||
if contentLength > s.maxBlobSize {
|
||||
s.setErrorResponse(w, http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("file too large: max %d bytes", s.maxBlobSize))
|
||||
return
|
||||
}
|
||||
|
||||
// Check MIME type if provided
|
||||
if contentType != "" && len(s.allowedMimeTypes) > 0 {
|
||||
if !s.allowedMimeTypes[contentType] {
|
||||
s.setErrorResponse(w, http.StatusUnsupportedMediaType,
|
||||
fmt.Sprintf("unsupported file type: %s", contentType))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check if blob already exists
|
||||
sha256Hash, err := hex.Dec(sha256Hex)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid SHA256 format")
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := s.storage.HasBlob(sha256Hash)
|
||||
if err != nil {
|
||||
log.E.F("error checking blob existence: %v", err)
|
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
if exists {
|
||||
// Return 200 OK - blob already exists, upload can proceed
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Optional authorization check
|
||||
if r.Header.Get(AuthorizationHeader) != "" {
|
||||
authEv, err := ValidateAuthEvent(r, "upload", sha256Hash)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
if authEv == nil {
|
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
|
||||
return
|
||||
}
|
||||
|
||||
// Check ACL
|
||||
remoteAddr := s.getRemoteAddr(r)
|
||||
if !s.checkACL(authEv.Pubkey, remoteAddr, "write") {
|
||||
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleListBlobs handles GET /list/<pubkey> requests (BUD-02)
|
||||
func (s *Server) handleListBlobs(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
|
||||
// Extract pubkey from path: list/<pubkey>
|
||||
if !strings.HasPrefix(path, "list/") {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid path")
|
||||
return
|
||||
}
|
||||
|
||||
pubkeyHex := strings.TrimPrefix(path, "list/")
|
||||
if len(pubkeyHex) != 64 {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid pubkey format")
|
||||
return
|
||||
}
|
||||
|
||||
pubkey, err := hex.Dec(pubkeyHex)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid pubkey format")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
var since, until int64
|
||||
if sinceStr := r.URL.Query().Get("since"); sinceStr != "" {
|
||||
since, err = strconv.ParseInt(sinceStr, 10, 64)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid since parameter")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if untilStr := r.URL.Query().Get("until"); untilStr != "" {
|
||||
until, err = strconv.ParseInt(untilStr, 10, 64)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid until parameter")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Optional authorization check
|
||||
requestPubkey, _ := GetPubkeyFromRequest(r)
|
||||
if r.Header.Get(AuthorizationHeader) != "" {
|
||||
authEv, err := ValidateAuthEvent(r, "list", nil)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
if authEv != nil {
|
||||
requestPubkey = authEv.Pubkey
|
||||
}
|
||||
}
|
||||
|
||||
// Check if requesting own list or has admin access
|
||||
if !utils.FastEqual(pubkey, requestPubkey) && !s.checkACL(requestPubkey, s.getRemoteAddr(r), "admin") {
|
||||
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
// List blobs
|
||||
descriptors, err := s.storage.ListBlobs(pubkey, since, until)
|
||||
if err != nil {
|
||||
log.E.F("error listing blobs: %v", err)
|
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
// Set URLs for descriptors
|
||||
for _, desc := range descriptors {
|
||||
desc.URL = BuildBlobURL(s.getBaseURL(r), desc.SHA256, "")
|
||||
}
|
||||
|
||||
// Return JSON array
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err = json.NewEncoder(w).Encode(descriptors); err != nil {
|
||||
log.E.F("error encoding response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteBlob handles DELETE /<sha256> requests (BUD-02)
|
||||
func (s *Server) handleDeleteBlob(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
|
||||
// Extract SHA256
|
||||
sha256Hex, _, err := ExtractSHA256FromPath(path)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sha256Hash, err := hex.Dec(sha256Hex)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid SHA256 format")
|
||||
return
|
||||
}
|
||||
|
||||
// Authorization required for delete
|
||||
authEv, err := ValidateAuthEvent(r, "delete", sha256Hash)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if authEv == nil {
|
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
|
||||
return
|
||||
}
|
||||
|
||||
// Check ACL
|
||||
remoteAddr := s.getRemoteAddr(r)
|
||||
if !s.checkACL(authEv.Pubkey, remoteAddr, "write") {
|
||||
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
metadata, err := s.storage.GetBlobMetadata(sha256Hash)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusNotFound, "blob not found")
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.FastEqual(metadata.Pubkey, authEv.Pubkey) && !s.checkACL(authEv.Pubkey, remoteAddr, "admin") {
|
||||
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions to delete this blob")
|
||||
return
|
||||
}
|
||||
|
||||
// Delete blob
|
||||
if err = s.storage.DeleteBlob(sha256Hash, authEv.Pubkey); err != nil {
|
||||
log.E.F("error deleting blob: %v", err)
|
||||
s.setErrorResponse(w, http.StatusInternalServerError, "error deleting blob")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleMirror handles PUT /mirror requests (BUD-04)
|
||||
func (s *Server) handleMirror(w http.ResponseWriter, r *http.Request) {
|
||||
// Check ACL
|
||||
pubkey, _ := GetPubkeyFromRequest(r)
|
||||
remoteAddr := s.getRemoteAddr(r)
|
||||
|
||||
if !s.checkACL(pubkey, remoteAddr, "write") {
|
||||
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
// Read request body (JSON with URL)
|
||||
var req struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.URL == "" {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "missing url field")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse URL
|
||||
mirrorURL, err := url.Parse(req.URL)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid URL")
|
||||
return
|
||||
}
|
||||
|
||||
// Download blob from remote URL
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(mirrorURL.String())
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadGateway, "failed to fetch blob from remote URL")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
s.setErrorResponse(w, http.StatusBadGateway,
|
||||
fmt.Sprintf("remote server returned status %d", resp.StatusCode))
|
||||
return
|
||||
}
|
||||
|
||||
// Read blob data
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, s.maxBlobSize+1))
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadGateway, "error reading remote blob")
|
||||
return
|
||||
}
|
||||
|
||||
if int64(len(body)) > s.maxBlobSize {
|
||||
s.setErrorResponse(w, http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("blob too large: max %d bytes", s.maxBlobSize))
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate SHA256
|
||||
sha256Hash := CalculateSHA256(body)
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
// Optional authorization validation
|
||||
if r.Header.Get(AuthorizationHeader) != "" {
|
||||
authEv, err := ValidateAuthEvent(r, "upload", sha256Hash)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
if authEv != nil {
|
||||
pubkey = authEv.Pubkey
|
||||
}
|
||||
}
|
||||
|
||||
if len(pubkey) == 0 {
|
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
|
||||
return
|
||||
}
|
||||
|
||||
// Detect MIME type from remote response
|
||||
mimeType := DetectMimeType(
|
||||
resp.Header.Get("Content-Type"),
|
||||
GetFileExtensionFromPath(mirrorURL.Path),
|
||||
)
|
||||
|
||||
// Extract extension from path or infer from MIME type
|
||||
ext := GetFileExtensionFromPath(mirrorURL.Path)
|
||||
if ext == "" {
|
||||
ext = GetExtensionFromMimeType(mimeType)
|
||||
}
|
||||
|
||||
// Save blob
|
||||
if err = s.storage.SaveBlob(sha256Hash, body, pubkey, mimeType, ext); err != nil {
|
||||
log.E.F("error saving mirrored blob: %v", err)
|
||||
s.setErrorResponse(w, http.StatusInternalServerError, "error saving blob")
|
||||
return
|
||||
}
|
||||
|
||||
// Build URL
|
||||
blobURL := BuildBlobURL(s.getBaseURL(r), sha256Hex, ext)
|
||||
|
||||
// Create descriptor
|
||||
descriptor := NewBlobDescriptor(
|
||||
blobURL,
|
||||
sha256Hex,
|
||||
int64(len(body)),
|
||||
mimeType,
|
||||
time.Now().Unix(),
|
||||
)
|
||||
|
||||
// Return descriptor
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err = json.NewEncoder(w).Encode(descriptor); err != nil {
|
||||
log.E.F("error encoding response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleMediaUpload handles PUT /media requests (BUD-05)
|
||||
func (s *Server) handleMediaUpload(w http.ResponseWriter, r *http.Request) {
|
||||
// Check ACL
|
||||
pubkey, _ := GetPubkeyFromRequest(r)
|
||||
remoteAddr := s.getRemoteAddr(r)
|
||||
|
||||
if !s.checkACL(pubkey, remoteAddr, "write") {
|
||||
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
// Read request body
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, s.maxBlobSize+1))
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "error reading request body")
|
||||
return
|
||||
}
|
||||
|
||||
if int64(len(body)) > s.maxBlobSize {
|
||||
s.setErrorResponse(w, http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("blob too large: max %d bytes", s.maxBlobSize))
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate SHA256 for authorization validation
|
||||
sha256Hash := CalculateSHA256(body)
|
||||
|
||||
// Optional authorization validation
|
||||
if r.Header.Get(AuthorizationHeader) != "" {
|
||||
authEv, err := ValidateAuthEvent(r, "media", sha256Hash)
|
||||
if err != nil {
|
||||
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
if authEv != nil {
|
||||
pubkey = authEv.Pubkey
|
||||
}
|
||||
}
|
||||
|
||||
if len(pubkey) == 0 {
|
||||
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
|
||||
return
|
||||
}
|
||||
|
||||
// Optimize media (placeholder - actual optimization would be implemented here)
|
||||
originalMimeType := DetectMimeType(
|
||||
r.Header.Get("Content-Type"),
|
||||
GetFileExtensionFromPath(r.URL.Path),
|
||||
)
|
||||
optimizedData, mimeType := OptimizeMedia(body, originalMimeType)
|
||||
|
||||
// Extract extension from path or infer from MIME type
|
||||
ext := GetFileExtensionFromPath(r.URL.Path)
|
||||
if ext == "" {
|
||||
ext = GetExtensionFromMimeType(mimeType)
|
||||
}
|
||||
|
||||
// Calculate optimized blob SHA256
|
||||
optimizedHash := CalculateSHA256(optimizedData)
|
||||
optimizedHex := hex.Enc(optimizedHash)
|
||||
|
||||
// Check if optimized blob already exists
|
||||
exists, err := s.storage.HasBlob(optimizedHash)
|
||||
if err != nil {
|
||||
log.E.F("error checking blob existence: %v", err)
|
||||
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
// Check storage quota if optimized blob doesn't exist (new upload)
|
||||
if !exists {
|
||||
blobSizeMB := int64(len(optimizedData)) / (1024 * 1024)
|
||||
if blobSizeMB == 0 && len(optimizedData) > 0 {
|
||||
blobSizeMB = 1 // At least 1 MB for any non-zero blob
|
||||
}
|
||||
|
||||
// Get storage quota from database
|
||||
quotaMB, err := s.db.GetBlossomStorageQuota(pubkey)
|
||||
if err != nil {
|
||||
log.W.F("failed to get storage quota: %v", err)
|
||||
} else if quotaMB > 0 {
|
||||
// Get current storage used
|
||||
usedMB, err := s.storage.GetTotalStorageUsed(pubkey)
|
||||
if err != nil {
|
||||
log.W.F("failed to calculate storage used: %v", err)
|
||||
} else {
|
||||
// Check if upload would exceed quota
|
||||
if usedMB+blobSizeMB > quotaMB {
|
||||
s.setErrorResponse(w, http.StatusPaymentRequired,
|
||||
fmt.Sprintf("storage quota exceeded: %d/%d MB used, %d MB needed",
|
||||
usedMB, quotaMB, blobSizeMB))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save optimized blob
|
||||
if err = s.storage.SaveBlob(optimizedHash, optimizedData, pubkey, mimeType, ext); err != nil {
|
||||
log.E.F("error saving optimized blob: %v", err)
|
||||
s.setErrorResponse(w, http.StatusInternalServerError, "error saving blob")
|
||||
return
|
||||
}
|
||||
|
||||
// Build URL
|
||||
blobURL := BuildBlobURL(s.baseURL, optimizedHex, ext)
|
||||
|
||||
// Create descriptor
|
||||
descriptor := NewBlobDescriptor(
|
||||
blobURL,
|
||||
optimizedHex,
|
||||
int64(len(optimizedData)),
|
||||
mimeType,
|
||||
time.Now().Unix(),
|
||||
)
|
||||
|
||||
// Return descriptor
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err = json.NewEncoder(w).Encode(descriptor); err != nil {
|
||||
log.E.F("error encoding response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleMediaHead handles HEAD /media requests (BUD-05)
|
||||
func (s *Server) handleMediaHead(w http.ResponseWriter, r *http.Request) {
|
||||
// Similar to handleUploadRequirements but for media
|
||||
// Return 200 OK if media optimization is available
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleReport handles PUT /report requests (BUD-09)
|
||||
func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||
// Check ACL
|
||||
pubkey, _ := GetPubkeyFromRequest(r)
|
||||
remoteAddr := s.getRemoteAddr(r)
|
||||
|
||||
if !s.checkACL(pubkey, remoteAddr, "read") {
|
||||
s.setErrorResponse(w, http.StatusForbidden, "insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
// Read request body (NIP-56 report event)
|
||||
var reportEv event.E
|
||||
if err := json.NewDecoder(r.Body).Decode(&reportEv); err != nil {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate report event (kind 1984 per NIP-56)
|
||||
if reportEv.Kind != 1984 {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "invalid event kind, expected 1984")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
valid, err := reportEv.Verify()
|
||||
if err != nil || !valid {
|
||||
s.setErrorResponse(w, http.StatusUnauthorized, "invalid event signature")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract x tags (blob hashes)
|
||||
xTags := reportEv.Tags.GetAll([]byte("x"))
|
||||
if len(xTags) == 0 {
|
||||
s.setErrorResponse(w, http.StatusBadRequest, "report event missing 'x' tags")
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize report event
|
||||
reportData := reportEv.Serialize()
|
||||
|
||||
// Save report for each blob hash
|
||||
for _, xTag := range xTags {
|
||||
sha256Hex := string(xTag.Value())
|
||||
if !ValidateSHA256Hex(sha256Hex) {
|
||||
continue
|
||||
}
|
||||
|
||||
sha256Hash, err := hex.Dec(sha256Hex)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err = s.storage.SaveReport(sha256Hash, reportData); err != nil {
|
||||
log.E.F("error saving report: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
756
pkg/blossom/http_test.go
Normal file
756
pkg/blossom/http_test.go
Normal file
@@ -0,0 +1,756 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
"next.orly.dev/pkg/encoders/timestamp"
|
||||
)
|
||||
|
||||
// TestHTTPGetBlob tests GET /<sha256> endpoint
|
||||
func TestHTTPGetBlob(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
// Upload a blob first
|
||||
testData := []byte("test blob content")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
pubkey := []byte("testpubkey123456789012345678901234")
|
||||
|
||||
err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save blob: %v", err)
|
||||
}
|
||||
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
// Test GET request
|
||||
req := httptest.NewRequest("GET", "/"+sha256Hex, nil)
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
body := w.Body.Bytes()
|
||||
if !bytes.Equal(body, testData) {
|
||||
t.Error("Response body mismatch")
|
||||
}
|
||||
|
||||
if w.Header().Get("Content-Type") != "text/plain" {
|
||||
t.Errorf("Expected Content-Type text/plain, got %s", w.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPHeadBlob tests HEAD /<sha256> endpoint
|
||||
func TestHTTPHeadBlob(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
testData := []byte("test blob content")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
pubkey := []byte("testpubkey123456789012345678901234")
|
||||
|
||||
err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save blob: %v", err)
|
||||
}
|
||||
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
req := httptest.NewRequest("HEAD", "/"+sha256Hex, nil)
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
if w.Body.Len() != 0 {
|
||||
t.Error("HEAD request should not return body")
|
||||
}
|
||||
|
||||
if w.Header().Get("Content-Length") != "18" {
|
||||
t.Errorf("Expected Content-Length 18, got %s", w.Header().Get("Content-Length"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPUpload tests PUT /upload endpoint
|
||||
func TestHTTPUpload(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
|
||||
testData := []byte("test upload data")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
|
||||
// Create auth event
|
||||
authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600)
|
||||
|
||||
// Create request
|
||||
req := httptest.NewRequest("PUT", "/upload", bytes.NewReader(testData))
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var desc BlobDescriptor
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &desc); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if desc.SHA256 != hex.Enc(sha256Hash) {
|
||||
t.Errorf("SHA256 mismatch: expected %s, got %s", hex.Enc(sha256Hash), desc.SHA256)
|
||||
}
|
||||
|
||||
if desc.Size != int64(len(testData)) {
|
||||
t.Errorf("Size mismatch: expected %d, got %d", len(testData), desc.Size)
|
||||
}
|
||||
|
||||
// Verify blob was saved
|
||||
exists, err := server.storage.HasBlob(sha256Hash)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check blob: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Error("Blob should exist after upload")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPUploadRequirements tests HEAD /upload endpoint
|
||||
func TestHTTPUploadRequirements(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
testData := []byte("test data")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
|
||||
req := httptest.NewRequest("HEAD", "/upload", nil)
|
||||
req.Header.Set("X-SHA-256", hex.Enc(sha256Hash))
|
||||
req.Header.Set("X-Content-Length", "9")
|
||||
req.Header.Set("X-Content-Type", "text/plain")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Header().Get("X-Reason"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPUploadTooLarge tests upload size limit
|
||||
func TestHTTPUploadTooLarge(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create request with size exceeding limit
|
||||
req := httptest.NewRequest("HEAD", "/upload", nil)
|
||||
req.Header.Set("X-SHA-256", hex.Enc(CalculateSHA256([]byte("test"))))
|
||||
req.Header.Set("X-Content-Length", "200000000") // 200MB
|
||||
req.Header.Set("X-Content-Type", "application/octet-stream")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("Expected status 413, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPListBlobs tests GET /list/<pubkey> endpoint
|
||||
func TestHTTPListBlobs(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
pubkey := signer.Pub()
|
||||
pubkeyHex := hex.Enc(pubkey)
|
||||
|
||||
// Upload multiple blobs
|
||||
for i := 0; i < 3; i++ {
|
||||
testData := []byte("test data " + string(rune('A'+i)))
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save blob: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create auth event
|
||||
authEv := createAuthEvent(t, signer, "list", nil, 3600)
|
||||
|
||||
req := httptest.NewRequest("GET", "/list/"+pubkeyHex, nil)
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var descriptors []BlobDescriptor
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &descriptors); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if len(descriptors) != 3 {
|
||||
t.Errorf("Expected 3 blobs, got %d", len(descriptors))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPDeleteBlob tests DELETE /<sha256> endpoint
|
||||
func TestHTTPDeleteBlob(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
pubkey := signer.Pub()
|
||||
|
||||
testData := []byte("test delete data")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
|
||||
// Upload blob first
|
||||
err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save blob: %v", err)
|
||||
}
|
||||
|
||||
// Create auth event
|
||||
authEv := createAuthEvent(t, signer, "delete", sha256Hash, 3600)
|
||||
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
req := httptest.NewRequest("DELETE", "/"+sha256Hex, nil)
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify blob was deleted
|
||||
exists, err := server.storage.HasBlob(sha256Hash)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check blob: %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Error("Blob should not exist after delete")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPMirror tests PUT /mirror endpoint
|
||||
func TestHTTPMirror(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
|
||||
// Create a mock remote server
|
||||
testData := []byte("mirrored blob data")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write(testData)
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Create mirror request
|
||||
mirrorReq := map[string]string{
|
||||
"url": mockServer.URL + "/" + sha256Hex,
|
||||
}
|
||||
reqBody, _ := json.Marshal(mirrorReq)
|
||||
|
||||
authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/mirror", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify blob was saved
|
||||
exists, err := server.storage.HasBlob(sha256Hash)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check blob: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Error("Blob should exist after mirror")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPMediaUpload tests PUT /media endpoint
|
||||
func TestHTTPMediaUpload(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
|
||||
testData := []byte("test media data")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
|
||||
authEv := createAuthEvent(t, signer, "media", sha256Hash, 3600)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/media", bytes.NewReader(testData))
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
req.Header.Set("Content-Type", "image/png")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var desc BlobDescriptor
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &desc); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if desc.SHA256 == "" {
|
||||
t.Error("Expected SHA256 in response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPReport tests PUT /report endpoint
|
||||
func TestHTTPReport(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
pubkey := signer.Pub()
|
||||
|
||||
// Upload a blob first
|
||||
testData := []byte("test blob")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
|
||||
err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save blob: %v", err)
|
||||
}
|
||||
|
||||
// Create report event (kind 1984)
|
||||
reportEv := &event.E{
|
||||
CreatedAt: timestamp.Now().V,
|
||||
Kind: 1984,
|
||||
Tags: tag.NewS(tag.NewFromAny("x", hex.Enc(sha256Hash))),
|
||||
Content: []byte("This blob violates policy"),
|
||||
Pubkey: pubkey,
|
||||
}
|
||||
|
||||
if err := reportEv.Sign(signer); err != nil {
|
||||
t.Fatalf("Failed to sign report: %v", err)
|
||||
}
|
||||
|
||||
reqBody := reportEv.Serialize()
|
||||
req := httptest.NewRequest("PUT", "/report", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPRangeRequest tests range request support
|
||||
func TestHTTPRangeRequest(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
testData := []byte("0123456789abcdef")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
pubkey := []byte("testpubkey123456789012345678901234")
|
||||
|
||||
err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save blob: %v", err)
|
||||
}
|
||||
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
// Test range request
|
||||
req := httptest.NewRequest("GET", "/"+sha256Hex, nil)
|
||||
req.Header.Set("Range", "bytes=4-9")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusPartialContent {
|
||||
t.Errorf("Expected status 206, got %d", w.Code)
|
||||
}
|
||||
|
||||
body := w.Body.Bytes()
|
||||
expected := testData[4:10]
|
||||
if !bytes.Equal(body, expected) {
|
||||
t.Errorf("Range response mismatch: expected %s, got %s", string(expected), string(body))
|
||||
}
|
||||
|
||||
if w.Header().Get("Content-Range") == "" {
|
||||
t.Error("Missing Content-Range header")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPNotFound tests 404 handling
|
||||
func TestHTTPNotFound(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
req := httptest.NewRequest("GET", "/nonexistent123456789012345678901234567890123456789012345678901234567890", nil)
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPServerIntegration tests full server integration
|
||||
func TestHTTPServerIntegration(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
// Start HTTP server
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
|
||||
// Upload blob via HTTP
|
||||
testData := []byte("integration test data")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600)
|
||||
|
||||
uploadReq, _ := http.NewRequest("PUT", httpServer.URL+"/upload", bytes.NewReader(testData))
|
||||
uploadReq.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
uploadReq.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(uploadReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to upload: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("Upload failed: status %d, body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Retrieve blob via HTTP
|
||||
getReq, _ := http.NewRequest("GET", httpServer.URL+"/"+sha256Hex, nil)
|
||||
getResp, err := client.Do(getReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get blob: %v", err)
|
||||
}
|
||||
defer getResp.Body.Close()
|
||||
|
||||
if getResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Get failed: status %d", getResp.StatusCode)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(getResp.Body)
|
||||
if !bytes.Equal(body, testData) {
|
||||
t.Error("Retrieved blob data mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCORSHeaders tests CORS header handling
|
||||
func TestCORSHeaders(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Error("Missing CORS header")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthorizationRequired tests authorization requirement
|
||||
func TestAuthorizationRequired(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
// Configure server to require auth
|
||||
server.requireAuth = true
|
||||
|
||||
testData := []byte("test")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
pubkey := []byte("testpubkey123456789012345678901234")
|
||||
|
||||
err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save blob: %v", err)
|
||||
}
|
||||
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
// Request without auth should fail
|
||||
req := httptest.NewRequest("GET", "/"+sha256Hex, nil)
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestACLIntegration tests ACL integration
|
||||
func TestACLIntegration(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
// Note: This test assumes ACL is configured
|
||||
// In a real scenario, you'd set up a proper ACL instance
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
testData := []byte("test")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
|
||||
authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/upload", bytes.NewReader(testData))
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
// Should succeed if ACL allows, or fail if not
|
||||
// The exact behavior depends on ACL configuration
|
||||
if w.Code != http.StatusOK && w.Code != http.StatusForbidden {
|
||||
t.Errorf("Unexpected status: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMimeTypeDetection tests MIME type detection from various sources
|
||||
func TestMimeTypeDetection(t *testing.T) {
|
||||
tests := []struct {
|
||||
contentType string
|
||||
ext string
|
||||
expected string
|
||||
}{
|
||||
{"image/png", "", "image/png"},
|
||||
{"", ".png", "image/png"},
|
||||
{"", ".pdf", "application/pdf"},
|
||||
{"application/pdf", ".txt", "application/pdf"},
|
||||
{"", ".unknown", "application/octet-stream"},
|
||||
{"", "", "application/octet-stream"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := DetectMimeType(tt.contentType, tt.ext)
|
||||
if result != tt.expected {
|
||||
t.Errorf("DetectMimeType(%q, %q) = %q, want %q",
|
||||
tt.contentType, tt.ext, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSHA256Validation tests SHA256 validation
|
||||
func TestSHA256Validation(t *testing.T) {
|
||||
validHashes := []string{
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"abc123def456789012345678901234567890123456789012345678901234567890",
|
||||
}
|
||||
|
||||
invalidHashes := []string{
|
||||
"",
|
||||
"abc",
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855x",
|
||||
"12345",
|
||||
}
|
||||
|
||||
for _, hash := range validHashes {
|
||||
if !ValidateSHA256Hex(hash) {
|
||||
t.Errorf("Hash %s should be valid", hash)
|
||||
}
|
||||
}
|
||||
|
||||
for _, hash := range invalidHashes {
|
||||
if ValidateSHA256Hex(hash) {
|
||||
t.Errorf("Hash %s should be invalid", hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlobURLBuilding tests URL building
|
||||
func TestBlobURLBuilding(t *testing.T) {
|
||||
baseURL := "https://example.com"
|
||||
sha256Hex := "abc123def456"
|
||||
ext := ".pdf"
|
||||
|
||||
url := BuildBlobURL(baseURL, sha256Hex, ext)
|
||||
expected := baseURL + sha256Hex + ext
|
||||
|
||||
if url != expected {
|
||||
t.Errorf("Expected %s, got %s", expected, url)
|
||||
}
|
||||
|
||||
// Test without extension
|
||||
url2 := BuildBlobURL(baseURL, sha256Hex, "")
|
||||
expected2 := baseURL + sha256Hex
|
||||
|
||||
if url2 != expected2 {
|
||||
t.Errorf("Expected %s, got %s", expected2, url2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorResponses tests error response formatting
|
||||
func TestErrorResponses(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.setErrorResponse(w, http.StatusBadRequest, "Invalid request")
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
if w.Header().Get("X-Reason") == "" {
|
||||
t.Error("Missing X-Reason header")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractSHA256FromURL tests URL hash extraction
|
||||
func TestExtractSHA256FromURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
expected string
|
||||
hasError bool
|
||||
}{
|
||||
{"https://example.com/abc123def456", "abc123def456", false},
|
||||
{"https://example.com/user/path/abc123def456.pdf", "abc123def456", false},
|
||||
{"https://example.com/", "", true},
|
||||
{"no hash here", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
hash, err := ExtractSHA256FromURL(tt.url)
|
||||
if tt.hasError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for URL %s", tt.url)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error for URL %s: %v", tt.url, err)
|
||||
}
|
||||
if hash != tt.expected {
|
||||
t.Errorf("Expected %s, got %s for URL %s", tt.expected, hash, tt.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStorageReport tests report storage
|
||||
func TestStorageReport(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
sha256Hash := CalculateSHA256([]byte("test"))
|
||||
reportData := []byte("report data")
|
||||
|
||||
err := server.storage.SaveReport(sha256Hash, reportData)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save report: %v", err)
|
||||
}
|
||||
|
||||
// Reports are stored but not retrieved in current implementation
|
||||
// This test verifies the operation doesn't fail
|
||||
}
|
||||
|
||||
// BenchmarkStorageOperations benchmarks storage operations
|
||||
func BenchmarkStorageOperations(b *testing.B) {
|
||||
server, cleanup := testSetup(&testing.T{})
|
||||
defer cleanup()
|
||||
|
||||
testData := []byte("benchmark test data")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
pubkey := []byte("testpubkey123456789012345678901234")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "")
|
||||
_, _, _ = server.storage.GetBlob(sha256Hash)
|
||||
_ = server.storage.DeleteBlob(sha256Hash, pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentUploads tests concurrent uploads
|
||||
func TestConcurrentUploads(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
|
||||
const numUploads = 10
|
||||
done := make(chan error, numUploads)
|
||||
|
||||
for i := 0; i < numUploads; i++ {
|
||||
go func(id int) {
|
||||
testData := []byte("concurrent test " + string(rune('A'+id)))
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/upload", bytes.NewReader(testData))
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
done <- &testError{code: w.Code, body: w.Body.String()}
|
||||
return
|
||||
}
|
||||
done <- nil
|
||||
}(i)
|
||||
}
|
||||
|
||||
for i := 0; i < numUploads; i++ {
|
||||
if err := <-done; err != nil {
|
||||
t.Errorf("Concurrent upload failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type testError struct {
|
||||
code int
|
||||
body string
|
||||
}
|
||||
|
||||
func (e *testError) Error() string {
|
||||
return strings.Join([]string{"HTTP", string(rune(e.code)), e.body}, " ")
|
||||
}
|
||||
|
||||
852
pkg/blossom/integration_test.go
Normal file
852
pkg/blossom/integration_test.go
Normal file
@@ -0,0 +1,852 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
"next.orly.dev/pkg/encoders/timestamp"
|
||||
)
|
||||
|
||||
// TestFullServerIntegration tests a complete workflow with a real HTTP server
|
||||
func TestFullServerIntegration(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
// Start real HTTP server
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
|
||||
baseURL := httpServer.URL
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// Create test keypair
|
||||
_, signer := createTestKeypair(t)
|
||||
pubkey := signer.Pub()
|
||||
pubkeyHex := hex.Enc(pubkey)
|
||||
|
||||
// Step 1: Upload a blob
|
||||
testData := []byte("integration test blob content")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600)
|
||||
|
||||
uploadReq, err := http.NewRequest("PUT", baseURL+"/upload", bytes.NewReader(testData))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create upload request: %v", err)
|
||||
}
|
||||
uploadReq.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
uploadReq.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
uploadResp, err := client.Do(uploadReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to upload: %v", err)
|
||||
}
|
||||
defer uploadResp.Body.Close()
|
||||
|
||||
if uploadResp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(uploadResp.Body)
|
||||
t.Fatalf("Upload failed: status %d, body: %s", uploadResp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var uploadDesc BlobDescriptor
|
||||
if err := json.NewDecoder(uploadResp.Body).Decode(&uploadDesc); err != nil {
|
||||
t.Fatalf("Failed to parse upload response: %v", err)
|
||||
}
|
||||
|
||||
if uploadDesc.SHA256 != sha256Hex {
|
||||
t.Errorf("SHA256 mismatch: expected %s, got %s", sha256Hex, uploadDesc.SHA256)
|
||||
}
|
||||
|
||||
// Step 2: Retrieve the blob
|
||||
getReq, err := http.NewRequest("GET", baseURL+"/"+sha256Hex, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create GET request: %v", err)
|
||||
}
|
||||
|
||||
getResp, err := client.Do(getReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get blob: %v", err)
|
||||
}
|
||||
defer getResp.Body.Close()
|
||||
|
||||
if getResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Get failed: status %d", getResp.StatusCode)
|
||||
}
|
||||
|
||||
retrievedData, err := io.ReadAll(getResp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read response: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(retrievedData, testData) {
|
||||
t.Error("Retrieved blob data mismatch")
|
||||
}
|
||||
|
||||
// Step 3: List blobs
|
||||
listAuthEv := createAuthEvent(t, signer, "list", nil, 3600)
|
||||
listReq, err := http.NewRequest("GET", baseURL+"/list/"+pubkeyHex, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create list request: %v", err)
|
||||
}
|
||||
listReq.Header.Set("Authorization", createAuthHeader(listAuthEv))
|
||||
|
||||
listResp, err := client.Do(listReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list blobs: %v", err)
|
||||
}
|
||||
defer listResp.Body.Close()
|
||||
|
||||
if listResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("List failed: status %d", listResp.StatusCode)
|
||||
}
|
||||
|
||||
var descriptors []BlobDescriptor
|
||||
if err := json.NewDecoder(listResp.Body).Decode(&descriptors); err != nil {
|
||||
t.Fatalf("Failed to parse list response: %v", err)
|
||||
}
|
||||
|
||||
if len(descriptors) == 0 {
|
||||
t.Error("Expected at least one blob in list")
|
||||
}
|
||||
|
||||
// Step 4: Delete the blob
|
||||
deleteAuthEv := createAuthEvent(t, signer, "delete", sha256Hash, 3600)
|
||||
deleteReq, err := http.NewRequest("DELETE", baseURL+"/"+sha256Hex, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create delete request: %v", err)
|
||||
}
|
||||
deleteReq.Header.Set("Authorization", createAuthHeader(deleteAuthEv))
|
||||
|
||||
deleteResp, err := client.Do(deleteReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete blob: %v", err)
|
||||
}
|
||||
defer deleteResp.Body.Close()
|
||||
|
||||
if deleteResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("Delete failed: status %d", deleteResp.StatusCode)
|
||||
}
|
||||
|
||||
// Step 5: Verify blob is gone
|
||||
getResp2, err := client.Do(getReq)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get blob: %v", err)
|
||||
}
|
||||
defer getResp2.Body.Close()
|
||||
|
||||
if getResp2.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("Expected 404 after delete, got %d", getResp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerWithMultipleBlobs tests multiple blob operations
|
||||
func TestServerWithMultipleBlobs(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
pubkey := signer.Pub()
|
||||
pubkeyHex := hex.Enc(pubkey)
|
||||
|
||||
// Upload multiple blobs
|
||||
const numBlobs = 5
|
||||
var hashes []string
|
||||
var data []byte
|
||||
|
||||
for i := 0; i < numBlobs; i++ {
|
||||
testData := []byte(fmt.Sprintf("blob %d content", i))
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
hashes = append(hashes, sha256Hex)
|
||||
data = append(data, testData...)
|
||||
|
||||
authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600)
|
||||
|
||||
req, _ := http.NewRequest("PUT", httpServer.URL+"/upload", bytes.NewReader(testData))
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to upload blob %d: %v", i, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Upload %d failed: status %d", i, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// List all blobs
|
||||
authEv := createAuthEvent(t, signer, "list", nil, 3600)
|
||||
req, _ := http.NewRequest("GET", httpServer.URL+"/list/"+pubkeyHex, nil)
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list blobs: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var descriptors []BlobDescriptor
|
||||
json.NewDecoder(resp.Body).Decode(&descriptors)
|
||||
|
||||
if len(descriptors) != numBlobs {
|
||||
t.Errorf("Expected %d blobs, got %d", numBlobs, len(descriptors))
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerCORS tests CORS headers on all endpoints
|
||||
func TestServerCORS(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
|
||||
endpoints := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{"GET", "/test123456789012345678901234567890123456789012345678901234567890"},
|
||||
{"HEAD", "/test123456789012345678901234567890123456789012345678901234567890"},
|
||||
{"PUT", "/upload"},
|
||||
{"HEAD", "/upload"},
|
||||
{"GET", "/list/test123456789012345678901234567890123456789012345678901234567890"},
|
||||
{"PUT", "/media"},
|
||||
{"HEAD", "/media"},
|
||||
{"PUT", "/mirror"},
|
||||
{"PUT", "/report"},
|
||||
{"DELETE", "/test123456789012345678901234567890123456789012345678901234567890"},
|
||||
{"OPTIONS", "/"},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
req, _ := http.NewRequest(ep.method, httpServer.URL+ep.path, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to test %s %s: %v", ep.method, ep.path, err)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
corsHeader := resp.Header.Get("Access-Control-Allow-Origin")
|
||||
if corsHeader != "*" {
|
||||
t.Errorf("Missing CORS header on %s %s", ep.method, ep.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerRangeRequests tests range request handling
|
||||
func TestServerRangeRequests(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
|
||||
// Upload a blob
|
||||
testData := []byte("0123456789abcdefghij")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
pubkey := []byte("testpubkey123456789012345678901234")
|
||||
|
||||
err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save blob: %v", err)
|
||||
}
|
||||
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
// Test various range requests
|
||||
tests := []struct {
|
||||
rangeHeader string
|
||||
expected string
|
||||
status int
|
||||
}{
|
||||
{"bytes=0-4", "01234", http.StatusPartialContent},
|
||||
{"bytes=5-9", "56789", http.StatusPartialContent},
|
||||
{"bytes=10-", "abcdefghij", http.StatusPartialContent},
|
||||
{"bytes=-5", "hij", http.StatusPartialContent},
|
||||
{"bytes=0-0", "0", http.StatusPartialContent},
|
||||
{"bytes=100-200", "", http.StatusRequestedRangeNotSatisfiable},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
req, _ := http.NewRequest("GET", httpServer.URL+"/"+sha256Hex, nil)
|
||||
req.Header.Set("Range", tt.rangeHeader)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to request range %s: %v", tt.rangeHeader, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != tt.status {
|
||||
t.Errorf("Range %s: expected status %d, got %d", tt.rangeHeader, tt.status, resp.StatusCode)
|
||||
resp.Body.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
if tt.status == http.StatusPartialContent {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if string(body) != tt.expected {
|
||||
t.Errorf("Range %s: expected %q, got %q", tt.rangeHeader, tt.expected, string(body))
|
||||
}
|
||||
|
||||
if resp.Header.Get("Content-Range") == "" {
|
||||
t.Errorf("Range %s: missing Content-Range header", tt.rangeHeader)
|
||||
}
|
||||
}
|
||||
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerAuthorizationFlow tests complete authorization flow
|
||||
func TestServerAuthorizationFlow(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
|
||||
testData := []byte("authorized blob")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
|
||||
// Test with valid authorization
|
||||
authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/upload", bytes.NewReader(testData))
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Valid auth failed: status %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Test with expired authorization
|
||||
expiredAuthEv := createAuthEvent(t, signer, "upload", sha256Hash, -3600)
|
||||
|
||||
req2 := httptest.NewRequest("PUT", "/upload", bytes.NewReader(testData))
|
||||
req2.Header.Set("Authorization", createAuthHeader(expiredAuthEv))
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expired auth should fail: status %d", w2.Code)
|
||||
}
|
||||
|
||||
// Test with wrong verb
|
||||
wrongVerbAuthEv := createAuthEvent(t, signer, "delete", sha256Hash, 3600)
|
||||
|
||||
req3 := httptest.NewRequest("PUT", "/upload", bytes.NewReader(testData))
|
||||
req3.Header.Set("Authorization", createAuthHeader(wrongVerbAuthEv))
|
||||
|
||||
w3 := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w3, req3)
|
||||
|
||||
if w3.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Wrong verb auth should fail: status %d", w3.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerUploadRequirementsFlow tests upload requirements check flow
|
||||
func TestServerUploadRequirementsFlow(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
testData := []byte("test")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
|
||||
// Test HEAD /upload with valid requirements
|
||||
req := httptest.NewRequest("HEAD", "/upload", nil)
|
||||
req.Header.Set("X-SHA-256", hex.Enc(sha256Hash))
|
||||
req.Header.Set("X-Content-Length", "4")
|
||||
req.Header.Set("X-Content-Type", "text/plain")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Upload requirements check failed: status %d", w.Code)
|
||||
}
|
||||
|
||||
// Test HEAD /upload with missing header
|
||||
req2 := httptest.NewRequest("HEAD", "/upload", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected BadRequest for missing header, got %d", w2.Code)
|
||||
}
|
||||
|
||||
// Test HEAD /upload with invalid hash
|
||||
req3 := httptest.NewRequest("HEAD", "/upload", nil)
|
||||
req3.Header.Set("X-SHA-256", "invalid")
|
||||
req3.Header.Set("X-Content-Length", "4")
|
||||
|
||||
w3 := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w3, req3)
|
||||
|
||||
if w3.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected BadRequest for invalid hash, got %d", w3.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerMirrorFlow tests mirror endpoint flow
|
||||
func TestServerMirrorFlow(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
|
||||
// Create mock remote server
|
||||
remoteData := []byte("remote blob data")
|
||||
sha256Hash := CalculateSHA256(remoteData)
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(remoteData)))
|
||||
w.Write(remoteData)
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
// Mirror the blob
|
||||
mirrorReq := map[string]string{
|
||||
"url": mockServer.URL + "/" + sha256Hex,
|
||||
}
|
||||
reqBody, _ := json.Marshal(mirrorReq)
|
||||
|
||||
authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/mirror", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Mirror failed: status %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify blob was stored
|
||||
exists, err := server.storage.HasBlob(sha256Hash)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check blob: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Error("Blob should exist after mirror")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerReportFlow tests report endpoint flow
|
||||
func TestServerReportFlow(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
pubkey := signer.Pub()
|
||||
|
||||
// Upload a blob first
|
||||
testData := []byte("reportable blob")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
|
||||
err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save blob: %v", err)
|
||||
}
|
||||
|
||||
// Create report event
|
||||
reportEv := &event.E{
|
||||
CreatedAt: timestamp.Now().V,
|
||||
Kind: 1984,
|
||||
Tags: tag.NewS(tag.NewFromAny("x", hex.Enc(sha256Hash))),
|
||||
Content: []byte("This blob should be reported"),
|
||||
Pubkey: pubkey,
|
||||
}
|
||||
|
||||
if err := reportEv.Sign(signer); err != nil {
|
||||
t.Fatalf("Failed to sign report: %v", err)
|
||||
}
|
||||
|
||||
reqBody := reportEv.Serialize()
|
||||
req := httptest.NewRequest("PUT", "/report", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Report failed: status %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerErrorHandling tests various error scenarios
|
||||
func TestServerErrorHandling(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
headers map[string]string
|
||||
body []byte
|
||||
statusCode int
|
||||
}{
|
||||
{
|
||||
name: "Invalid path",
|
||||
method: "GET",
|
||||
path: "/invalid",
|
||||
statusCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "Non-existent blob",
|
||||
method: "GET",
|
||||
path: "/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
statusCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "Missing auth header",
|
||||
method: "PUT",
|
||||
path: "/upload",
|
||||
body: []byte("test"),
|
||||
statusCode: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "Invalid JSON in mirror",
|
||||
method: "PUT",
|
||||
path: "/mirror",
|
||||
body: []byte("invalid json"),
|
||||
statusCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "Invalid JSON in report",
|
||||
method: "PUT",
|
||||
path: "/report",
|
||||
body: []byte("invalid json"),
|
||||
statusCode: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var body io.Reader
|
||||
if tt.body != nil {
|
||||
body = bytes.NewReader(tt.body)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(tt.method, tt.path, body)
|
||||
for k, v := range tt.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.statusCode {
|
||||
t.Errorf("Expected status %d, got %d: %s", tt.statusCode, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerMediaOptimization tests media optimization endpoint
|
||||
func TestServerMediaOptimization(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
|
||||
testData := []byte("test media for optimization")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
|
||||
authEv := createAuthEvent(t, signer, "media", sha256Hash, 3600)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/media", bytes.NewReader(testData))
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
req.Header.Set("Content-Type", "image/png")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Media upload failed: status %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var desc BlobDescriptor
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &desc); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if desc.SHA256 == "" {
|
||||
t.Error("Expected SHA256 in response")
|
||||
}
|
||||
|
||||
// Test HEAD /media
|
||||
req2 := httptest.NewRequest("HEAD", "/media", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Errorf("HEAD /media failed: status %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerListWithQueryParams tests list endpoint with query parameters
|
||||
func TestServerListWithQueryParams(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
pubkey := signer.Pub()
|
||||
pubkeyHex := hex.Enc(pubkey)
|
||||
|
||||
// Upload blobs at different times
|
||||
now := time.Now().Unix()
|
||||
blobs := []struct {
|
||||
data []byte
|
||||
timestamp int64
|
||||
}{
|
||||
{[]byte("blob 1"), now - 1000},
|
||||
{[]byte("blob 2"), now - 500},
|
||||
{[]byte("blob 3"), now},
|
||||
}
|
||||
|
||||
for _, b := range blobs {
|
||||
sha256Hash := CalculateSHA256(b.data)
|
||||
// Manually set uploaded timestamp
|
||||
err := server.storage.SaveBlob(sha256Hash, b.data, pubkey, "text/plain", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save blob: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// List with since parameter
|
||||
authEv := createAuthEvent(t, signer, "list", nil, 3600)
|
||||
req := httptest.NewRequest("GET", "/list/"+pubkeyHex+"?since="+fmt.Sprintf("%d", now-600), nil)
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("List failed: status %d", w.Code)
|
||||
}
|
||||
|
||||
var descriptors []BlobDescriptor
|
||||
if err := json.NewDecoder(w.Body).Decode(&descriptors); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
// Should only get blobs uploaded after since timestamp
|
||||
if len(descriptors) != 1 {
|
||||
t.Errorf("Expected 1 blob, got %d", len(descriptors))
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerConcurrentOperations tests concurrent operations on server
|
||||
func TestServerConcurrentOperations(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
|
||||
const numOps = 20
|
||||
done := make(chan error, numOps)
|
||||
|
||||
for i := 0; i < numOps; i++ {
|
||||
go func(id int) {
|
||||
testData := []byte(fmt.Sprintf("concurrent op %d", id))
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
// Upload
|
||||
authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600)
|
||||
req, _ := http.NewRequest("PUT", httpServer.URL+"/upload", bytes.NewReader(testData))
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
done <- fmt.Errorf("upload failed: %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
// Get
|
||||
req2, _ := http.NewRequest("GET", httpServer.URL+"/"+sha256Hex, nil)
|
||||
resp2, err := http.DefaultClient.Do(req2)
|
||||
if err != nil {
|
||||
done <- err
|
||||
return
|
||||
}
|
||||
resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
done <- fmt.Errorf("get failed: %d", resp2.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
done <- nil
|
||||
}(i)
|
||||
}
|
||||
|
||||
for i := 0; i < numOps; i++ {
|
||||
if err := <-done; err != nil {
|
||||
t.Errorf("Concurrent operation failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerBlobExtensionHandling tests blob retrieval with file extensions
|
||||
func TestServerBlobExtensionHandling(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
testData := []byte("test PDF content")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
pubkey := []byte("testpubkey123456789012345678901234")
|
||||
|
||||
err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "application/pdf", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save blob: %v", err)
|
||||
}
|
||||
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
// Test GET with extension
|
||||
req := httptest.NewRequest("GET", "/"+sha256Hex+".pdf", nil)
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("GET with extension failed: status %d", w.Code)
|
||||
}
|
||||
|
||||
// Should still return correct MIME type
|
||||
if w.Header().Get("Content-Type") != "application/pdf" {
|
||||
t.Errorf("Expected application/pdf, got %s", w.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerBlobAlreadyExists tests uploading existing blob
|
||||
func TestServerBlobAlreadyExists(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
pubkey := signer.Pub()
|
||||
|
||||
testData := []byte("existing blob")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
|
||||
// Upload blob first time
|
||||
err := server.storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save blob: %v", err)
|
||||
}
|
||||
|
||||
// Try to upload same blob again
|
||||
authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/upload", bytes.NewReader(testData))
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
// Should succeed and return existing blob descriptor
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Re-upload should succeed: status %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerInvalidAuthorization tests various invalid authorization scenarios
|
||||
func TestServerInvalidAuthorization(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
_, signer := createTestKeypair(t)
|
||||
|
||||
testData := []byte("test")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
modifyEv func(*event.E)
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "Missing expiration",
|
||||
modifyEv: func(ev *event.E) {
|
||||
ev.Tags = tag.NewS(tag.NewFromAny("t", "upload"))
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "Wrong kind",
|
||||
modifyEv: func(ev *event.E) {
|
||||
ev.Kind = 1
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "Wrong verb",
|
||||
modifyEv: func(ev *event.E) {
|
||||
ev.Tags = tag.NewS(
|
||||
tag.NewFromAny("t", "delete"),
|
||||
tag.NewFromAny("expiration", timestamp.FromUnix(time.Now().Unix()+3600).String()),
|
||||
)
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ev := createAuthEvent(t, signer, "upload", sha256Hash, 3600)
|
||||
tt.modifyEv(ev)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/upload", bytes.NewReader(testData))
|
||||
req.Header.Set("Authorization", createAuthHeader(ev))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(w, req)
|
||||
|
||||
if tt.expectErr {
|
||||
if w.Code == http.StatusOK {
|
||||
t.Error("Expected error but got success")
|
||||
}
|
||||
} else {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected success but got error: status %d", w.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
19
pkg/blossom/media.go
Normal file
19
pkg/blossom/media.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package blossom
|
||||
|
||||
// OptimizeMedia optimizes media content (BUD-05)
|
||||
// This is a placeholder implementation - actual optimization would use
|
||||
// libraries like image processing, video encoding, etc.
|
||||
func OptimizeMedia(data []byte, mimeType string) (optimizedData []byte, optimizedMimeType string) {
|
||||
// For now, just return the original data unchanged
|
||||
// In a real implementation, this would:
|
||||
// - Resize images to optimal dimensions
|
||||
// - Compress images (JPEG quality, PNG optimization)
|
||||
// - Convert formats if beneficial
|
||||
// - Optimize video encoding
|
||||
// - etc.
|
||||
|
||||
optimizedData = data
|
||||
optimizedMimeType = mimeType
|
||||
return
|
||||
}
|
||||
|
||||
53
pkg/blossom/payment.go
Normal file
53
pkg/blossom/payment.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// PaymentChecker handles payment requirements (BUD-07)
|
||||
type PaymentChecker struct {
|
||||
// Payment configuration would go here
|
||||
// For now, this is a placeholder
|
||||
}
|
||||
|
||||
// NewPaymentChecker creates a new payment checker
|
||||
func NewPaymentChecker() *PaymentChecker {
|
||||
return &PaymentChecker{}
|
||||
}
|
||||
|
||||
// CheckPaymentRequired checks if payment is required for an endpoint
|
||||
// Returns payment method headers if payment is required
|
||||
func (pc *PaymentChecker) CheckPaymentRequired(
|
||||
endpoint string,
|
||||
) (required bool, paymentHeaders map[string]string) {
|
||||
// Placeholder implementation - always returns false
|
||||
// In a real implementation, this would check:
|
||||
// - Per-endpoint payment requirements
|
||||
// - User payment status
|
||||
// - Blob size/cost thresholds
|
||||
// etc.
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ValidatePayment validates a payment proof
|
||||
func (pc *PaymentChecker) ValidatePayment(
|
||||
paymentMethod, proof string,
|
||||
) (valid bool, err error) {
|
||||
// Placeholder implementation
|
||||
// In a real implementation, this would validate:
|
||||
// - Cashu tokens (NUT-24)
|
||||
// - Lightning payment preimages (BOLT-11)
|
||||
// etc.
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SetPaymentRequired sets a 402 Payment Required response with payment headers
|
||||
func SetPaymentRequired(w http.ResponseWriter, paymentHeaders map[string]string) {
|
||||
for header, value := range paymentHeaders {
|
||||
w.Header().Set(header, value)
|
||||
}
|
||||
w.WriteHeader(http.StatusPaymentRequired)
|
||||
}
|
||||
|
||||
210
pkg/blossom/server.go
Normal file
210
pkg/blossom/server.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"next.orly.dev/pkg/acl"
|
||||
"next.orly.dev/pkg/database"
|
||||
)
|
||||
|
||||
// Server provides a Blossom server implementation
|
||||
type Server struct {
|
||||
db *database.D
|
||||
storage *Storage
|
||||
acl *acl.S
|
||||
baseURL string
|
||||
|
||||
// Configuration
|
||||
maxBlobSize int64
|
||||
allowedMimeTypes map[string]bool
|
||||
requireAuth bool
|
||||
}
|
||||
|
||||
// Config holds configuration for the Blossom server
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
MaxBlobSize int64
|
||||
AllowedMimeTypes []string
|
||||
RequireAuth bool
|
||||
}
|
||||
|
||||
// NewServer creates a new Blossom server instance
|
||||
func NewServer(db *database.D, aclRegistry *acl.S, cfg *Config) *Server {
|
||||
if cfg == nil {
|
||||
cfg = &Config{
|
||||
MaxBlobSize: 100 * 1024 * 1024, // 100MB default
|
||||
RequireAuth: false,
|
||||
}
|
||||
}
|
||||
|
||||
storage := NewStorage(db)
|
||||
|
||||
// Build allowed MIME types map
|
||||
allowedMap := make(map[string]bool)
|
||||
if len(cfg.AllowedMimeTypes) > 0 {
|
||||
for _, mime := range cfg.AllowedMimeTypes {
|
||||
allowedMap[mime] = true
|
||||
}
|
||||
}
|
||||
|
||||
return &Server{
|
||||
db: db,
|
||||
storage: storage,
|
||||
acl: aclRegistry,
|
||||
baseURL: cfg.BaseURL,
|
||||
maxBlobSize: cfg.MaxBlobSize,
|
||||
allowedMimeTypes: allowedMap,
|
||||
requireAuth: cfg.RequireAuth,
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns an http.Handler that can be attached to a router
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Set CORS headers (BUD-01 requirement)
|
||||
s.setCORSHeaders(w, r)
|
||||
|
||||
// Handle preflight OPTIONS requests
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Route based on path and method
|
||||
path := r.URL.Path
|
||||
|
||||
// Remove leading slash
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
|
||||
// Handle specific endpoints
|
||||
switch {
|
||||
case r.Method == http.MethodGet && path == "upload":
|
||||
// This shouldn't happen, but handle gracefully
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
|
||||
case r.Method == http.MethodHead && path == "upload":
|
||||
s.handleUploadRequirements(w, r)
|
||||
return
|
||||
|
||||
case r.Method == http.MethodPut && path == "upload":
|
||||
s.handleUpload(w, r)
|
||||
return
|
||||
|
||||
case r.Method == http.MethodHead && path == "media":
|
||||
s.handleMediaHead(w, r)
|
||||
return
|
||||
|
||||
case r.Method == http.MethodPut && path == "media":
|
||||
s.handleMediaUpload(w, r)
|
||||
return
|
||||
|
||||
case r.Method == http.MethodPut && path == "mirror":
|
||||
s.handleMirror(w, r)
|
||||
return
|
||||
|
||||
case r.Method == http.MethodPut && path == "report":
|
||||
s.handleReport(w, r)
|
||||
return
|
||||
|
||||
case strings.HasPrefix(path, "list/"):
|
||||
if r.Method == http.MethodGet {
|
||||
s.handleListBlobs(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
|
||||
case r.Method == http.MethodGet:
|
||||
// Handle GET /<sha256>
|
||||
s.handleGetBlob(w, r)
|
||||
return
|
||||
|
||||
case r.Method == http.MethodHead:
|
||||
// Handle HEAD /<sha256>
|
||||
s.handleHeadBlob(w, r)
|
||||
return
|
||||
|
||||
case r.Method == http.MethodDelete:
|
||||
// Handle DELETE /<sha256>
|
||||
s.handleDeleteBlob(w, r)
|
||||
return
|
||||
|
||||
default:
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// setCORSHeaders sets CORS headers as required by BUD-01
|
||||
func (s *Server) setCORSHeaders(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, DELETE")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, *")
|
||||
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers")
|
||||
}
|
||||
|
||||
// setErrorResponse sets an error response with X-Reason header (BUD-01)
|
||||
func (s *Server) setErrorResponse(w http.ResponseWriter, status int, reason string) {
|
||||
w.Header().Set("X-Reason", reason)
|
||||
http.Error(w, reason, status)
|
||||
}
|
||||
|
||||
// getRemoteAddr extracts the remote address from the request
|
||||
func (s *Server) getRemoteAddr(r *http.Request) string {
|
||||
// Check X-Forwarded-For header
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
parts := strings.Split(forwarded, ",")
|
||||
if len(parts) > 0 {
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Check X-Real-IP header
|
||||
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
|
||||
return realIP
|
||||
}
|
||||
|
||||
// Fall back to RemoteAddr
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// checkACL checks if the user has the required access level
|
||||
func (s *Server) checkACL(
|
||||
pubkey []byte, remoteAddr string, requiredLevel string,
|
||||
) bool {
|
||||
if s.acl == nil {
|
||||
return true // No ACL configured, allow all
|
||||
}
|
||||
|
||||
level := s.acl.GetAccessLevel(pubkey, remoteAddr)
|
||||
|
||||
// Map ACL levels to permissions
|
||||
levelMap := map[string]int{
|
||||
"none": 0,
|
||||
"read": 1,
|
||||
"write": 2,
|
||||
"admin": 3,
|
||||
"owner": 4,
|
||||
}
|
||||
|
||||
required := levelMap[requiredLevel]
|
||||
actual := levelMap[level]
|
||||
|
||||
return actual >= required
|
||||
}
|
||||
|
||||
// getBaseURL returns the base URL, preferring request context if available
|
||||
func (s *Server) getBaseURL(r *http.Request) string {
|
||||
type baseURLKey struct{}
|
||||
if baseURL := r.Context().Value(baseURLKey{}); baseURL != nil {
|
||||
if url, ok := baseURL.(string); ok && url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return s.baseURL
|
||||
}
|
||||
455
pkg/blossom/storage.go
Normal file
455
pkg/blossom/storage.go
Normal file
@@ -0,0 +1,455 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/dgraph-io/badger/v4"
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/errorf"
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/crypto/sha256"
|
||||
"next.orly.dev/pkg/database"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
// Database key prefixes (metadata and indexes only, blob data stored as files)
|
||||
prefixBlobMeta = "blob:meta:"
|
||||
prefixBlobIndex = "blob:index:"
|
||||
prefixBlobReport = "blob:report:"
|
||||
)
|
||||
|
||||
// Storage provides blob storage operations
|
||||
type Storage struct {
|
||||
db *database.D
|
||||
blobDir string // Directory for storing blob files
|
||||
}
|
||||
|
||||
// NewStorage creates a new storage instance
|
||||
func NewStorage(db *database.D) *Storage {
|
||||
// Derive blob directory from database path
|
||||
blobDir := filepath.Join(db.Path(), "blossom")
|
||||
|
||||
// Ensure blob directory exists
|
||||
if err := os.MkdirAll(blobDir, 0755); err != nil {
|
||||
log.E.F("failed to create blob directory %s: %v", blobDir, err)
|
||||
}
|
||||
|
||||
return &Storage{
|
||||
db: db,
|
||||
blobDir: blobDir,
|
||||
}
|
||||
}
|
||||
|
||||
// getBlobPath returns the filesystem path for a blob given its hash and extension
|
||||
func (s *Storage) getBlobPath(sha256Hex string, ext string) string {
|
||||
filename := sha256Hex + ext
|
||||
return filepath.Join(s.blobDir, filename)
|
||||
}
|
||||
|
||||
// SaveBlob stores a blob with its metadata
|
||||
func (s *Storage) SaveBlob(
|
||||
sha256Hash []byte, data []byte, pubkey []byte, mimeType string, extension string,
|
||||
) (err error) {
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
// Verify SHA256 matches
|
||||
calculatedHash := sha256.Sum256(data)
|
||||
if !utils.FastEqual(calculatedHash[:], sha256Hash) {
|
||||
err = errorf.E(
|
||||
"SHA256 mismatch: calculated %x, provided %x",
|
||||
calculatedHash[:], sha256Hash,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// If extension not provided, infer from MIME type
|
||||
if extension == "" {
|
||||
extension = GetExtensionFromMimeType(mimeType)
|
||||
}
|
||||
|
||||
// Create metadata with extension
|
||||
metadata := NewBlobMetadata(pubkey, mimeType, int64(len(data)))
|
||||
metadata.Extension = extension
|
||||
var metaData []byte
|
||||
if metaData, err = metadata.Serialize(); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get blob file path
|
||||
blobPath := s.getBlobPath(sha256Hex, extension)
|
||||
|
||||
// Check if blob file already exists (deduplication)
|
||||
if _, err = os.Stat(blobPath); err == nil {
|
||||
// File exists, just update metadata and index
|
||||
log.D.F("blob file already exists: %s", blobPath)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return errorf.E("error checking blob file: %w", err)
|
||||
} else {
|
||||
// Write blob data to file
|
||||
if err = os.WriteFile(blobPath, data, 0644); chk.E(err) {
|
||||
return errorf.E("failed to write blob file: %w", err)
|
||||
}
|
||||
log.D.F("wrote blob file: %s (%d bytes)", blobPath, len(data))
|
||||
}
|
||||
|
||||
// Store metadata and index in database
|
||||
if err = s.db.Update(func(txn *badger.Txn) error {
|
||||
// Store metadata
|
||||
metaKey := prefixBlobMeta + sha256Hex
|
||||
if err := txn.Set([]byte(metaKey), metaData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Index by pubkey
|
||||
indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex
|
||||
if err := txn.Set([]byte(indexKey), []byte{1}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
log.D.F("saved blob %s (%d bytes) for pubkey %s", sha256Hex, len(data), hex.Enc(pubkey))
|
||||
return
|
||||
}
|
||||
|
||||
// GetBlob retrieves blob data by SHA256 hash
|
||||
func (s *Storage) GetBlob(sha256Hash []byte) (data []byte, metadata *BlobMetadata, err error) {
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
// Get metadata first to get extension
|
||||
metaKey := prefixBlobMeta + sha256Hex
|
||||
if err = s.db.View(func(txn *badger.Txn) error {
|
||||
item, err := txn.Get([]byte(metaKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return item.Value(func(val []byte) error {
|
||||
if metadata, err = DeserializeBlobMetadata(val); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Read blob data from file
|
||||
blobPath := s.getBlobPath(sha256Hex, metadata.Extension)
|
||||
data, err = os.ReadFile(blobPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
err = badger.ErrKeyNotFound
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// HasBlob checks if a blob exists
|
||||
func (s *Storage) HasBlob(sha256Hash []byte) (exists bool, err error) {
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
// Get metadata to find extension
|
||||
metaKey := prefixBlobMeta + sha256Hex
|
||||
var metadata *BlobMetadata
|
||||
if err = s.db.View(func(txn *badger.Txn) error {
|
||||
item, err := txn.Get([]byte(metaKey))
|
||||
if err == badger.ErrKeyNotFound {
|
||||
return badger.ErrKeyNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return item.Value(func(val []byte) error {
|
||||
if metadata, err = DeserializeBlobMetadata(val); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err == badger.ErrKeyNotFound {
|
||||
exists = false
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
blobPath := s.getBlobPath(sha256Hex, metadata.Extension)
|
||||
if _, err = os.Stat(blobPath); err == nil {
|
||||
exists = true
|
||||
return
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
exists = false
|
||||
err = nil
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// DeleteBlob deletes a blob and its metadata
|
||||
func (s *Storage) DeleteBlob(sha256Hash []byte, pubkey []byte) (err error) {
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
|
||||
// Get metadata to find extension
|
||||
metaKey := prefixBlobMeta + sha256Hex
|
||||
var metadata *BlobMetadata
|
||||
if err = s.db.View(func(txn *badger.Txn) error {
|
||||
item, err := txn.Get([]byte(metaKey))
|
||||
if err == badger.ErrKeyNotFound {
|
||||
return badger.ErrKeyNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return item.Value(func(val []byte) error {
|
||||
if metadata, err = DeserializeBlobMetadata(val); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err == badger.ErrKeyNotFound {
|
||||
return errorf.E("blob %s not found", sha256Hex)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
blobPath := s.getBlobPath(sha256Hex, metadata.Extension)
|
||||
indexKey := prefixBlobIndex + hex.Enc(pubkey) + ":" + sha256Hex
|
||||
|
||||
if err = s.db.Update(func(txn *badger.Txn) error {
|
||||
// Delete metadata
|
||||
if err := txn.Delete([]byte(metaKey)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete index entry
|
||||
if err := txn.Delete([]byte(indexKey)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Delete blob file
|
||||
if err = os.Remove(blobPath); err != nil && !os.IsNotExist(err) {
|
||||
log.E.F("failed to delete blob file %s: %v", blobPath, err)
|
||||
// Don't fail if file doesn't exist
|
||||
}
|
||||
|
||||
log.D.F("deleted blob %s for pubkey %s", sha256Hex, hex.Enc(pubkey))
|
||||
return
|
||||
}
|
||||
|
||||
// ListBlobs lists all blobs for a given pubkey
|
||||
func (s *Storage) ListBlobs(
|
||||
pubkey []byte, since, until int64,
|
||||
) (descriptors []*BlobDescriptor, err error) {
|
||||
pubkeyHex := hex.Enc(pubkey)
|
||||
prefix := prefixBlobIndex + pubkeyHex + ":"
|
||||
|
||||
descriptors = make([]*BlobDescriptor, 0)
|
||||
|
||||
if err = s.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = []byte(prefix)
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
item := it.Item()
|
||||
key := item.Key()
|
||||
|
||||
// Extract SHA256 from key: prefixBlobIndex + pubkeyHex + ":" + sha256Hex
|
||||
sha256Hex := string(key[len(prefix):])
|
||||
|
||||
// Get blob metadata
|
||||
metaKey := prefixBlobMeta + sha256Hex
|
||||
metaItem, err := txn.Get([]byte(metaKey))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var metadata *BlobMetadata
|
||||
if err = metaItem.Value(func(val []byte) error {
|
||||
if metadata, err = DeserializeBlobMetadata(val); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by time range
|
||||
if since > 0 && metadata.Uploaded < since {
|
||||
continue
|
||||
}
|
||||
if until > 0 && metadata.Uploaded > until {
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify blob file exists
|
||||
blobPath := s.getBlobPath(sha256Hex, metadata.Extension)
|
||||
if _, errGet := os.Stat(blobPath); errGet != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create descriptor (URL will be set by handler)
|
||||
descriptor := NewBlobDescriptor(
|
||||
"", // URL will be set by handler
|
||||
sha256Hex,
|
||||
metadata.Size,
|
||||
metadata.MimeType,
|
||||
metadata.Uploaded,
|
||||
)
|
||||
|
||||
descriptors = append(descriptors, descriptor)
|
||||
}
|
||||
|
||||
return nil
|
||||
}); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// GetTotalStorageUsed calculates total storage used by a pubkey in MB
|
||||
func (s *Storage) GetTotalStorageUsed(pubkey []byte) (totalMB int64, err error) {
|
||||
pubkeyHex := hex.Enc(pubkey)
|
||||
prefix := prefixBlobIndex + pubkeyHex + ":"
|
||||
|
||||
totalBytes := int64(0)
|
||||
|
||||
if err = s.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = []byte(prefix)
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
item := it.Item()
|
||||
key := item.Key()
|
||||
|
||||
// Extract SHA256 from key: prefixBlobIndex + pubkeyHex + ":" + sha256Hex
|
||||
sha256Hex := string(key[len(prefix):])
|
||||
|
||||
// Get blob metadata
|
||||
metaKey := prefixBlobMeta + sha256Hex
|
||||
metaItem, err := txn.Get([]byte(metaKey))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var metadata *BlobMetadata
|
||||
if err = metaItem.Value(func(val []byte) error {
|
||||
if metadata, err = DeserializeBlobMetadata(val); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify blob file exists
|
||||
blobPath := s.getBlobPath(sha256Hex, metadata.Extension)
|
||||
if _, errGet := os.Stat(blobPath); errGet != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
totalBytes += metadata.Size
|
||||
}
|
||||
|
||||
return nil
|
||||
}); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert bytes to MB (rounding up)
|
||||
totalMB = (totalBytes + 1024*1024 - 1) / (1024 * 1024)
|
||||
return
|
||||
}
|
||||
|
||||
// SaveReport stores a report for a blob (BUD-09)
|
||||
func (s *Storage) SaveReport(sha256Hash []byte, reportData []byte) (err error) {
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
reportKey := prefixBlobReport + sha256Hex
|
||||
|
||||
// Get existing reports
|
||||
var existingReports [][]byte
|
||||
if err = s.db.View(func(txn *badger.Txn) error {
|
||||
item, err := txn.Get([]byte(reportKey))
|
||||
if err == badger.ErrKeyNotFound {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return item.Value(func(val []byte) error {
|
||||
if err = json.Unmarshal(val, &existingReports); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
// Append new report
|
||||
existingReports = append(existingReports, reportData)
|
||||
|
||||
// Store updated reports
|
||||
var reportsData []byte
|
||||
if reportsData, err = json.Marshal(existingReports); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
if err = s.db.Update(func(txn *badger.Txn) error {
|
||||
return txn.Set([]byte(reportKey), reportsData)
|
||||
}); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
log.D.F("saved report for blob %s", sha256Hex)
|
||||
return
|
||||
}
|
||||
|
||||
// GetBlobMetadata retrieves only metadata for a blob
|
||||
func (s *Storage) GetBlobMetadata(sha256Hash []byte) (metadata *BlobMetadata, err error) {
|
||||
sha256Hex := hex.Enc(sha256Hash)
|
||||
metaKey := prefixBlobMeta + sha256Hex
|
||||
|
||||
if err = s.db.View(func(txn *badger.Txn) error {
|
||||
item, err := txn.Get([]byte(metaKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return item.Value(func(val []byte) error {
|
||||
if metadata, err = DeserializeBlobMetadata(val); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
282
pkg/blossom/utils.go
Normal file
282
pkg/blossom/utils.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"lol.mleku.dev/errorf"
|
||||
"next.orly.dev/pkg/crypto/sha256"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
)
|
||||
|
||||
const (
|
||||
sha256HexLength = 64
|
||||
maxRangeSize = 10 * 1024 * 1024 // 10MB max range request
|
||||
)
|
||||
|
||||
var sha256Regex = regexp.MustCompile(`^[a-fA-F0-9]{64}`)
|
||||
|
||||
// CalculateSHA256 calculates the SHA256 hash of data
|
||||
func CalculateSHA256(data []byte) []byte {
|
||||
hash := sha256.Sum256(data)
|
||||
return hash[:]
|
||||
}
|
||||
|
||||
// CalculateSHA256Hex calculates the SHA256 hash and returns it as hex string
|
||||
func CalculateSHA256Hex(data []byte) string {
|
||||
hash := sha256.Sum256(data)
|
||||
return hex.Enc(hash[:])
|
||||
}
|
||||
|
||||
// ExtractSHA256FromPath extracts SHA256 hash from URL path
|
||||
// Supports both /<sha256> and /<sha256>.<ext> formats
|
||||
func ExtractSHA256FromPath(path string) (sha256Hex string, ext string, err error) {
|
||||
// Remove leading slash
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
|
||||
// Split by dot to separate hash and extension
|
||||
parts := strings.SplitN(path, ".", 2)
|
||||
sha256Hex = parts[0]
|
||||
|
||||
if len(parts) > 1 {
|
||||
ext = "." + parts[1]
|
||||
}
|
||||
|
||||
// Validate SHA256 hex format
|
||||
if len(sha256Hex) != sha256HexLength {
|
||||
err = errorf.E(
|
||||
"invalid SHA256 length: expected %d, got %d",
|
||||
sha256HexLength, len(sha256Hex),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if !sha256Regex.MatchString(sha256Hex) {
|
||||
err = errorf.E("invalid SHA256 format: %s", sha256Hex)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ExtractSHA256FromURL extracts SHA256 hash from a URL string
|
||||
// Uses the last occurrence of a 64 char hex string (as per BUD-03)
|
||||
func ExtractSHA256FromURL(urlStr string) (sha256Hex string, err error) {
|
||||
// Find all 64-char hex strings
|
||||
matches := sha256Regex.FindAllString(urlStr, -1)
|
||||
if len(matches) == 0 {
|
||||
err = errorf.E("no SHA256 hash found in URL: %s", urlStr)
|
||||
return
|
||||
}
|
||||
|
||||
// Return the last occurrence
|
||||
sha256Hex = matches[len(matches)-1]
|
||||
return
|
||||
}
|
||||
|
||||
// GetMimeTypeFromExtension returns MIME type based on file extension
|
||||
func GetMimeTypeFromExtension(ext string) string {
|
||||
ext = strings.ToLower(ext)
|
||||
mimeTypes := map[string]string{
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".txt": "text/plain",
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
".xml": "application/xml",
|
||||
".zip": "application/zip",
|
||||
".tar": "application/x-tar",
|
||||
".gz": "application/gzip",
|
||||
}
|
||||
|
||||
if mime, ok := mimeTypes[ext]; ok {
|
||||
return mime
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// DetectMimeType detects MIME type from Content-Type header or file extension
|
||||
func DetectMimeType(contentType string, ext string) string {
|
||||
// First try Content-Type header
|
||||
if contentType != "" {
|
||||
// Remove any parameters (e.g., "text/plain; charset=utf-8")
|
||||
parts := strings.Split(contentType, ";")
|
||||
mime := strings.TrimSpace(parts[0])
|
||||
if mime != "" && mime != "application/octet-stream" {
|
||||
return mime
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to extension
|
||||
if ext != "" {
|
||||
return GetMimeTypeFromExtension(ext)
|
||||
}
|
||||
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// ParseRangeHeader parses HTTP Range header (RFC 7233)
|
||||
// Returns start, end, and total length
|
||||
func ParseRangeHeader(rangeHeader string, contentLength int64) (
|
||||
start, end int64, valid bool, err error,
|
||||
) {
|
||||
if rangeHeader == "" {
|
||||
return 0, 0, false, nil
|
||||
}
|
||||
|
||||
// Only support "bytes" unit
|
||||
if !strings.HasPrefix(rangeHeader, "bytes=") {
|
||||
return 0, 0, false, errorf.E("unsupported range unit")
|
||||
}
|
||||
|
||||
rangeSpec := strings.TrimPrefix(rangeHeader, "bytes=")
|
||||
parts := strings.Split(rangeSpec, "-")
|
||||
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, false, errorf.E("invalid range format")
|
||||
}
|
||||
|
||||
var startStr, endStr string
|
||||
startStr = strings.TrimSpace(parts[0])
|
||||
endStr = strings.TrimSpace(parts[1])
|
||||
|
||||
if startStr == "" && endStr == "" {
|
||||
return 0, 0, false, errorf.E("invalid range: both start and end empty")
|
||||
}
|
||||
|
||||
// Parse start
|
||||
if startStr != "" {
|
||||
if start, err = strconv.ParseInt(startStr, 10, 64); err != nil {
|
||||
return 0, 0, false, errorf.E("invalid range start: %w", err)
|
||||
}
|
||||
if start < 0 {
|
||||
return 0, 0, false, errorf.E("range start cannot be negative")
|
||||
}
|
||||
if start >= contentLength {
|
||||
return 0, 0, false, errorf.E("range start exceeds content length")
|
||||
}
|
||||
} else {
|
||||
// Suffix range: last N bytes
|
||||
if end, err = strconv.ParseInt(endStr, 10, 64); err != nil {
|
||||
return 0, 0, false, errorf.E("invalid range end: %w", err)
|
||||
}
|
||||
if end <= 0 {
|
||||
return 0, 0, false, errorf.E("suffix range must be positive")
|
||||
}
|
||||
start = contentLength - end
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end = contentLength - 1
|
||||
return start, end, true, nil
|
||||
}
|
||||
|
||||
// Parse end
|
||||
if endStr != "" {
|
||||
if end, err = strconv.ParseInt(endStr, 10, 64); err != nil {
|
||||
return 0, 0, false, errorf.E("invalid range end: %w", err)
|
||||
}
|
||||
if end < start {
|
||||
return 0, 0, false, errorf.E("range end before start")
|
||||
}
|
||||
if end >= contentLength {
|
||||
end = contentLength - 1
|
||||
}
|
||||
} else {
|
||||
// Open-ended range: from start to end
|
||||
end = contentLength - 1
|
||||
}
|
||||
|
||||
// Validate range size
|
||||
if end-start+1 > maxRangeSize {
|
||||
return 0, 0, false, errorf.E("range too large: max %d bytes", maxRangeSize)
|
||||
}
|
||||
|
||||
return start, end, true, nil
|
||||
}
|
||||
|
||||
// WriteRangeResponse writes a partial content response (206)
|
||||
func WriteRangeResponse(
|
||||
w http.ResponseWriter, data []byte, start, end, totalLength int64,
|
||||
) {
|
||||
w.Header().Set("Content-Range",
|
||||
"bytes "+strconv.FormatInt(start, 10)+"-"+
|
||||
strconv.FormatInt(end, 10)+"/"+
|
||||
strconv.FormatInt(totalLength, 10))
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(end-start+1, 10))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write(data[start : end+1])
|
||||
}
|
||||
|
||||
// BuildBlobURL builds a blob URL with optional extension
|
||||
func BuildBlobURL(baseURL, sha256Hex, ext string) string {
|
||||
url := baseURL + sha256Hex
|
||||
if ext != "" {
|
||||
url += ext
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// ValidateSHA256Hex validates that a string is a valid SHA256 hex string
|
||||
func ValidateSHA256Hex(s string) bool {
|
||||
if len(s) != sha256HexLength {
|
||||
return false
|
||||
}
|
||||
_, err := hex.Dec(s)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetFileExtensionFromPath extracts file extension from a path
|
||||
func GetFileExtensionFromPath(path string) string {
|
||||
ext := filepath.Ext(path)
|
||||
return ext
|
||||
}
|
||||
|
||||
// GetExtensionFromMimeType returns file extension based on MIME type
|
||||
func GetExtensionFromMimeType(mimeType string) string {
|
||||
// Reverse lookup of GetMimeTypeFromExtension
|
||||
mimeToExt := map[string]string{
|
||||
"application/pdf": ".pdf",
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
"video/mp4": ".mp4",
|
||||
"video/webm": ".webm",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/wav": ".wav",
|
||||
"audio/ogg": ".ogg",
|
||||
"text/plain": ".txt",
|
||||
"text/html": ".html",
|
||||
"text/css": ".css",
|
||||
"application/javascript": ".js",
|
||||
"application/json": ".json",
|
||||
"application/xml": ".xml",
|
||||
"application/zip": ".zip",
|
||||
"application/x-tar": ".tar",
|
||||
"application/gzip": ".gz",
|
||||
}
|
||||
|
||||
if ext, ok := mimeToExt[mimeType]; ok {
|
||||
return ext
|
||||
}
|
||||
return "" // No extension for unknown MIME types
|
||||
}
|
||||
|
||||
381
pkg/blossom/utils_test.go
Normal file
381
pkg/blossom/utils_test.go
Normal file
@@ -0,0 +1,381 @@
|
||||
package blossom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"next.orly.dev/pkg/acl"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/database"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
"next.orly.dev/pkg/encoders/timestamp"
|
||||
)
|
||||
|
||||
// testSetup creates a test database, ACL, and server
|
||||
func testSetup(t *testing.T) (*Server, func()) {
|
||||
// Create temporary directory for database
|
||||
tempDir, err := os.MkdirTemp("", "blossom-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Create database
|
||||
db, err := database.New(ctx, cancel, tempDir, "error")
|
||||
if err != nil {
|
||||
os.RemoveAll(tempDir)
|
||||
t.Fatalf("Failed to create database: %v", err)
|
||||
}
|
||||
|
||||
// Create ACL registry
|
||||
aclRegistry := acl.Registry
|
||||
|
||||
// Create server
|
||||
cfg := &Config{
|
||||
BaseURL: "http://localhost:8080",
|
||||
MaxBlobSize: 100 * 1024 * 1024, // 100MB
|
||||
AllowedMimeTypes: nil,
|
||||
RequireAuth: false,
|
||||
}
|
||||
|
||||
server := NewServer(db, aclRegistry, cfg)
|
||||
|
||||
cleanup := func() {
|
||||
cancel()
|
||||
db.Close()
|
||||
os.RemoveAll(tempDir)
|
||||
}
|
||||
|
||||
return server, cleanup
|
||||
}
|
||||
|
||||
// createTestKeypair creates a test keypair for signing events
|
||||
func createTestKeypair(t *testing.T) ([]byte, *p256k1signer.P256K1Signer) {
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err := signer.Generate(); err != nil {
|
||||
t.Fatalf("Failed to generate keypair: %v", err)
|
||||
}
|
||||
pubkey := signer.Pub()
|
||||
return pubkey, signer
|
||||
}
|
||||
|
||||
// createAuthEvent creates a valid kind 24242 authorization event
|
||||
func createAuthEvent(
|
||||
t *testing.T, signer *p256k1signer.P256K1Signer, verb string,
|
||||
sha256Hash []byte, expiresIn int64,
|
||||
) *event.E {
|
||||
now := time.Now().Unix()
|
||||
expires := now + expiresIn
|
||||
|
||||
tags := tag.NewS()
|
||||
tags.Append(tag.NewFromAny("t", verb))
|
||||
tags.Append(tag.NewFromAny("expiration", timestamp.FromUnix(expires).String()))
|
||||
|
||||
if sha256Hash != nil {
|
||||
tags.Append(tag.NewFromAny("x", hex.Enc(sha256Hash)))
|
||||
}
|
||||
|
||||
ev := &event.E{
|
||||
CreatedAt: now,
|
||||
Kind: BlossomAuthKind,
|
||||
Tags: tags,
|
||||
Content: []byte("Test authorization"),
|
||||
Pubkey: signer.Pub(),
|
||||
}
|
||||
|
||||
// Sign event
|
||||
if err := ev.Sign(signer); err != nil {
|
||||
t.Fatalf("Failed to sign event: %v", err)
|
||||
}
|
||||
|
||||
return ev
|
||||
}
|
||||
|
||||
// createAuthHeader creates an Authorization header from an event
|
||||
func createAuthHeader(ev *event.E) string {
|
||||
eventJSON := ev.Serialize()
|
||||
b64 := base64.StdEncoding.EncodeToString(eventJSON)
|
||||
return "Nostr " + b64
|
||||
}
|
||||
|
||||
// makeRequest creates an HTTP request with optional authorization
|
||||
func makeRequest(
|
||||
t *testing.T, method, path string, body []byte, authEv *event.E,
|
||||
) *http.Request {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if body != nil {
|
||||
req.Body = httptest.NewRequest(method, path, nil).Body
|
||||
req.ContentLength = int64(len(body))
|
||||
}
|
||||
|
||||
if authEv != nil {
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// TestBlobDescriptor tests BlobDescriptor creation and serialization
|
||||
func TestBlobDescriptor(t *testing.T) {
|
||||
desc := NewBlobDescriptor(
|
||||
"https://example.com/blob.pdf",
|
||||
"abc123",
|
||||
1024,
|
||||
"application/pdf",
|
||||
1234567890,
|
||||
)
|
||||
|
||||
if desc.URL != "https://example.com/blob.pdf" {
|
||||
t.Errorf("Expected URL %s, got %s", "https://example.com/blob.pdf", desc.URL)
|
||||
}
|
||||
if desc.SHA256 != "abc123" {
|
||||
t.Errorf("Expected SHA256 %s, got %s", "abc123", desc.SHA256)
|
||||
}
|
||||
if desc.Size != 1024 {
|
||||
t.Errorf("Expected Size %d, got %d", 1024, desc.Size)
|
||||
}
|
||||
if desc.Type != "application/pdf" {
|
||||
t.Errorf("Expected Type %s, got %s", "application/pdf", desc.Type)
|
||||
}
|
||||
|
||||
// Test default MIME type
|
||||
desc2 := NewBlobDescriptor("url", "hash", 0, "", 0)
|
||||
if desc2.Type != "application/octet-stream" {
|
||||
t.Errorf("Expected default MIME type, got %s", desc2.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlobMetadata tests BlobMetadata serialization
|
||||
func TestBlobMetadata(t *testing.T) {
|
||||
pubkey := []byte("testpubkey123456789012345678901234")
|
||||
meta := NewBlobMetadata(pubkey, "image/png", 2048)
|
||||
|
||||
if meta.Size != 2048 {
|
||||
t.Errorf("Expected Size %d, got %d", 2048, meta.Size)
|
||||
}
|
||||
if meta.MimeType != "image/png" {
|
||||
t.Errorf("Expected MIME type %s, got %s", "image/png", meta.MimeType)
|
||||
}
|
||||
|
||||
// Test serialization
|
||||
data, err := meta.Serialize()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to serialize metadata: %v", err)
|
||||
}
|
||||
|
||||
// Test deserialization
|
||||
meta2, err := DeserializeBlobMetadata(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to deserialize metadata: %v", err)
|
||||
}
|
||||
|
||||
if meta2.Size != meta.Size {
|
||||
t.Errorf("Size mismatch after deserialize")
|
||||
}
|
||||
if meta2.MimeType != meta.MimeType {
|
||||
t.Errorf("MIME type mismatch after deserialize")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUtils tests utility functions
|
||||
func TestUtils(t *testing.T) {
|
||||
data := []byte("test data")
|
||||
hash := CalculateSHA256(data)
|
||||
if len(hash) != 32 {
|
||||
t.Errorf("Expected hash length 32, got %d", len(hash))
|
||||
}
|
||||
|
||||
hashHex := CalculateSHA256Hex(data)
|
||||
if len(hashHex) != 64 {
|
||||
t.Errorf("Expected hex hash length 64, got %d", len(hashHex))
|
||||
}
|
||||
|
||||
// Test ExtractSHA256FromPath
|
||||
sha256Hex, ext, err := ExtractSHA256FromPath("abc123def456")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to extract SHA256: %v", err)
|
||||
}
|
||||
if sha256Hex != "abc123def456" {
|
||||
t.Errorf("Expected %s, got %s", "abc123def456", sha256Hex)
|
||||
}
|
||||
if ext != "" {
|
||||
t.Errorf("Expected empty ext, got %s", ext)
|
||||
}
|
||||
|
||||
sha256Hex, ext, err = ExtractSHA256FromPath("abc123def456.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to extract SHA256: %v", err)
|
||||
}
|
||||
if sha256Hex != "abc123def456" {
|
||||
t.Errorf("Expected %s, got %s", "abc123def456", sha256Hex)
|
||||
}
|
||||
if ext != ".pdf" {
|
||||
t.Errorf("Expected .pdf, got %s", ext)
|
||||
}
|
||||
|
||||
// Test MIME type detection
|
||||
mime := GetMimeTypeFromExtension(".pdf")
|
||||
if mime != "application/pdf" {
|
||||
t.Errorf("Expected application/pdf, got %s", mime)
|
||||
}
|
||||
|
||||
mime = DetectMimeType("image/png", ".png")
|
||||
if mime != "image/png" {
|
||||
t.Errorf("Expected image/png, got %s", mime)
|
||||
}
|
||||
|
||||
mime = DetectMimeType("", ".jpg")
|
||||
if mime != "image/jpeg" {
|
||||
t.Errorf("Expected image/jpeg, got %s", mime)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStorage tests storage operations
|
||||
func TestStorage(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
storage := server.storage
|
||||
|
||||
// Create test data
|
||||
testData := []byte("test blob data")
|
||||
sha256Hash := CalculateSHA256(testData)
|
||||
pubkey := []byte("testpubkey123456789012345678901234")
|
||||
|
||||
// Test SaveBlob
|
||||
err := storage.SaveBlob(sha256Hash, testData, pubkey, "text/plain", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save blob: %v", err)
|
||||
}
|
||||
|
||||
// Test HasBlob
|
||||
exists, err := storage.HasBlob(sha256Hash)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check blob existence: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Error("Blob should exist after save")
|
||||
}
|
||||
|
||||
// Test GetBlob
|
||||
blobData, metadata, err := storage.GetBlob(sha256Hash)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get blob: %v", err)
|
||||
}
|
||||
if string(blobData) != string(testData) {
|
||||
t.Error("Blob data mismatch")
|
||||
}
|
||||
if metadata.Size != int64(len(testData)) {
|
||||
t.Errorf("Size mismatch: expected %d, got %d", len(testData), metadata.Size)
|
||||
}
|
||||
|
||||
// Test ListBlobs
|
||||
descriptors, err := storage.ListBlobs(pubkey, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list blobs: %v", err)
|
||||
}
|
||||
if len(descriptors) != 1 {
|
||||
t.Errorf("Expected 1 blob, got %d", len(descriptors))
|
||||
}
|
||||
|
||||
// Test DeleteBlob
|
||||
err = storage.DeleteBlob(sha256Hash, pubkey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete blob: %v", err)
|
||||
}
|
||||
|
||||
exists, err = storage.HasBlob(sha256Hash)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check blob existence: %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Error("Blob should not exist after delete")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthEvent tests authorization event validation
|
||||
func TestAuthEvent(t *testing.T) {
|
||||
pubkey, signer := createTestKeypair(t)
|
||||
sha256Hash := CalculateSHA256([]byte("test"))
|
||||
|
||||
// Create valid auth event
|
||||
authEv := createAuthEvent(t, signer, "upload", sha256Hash, 3600)
|
||||
|
||||
// Create HTTP request
|
||||
req := httptest.NewRequest("PUT", "/upload", nil)
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
|
||||
// Extract and validate
|
||||
ev, err := ExtractAuthEvent(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to extract auth event: %v", err)
|
||||
}
|
||||
|
||||
if ev.Kind != BlossomAuthKind {
|
||||
t.Errorf("Expected kind %d, got %d", BlossomAuthKind, ev.Kind)
|
||||
}
|
||||
|
||||
// Validate auth event
|
||||
authEv2, err := ValidateAuthEvent(req, "upload", sha256Hash)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to validate auth event: %v", err)
|
||||
}
|
||||
|
||||
if authEv2.Verb != "upload" {
|
||||
t.Errorf("Expected verb 'upload', got '%s'", authEv2.Verb)
|
||||
}
|
||||
|
||||
// Verify pubkey matches
|
||||
if !bytes.Equal(authEv2.Pubkey, pubkey) {
|
||||
t.Error("Pubkey mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthEventExpired tests expired authorization events
|
||||
func TestAuthEventExpired(t *testing.T) {
|
||||
_, signer := createTestKeypair(t)
|
||||
sha256Hash := CalculateSHA256([]byte("test"))
|
||||
|
||||
// Create expired auth event
|
||||
authEv := createAuthEvent(t, signer, "upload", sha256Hash, -3600)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/upload", nil)
|
||||
req.Header.Set("Authorization", createAuthHeader(authEv))
|
||||
|
||||
_, err := ValidateAuthEvent(req, "upload", sha256Hash)
|
||||
if err == nil {
|
||||
t.Error("Expected error for expired auth event")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerHandler tests the server handler routing
|
||||
func TestServerHandler(t *testing.T) {
|
||||
server, cleanup := testSetup(t)
|
||||
defer cleanup()
|
||||
|
||||
handler := server.Handler()
|
||||
|
||||
// Test OPTIONS request (CORS preflight)
|
||||
req := httptest.NewRequest("OPTIONS", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Check CORS headers
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Error("Missing CORS header")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package encryption
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"lukechampine.com/frand"
|
||||
)
|
||||
|
||||
@@ -13,8 +13,8 @@ func createTestConversationKey() []byte {
|
||||
}
|
||||
|
||||
// createTestKeyPair creates a key pair for ECDH testing
|
||||
func createTestKeyPair() (*p256k.Signer, []byte) {
|
||||
signer := &p256k.Signer{}
|
||||
func createTestKeyPair() (*p256k1signer.P256K1Signer, []byte) {
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err := signer.Generate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ import (
|
||||
"golang.org/x/crypto/hkdf"
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/errorf"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/crypto/sha256"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/interfaces/signer"
|
||||
"next.orly.dev/pkg/utils"
|
||||
)
|
||||
@@ -176,11 +177,16 @@ func GenerateConversationKeyFromHex(pkh, skh string) (ck []byte, err error) {
|
||||
return
|
||||
}
|
||||
var sign signer.I
|
||||
if sign, err = p256k.NewSecFromHex(skh); chk.E(err) {
|
||||
sign = p256k1signer.NewP256K1Signer()
|
||||
var sk []byte
|
||||
if sk, err = hex.Dec(skh); chk.E(err) {
|
||||
return
|
||||
}
|
||||
if err = sign.InitSec(sk); chk.E(err) {
|
||||
return
|
||||
}
|
||||
var pk []byte
|
||||
if pk, err = p256k.HexToBin(pkh); chk.E(err) {
|
||||
if pk, err = hex.Dec(pkh); chk.E(err) {
|
||||
return
|
||||
}
|
||||
var shared []byte
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/ec/schnorr"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/utils"
|
||||
)
|
||||
@@ -17,7 +17,7 @@ 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{}
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err = signer.Generate(); chk.E(err) {
|
||||
return
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func GetPublicKeyHex(sk string) (pk string, err error) {
|
||||
if b, err = hex.Dec(sk); chk.E(err) {
|
||||
return
|
||||
}
|
||||
signer := &p256k.Signer{}
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err = signer.InitSec(b); chk.E(err) {
|
||||
return
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func GetPublicKeyHex(sk string) (pk string, err error) {
|
||||
|
||||
// SecretBytesToPubKeyHex generates a public key from secret key bytes.
|
||||
func SecretBytesToPubKeyHex(skb []byte) (pk string, err error) {
|
||||
signer := &p256k.Signer{}
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err = signer.InitSec(skb); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
# p256k1
|
||||
|
||||
This is a library that uses the `bitcoin-core` optimized secp256k1 elliptic
|
||||
curve signatures library for `nostr` schnorr signatures.
|
||||
|
||||
If you need to build it without `libsecp256k1` C library, you must disable cgo:
|
||||
|
||||
export CGO_ENABLED='0'
|
||||
|
||||
This enables the fallback `btcec` pure Go library to be used in its place. This
|
||||
CGO setting is not default for Go, so it must be set in order to disable this.
|
||||
|
||||
The standard `libsecp256k1-0` and `libsecp256k1-dev` available through the
|
||||
ubuntu dpkg repositories do not include support for the BIP-340 schnorr
|
||||
signatures or the ECDH X-only shared secret generation algorithm, so you must
|
||||
follow the following instructions to get the benefits of using this library. It
|
||||
is 4x faster at signing and generating shared secrets so it is a must if your
|
||||
intention is to use it for high throughput systems like a network transport.
|
||||
|
||||
The easy way to install it, if you have ubuntu/debian, is the script
|
||||
[../ubuntu_install_libsecp256k1.sh](../../../scripts/ubuntu_install_libsecp256k1.sh),
|
||||
it
|
||||
handles the dependencies and runs the build all in one step for you. Note that
|
||||
it
|
||||
|
||||
For ubuntu, you need these:
|
||||
|
||||
sudo apt -y install build-essential autoconf libtool
|
||||
|
||||
For other linux distributions, the process is the same but the dependencies are
|
||||
likely different. The main thing is it requires make, gcc/++, autoconf and
|
||||
libtool to run. The most important thing to point out is that you must enable
|
||||
the schnorr signatures feature, and ECDH.
|
||||
|
||||
The directory `p256k/secp256k1` needs to be initialized, built and installed,
|
||||
like so:
|
||||
|
||||
```bash
|
||||
cd secp256k1
|
||||
git submodule init
|
||||
git submodule update
|
||||
```
|
||||
|
||||
Then to build, you can refer to the [instructions](./secp256k1/README.md) or
|
||||
just use the default autotools:
|
||||
|
||||
```bash
|
||||
./autogen.sh
|
||||
./configure --enable-module-schnorrsig --enable-module-ecdh --prefix=/usr
|
||||
make
|
||||
sudo make install
|
||||
```
|
||||
|
||||
On WSL2 you may have to attend to various things to make this work, setting up
|
||||
your basic locale (uncomment one or more in `/etc/locale.gen`, and run
|
||||
`locale-gen`), installing the basic build tools (build-essential or base-devel)
|
||||
and of course git, curl, wget, libtool and
|
||||
autoconf.
|
||||
|
||||
## ECDH
|
||||
|
||||
TODO: Currently the use of the libsecp256k1 library for ECDH, used in nip-04 and
|
||||
nip-44 encryption is not enabled, because the default version uses the Y
|
||||
coordinate and this is incorrect for nostr. It will be enabled soon... for now
|
||||
it is done with the `btcec` fallback version. This is slower, however previous
|
||||
tests have shown that this ECDH library is fast enough to enable 8mb/s
|
||||
throughput per CPU thread when used to generate a distinct secret for TCP
|
||||
packets. The C library will likely raise this to 20mb/s or more.
|
||||
@@ -1,21 +0,0 @@
|
||||
//go:build !cgo
|
||||
|
||||
package p256k
|
||||
|
||||
import (
|
||||
"lol.mleku.dev/log"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.T.Ln("using p256k1.mleku.dev/signer (pure Go/Btcec)")
|
||||
}
|
||||
|
||||
// Signer is an alias for the BtcecSigner type from p256k1.mleku.dev/signer (btcec version).
|
||||
// This is used when CGO is not available.
|
||||
type Signer = p256k1signer.BtcecSigner
|
||||
|
||||
// Keygen is an alias for the P256K1Gen type from p256k1.mleku.dev/signer (btcec version).
|
||||
type Keygen = p256k1signer.P256K1Gen
|
||||
|
||||
var NewKeygen = p256k1signer.NewP256K1Gen
|
||||
@@ -1,169 +0,0 @@
|
||||
//go:build !cgo
|
||||
|
||||
// Package btcec implements the signer.I interface for signatures and ECDH with nostr.
|
||||
package btcec
|
||||
|
||||
import (
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/errorf"
|
||||
"next.orly.dev/pkg/crypto/ec/schnorr"
|
||||
"next.orly.dev/pkg/crypto/ec/secp256k1"
|
||||
"next.orly.dev/pkg/interfaces/signer"
|
||||
)
|
||||
|
||||
// Signer is an implementation of signer.I that uses the btcec library.
|
||||
type Signer struct {
|
||||
SecretKey *secp256k1.SecretKey
|
||||
PublicKey *secp256k1.PublicKey
|
||||
BTCECSec *secp256k1.SecretKey
|
||||
pkb, skb []byte
|
||||
}
|
||||
|
||||
var _ signer.I = &Signer{}
|
||||
|
||||
// Generate creates a new Signer.
|
||||
func (s *Signer) Generate() (err error) {
|
||||
if s.SecretKey, err = secp256k1.GenerateSecretKey(); chk.E(err) {
|
||||
return
|
||||
}
|
||||
s.skb = s.SecretKey.Serialize()
|
||||
s.BTCECSec = secp256k1.PrivKeyFromBytes(s.skb)
|
||||
s.PublicKey = s.SecretKey.PubKey()
|
||||
s.pkb = schnorr.SerializePubKey(s.PublicKey)
|
||||
return
|
||||
}
|
||||
|
||||
// InitSec initialises a Signer using raw secret key bytes.
|
||||
func (s *Signer) InitSec(sec []byte) (err error) {
|
||||
if len(sec) != secp256k1.SecKeyBytesLen {
|
||||
err = errorf.E("sec key must be %d bytes", secp256k1.SecKeyBytesLen)
|
||||
return
|
||||
}
|
||||
s.skb = sec
|
||||
s.SecretKey = secp256k1.SecKeyFromBytes(sec)
|
||||
s.PublicKey = s.SecretKey.PubKey()
|
||||
s.pkb = schnorr.SerializePubKey(s.PublicKey)
|
||||
s.BTCECSec = secp256k1.PrivKeyFromBytes(s.skb)
|
||||
return
|
||||
}
|
||||
|
||||
// InitPub initializes a signature verifier Signer from raw public key bytes.
|
||||
func (s *Signer) InitPub(pub []byte) (err error) {
|
||||
if s.PublicKey, err = schnorr.ParsePubKey(pub); chk.E(err) {
|
||||
return
|
||||
}
|
||||
s.pkb = pub
|
||||
return
|
||||
}
|
||||
|
||||
// Sec returns the raw secret key bytes.
|
||||
func (s *Signer) Sec() (b []byte) {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.skb
|
||||
}
|
||||
|
||||
// Pub returns the raw BIP-340 schnorr public key bytes.
|
||||
func (s *Signer) Pub() (b []byte) {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.pkb
|
||||
}
|
||||
|
||||
// Sign a message with the Signer. Requires an initialised secret key.
|
||||
func (s *Signer) Sign(msg []byte) (sig []byte, err error) {
|
||||
if s.SecretKey == nil {
|
||||
err = errorf.E("btcec: Signer not initialized")
|
||||
return
|
||||
}
|
||||
var si *schnorr.Signature
|
||||
if si, err = schnorr.Sign(s.SecretKey, msg); chk.E(err) {
|
||||
return
|
||||
}
|
||||
sig = si.Serialize()
|
||||
return
|
||||
}
|
||||
|
||||
// Verify a message signature, only requires the public key is initialised.
|
||||
func (s *Signer) Verify(msg, sig []byte) (valid bool, err error) {
|
||||
if s.PublicKey == nil {
|
||||
err = errorf.E("btcec: Pubkey not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
// First try to verify using the schnorr package
|
||||
var si *schnorr.Signature
|
||||
if si, err = schnorr.ParseSignature(sig); err == nil {
|
||||
valid = si.Verify(msg, s.PublicKey)
|
||||
return
|
||||
}
|
||||
|
||||
// If parsing the signature failed, log it at debug level
|
||||
chk.D(err)
|
||||
|
||||
// If the signature is exactly 64 bytes, try to verify it directly
|
||||
// This is to handle signatures created by p256k.Signer which uses libsecp256k1
|
||||
if len(sig) == schnorr.SignatureSize {
|
||||
// Create a new signature with the raw bytes
|
||||
var r secp256k1.FieldVal
|
||||
var sScalar secp256k1.ModNScalar
|
||||
|
||||
// Split the signature into r and s components
|
||||
if overflow := r.SetByteSlice(sig[0:32]); !overflow {
|
||||
sScalar.SetByteSlice(sig[32:64])
|
||||
|
||||
// Create a new signature and verify it
|
||||
newSig := schnorr.NewSignature(&r, &sScalar)
|
||||
valid = newSig.Verify(msg, s.PublicKey)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If all verification methods failed, return an error
|
||||
err = errorf.E(
|
||||
"failed to verify signature:\n%d %s", len(sig), sig,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Zero wipes the bytes of the secret key.
|
||||
func (s *Signer) Zero() { s.SecretKey.Key.Zero() }
|
||||
|
||||
// ECDH creates a shared secret from a secret key and a provided public key bytes. It is advised
|
||||
// to hash this result for security reasons.
|
||||
func (s *Signer) ECDH(pubkeyBytes []byte) (secret []byte, err error) {
|
||||
var pub *secp256k1.PublicKey
|
||||
if pub, err = secp256k1.ParsePubKey(
|
||||
append(
|
||||
[]byte{0x02}, pubkeyBytes...,
|
||||
),
|
||||
); chk.E(err) {
|
||||
return
|
||||
}
|
||||
secret = secp256k1.GenerateSharedSecret(s.BTCECSec, pub)
|
||||
return
|
||||
}
|
||||
|
||||
// Keygen implements a key generator. Used for such things as vanity npub mining.
|
||||
type Keygen struct {
|
||||
Signer
|
||||
}
|
||||
|
||||
// Generate a new key pair. If the result is suitable, the embedded Signer can have its contents
|
||||
// extracted.
|
||||
func (k *Keygen) Generate() (pubBytes []byte, err error) {
|
||||
if k.Signer.SecretKey, err = secp256k1.GenerateSecretKey(); chk.E(err) {
|
||||
return
|
||||
}
|
||||
k.Signer.PublicKey = k.SecretKey.PubKey()
|
||||
k.Signer.pkb = schnorr.SerializePubKey(k.Signer.PublicKey)
|
||||
pubBytes = k.Signer.pkb
|
||||
return
|
||||
}
|
||||
|
||||
// KeyPairBytes returns the raw bytes of the embedded Signer.
|
||||
func (k *Keygen) KeyPairBytes() (secBytes, cmprPubBytes []byte) {
|
||||
return k.Signer.SecretKey.Serialize(), k.Signer.PublicKey.SerializeCompressed()
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
//go:build !cgo
|
||||
|
||||
package btcec_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/crypto/p256k/btcec"
|
||||
"next.orly.dev/pkg/utils"
|
||||
)
|
||||
|
||||
func TestSigner_Generate(t *testing.T) {
|
||||
for _ = range 100 {
|
||||
var err error
|
||||
signer := &btcec.Signer{}
|
||||
var skb []byte
|
||||
if err = signer.Generate(); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skb = signer.Sec()
|
||||
if err = signer.InitSec(skb); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// func TestBTCECSignerVerify(t *testing.T) {
|
||||
// evs := make([]*event.E, 0, 10000)
|
||||
// scanner := bufio.NewScanner(bytes.NewBuffer(examples.Cache))
|
||||
// buf := make([]byte, 1_000_000)
|
||||
// scanner.Buffer(buf, len(buf))
|
||||
// var err error
|
||||
//
|
||||
// // Create both btcec and p256k signers
|
||||
// btcecSigner := &btcec.Signer{}
|
||||
// p256kSigner := &p256k.Signer{}
|
||||
//
|
||||
// for scanner.Scan() {
|
||||
// var valid bool
|
||||
// b := scanner.Bytes()
|
||||
// ev := event.New()
|
||||
// if _, err = ev.Unmarshal(b); chk.E(err) {
|
||||
// t.Errorf("failed to marshal\n%s", b)
|
||||
// } else {
|
||||
// // We know ev.Verify() works, so we'll use it as a reference
|
||||
// if valid, err = ev.Verify(); chk.E(err) || !valid {
|
||||
// t.Errorf("invalid signature\n%s", b)
|
||||
// continue
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Get the ID from the event
|
||||
// storedID := ev.ID
|
||||
// calculatedID := ev.GetIDBytes()
|
||||
//
|
||||
// // Check if the stored ID matches the calculated ID
|
||||
// if !utils.FastEqual(storedID, calculatedID) {
|
||||
// log.D.Ln("Event ID mismatch: stored ID doesn't match calculated ID")
|
||||
// // Use the calculated ID for verification as ev.Verify() would do
|
||||
// ev.ID = calculatedID
|
||||
// }
|
||||
//
|
||||
// if len(ev.ID) != sha256.Size {
|
||||
// t.Errorf("id should be 32 bytes, got %d", len(ev.ID))
|
||||
// continue
|
||||
// }
|
||||
//
|
||||
// // Initialize both signers with the same public key
|
||||
// if err = btcecSigner.InitPub(ev.Pubkey); chk.E(err) {
|
||||
// t.Errorf("failed to init btcec pub key: %s\n%0x", err, b)
|
||||
// }
|
||||
// if err = p256kSigner.InitPub(ev.Pubkey); chk.E(err) {
|
||||
// t.Errorf("failed to init p256k pub key: %s\n%0x", err, b)
|
||||
// }
|
||||
//
|
||||
// // First try to verify with btcec.Signer
|
||||
// if valid, err = btcecSigner.Verify(ev.ID, ev.Sig); err == nil && valid {
|
||||
// // If btcec.Signer verification succeeds, great!
|
||||
// log.D.Ln("btcec.Signer verification succeeded")
|
||||
// } else {
|
||||
// // If btcec.Signer verification fails, try with p256k.Signer
|
||||
// // Use chk.T(err) like ev.Verify() does
|
||||
// if valid, err = p256kSigner.Verify(ev.ID, ev.Sig); chk.T(err) {
|
||||
// // If there's an error, log it but don't fail the test
|
||||
// log.D.Ln("p256k.Signer verification error:", err)
|
||||
// } else if !valid {
|
||||
// // Only fail the test if both verifications fail
|
||||
// t.Errorf(
|
||||
// "invalid signature for pub %0x %0x %0x", ev.Pubkey, ev.ID,
|
||||
// ev.Sig,
|
||||
// )
|
||||
// } else {
|
||||
// log.D.Ln("p256k.Signer verification succeeded where btcec.Signer failed")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// evs = append(evs, ev)
|
||||
// }
|
||||
// }
|
||||
|
||||
// func TestBTCECSignerSign(t *testing.T) {
|
||||
// evs := make([]*event.E, 0, 10000)
|
||||
// scanner := bufio.NewScanner(bytes.NewBuffer(examples.Cache))
|
||||
// buf := make([]byte, 1_000_000)
|
||||
// scanner.Buffer(buf, len(buf))
|
||||
// var err error
|
||||
// signer := &btcec.Signer{}
|
||||
// var skb []byte
|
||||
// if err = signer.Generate(); chk.E(err) {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// skb = signer.Sec()
|
||||
// if err = signer.InitSec(skb); chk.E(err) {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// verifier := &btcec.Signer{}
|
||||
// pkb := signer.Pub()
|
||||
// if err = verifier.InitPub(pkb); chk.E(err) {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// counter := 0
|
||||
// for scanner.Scan() {
|
||||
// counter++
|
||||
// if counter > 1000 {
|
||||
// break
|
||||
// }
|
||||
// b := scanner.Bytes()
|
||||
// ev := event.New()
|
||||
// if _, err = ev.Unmarshal(b); chk.E(err) {
|
||||
// t.Errorf("failed to marshal\n%s", b)
|
||||
// }
|
||||
// evs = append(evs, ev)
|
||||
// }
|
||||
// var valid bool
|
||||
// sig := make([]byte, schnorr.SignatureSize)
|
||||
// for _, ev := range evs {
|
||||
// ev.Pubkey = pkb
|
||||
// id := ev.GetIDBytes()
|
||||
// if sig, err = signer.Sign(id); chk.E(err) {
|
||||
// t.Errorf("failed to sign: %s\n%0x", err, id)
|
||||
// }
|
||||
// if valid, err = verifier.Verify(id, sig); chk.E(err) {
|
||||
// t.Errorf("failed to verify: %s\n%0x", err, id)
|
||||
// }
|
||||
// if !valid {
|
||||
// t.Errorf("invalid signature")
|
||||
// }
|
||||
// }
|
||||
// signer.Zero()
|
||||
// }
|
||||
|
||||
func TestBTCECECDH(t *testing.T) {
|
||||
n := time.Now()
|
||||
var err error
|
||||
var counter int
|
||||
const total = 50
|
||||
for _ = range total {
|
||||
s1 := new(btcec.Signer)
|
||||
if err = s1.Generate(); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2 := new(btcec.Signer)
|
||||
if err = s2.Generate(); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _ = range total {
|
||||
var secret1, secret2 []byte
|
||||
if secret1, err = s1.ECDH(s2.Pub()); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secret2, err = s2.ECDH(s1.Pub()); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !utils.FastEqual(secret1, secret2) {
|
||||
counter++
|
||||
t.Errorf(
|
||||
"ECDH generation failed to work in both directions, %x %x",
|
||||
secret1,
|
||||
secret2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
a := time.Now()
|
||||
duration := a.Sub(n)
|
||||
log.I.Ln(
|
||||
"errors", counter, "total", total, "time", duration, "time/op",
|
||||
int(duration/total),
|
||||
"ops/sec", int(time.Second)/int(duration/total),
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//go:build !cgo
|
||||
|
||||
package btcec
|
||||
|
||||
import (
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/interfaces/signer"
|
||||
)
|
||||
|
||||
func NewSecFromHex[V []byte | string](skh V) (sign signer.I, err error) {
|
||||
sk := make([]byte, len(skh)/2)
|
||||
if _, err = hex.DecBytes(sk, []byte(skh)); chk.E(err) {
|
||||
return
|
||||
}
|
||||
sign = &Signer{}
|
||||
if err = sign.InitSec(sk); chk.E(err) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func NewPubFromHex[V []byte | string](pkh V) (sign signer.I, err error) {
|
||||
pk := make([]byte, len(pkh)/2)
|
||||
if _, err = hex.DecBytes(pk, []byte(pkh)); chk.E(err) {
|
||||
return
|
||||
}
|
||||
sign = &Signer{}
|
||||
if err = sign.InitPub(pk); chk.E(err) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func HexToBin(hexStr string) (b []byte, err error) {
|
||||
b = make([]byte, len(hexStr)/2)
|
||||
if _, err = hex.DecBytes(b, []byte(hexStr)); chk.E(err) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// Package p256k provides a signer interface that uses p256k1.mleku.dev library for
|
||||
// fast signature creation and verification of BIP-340 nostr X-only signatures and
|
||||
// public keys, and ECDH.
|
||||
//
|
||||
// The package provides type aliases to p256k1.mleku.dev/signer:
|
||||
// - cgo: Uses the CGO-optimized version from p256k1.mleku.dev
|
||||
// - btcec: Uses the btcec version from p256k1.mleku.dev
|
||||
// - default: Uses the pure Go version from p256k1.mleku.dev
|
||||
package p256k
|
||||
@@ -1,41 +0,0 @@
|
||||
//go:build !cgo
|
||||
|
||||
package p256k
|
||||
|
||||
import (
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/interfaces/signer"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
)
|
||||
|
||||
func NewSecFromHex[V []byte | string](skh V) (sign signer.I, err error) {
|
||||
sk := make([]byte, len(skh)/2)
|
||||
if _, err = hex.DecBytes(sk, []byte(skh)); chk.E(err) {
|
||||
return
|
||||
}
|
||||
sign = p256k1signer.NewBtcecSigner()
|
||||
if err = sign.InitSec(sk); chk.E(err) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func NewPubFromHex[V []byte | string](pkh V) (sign signer.I, err error) {
|
||||
pk := make([]byte, len(pkh)/2)
|
||||
if _, err = hex.DecBytes(pk, []byte(pkh)); chk.E(err) {
|
||||
return
|
||||
}
|
||||
sign = p256k1signer.NewBtcecSigner()
|
||||
if err = sign.InitPub(pk); chk.E(err) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func HexToBin(hexStr string) (b []byte, err error) {
|
||||
if b, err = hex.DecAppend(b, []byte(hexStr)); chk.E(err) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//go:build cgo
|
||||
|
||||
package p256k
|
||||
|
||||
import (
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/interfaces/signer"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
)
|
||||
|
||||
func NewSecFromHex[V []byte | string](skh V) (sign signer.I, err error) {
|
||||
sk := make([]byte, len(skh)/2)
|
||||
if _, err = hex.DecBytes(sk, []byte(skh)); chk.E(err) {
|
||||
return
|
||||
}
|
||||
sign = p256k1signer.NewP256K1Signer()
|
||||
if err = sign.InitSec(sk); chk.E(err) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func NewPubFromHex[V []byte | string](pkh V) (sign signer.I, err error) {
|
||||
pk := make([]byte, len(pkh)/2)
|
||||
if _, err = hex.DecBytes(pk, []byte(pkh)); chk.E(err) {
|
||||
return
|
||||
}
|
||||
sign = p256k1signer.NewP256K1Signer()
|
||||
if err = sign.InitPub(pk); chk.E(err) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func HexToBin(hexStr string) (b []byte, err error) {
|
||||
if b, err = hex.DecAppend(b, []byte(hexStr)); chk.E(err) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
//go:build cgo
|
||||
|
||||
package p256k
|
||||
|
||||
import (
|
||||
"lol.mleku.dev/log"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.T.Ln("using p256k1.mleku.dev/signer (CGO)")
|
||||
}
|
||||
|
||||
// Signer is an alias for the P256K1Signer type from p256k1.mleku.dev/signer (cgo version).
|
||||
type Signer = p256k1signer.P256K1Signer
|
||||
|
||||
// Keygen is an alias for the P256K1Gen type from p256k1.mleku.dev/signer (cgo version).
|
||||
type Keygen = p256k1signer.P256K1Gen
|
||||
|
||||
var NewKeygen = p256k1signer.NewP256K1Gen
|
||||
@@ -1,161 +0,0 @@
|
||||
//go:build cgo
|
||||
|
||||
package p256k_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
"next.orly.dev/pkg/interfaces/signer"
|
||||
"next.orly.dev/pkg/utils"
|
||||
)
|
||||
|
||||
func TestSigner_Generate(t *testing.T) {
|
||||
for _ = range 10000 {
|
||||
var err error
|
||||
sign := &p256k.Signer{}
|
||||
var skb []byte
|
||||
if err = sign.Generate(); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skb = sign.Sec()
|
||||
if err = sign.InitSec(skb); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// func TestSignerVerify(t *testing.T) {
|
||||
// // evs := make([]*event.E, 0, 10000)
|
||||
// scanner := bufio.NewScanner(bytes.NewBuffer(examples.Cache))
|
||||
// buf := make([]byte, 1_000_000)
|
||||
// scanner.Buffer(buf, len(buf))
|
||||
// var err error
|
||||
// signer := &p256k.Signer{}
|
||||
// for scanner.Scan() {
|
||||
// var valid bool
|
||||
// b := scanner.Bytes()
|
||||
// bc := make([]byte, 0, len(b))
|
||||
// bc = append(bc, b...)
|
||||
// ev := event.New()
|
||||
// if _, err = ev.Unmarshal(b); chk.E(err) {
|
||||
// t.Errorf("failed to marshal\n%s", b)
|
||||
// } else {
|
||||
// if valid, err = ev.Verify(); chk.T(err) || !valid {
|
||||
// t.Errorf("invalid signature\n%s", bc)
|
||||
// continue
|
||||
// }
|
||||
// }
|
||||
// id := ev.GetIDBytes()
|
||||
// if len(id) != sha256.Size {
|
||||
// t.Errorf("id should be 32 bytes, got %d", len(id))
|
||||
// continue
|
||||
// }
|
||||
// if err = signer.InitPub(ev.Pubkey); chk.T(err) {
|
||||
// t.Errorf("failed to init pub key: %s\n%0x", err, ev.Pubkey)
|
||||
// continue
|
||||
// }
|
||||
// if valid, err = signer.Verify(id, ev.Sig); chk.E(err) {
|
||||
// t.Errorf("failed to verify: %s\n%0x", err, ev.ID)
|
||||
// continue
|
||||
// }
|
||||
// if !valid {
|
||||
// t.Errorf(
|
||||
// "invalid signature for\npub %0x\neid %0x\nsig %0x\n%s",
|
||||
// ev.Pubkey, id, ev.Sig, bc,
|
||||
// )
|
||||
// continue
|
||||
// }
|
||||
// // fmt.Printf("%s\n", bc)
|
||||
// // evs = append(evs, ev)
|
||||
// }
|
||||
// }
|
||||
|
||||
// func TestSignerSign(t *testing.T) {
|
||||
// evs := make([]*event.E, 0, 10000)
|
||||
// scanner := bufio.NewScanner(bytes.NewBuffer(examples.Cache))
|
||||
// buf := make([]byte, 1_000_000)
|
||||
// scanner.Buffer(buf, len(buf))
|
||||
// var err error
|
||||
// signer := &p256k.Signer{}
|
||||
// var skb, pkb []byte
|
||||
// if skb, pkb, _, _, err = p256k.Generate(); chk.E(err) {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// log.I.S(skb, pkb)
|
||||
// if err = signer.InitSec(skb); chk.E(err) {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// verifier := &p256k.Signer{}
|
||||
// if err = verifier.InitPub(pkb); chk.E(err) {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// for scanner.Scan() {
|
||||
// b := scanner.Bytes()
|
||||
// ev := event.New()
|
||||
// if _, err = ev.Unmarshal(b); chk.E(err) {
|
||||
// t.Errorf("failed to marshal\n%s", b)
|
||||
// }
|
||||
// evs = append(evs, ev)
|
||||
// }
|
||||
// var valid bool
|
||||
// sig := make([]byte, schnorr.SignatureSize)
|
||||
// for _, ev := range evs {
|
||||
// ev.Pubkey = pkb
|
||||
// id := ev.GetIDBytes()
|
||||
// if sig, err = signer.Sign(id); chk.E(err) {
|
||||
// t.Errorf("failed to sign: %s\n%0x", err, id)
|
||||
// }
|
||||
// if valid, err = verifier.Verify(id, sig); chk.E(err) {
|
||||
// t.Errorf("failed to verify: %s\n%0x", err, id)
|
||||
// }
|
||||
// if !valid {
|
||||
// t.Errorf("invalid signature")
|
||||
// }
|
||||
// }
|
||||
// signer.Zero()
|
||||
// }
|
||||
|
||||
func TestECDH(t *testing.T) {
|
||||
n := time.Now()
|
||||
var err error
|
||||
var s1, s2 signer.I
|
||||
var counter int
|
||||
const total = 100
|
||||
for _ = range total {
|
||||
s1, s2 = &p256k.Signer{}, &p256k.Signer{}
|
||||
if err = s1.Generate(); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _ = range total {
|
||||
if err = s2.Generate(); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var secret1, secret2 []byte
|
||||
if secret1, err = s1.ECDH(s2.Pub()); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secret2, err = s2.ECDH(s1.Pub()); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !utils.FastEqual(secret1, secret2) {
|
||||
counter++
|
||||
t.Errorf(
|
||||
"ECDH generation failed to work in both directions, %x %x",
|
||||
secret1,
|
||||
secret2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
a := time.Now()
|
||||
duration := a.Sub(n)
|
||||
log.I.Ln(
|
||||
"errors", counter, "total", total*total, "time", duration, "time/op",
|
||||
duration/total/total, "ops/sec",
|
||||
float64(time.Second)/float64(duration/total/total),
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//go:build cgo
|
||||
|
||||
package p256k_test
|
||||
|
||||
// func TestVerify(t *testing.T) {
|
||||
// evs := make([]*event.E, 0, 10000)
|
||||
// scanner := bufio.NewScanner(bytes.NewBuffer(examples.Cache))
|
||||
// buf := make([]byte, 1_000_000)
|
||||
// scanner.Buffer(buf, len(buf))
|
||||
// var err error
|
||||
// for scanner.Scan() {
|
||||
// var valid bool
|
||||
// b := scanner.Bytes()
|
||||
// ev := event.New()
|
||||
// if _, err = ev.Unmarshal(b); chk.E(err) {
|
||||
// t.Errorf("failed to marshal\n%s", b)
|
||||
// } else {
|
||||
// if valid, err = ev.Verify(); chk.E(err) || !valid {
|
||||
// t.Errorf("btcec: invalid signature\n%s", b)
|
||||
// continue
|
||||
// }
|
||||
// }
|
||||
// id := ev.GetIDBytes()
|
||||
// if len(id) != sha256.Size {
|
||||
// t.Errorf("id should be 32 bytes, got %d", len(id))
|
||||
// continue
|
||||
// }
|
||||
// if err = p256k.VerifyFromBytes(id, ev.Sig, ev.Pubkey); chk.E(err) {
|
||||
// t.Error(err)
|
||||
// continue
|
||||
// }
|
||||
// evs = append(evs, ev)
|
||||
// }
|
||||
// }
|
||||
|
||||
// func TestSign(t *testing.T) {
|
||||
// evs := make([]*event.E, 0, 10000)
|
||||
// scanner := bufio.NewScanner(bytes.NewBuffer(examples.Cache))
|
||||
// buf := make([]byte, 1_000_000)
|
||||
// scanner.Buffer(buf, len(buf))
|
||||
// var err error
|
||||
// var sec1 *p256k.Sec
|
||||
// var pub1 *p256k.XPublicKey
|
||||
// var pb []byte
|
||||
// if _, pb, sec1, pub1, err = p256k.Generate(); chk.E(err) {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// for scanner.Scan() {
|
||||
// b := scanner.Bytes()
|
||||
// ev := event.New()
|
||||
// if _, err = ev.Unmarshal(b); chk.E(err) {
|
||||
// t.Errorf("failed to marshal\n%s", b)
|
||||
// }
|
||||
// evs = append(evs, ev)
|
||||
// }
|
||||
// sig := make([]byte, schnorr.SignatureSize)
|
||||
// for _, ev := range evs {
|
||||
// ev.Pubkey = pb
|
||||
// var uid *p256k.Uchar
|
||||
// if uid, err = p256k.Msg(ev.GetIDBytes()); chk.E(err) {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// if sig, err = p256k.Sign(uid, sec1.Sec()); chk.E(err) {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// ev.Sig = sig
|
||||
// var usig *p256k.Uchar
|
||||
// if usig, err = p256k.Sig(sig); chk.E(err) {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// if !p256k.Verify(uid, usig, pub1.Key) {
|
||||
// t.Errorf("invalid signature")
|
||||
// }
|
||||
// }
|
||||
// p256k.Zero(&sec1.Key)
|
||||
// }
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/database/indexes/types"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/event/examples"
|
||||
@@ -73,7 +73,7 @@ func BenchmarkSaveEvent(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Create a simple test event
|
||||
signer := &p256k.Signer{}
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err := signer.Generate(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,86 +1,17 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
)
|
||||
|
||||
const maxLen = 500000000
|
||||
|
||||
// Import a collection of events in line structured minified JSON format (JSONL).
|
||||
func (d *D) Import(rr io.Reader) {
|
||||
// store to disk so we can return fast
|
||||
tmpPath := os.TempDir() + string(os.PathSeparator) + "orly"
|
||||
os.MkdirAll(tmpPath, 0700)
|
||||
tmp, err := os.CreateTemp(tmpPath, "")
|
||||
if chk.E(err) {
|
||||
return
|
||||
}
|
||||
log.I.F("buffering upload to %s", tmp.Name())
|
||||
if _, err = io.Copy(tmp, rr); chk.E(err) {
|
||||
return
|
||||
}
|
||||
if _, err = tmp.Seek(0, 0); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
var err error
|
||||
// Create a scanner to read the buffer line by line
|
||||
scan := bufio.NewScanner(tmp)
|
||||
scanBuf := make([]byte, maxLen)
|
||||
scan.Buffer(scanBuf, maxLen)
|
||||
|
||||
var count, total int
|
||||
for scan.Scan() {
|
||||
select {
|
||||
case <-d.ctx.Done():
|
||||
log.I.F("context closed")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
b := scan.Bytes()
|
||||
total += len(b) + 1
|
||||
if len(b) < 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
ev := event.New()
|
||||
if _, err = ev.Unmarshal(b); err != nil {
|
||||
// return the pooled buffer on error
|
||||
ev.Free()
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err = d.SaveEvent(d.ctx, ev); err != nil {
|
||||
// return the pooled buffer on error paths too
|
||||
ev.Free()
|
||||
continue
|
||||
}
|
||||
|
||||
// return the pooled buffer after successful save
|
||||
ev.Free()
|
||||
b = nil
|
||||
count++
|
||||
if count%100 == 0 {
|
||||
log.I.F("received %d events", count)
|
||||
debug.FreeOSMemory()
|
||||
}
|
||||
if err := d.ImportEventsFromReader(d.ctx, rr); chk.E(err) {
|
||||
log.E.F("import failed: %v", err)
|
||||
}
|
||||
|
||||
log.I.F("read %d bytes and saved %d events", total, count)
|
||||
err = scan.Err()
|
||||
if chk.E(err) {
|
||||
}
|
||||
|
||||
// Help garbage collection
|
||||
tmp = nil
|
||||
}()
|
||||
}
|
||||
|
||||
101
pkg/database/import_utils.go
Normal file
101
pkg/database/import_utils.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Package database provides shared import utilities for events
|
||||
package database
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
)
|
||||
|
||||
const maxLen = 500000000
|
||||
|
||||
// ImportEventsFromReader imports events from an io.Reader containing JSONL data
|
||||
func (d *D) ImportEventsFromReader(ctx context.Context, rr io.Reader) error {
|
||||
// store to disk so we can return fast
|
||||
tmpPath := os.TempDir() + string(os.PathSeparator) + "orly"
|
||||
os.MkdirAll(tmpPath, 0700)
|
||||
tmp, err := os.CreateTemp(tmpPath, "")
|
||||
if chk.E(err) {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // Clean up temp file when done
|
||||
|
||||
log.I.F("buffering upload to %s", tmp.Name())
|
||||
if _, err = io.Copy(tmp, rr); chk.E(err) {
|
||||
return err
|
||||
}
|
||||
if _, err = tmp.Seek(0, 0); chk.E(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
return d.processJSONLEvents(ctx, tmp)
|
||||
}
|
||||
|
||||
// ImportEventsFromStrings imports events from a slice of JSON strings
|
||||
func (d *D) ImportEventsFromStrings(ctx context.Context, eventJSONs []string) error {
|
||||
// Create a reader from the string slice
|
||||
reader := strings.NewReader(strings.Join(eventJSONs, "\n"))
|
||||
return d.processJSONLEvents(ctx, reader)
|
||||
}
|
||||
|
||||
// processJSONLEvents processes JSONL events from a reader
|
||||
func (d *D) processJSONLEvents(ctx context.Context, rr io.Reader) error {
|
||||
// Create a scanner to read the buffer line by line
|
||||
scan := bufio.NewScanner(rr)
|
||||
scanBuf := make([]byte, maxLen)
|
||||
scan.Buffer(scanBuf, maxLen)
|
||||
|
||||
var count, total int
|
||||
for scan.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.I.F("context closed")
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
b := scan.Bytes()
|
||||
total += len(b) + 1
|
||||
if len(b) < 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
ev := event.New()
|
||||
if _, err := ev.Unmarshal(b); err != nil {
|
||||
// return the pooled buffer on error
|
||||
ev.Free()
|
||||
log.W.F("failed to unmarshal event: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := d.SaveEvent(ctx, ev); err != nil {
|
||||
// return the pooled buffer on error paths too
|
||||
ev.Free()
|
||||
log.W.F("failed to save event: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// return the pooled buffer after successful save
|
||||
ev.Free()
|
||||
b = nil
|
||||
count++
|
||||
if count%100 == 0 {
|
||||
log.I.F("processed %d events", count)
|
||||
debug.FreeOSMemory()
|
||||
}
|
||||
}
|
||||
|
||||
log.I.F("read %d bytes and saved %d events", total, count)
|
||||
if err := scan.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/filter"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
@@ -25,7 +25,7 @@ func TestMultipleParameterizedReplaceableEvents(t *testing.T) {
|
||||
defer cancel()
|
||||
defer db.Close()
|
||||
|
||||
sign := new(p256k.Signer)
|
||||
sign := p256k1signer.NewP256K1Signer()
|
||||
if err := sign.Generate(); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/filter"
|
||||
"next.orly.dev/pkg/encoders/kind"
|
||||
@@ -44,7 +44,7 @@ func TestQueryEventsBySearchTerms(t *testing.T) {
|
||||
}()
|
||||
|
||||
// signer for all events
|
||||
sign := new(p256k.Signer)
|
||||
sign := p256k1signer.NewP256K1Signer()
|
||||
if err := sign.Generate(); chk.E(err) {
|
||||
t.Fatalf("signer generate: %v", err)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/event/examples"
|
||||
"next.orly.dev/pkg/encoders/filter"
|
||||
@@ -198,7 +198,7 @@ func TestReplaceableEventsAndDeletion(t *testing.T) {
|
||||
defer db.Close()
|
||||
|
||||
// Test querying for replaced events by ID
|
||||
sign := new(p256k.Signer)
|
||||
sign := p256k1signer.NewP256K1Signer()
|
||||
if err := sign.Generate(); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -380,7 +380,7 @@ func TestParameterizedReplaceableEventsAndDeletion(t *testing.T) {
|
||||
defer cancel()
|
||||
defer db.Close()
|
||||
|
||||
sign := new(p256k.Signer)
|
||||
sign := p256k1signer.NewP256K1Signer()
|
||||
if err := sign.Generate(); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/errorf"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/event/examples"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
@@ -120,7 +120,7 @@ func TestDeletionEventWithETagRejection(t *testing.T) {
|
||||
defer db.Close()
|
||||
|
||||
// Create a signer
|
||||
sign := new(p256k.Signer)
|
||||
sign := p256k1signer.NewP256K1Signer()
|
||||
if err := sign.Generate(); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func TestSaveExistingEvent(t *testing.T) {
|
||||
defer db.Close()
|
||||
|
||||
// Create a signer
|
||||
sign := new(p256k.Signer)
|
||||
sign := p256k1signer.NewP256K1Signer()
|
||||
if err := sign.Generate(); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
)
|
||||
|
||||
type Subscription struct {
|
||||
TrialEnd time.Time `json:"trial_end"`
|
||||
PaidUntil time.Time `json:"paid_until"`
|
||||
TrialEnd time.Time `json:"trial_end"`
|
||||
PaidUntil time.Time `json:"paid_until"`
|
||||
BlossomLevel string `json:"blossom_level,omitempty"` // Service level name (e.g., "basic", "premium")
|
||||
BlossomStorage int64 `json:"blossom_storage,omitempty"` // Storage quota in MB
|
||||
}
|
||||
|
||||
func (d *D) GetSubscription(pubkey []byte) (*Subscription, error) {
|
||||
@@ -190,6 +192,77 @@ func (d *D) GetPaymentHistory(pubkey []byte) ([]Payment, error) {
|
||||
return payments, err
|
||||
}
|
||||
|
||||
// ExtendBlossomSubscription extends or creates a blossom subscription with service level
|
||||
func (d *D) ExtendBlossomSubscription(
|
||||
pubkey []byte, level string, storageMB int64, days int,
|
||||
) error {
|
||||
if days <= 0 {
|
||||
return fmt.Errorf("invalid days: %d", days)
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("sub:%s", hex.EncodeToString(pubkey))
|
||||
now := time.Now()
|
||||
|
||||
return d.DB.Update(
|
||||
func(txn *badger.Txn) error {
|
||||
var sub Subscription
|
||||
item, err := txn.Get([]byte(key))
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
sub.PaidUntil = now.AddDate(0, 0, days)
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else {
|
||||
err = item.Value(
|
||||
func(val []byte) error {
|
||||
return json.Unmarshal(val, &sub)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
extendFrom := now
|
||||
if !sub.PaidUntil.IsZero() && sub.PaidUntil.After(now) {
|
||||
extendFrom = sub.PaidUntil
|
||||
}
|
||||
sub.PaidUntil = extendFrom.AddDate(0, 0, days)
|
||||
}
|
||||
|
||||
// Set blossom service level and storage
|
||||
sub.BlossomLevel = level
|
||||
// Add storage quota (accumulate if subscription already exists)
|
||||
if sub.BlossomStorage > 0 && sub.PaidUntil.After(now) {
|
||||
// Add to existing quota
|
||||
sub.BlossomStorage += storageMB
|
||||
} else {
|
||||
// Set new quota
|
||||
sub.BlossomStorage = storageMB
|
||||
}
|
||||
|
||||
data, err := json.Marshal(&sub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return txn.Set([]byte(key), data)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// GetBlossomStorageQuota returns the current blossom storage quota in MB for a pubkey
|
||||
func (d *D) GetBlossomStorageQuota(pubkey []byte) (quotaMB int64, err error) {
|
||||
sub, err := d.GetSubscription(pubkey)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if sub == nil {
|
||||
return 0, nil
|
||||
}
|
||||
// Only return quota if subscription is active
|
||||
if sub.PaidUntil.IsZero() || time.Now().After(sub.PaidUntil) {
|
||||
return 0, nil
|
||||
}
|
||||
return sub.BlossomStorage, nil
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/envelopes"
|
||||
"next.orly.dev/pkg/protocol/auth"
|
||||
"next.orly.dev/pkg/utils"
|
||||
@@ -15,7 +15,7 @@ const relayURL = "wss://example.com"
|
||||
|
||||
func TestAuth(t *testing.T) {
|
||||
var err error
|
||||
signer := new(p256k.Signer)
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err = signer.Generate(); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/encoders/kind"
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
// createTestEvent creates a realistic test event with proper signing
|
||||
func createTestEvent() *E {
|
||||
signer := &p256k.Signer{}
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err := signer.Generate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func createTestEvent() *E {
|
||||
|
||||
// createLargeTestEvent creates a larger event with more tags and content
|
||||
func createLargeTestEvent() *E {
|
||||
signer := &p256k.Signer{}
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err := signer.Generate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/errorf"
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/interfaces/signer"
|
||||
"next.orly.dev/pkg/utils"
|
||||
)
|
||||
@@ -26,7 +26,7 @@ func (ev *E) Sign(keys signer.I) (err error) {
|
||||
// Verify an event is signed by the pubkey it contains. Uses
|
||||
// github.com/bitcoin-core/secp256k1 if available for faster verification.
|
||||
func (ev *E) Verify() (valid bool, err error) {
|
||||
keys := p256k.Signer{}
|
||||
keys := p256k1signer.NewP256K1Signer()
|
||||
if err = keys.InitPub(ev.Pubkey); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/crypto/sha256"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
@@ -29,7 +29,7 @@ func createTestFilter() *F {
|
||||
|
||||
// Add some authors
|
||||
for i := 0; i < 3; i++ {
|
||||
signer := &p256k.Signer{}
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err := signer.Generate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func createComplexFilter() *F {
|
||||
|
||||
// Add many authors
|
||||
for i := 0; i < 15; i++ {
|
||||
signer := &p256k.Signer{}
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err := signer.Generate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func createComplexFilter() *F {
|
||||
|
||||
// createTestEvent creates a test event for matching
|
||||
func createTestEvent() *event.E {
|
||||
signer := &p256k.Signer{}
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err := signer.Generate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
)
|
||||
|
||||
// Helper function to create test event for benchmarks (reuses signer)
|
||||
func createTestEventBench(b *testing.B, signer *p256k.Signer, content string, kind uint16) *event.E {
|
||||
func createTestEventBench(b *testing.B, signer *p256k1signer.P256K1Signer, content string, kind uint16) *event.E {
|
||||
ev := event.New()
|
||||
ev.CreatedAt = time.Now().Unix()
|
||||
ev.Kind = kind
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/encoders/kind"
|
||||
@@ -23,13 +23,13 @@ func TestPolicyIntegration(t *testing.T) {
|
||||
}
|
||||
|
||||
// Generate test keys
|
||||
allowedSigner := &p256k.Signer{}
|
||||
allowedSigner := p256k1signer.NewP256K1Signer()
|
||||
if err := allowedSigner.Generate(); chk.E(err) {
|
||||
t.Fatalf("Failed to generate allowed signer: %v", err)
|
||||
}
|
||||
allowedPubkeyHex := hex.Enc(allowedSigner.Pub())
|
||||
|
||||
unauthorizedSigner := &p256k.Signer{}
|
||||
unauthorizedSigner := p256k1signer.NewP256K1Signer()
|
||||
if err := unauthorizedSigner.Generate(); chk.E(err) {
|
||||
t.Fatalf("Failed to generate unauthorized signer: %v", err)
|
||||
}
|
||||
@@ -367,13 +367,13 @@ func TestPolicyWithRelay(t *testing.T) {
|
||||
}
|
||||
|
||||
// Generate keys
|
||||
allowedSigner := &p256k.Signer{}
|
||||
allowedSigner := p256k1signer.NewP256K1Signer()
|
||||
if err := allowedSigner.Generate(); chk.E(err) {
|
||||
t.Fatalf("Failed to generate allowed signer: %v", err)
|
||||
}
|
||||
allowedPubkeyHex := hex.Enc(allowedSigner.Pub())
|
||||
|
||||
unauthorizedSigner := &p256k.Signer{}
|
||||
unauthorizedSigner := p256k1signer.NewP256K1Signer()
|
||||
if err := unauthorizedSigner.Generate(); chk.E(err) {
|
||||
t.Fatalf("Failed to generate unauthorized signer: %v", err)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
@@ -22,8 +22,8 @@ func int64Ptr(i int64) *int64 {
|
||||
}
|
||||
|
||||
// Helper function to generate a keypair for testing
|
||||
func generateTestKeypair(t *testing.T) (signer *p256k.Signer, pubkey []byte) {
|
||||
signer = &p256k.Signer{}
|
||||
func generateTestKeypair(t *testing.T) (signer *p256k1signer.P256K1Signer, pubkey []byte) {
|
||||
signer = p256k1signer.NewP256K1Signer()
|
||||
if err := signer.Generate(); chk.E(err) {
|
||||
t.Fatalf("Failed to generate test keypair: %v", err)
|
||||
}
|
||||
@@ -32,8 +32,8 @@ func generateTestKeypair(t *testing.T) (signer *p256k.Signer, pubkey []byte) {
|
||||
}
|
||||
|
||||
// Helper function to generate a keypair for benchmarks
|
||||
func generateTestKeypairB(b *testing.B) (signer *p256k.Signer, pubkey []byte) {
|
||||
signer = &p256k.Signer{}
|
||||
func generateTestKeypairB(b *testing.B) (signer *p256k1signer.P256K1Signer, pubkey []byte) {
|
||||
signer = p256k1signer.NewP256K1Signer()
|
||||
if err := signer.Generate(); chk.E(err) {
|
||||
b.Fatalf("Failed to generate test keypair: %v", err)
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func generateTestKeypairB(b *testing.B) (signer *p256k.Signer, pubkey []byte) {
|
||||
}
|
||||
|
||||
// Helper function to create a real test event with proper signing
|
||||
func createTestEvent(t *testing.T, signer *p256k.Signer, content string, kind uint16) *event.E {
|
||||
func createTestEvent(t *testing.T, signer *p256k1signer.P256K1Signer, content string, kind uint16) *event.E {
|
||||
ev := event.New()
|
||||
ev.CreatedAt = time.Now().Unix()
|
||||
ev.Kind = kind
|
||||
@@ -58,7 +58,7 @@ func createTestEvent(t *testing.T, signer *p256k.Signer, content string, kind ui
|
||||
}
|
||||
|
||||
// Helper function to create a test event with a specific pubkey (for unauthorized tests)
|
||||
func createTestEventWithPubkey(t *testing.T, signer *p256k.Signer, content string, kind uint16) *event.E {
|
||||
func createTestEventWithPubkey(t *testing.T, signer *p256k1signer.P256K1Signer, content string, kind uint16) *event.E {
|
||||
ev := event.New()
|
||||
ev.CreatedAt = time.Now().Unix()
|
||||
ev.Kind = kind
|
||||
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
)
|
||||
|
||||
func TestCreateUnsigned(t *testing.T) {
|
||||
var err error
|
||||
signer := new(p256k.Signer)
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err = signer.Generate(); chk.E(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
1
pkg/protocol/blossom/blossom
Submodule
1
pkg/protocol/blossom/blossom
Submodule
Submodule pkg/protocol/blossom/blossom added at e8d0a1ec44
@@ -7,14 +7,14 @@ import (
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/ec/secp256k1"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/bech32encoding"
|
||||
"next.orly.dev/pkg/protocol/directory"
|
||||
)
|
||||
|
||||
// Helper to create a test keypair using p256k.Signer
|
||||
func createTestKeypair(t *testing.T) (*p256k.Signer, []byte) {
|
||||
signer := new(p256k.Signer)
|
||||
// Helper to create a test keypair using p256k1signer.P256K1Signer
|
||||
func createTestKeypair(t *testing.T) (*p256k1signer.P256K1Signer, []byte) {
|
||||
signer := p256k1signer.NewP256K1Signer()
|
||||
if err := signer.Generate(); chk.E(err) {
|
||||
t.Fatalf("failed to generate keypair: %v", err)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"next.orly.dev/pkg/crypto/encryption"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/encoders/tag"
|
||||
@@ -101,7 +101,7 @@ func TestNWCEventCreation(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientKey := &p256k.Signer{}
|
||||
clientKey := p256k1signer.NewP256K1Signer()
|
||||
if err := clientKey.InitSec(secretBytes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/encryption"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/filter"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
@@ -40,7 +40,7 @@ func NewMockWalletService(
|
||||
relay string, initialBalance int64,
|
||||
) (service *MockWalletService, err error) {
|
||||
// Generate wallet keypair
|
||||
walletKey := &p256k.Signer{}
|
||||
walletKey := p256k1signer.NewP256K1Signer()
|
||||
if err = walletKey.Generate(); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/encryption"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
"next.orly.dev/pkg/interfaces/signer"
|
||||
)
|
||||
|
||||
@@ -41,7 +42,7 @@ func ParseConnectionURI(nwcUri string) (parts *ConnectionParams, err error) {
|
||||
err = errors.New("incorrect scheme")
|
||||
return
|
||||
}
|
||||
if parts.walletPublicKey, err = p256k.HexToBin(p.Host); chk.E(err) {
|
||||
if parts.walletPublicKey, err = hex.Dec(p.Host); chk.E(err) {
|
||||
err = errors.New("invalid public key")
|
||||
return
|
||||
}
|
||||
@@ -62,11 +63,11 @@ func ParseConnectionURI(nwcUri string) (parts *ConnectionParams, err error) {
|
||||
return
|
||||
}
|
||||
var secretBytes []byte
|
||||
if secretBytes, err = p256k.HexToBin(secret); chk.E(err) {
|
||||
if secretBytes, err = hex.Dec(secret); chk.E(err) {
|
||||
err = errors.New("invalid secret")
|
||||
return
|
||||
}
|
||||
clientKey := &p256k.Signer{}
|
||||
clientKey := p256k1signer.NewP256K1Signer()
|
||||
if err = clientKey.InitSec(secretBytes); chk.E(err) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ type WriteRequest struct {
|
||||
MsgType int
|
||||
IsControl bool
|
||||
Deadline time.Time
|
||||
IsPing bool // Special marker for ping messages
|
||||
}
|
||||
|
||||
// WriteChanSetter defines the interface for setting write channels
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/net/websocket"
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/filter"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
@@ -36,7 +36,7 @@ func TestPublish(t *testing.T) {
|
||||
Tags: tag.NewS(tag.NewFromAny("foo", "bar")),
|
||||
Pubkey: pub,
|
||||
}
|
||||
sign := &p256k.Signer{}
|
||||
sign := p256k1signer.NewP256K1Signer()
|
||||
var err error
|
||||
if err = sign.InitSec(priv); chk.E(err) {
|
||||
}
|
||||
@@ -208,7 +208,7 @@ var anyOriginHandshake = func(conf *websocket.Config, r *http.Request) error {
|
||||
|
||||
func makeKeyPair(t *testing.T) (sec, pub []byte) {
|
||||
t.Helper()
|
||||
sign := &p256k.Signer{}
|
||||
sign := p256k1signer.NewP256K1Signer()
|
||||
var err error
|
||||
if err = sign.Generate(); chk.E(err) {
|
||||
return
|
||||
|
||||
288
pkg/sync/manager.go
Normal file
288
pkg/sync/manager.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/log"
|
||||
"next.orly.dev/pkg/database"
|
||||
)
|
||||
|
||||
// Manager handles distributed synchronization between relay peers using serial numbers as clocks
|
||||
type Manager struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
db *database.D
|
||||
nodeID string
|
||||
relayURL string
|
||||
peers []string
|
||||
currentSerial uint64
|
||||
peerSerials map[string]uint64 // peer URL -> latest serial seen
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// CurrentRequest represents a request for the current serial number
|
||||
type CurrentRequest struct {
|
||||
NodeID string `json:"node_id"`
|
||||
RelayURL string `json:"relay_url"`
|
||||
}
|
||||
|
||||
// CurrentResponse returns the current serial number
|
||||
type CurrentResponse struct {
|
||||
NodeID string `json:"node_id"`
|
||||
RelayURL string `json:"relay_url"`
|
||||
Serial uint64 `json:"serial"`
|
||||
}
|
||||
|
||||
// FetchRequest represents a request for events in a serial range
|
||||
type FetchRequest struct {
|
||||
NodeID string `json:"node_id"`
|
||||
RelayURL string `json:"relay_url"`
|
||||
From uint64 `json:"from"`
|
||||
To uint64 `json:"to"`
|
||||
}
|
||||
|
||||
// FetchResponse contains the requested events as JSONL
|
||||
type FetchResponse struct {
|
||||
Events []string `json:"events"` // JSONL formatted events
|
||||
}
|
||||
|
||||
// NewManager creates a new sync manager
|
||||
func NewManager(ctx context.Context, db *database.D, nodeID, relayURL string, peers []string) *Manager {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
m := &Manager{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
db: db,
|
||||
nodeID: nodeID,
|
||||
relayURL: relayURL,
|
||||
peers: peers,
|
||||
currentSerial: 0,
|
||||
peerSerials: make(map[string]uint64),
|
||||
}
|
||||
|
||||
// Start sync routine
|
||||
go m.syncRoutine()
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// Stop stops the sync manager
|
||||
func (m *Manager) Stop() {
|
||||
m.cancel()
|
||||
}
|
||||
|
||||
// GetCurrentSerial returns the current serial number
|
||||
func (m *Manager) GetCurrentSerial() uint64 {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
return m.currentSerial
|
||||
}
|
||||
|
||||
// UpdateSerial updates the current serial number when a new event is stored
|
||||
func (m *Manager) UpdateSerial() {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
// Get the latest serial from database
|
||||
if latest, err := m.getLatestSerial(); err == nil {
|
||||
m.currentSerial = latest
|
||||
}
|
||||
}
|
||||
|
||||
// getLatestSerial gets the latest serial number from the database
|
||||
func (m *Manager) getLatestSerial() (uint64, error) {
|
||||
// This is a simplified implementation
|
||||
// In practice, you'd want to track the highest serial number
|
||||
// For now, return the current serial
|
||||
return m.currentSerial, nil
|
||||
}
|
||||
|
||||
// syncRoutine periodically syncs with peers
|
||||
func (m *Manager) syncRoutine() {
|
||||
ticker := time.NewTicker(5 * time.Second) // Sync every 5 seconds
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.syncWithPeers()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncWithPeers syncs with all configured peers
|
||||
func (m *Manager) syncWithPeers() {
|
||||
for _, peerURL := range m.peers {
|
||||
go m.syncWithPeer(peerURL)
|
||||
}
|
||||
}
|
||||
|
||||
// syncWithPeer syncs with a specific peer
|
||||
func (m *Manager) syncWithPeer(peerURL string) {
|
||||
// Get the peer's current serial
|
||||
currentReq := CurrentRequest{
|
||||
NodeID: m.nodeID,
|
||||
RelayURL: m.relayURL,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(currentReq)
|
||||
if err != nil {
|
||||
log.E.F("failed to marshal current request: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.Post(peerURL+"/api/sync/current", "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
log.D.F("failed to get current serial from %s: %v", peerURL, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.D.F("current request failed with %s: status %d", peerURL, resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var currentResp CurrentResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(¤tResp); err != nil {
|
||||
log.E.F("failed to decode current response from %s: %v", peerURL, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we need to sync
|
||||
peerSerial := currentResp.Serial
|
||||
ourLastSeen := m.peerSerials[peerURL]
|
||||
|
||||
if peerSerial > ourLastSeen {
|
||||
// Request missing events
|
||||
m.requestEvents(peerURL, ourLastSeen+1, peerSerial)
|
||||
// Update our knowledge of peer's serial
|
||||
m.mutex.Lock()
|
||||
m.peerSerials[peerURL] = peerSerial
|
||||
m.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// requestEvents requests a range of events from a peer
|
||||
func (m *Manager) requestEvents(peerURL string, from, to uint64) {
|
||||
req := FetchRequest{
|
||||
NodeID: m.nodeID,
|
||||
RelayURL: m.relayURL,
|
||||
From: from,
|
||||
To: to,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
log.E.F("failed to marshal fetch request: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := http.Post(peerURL+"/api/sync/fetch", "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
log.E.F("failed to request events from %s: %v", peerURL, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.E.F("fetch request failed with %s: status %d", peerURL, resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var fetchResp FetchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&fetchResp); err != nil {
|
||||
log.E.F("failed to decode fetch response from %s: %v", peerURL, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Import the received events
|
||||
if len(fetchResp.Events) > 0 {
|
||||
if err := m.db.ImportEventsFromStrings(context.Background(), fetchResp.Events); err != nil {
|
||||
log.E.F("failed to import events from %s: %v", peerURL, err)
|
||||
return
|
||||
}
|
||||
log.I.F("imported %d events from peer %s", len(fetchResp.Events), peerURL)
|
||||
}
|
||||
}
|
||||
|
||||
// getEventsBySerialRange retrieves events by serial range from the database as JSONL
|
||||
func (m *Manager) getEventsBySerialRange(from, to uint64) ([]string, error) {
|
||||
var events []string
|
||||
|
||||
// Get event serials by serial range
|
||||
serials, err := m.db.EventIdsBySerial(from, int(to-from+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: For each serial, retrieve the actual event and marshal to JSONL
|
||||
// For now, return serial numbers as placeholder JSON strings
|
||||
for _, serial := range serials {
|
||||
// This should be replaced with actual event JSON marshalling
|
||||
events = append(events, `{"serial":`+strconv.FormatUint(serial, 10)+`}`)
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// HandleCurrentRequest handles requests for current serial number
|
||||
func (m *Manager) HandleCurrentRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req CurrentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp := CurrentResponse{
|
||||
NodeID: m.nodeID,
|
||||
RelayURL: m.relayURL,
|
||||
Serial: m.GetCurrentSerial(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// HandleFetchRequest handles requests for events in a serial range
|
||||
func (m *Manager) HandleFetchRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req FetchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get events in the requested range
|
||||
events, err := m.getEventsBySerialRange(req.From, req.To)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to get events: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
resp := FetchResponse{
|
||||
Events: events,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
v0.23.1
|
||||
v0.24.0
|
||||
@@ -36,6 +36,19 @@ func NewClient(url string) (c *Client, err error) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
// Set up ping/pong handling to keep connection alive
|
||||
pongWait := 60 * time.Second
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
// Set pong handler to extend deadline when pongs are received
|
||||
// Note: Relay sends pings, gorilla/websocket auto-responds with pongs
|
||||
// The relay typically doesn't send pongs back, so we also handle timeouts in readLoop
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
return nil
|
||||
})
|
||||
// Don't set ping handler - let gorilla/websocket auto-respond to pings
|
||||
|
||||
c = &Client{
|
||||
conn: conn,
|
||||
url: url,
|
||||
@@ -78,16 +91,41 @@ func (c *Client) Send(msg interface{}) (err error) {
|
||||
// readLoop reads messages from the relay and routes them to subscriptions.
|
||||
func (c *Client) readLoop() {
|
||||
defer c.conn.Close()
|
||||
pongWait := 60 * time.Second
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
// Don't set deadline here - let pong handler manage it
|
||||
// SetReadDeadline is called initially in NewClient and extended by pong handler
|
||||
_, msg, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
// Check if context is done
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
// Check if it's a timeout - connection might still be alive
|
||||
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||
// Pong handler should have extended deadline, but if we timeout,
|
||||
// reset it and continue - connection might still be alive
|
||||
// This can happen during idle periods when no messages are received
|
||||
c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
// Continue reading - connection should still be alive if pings/pongs are working
|
||||
continue
|
||||
}
|
||||
// For other errors, check if it's a close error
|
||||
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
return
|
||||
}
|
||||
// For other errors, return (connection is likely dead)
|
||||
return
|
||||
}
|
||||
// Extend read deadline on successful read
|
||||
c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
var raw []interface{}
|
||||
if err = json.Unmarshal(msg, &raw); err != nil {
|
||||
continue
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/bech32encoding"
|
||||
"next.orly.dev/pkg/encoders/event"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
// KeyPair represents a test keypair.
|
||||
type KeyPair struct {
|
||||
Secret *p256k.Signer
|
||||
Secret *p256k1signer.P256K1Signer
|
||||
Pubkey []byte
|
||||
Nsec string
|
||||
Npub string
|
||||
@@ -25,7 +25,7 @@ type KeyPair struct {
|
||||
// GenerateKeyPair generates a new keypair for testing.
|
||||
func GenerateKeyPair() (kp *KeyPair, err error) {
|
||||
kp = &KeyPair{}
|
||||
kp.Secret = &p256k.Signer{}
|
||||
kp.Secret = p256k1signer.NewP256K1Signer()
|
||||
if err = kp.Secret.Generate(); chk.E(err) {
|
||||
return
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func GenerateKeyPair() (kp *KeyPair, err error) {
|
||||
}
|
||||
|
||||
// CreateEvent creates a signed event with the given parameters.
|
||||
func CreateEvent(signer *p256k.Signer, kindNum uint16, content string, tags *tag.S) (ev *event.E, err error) {
|
||||
func CreateEvent(signer *p256k1signer.P256K1Signer, kindNum uint16, content string, tags *tag.S) (ev *event.E, err error) {
|
||||
ev = event.New()
|
||||
ev.CreatedAt = time.Now().Unix()
|
||||
ev.Kind = kindNum
|
||||
@@ -61,7 +61,7 @@ func CreateEvent(signer *p256k.Signer, kindNum uint16, content string, tags *tag
|
||||
}
|
||||
|
||||
// CreateEventWithTags creates an event with specific tags.
|
||||
func CreateEventWithTags(signer *p256k.Signer, kindNum uint16, content string, tagPairs [][]string) (ev *event.E, err error) {
|
||||
func CreateEventWithTags(signer *p256k1signer.P256K1Signer, kindNum uint16, content string, tagPairs [][]string) (ev *event.E, err error) {
|
||||
tags := tag.NewS()
|
||||
for _, pair := range tagPairs {
|
||||
if len(pair) >= 2 {
|
||||
@@ -78,17 +78,17 @@ func CreateEventWithTags(signer *p256k.Signer, kindNum uint16, content string, t
|
||||
}
|
||||
|
||||
// CreateReplaceableEvent creates a replaceable event (kind 0-3, 10000-19999).
|
||||
func CreateReplaceableEvent(signer *p256k.Signer, kindNum uint16, content string) (ev *event.E, err error) {
|
||||
func CreateReplaceableEvent(signer *p256k1signer.P256K1Signer, kindNum uint16, content string) (ev *event.E, err error) {
|
||||
return CreateEvent(signer, kindNum, content, nil)
|
||||
}
|
||||
|
||||
// CreateEphemeralEvent creates an ephemeral event (kind 20000-29999).
|
||||
func CreateEphemeralEvent(signer *p256k.Signer, kindNum uint16, content string) (ev *event.E, err error) {
|
||||
func CreateEphemeralEvent(signer *p256k1signer.P256K1Signer, kindNum uint16, content string) (ev *event.E, err error) {
|
||||
return CreateEvent(signer, kindNum, content, nil)
|
||||
}
|
||||
|
||||
// CreateDeleteEvent creates a deletion event (kind 5).
|
||||
func CreateDeleteEvent(signer *p256k.Signer, eventIDs [][]byte, reason string) (ev *event.E, err error) {
|
||||
func CreateDeleteEvent(signer *p256k1signer.P256K1Signer, eventIDs [][]byte, reason string) (ev *event.E, err error) {
|
||||
tags := tag.NewS()
|
||||
for _, id := range eventIDs {
|
||||
// e tags must contain hex-encoded event IDs
|
||||
@@ -101,7 +101,7 @@ func CreateDeleteEvent(signer *p256k.Signer, eventIDs [][]byte, reason string) (
|
||||
}
|
||||
|
||||
// CreateParameterizedReplaceableEvent creates a parameterized replaceable event (kind 30000-39999).
|
||||
func CreateParameterizedReplaceableEvent(signer *p256k.Signer, kindNum uint16, content string, dTag string) (ev *event.E, err error) {
|
||||
func CreateParameterizedReplaceableEvent(signer *p256k1signer.P256K1Signer, kindNum uint16, content string, dTag string) (ev *event.E, err error) {
|
||||
tags := tag.NewS()
|
||||
tags.Append(tag.NewFromBytesSlice([]byte("d"), []byte(dTag)))
|
||||
return CreateEvent(signer, kindNum, content, tags)
|
||||
|
||||
@@ -38,13 +38,13 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"next.orly.dev/pkg/crypto/p256k"
|
||||
p256k1signer "p256k1.mleku.dev/signer"
|
||||
"next.orly.dev/pkg/encoders/hex"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Generate allowed signer
|
||||
allowedSigner := &p256k.Signer{}
|
||||
allowedSigner := p256k1signer.NewP256K1Signer()
|
||||
if err := allowedSigner.Generate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func main() {
|
||||
allowedSecHex := hex.Enc(allowedSigner.Sec())
|
||||
|
||||
// Generate unauthorized signer
|
||||
unauthorizedSigner := &p256k.Signer{}
|
||||
unauthorizedSigner := p256k1signer.NewP256K1Signer()
|
||||
if err := unauthorizedSigner.Generate(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
89
scripts/test-workflow-act.sh
Executable file
89
scripts/test-workflow-act.sh
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run GitHub Actions workflow locally using act
|
||||
# Usage: ./scripts/test-workflow-local.sh [job-name]
|
||||
# job-name: optional, defaults to 'build'. Can be 'build' or 'release'
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
WORKFLOW_FILE="${SCRIPT_DIR}/../.github/workflows/go.yml"
|
||||
JOB_NAME="${1:-build}"
|
||||
|
||||
# Check if act is installed
|
||||
if ! command -v act >/dev/null 2>&1; then
|
||||
echo "Error: 'act' is not installed"
|
||||
echo "Install it with:"
|
||||
echo " curl https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash"
|
||||
echo " # or on macOS: brew install act"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Running GitHub Actions workflow locally ==="
|
||||
echo "Workflow: .github/workflows/go.yml"
|
||||
echo "Job: $JOB_NAME"
|
||||
echo ""
|
||||
|
||||
case "$JOB_NAME" in
|
||||
build)
|
||||
echo "Running build job..."
|
||||
act push --workflows "$WORKFLOW_FILE" --job build
|
||||
;;
|
||||
release)
|
||||
echo "Running release job (simulating tag push)..."
|
||||
# Simulate a tag push event with a valid tag format
|
||||
# The workflow requires build to run first and succeed
|
||||
echo "Step 1: Running build job (required dependency)..."
|
||||
if ! act push --workflows "$WORKFLOW_FILE" --job build; then
|
||||
echo "Error: Build job failed. Release job cannot proceed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Step 2: Running release job..."
|
||||
echo "Note: GitHub release creation may fail locally (no valid token), but binary building will be tested"
|
||||
# Use a tag that matches the workflow pattern: v[0-9]+.[0-9]+.[0-9]+
|
||||
# Provide a dummy GITHUB_TOKEN to prevent immediate failure
|
||||
# The release won't actually be created, but the workflow will test binary building
|
||||
# Temporarily disable exit on error to allow release step to fail gracefully
|
||||
set +e
|
||||
GITHUB_REF=refs/tags/v1.0.0 \
|
||||
GITHUB_TOKEN=dummy_token_for_local_testing \
|
||||
act push \
|
||||
--workflows "$WORKFLOW_FILE" \
|
||||
--job release \
|
||||
--secret GITHUB_TOKEN=dummy_token_for_local_testing \
|
||||
--eventpath /dev/stdin <<EOF
|
||||
{
|
||||
"ref": "refs/tags/v1.0.0",
|
||||
"pusher": {"name": "test"},
|
||||
"repository": {
|
||||
"name": "next.orly.dev",
|
||||
"full_name": "test/next.orly.dev"
|
||||
},
|
||||
"head_commit": {
|
||||
"id": "test123"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
RELEASE_EXIT_CODE=$?
|
||||
set -e
|
||||
|
||||
# Check if binary building succeeded (exit code 0) or if only release creation failed
|
||||
if [ $RELEASE_EXIT_CODE -eq 0 ]; then
|
||||
echo "✓ Release job completed successfully (including binary building)"
|
||||
else
|
||||
echo "⚠ Release job completed with errors (likely GitHub release creation failed)"
|
||||
echo " This is expected in local testing. Binary building should have succeeded."
|
||||
echo " Check the output above to verify 'Build Release Binaries' step succeeded."
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unknown job '$JOB_NAME'"
|
||||
echo "Valid jobs: build, release"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "=== Workflow completed ==="
|
||||
|
||||
26
scripts/test-workflow-local.sh
Executable file
26
scripts/test-workflow-local.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Manual test script for .github/workflows/go.yml
|
||||
# This replicates the build job steps locally
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Testing GitHub Actions Workflow Locally ==="
|
||||
echo ""
|
||||
|
||||
# Check Go version
|
||||
echo "Checking Go version..."
|
||||
go version
|
||||
echo ""
|
||||
|
||||
# Build without cgo
|
||||
echo "Building with cgo disabled..."
|
||||
CGO_ENABLED=0 go build -v ./...
|
||||
echo ""
|
||||
|
||||
# Test without cgo
|
||||
echo "Testing with cgo disabled..."
|
||||
CGO_ENABLED=0 go test -v $(go list ./... | xargs -n1 sh -c 'ls $0/*_test.go 1>/dev/null 2>&1 && echo $0' | grep .)
|
||||
echo ""
|
||||
|
||||
echo "=== Build job completed successfully ==="
|
||||
|
||||
140
workaround_test.go
Normal file
140
workaround_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"next.orly.dev/app/config"
|
||||
"next.orly.dev/pkg/run"
|
||||
)
|
||||
|
||||
func TestDumbClientWorkaround(t *testing.T) {
|
||||
var relay *run.Relay
|
||||
var err error
|
||||
|
||||
// Start local relay for testing
|
||||
if relay, _, err = startWorkaroundTestRelay(); err != nil {
|
||||
t.Fatalf("Failed to start test relay: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if stopErr := relay.Stop(); stopErr != nil {
|
||||
t.Logf("Error stopping relay: %v", stopErr)
|
||||
}
|
||||
}()
|
||||
|
||||
relayURL := "ws://127.0.0.1:3338"
|
||||
|
||||
// Wait for relay to be ready
|
||||
if err = waitForRelay(relayURL, 10*time.Second); err != nil {
|
||||
t.Fatalf("Relay not ready after timeout: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Relay is ready at %s", relayURL)
|
||||
|
||||
// Test connection with a "dumb" client that doesn't handle ping/pong properly
|
||||
dialer := websocket.Dialer{
|
||||
HandshakeTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
conn, _, err := dialer.Dial(relayURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to connect: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
t.Logf("Connection established")
|
||||
|
||||
// Simulate a dumb client that sets a short read deadline and doesn't handle ping/pong
|
||||
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||
|
||||
startTime := time.Now()
|
||||
messageCount := 0
|
||||
|
||||
// The connection should stay alive despite the short client-side deadline
|
||||
// because our workaround sets a 24-hour server-side deadline
|
||||
for time.Since(startTime) < 2*time.Minute {
|
||||
// Extend client deadline every 10 seconds (simulating dumb client behavior)
|
||||
if time.Since(startTime).Seconds() > 10 && int(time.Since(startTime).Seconds())%10 == 0 {
|
||||
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||
t.Logf("Dumb client extended its own deadline")
|
||||
}
|
||||
|
||||
// Try to read with a short timeout to avoid blocking
|
||||
conn.SetReadDeadline(time.Now().Add(1 * time.Second))
|
||||
msgType, data, err := conn.ReadMessage()
|
||||
conn.SetReadDeadline(time.Now().Add(30 * time.Second)) // Reset
|
||||
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
// Timeout is expected - just continue
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
t.Logf("Connection closed normally: %v", err)
|
||||
break
|
||||
}
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
messageCount++
|
||||
t.Logf("Received message %d: type=%d, len=%d", messageCount, msgType, len(data))
|
||||
}
|
||||
|
||||
elapsed := time.Since(startTime)
|
||||
if elapsed < 90*time.Second {
|
||||
t.Errorf("Connection died too early after %v (expected at least 90s)", elapsed)
|
||||
} else {
|
||||
t.Logf("Workaround successful: connection lasted %v with %d messages", elapsed, messageCount)
|
||||
}
|
||||
}
|
||||
|
||||
// startWorkaroundTestRelay starts a relay for workaround testing
|
||||
func startWorkaroundTestRelay() (relay *run.Relay, port int, err error) {
|
||||
cfg := &config.C{
|
||||
AppName: "ORLY-WORKAROUND-TEST",
|
||||
DataDir: "",
|
||||
Listen: "127.0.0.1",
|
||||
Port: 3338,
|
||||
HealthPort: 0,
|
||||
EnableShutdown: false,
|
||||
LogLevel: "info",
|
||||
DBLogLevel: "warn",
|
||||
DBBlockCacheMB: 512,
|
||||
DBIndexCacheMB: 256,
|
||||
LogToStdout: false,
|
||||
PprofHTTP: false,
|
||||
ACLMode: "none",
|
||||
AuthRequired: false,
|
||||
AuthToWrite: false,
|
||||
SubscriptionEnabled: false,
|
||||
MonthlyPriceSats: 6000,
|
||||
FollowListFrequency: time.Hour,
|
||||
WebDisableEmbedded: false,
|
||||
SprocketEnabled: false,
|
||||
SpiderMode: "none",
|
||||
PolicyEnabled: false,
|
||||
}
|
||||
|
||||
// Set default data dir if not specified
|
||||
if cfg.DataDir == "" {
|
||||
cfg.DataDir = fmt.Sprintf("/tmp/orly-workaround-test-%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// Create options
|
||||
cleanup := true
|
||||
opts := &run.Options{
|
||||
CleanupDataDir: &cleanup,
|
||||
}
|
||||
|
||||
// Start relay
|
||||
if relay, err = run.Start(cfg, opts); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to start relay: %w", err)
|
||||
}
|
||||
|
||||
return relay, cfg.Port, nil
|
||||
}
|
||||
Reference in New Issue
Block a user