From da66e2661427a796e1720c683f447734ca1c3b24 Mon Sep 17 00:00:00 2001 From: mleku Date: Tue, 21 Oct 2025 19:17:16 +0100 Subject: [PATCH] Implement private tag filtering for event visibility - Added functionality to filter events based on "private" tags, allowing only authorized users to see private events. - Introduced a new method `canSeePrivateEvent` to check user permissions against private tags. - Updated event delivery logic to deny access to unauthorized users for private events, enhancing security and user experience. - Bumped version to v0.17.7. --- app/handle-req.go | 38 ++++++++++ app/listener.go | 23 ++++++ app/publisher.go | 173 ++++++++++++++++++++++++++++---------------- pkg/version/version | 2 +- 4 files changed, 174 insertions(+), 62 deletions(-) diff --git a/app/handle-req.go b/app/handle-req.go index 396ff1b..0c9c3c7 100644 --- a/app/handle-req.go +++ b/app/handle-req.go @@ -431,6 +431,44 @@ privCheck: allEvents = aclFilteredEvents } + // Apply private tag filtering - only show events with "private" tags to authorized users + var privateFilteredEvents event.S + authedPubkey := l.authedPubkey.Load() + for _, ev := range allEvents { + // Check if event has private tags + hasPrivateTag := false + var privatePubkey []byte + + if ev.Tags != nil && ev.Tags.Len() > 0 { + for _, t := range *ev.Tags { + if t.Len() >= 2 { + keyBytes := t.Key() + if len(keyBytes) == 7 && string(keyBytes) == "private" { + hasPrivateTag = true + privatePubkey = t.Value() + break + } + } + } + } + + // If no private tag, include the event + if !hasPrivateTag { + privateFilteredEvents = append(privateFilteredEvents, ev) + continue + } + + // Event has private tag - check if user is authorized to see it + canSeePrivate := l.canSeePrivateEvent(authedPubkey, privatePubkey) + if canSeePrivate { + privateFilteredEvents = append(privateFilteredEvents, ev) + log.D.F("private tag: allowing event %s for authorized user", hexenc.Enc(ev.ID)) + } else { + log.D.F("private tag: filtering out event %s from unauthorized user", hexenc.Enc(ev.ID)) + } + } + allEvents = privateFilteredEvents + seen := make(map[string]struct{}) for _, ev := range allEvents { log.T.C( diff --git a/app/listener.go b/app/listener.go index 26a8b32..0ef4854 100644 --- a/app/listener.go +++ b/app/listener.go @@ -12,6 +12,7 @@ import ( "next.orly.dev/pkg/database" "next.orly.dev/pkg/encoders/event" "next.orly.dev/pkg/encoders/filter" + "next.orly.dev/pkg/utils" "next.orly.dev/pkg/utils/atomic" ) @@ -133,3 +134,25 @@ func (l *Listener) QueryEvents(ctx context.Context, f *filter.F) (event.S, error func (l *Listener) QueryAllVersions(ctx context.Context, f *filter.F) (event.S, error) { return l.D.QueryAllVersions(ctx, f) } + +// canSeePrivateEvent checks if the authenticated user can see an event with a private tag +func (l *Listener) canSeePrivateEvent(authedPubkey, privatePubkey []byte) (canSee bool) { + // If no authenticated user, deny access + if len(authedPubkey) == 0 { + return false + } + + // If the authenticated user matches the private tag pubkey, allow access + if len(privatePubkey) > 0 && utils.FastEqual(authedPubkey, privatePubkey) { + return true + } + + // Check if user is an admin or owner (they can see all private events) + accessLevel := acl.Registry.GetAccessLevel(authedPubkey, l.remote) + if accessLevel == "admin" || accessLevel == "owner" { + return true + } + + // Default deny + return false +} diff --git a/app/publisher.go b/app/publisher.go index f8d8c52..0d802c2 100644 --- a/app/publisher.go +++ b/app/publisher.go @@ -9,6 +9,7 @@ import ( "github.com/coder/websocket" "lol.mleku.dev/chk" "lol.mleku.dev/log" + "next.orly.dev/pkg/acl" "next.orly.dev/pkg/encoders/envelopes/eventenvelope" "next.orly.dev/pkg/encoders/event" "next.orly.dev/pkg/encoders/filter" @@ -211,68 +212,96 @@ func (p *P) Deliver(ev *event.E) { break } } - } - if !allowed { - log.D.F("subscription delivery DENIED for privileged event %s to %s (auth mismatch)", - hex.Enc(ev.ID), d.sub.remote) - // Skip delivery for this subscriber - continue - } - } - - var res *eventenvelope.Result - if res, err = eventenvelope.NewResultWith(d.id, ev); chk.E(err) { - log.E.F("failed to create event envelope for %s to %s: %v", - hex.Enc(ev.ID), d.sub.remote, err) - continue - } - - // Log delivery attempt - msgData := res.Marshal(nil) - log.D.F("attempting delivery of event %s (kind=%d, len=%d) to subscription %s @ %s", - hex.Enc(ev.ID), ev.Kind, len(msgData), d.id, d.sub.remote) - - // Use a separate context with timeout for writes to prevent race conditions - // where the publisher context gets cancelled while writing events - writeCtx, cancel := context.WithTimeout( - context.Background(), DefaultWriteTimeout, - ) - defer cancel() + } + if !allowed { + log.D.F("subscription delivery DENIED for privileged event %s to %s (auth mismatch)", + hex.Enc(ev.ID), d.sub.remote) + // Skip delivery for this subscriber + continue + } + } - deliveryStart := time.Now() - if err = d.w.Write( - writeCtx, websocket.MessageText, msgData, - ); err != nil { - deliveryDuration := time.Since(deliveryStart) - - // Log detailed failure information - log.E.F("subscription delivery FAILED: event=%s to=%s sub=%s duration=%v error=%v", - hex.Enc(ev.ID), d.sub.remote, d.id, deliveryDuration, err) - - // Check for timeout specifically - if writeCtx.Err() != nil { - log.E.F("subscription delivery TIMEOUT: event=%s to=%s after %v (limit=%v)", - hex.Enc(ev.ID), d.sub.remote, deliveryDuration, DefaultWriteTimeout) - } - - // Log connection cleanup - log.D.F("removing failed subscriber connection: %s", d.sub.remote) - - // On error, remove the subscriber connection safely - p.removeSubscriber(d.w) - _ = d.w.CloseNow() - continue - } - - deliveryDuration := time.Since(deliveryStart) - log.D.F("subscription delivery SUCCESS: event=%s to=%s sub=%s duration=%v len=%d", - hex.Enc(ev.ID), d.sub.remote, d.id, deliveryDuration, len(msgData)) - - // Log slow deliveries for performance monitoring - if deliveryDuration > time.Millisecond*50 { - log.D.F("SLOW subscription delivery: event=%s to=%s duration=%v (>50ms)", - hex.Enc(ev.ID), d.sub.remote, deliveryDuration) - } + // Check for private tags - only deliver to authorized users + if ev.Tags != nil && ev.Tags.Len() > 0 { + hasPrivateTag := false + var privatePubkey []byte + + for _, t := range *ev.Tags { + if t.Len() >= 2 { + keyBytes := t.Key() + if len(keyBytes) == 7 && string(keyBytes) == "private" { + hasPrivateTag = true + privatePubkey = t.Value() + break + } + } + } + + if hasPrivateTag { + canSeePrivate := p.canSeePrivateEvent(d.sub.AuthedPubkey, privatePubkey, d.sub.remote) + if !canSeePrivate { + log.D.F("subscription delivery DENIED for private event %s to %s (unauthorized)", + hex.Enc(ev.ID), d.sub.remote) + continue + } + log.D.F("subscription delivery ALLOWED for private event %s to %s (authorized)", + hex.Enc(ev.ID), d.sub.remote) + } + } + + var res *eventenvelope.Result + if res, err = eventenvelope.NewResultWith(d.id, ev); chk.E(err) { + log.E.F("failed to create event envelope for %s to %s: %v", + hex.Enc(ev.ID), d.sub.remote, err) + continue + } + + // Log delivery attempt + msgData := res.Marshal(nil) + log.D.F("attempting delivery of event %s (kind=%d, len=%d) to subscription %s @ %s", + hex.Enc(ev.ID), ev.Kind, len(msgData), d.id, d.sub.remote) + + // Use a separate context with timeout for writes to prevent race conditions + // where the publisher context gets cancelled while writing events + writeCtx, cancel := context.WithTimeout( + context.Background(), DefaultWriteTimeout, + ) + defer cancel() + + deliveryStart := time.Now() + if err = d.w.Write( + writeCtx, websocket.MessageText, msgData, + ); err != nil { + deliveryDuration := time.Since(deliveryStart) + + // Log detailed failure information + log.E.F("subscription delivery FAILED: event=%s to=%s sub=%s duration=%v error=%v", + hex.Enc(ev.ID), d.sub.remote, d.id, deliveryDuration, err) + + // Check for timeout specifically + if writeCtx.Err() != nil { + log.E.F("subscription delivery TIMEOUT: event=%s to=%s after %v (limit=%v)", + hex.Enc(ev.ID), d.sub.remote, deliveryDuration, DefaultWriteTimeout) + } + + // Log connection cleanup + log.D.F("removing failed subscriber connection: %s", d.sub.remote) + + // On error, remove the subscriber connection safely + p.removeSubscriber(d.w) + _ = d.w.CloseNow() + continue + } + + deliveryDuration := time.Since(deliveryStart) + log.D.F("subscription delivery SUCCESS: event=%s to=%s sub=%s duration=%v len=%d", + hex.Enc(ev.ID), d.sub.remote, d.id, deliveryDuration, len(msgData)) + + // Log slow deliveries for performance monitoring + if deliveryDuration > time.Millisecond*50 { + log.D.F("SLOW subscription delivery: event=%s to=%s duration=%v (>50ms)", + hex.Enc(ev.ID), d.sub.remote, deliveryDuration) + } } } @@ -299,3 +328,25 @@ func (p *P) removeSubscriber(ws *websocket.Conn) { clear(p.Map[ws]) delete(p.Map, ws) } + +// canSeePrivateEvent checks if the authenticated user can see an event with a private tag +func (p *P) canSeePrivateEvent(authedPubkey, privatePubkey []byte, remote string) (canSee bool) { + // If no authenticated user, deny access + if len(authedPubkey) == 0 { + return false + } + + // If the authenticated user matches the private tag pubkey, allow access + if len(privatePubkey) > 0 && utils.FastEqual(authedPubkey, privatePubkey) { + return true + } + + // Check if user is an admin or owner (they can see all private events) + accessLevel := acl.Registry.GetAccessLevel(authedPubkey, remote) + if accessLevel == "admin" || accessLevel == "owner" { + return true + } + + // Default deny + return false +} diff --git a/pkg/version/version b/pkg/version/version index 47d2d41..bf8fd8c 100644 --- a/pkg/version/version +++ b/pkg/version/version @@ -1 +1 @@ -v0.17.6 \ No newline at end of file +v0.17.7 \ No newline at end of file