Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

![netstr logo](art/logo.jpg)

Netstr is a relay for the [nostr protocol](https://github.com/nostr-protocol/nostr) written in C#. Currently in early stages of development.
Netstr is a modern relay for the [nostr protocol](https://github.com/nostr-protocol/nostr) written in C#.

* Prod instance: https://relay.netstr.io/
* Dev instance: https://relay-dev.netstr.io/
* **Prod** instance: https://relay.netstr.io/
* **Dev** instance: https://relay-dev.netstr.io/ (feel free to play with it / try to break it, just report if you find anything that needs fixing)

## Features

Expand All @@ -24,6 +24,7 @@ NIPs with a relay-specific implementation are listed here.
- [x] NIP-42: [Authentication of clients to relays](https://github.com/nostr-protocol/nips/blob/master/42.md)
- [x] NIP-45: [Counting results](https://github.com/nostr-protocol/nips/blob/master/45.md)
- [ ] NIP-50: [Search Capability](https://github.com/nostr-protocol/nips/blob/master/50.md)
- [x] NIP-62: [Request to Vanish](https://github.com/vitorpamplona/nips/blob/right-to-vanish/62.md)
- [x] NIP-70: [Protected events](https://github.com/nostr-protocol/nips/blob/master/70.md)

## Tests
Expand Down
13 changes: 13 additions & 0 deletions src/Netstr/Extensions/HttpExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Netstr.Extensions
{
public static class HttpExtensions
{
/// <summary>
/// Gets the current normalized URL (host+path) where the relay is running.
/// </summary>
public static string GetNormalizedUrl(this HttpRequest ctx)
{
return $"{ctx.Host}{ctx.Path}".TrimEnd('/');
}
}
}
3 changes: 3 additions & 0 deletions src/Netstr/Extensions/MessagingExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public static IServiceCollection AddMessaging(this IServiceCollection services)
{
services.AddSingleton<WebSocketAdapterFactory>();
services.AddSingleton<IWebSocketAdapterCollection, WebSocketAdapterCollection>();
services.AddSingleton<IUserCache, UserCache>();

// message
services.AddSingleton<IMessageDispatcher, MessageDispatcher>();
Expand All @@ -32,6 +33,7 @@ public static IServiceCollection AddMessaging(this IServiceCollection services)
services.AddSingleton<IEventHandler, ReplaceableEventHandler>();
services.AddSingleton<IEventHandler, EphemeralEventHandler>();
services.AddSingleton<IEventHandler, AddressableEventHandler>();
services.AddSingleton<IEventHandler, VanishEventHandler>();

// RegularEventHandler needs to go last
services.AddSingleton<IEventHandler, RegularEventHandler>();
Expand All @@ -51,6 +53,7 @@ public static IServiceCollection AddEventValidators(this IServiceCollection serv
services.AddSingleton<IEventValidator, ProtectedEventValidator>();
services.AddSingleton<IEventValidator, ExpiredEventValidator>();
services.AddSingleton<IEventValidator, EventTagsValidator>();
services.AddSingleton<IEventValidator, UserVanishedValidator>();

return services;
}
Expand Down
10 changes: 6 additions & 4 deletions src/Netstr/Messaging/Events/Handlers/DeleteEventHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ namespace Netstr.Messaging.Events.Handlers
/// </summary>
public class DeleteEventHandler : EventHandlerBase
{
private static readonly long[] CannotDeleteKinds = [ EventKind.Delete, EventKind.RequestToVanish ];

private record ReplaceableEventRef(int Kind, string PublicKey, string? Deduplication) { }

private readonly IDbContextFactory<NetstrDbContext> db;
Expand Down Expand Up @@ -45,15 +47,15 @@ protected override async Task HandleEventCoreAsync(IWebSocketAdapter sender, Eve
.Select(x => new
{
x.Id,
WrongKey = x.EventPublicKey != e.PublicKey, // only delete own events
WrongKind = x.EventKind == EventKind.Delete, // cannnot delete a delete event
AlreadyDeleted = x.DeletedAt.HasValue // was previously deleted
WrongKey = x.EventPublicKey != e.PublicKey, // only delete own events
WrongKind = CannotDeleteKinds.Contains(x.EventKind), // cannnot delete some events
AlreadyDeleted = x.DeletedAt.HasValue // was previously deleted
})
.ToArrayAsync();

if (events.Any(x => x.WrongKey || x.WrongKind))
{
this.logger.LogWarning("Someone's trying to delete someone else's event or a deletion.");
this.logger.LogWarning("Someone's trying to delete someone else's or undeletable event.");
await sender.SendNotOkAsync(e.Id, Messages.InvalidCannotDelete);
return;
}
Expand Down
77 changes: 77 additions & 0 deletions src/Netstr/Messaging/Events/Handlers/VanishEventHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Netstr.Data;
using Netstr.Extensions;
using Netstr.Messaging.Models;
using Netstr.Options;

namespace Netstr.Messaging.Events.Handlers
{
public class VanishEventHandler : EventHandlerBase
{
private readonly IDbContextFactory<NetstrDbContext> db;
private readonly IUserCache userCache;
private readonly IHttpContextAccessor http;

private readonly static string AllRelaysValue = "ALL_RELAYS";

public VanishEventHandler(
ILogger<EventHandlerBase> logger,
IOptions<AuthOptions> auth,
IWebSocketAdapterCollection adapters,
IDbContextFactory<NetstrDbContext> db,
IUserCache userCache,
IHttpContextAccessor http)
: base(logger, auth, adapters)
{
this.db = db;
this.userCache = userCache;
this.http = http;
}

public override bool CanHandleEvent(Event e) => e.IsRequestToVanish();

protected override async Task HandleEventCoreAsync(IWebSocketAdapter sender, Event e)
{
var ctx = this.http.HttpContext?.Request ?? throw new InvalidOperationException("HttpContext not set");
var user = this.userCache.GetByPublicKey(e.PublicKey);

var path = ctx.GetNormalizedUrl();
var relays = e.GetNormalizedRelayValues();

// check 'relay' tag matches current url or is set to ALL_RELAYS
if (!relays.Any(x => x == path || x == AllRelaysValue))
{
throw new MessageProcessingException(e, string.Format(Messages.InvalidWrongTagValue, EventTag.Relay));
}

using var db = this.db.CreateDbContext();
using var tx = db.Database.BeginTransaction();

// delete all user's events (or tagged GiftWraps) from before the vanish event
await db.Events
.Include(x => x.Tags)
.Where(x =>
(x.EventPublicKey == e.PublicKey ||
(x.EventKind == EventKind.GiftWrap && x.Tags.Any(t => t.Name == EventTag.PublicKey && t.Value == e.PublicKey))) &&
x.EventCreatedAt <= e.CreatedAt)
.ExecuteDeleteAsync();

// insert vanish entity to db
db.Events.Add(e.ToEntity(DateTimeOffset.UtcNow));

// save
await db.SaveChangesAsync();
await tx.CommitAsync();

// set vanished in cache
this.userCache.Vanish(e.PublicKey, e.CreatedAt);

// reply
await sender.SendOkAsync(e.Id);

// broadcast
await BroadcastEventAsync(e);
}
}
}
35 changes: 35 additions & 0 deletions src/Netstr/Messaging/Events/Validators/UserVanishedValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Netstr.Messaging.Models;

namespace Netstr.Messaging.Events.Validators
{
/// <summary>
/// Ensure older events cannot be republished if user vanished.
/// </summary>
public class UserVanishedValidator : IEventValidator
{
private readonly ILogger<UserVanishedValidator> logger;
private readonly IUserCache userCache;

public UserVanishedValidator(
ILogger<UserVanishedValidator> logger,
IUserCache userCache)
{
this.logger = logger;
this.userCache = userCache;
}

public string? Validate(Event e, ClientContext context)
{
var user = this.userCache.GetByPublicKey(e.PublicKey);
var vanished = user?.LastVanished ?? DateTimeOffset.MinValue;

if (e.CreatedAt <= vanished)
{
this.logger.LogInformation($"Event {e.Id} is from user who already vanished on {vanished} (this event is from {e.CreatedAt})");
return Messages.InvalidDeletedEvent;
}

return null;
}
}
}
8 changes: 4 additions & 4 deletions src/Netstr/Messaging/MessageHandlers/AuthMessageHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ private Event ValidateAuthEvent(JsonDocument[] parameters, ClientContext context
throw new MessageProcessingException(e, Messages.AuthRequiredWrongTags);
}

var path = $"{ctx.Host}{ctx.Path}".TrimEnd('/');
var relayTag = e.Tags.FirstOrDefault(x => x.Length == 2 && x[0] == EventTag.Relay);
var relay = relayTag?[1].Split("://")[1].TrimEnd('/');
if (relayTag == null || relay != path)
var path = ctx.GetNormalizedUrl();
var relays = e.GetNormalizedRelayValues();

if (!relays.Any(x => x == path))
{
throw new MessageProcessingException(e, Messages.AuthRequiredWrongTags);
}
Expand Down
3 changes: 2 additions & 1 deletion src/Netstr/Messaging/Messages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ public static class Messages
public const string InvalidTooManyTags = "invalid: too many tags";
public const string InvalidCannotDelete = "invalid: cannot delete deletions and someone else's events";
public const string InvalidDeletedEvent = "invalid: this event was already deleted";
public const string InvalidWrongTagValue = "invalid: this event has an unexpected value of tag {0}";
public const string AuthRequired = "auth-required: we only allow publishing and subscribing to authenticated clients";
public const string AuthRequiredProtected = "auth-required: this event may only be published by its author";
public const string AuthRequiredPublishing = "auth-required: we only allow publishing to authenticated clients";
public const string AuthRequiredKind = "auth-required: subscribing to specified kind(s) requires authentication";
public const string AuthRequiredWrongKind = "auth-required: event has a wrong kind";
public const string AuthRequiredWrongTags = "auth-required: event has a challenge or relay";
public const string AuthRequiredWrongTags = "auth-required: event has a wrong challenge or relay";
public const string DuplicateEvent = "duplicate: already have this event";
public const string DuplicateReplaceableEvent = "duplicate: already have a newer version of this event";
public const string PowNotEnough = "pow: difficulty {0} is less than {1}";
Expand Down
15 changes: 15 additions & 0 deletions src/Netstr/Messaging/Models/Event.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore.Diagnostics;
using Netstr.Json;
using System.Linq;
using System.Numerics;
using System.Text.Json.Serialization;

Expand Down Expand Up @@ -42,6 +43,8 @@ public record Event

public bool IsDelete() => Kind == EventKind.Delete;

public bool IsRequestToVanish() => Kind == EventKind.RequestToVanish;

public bool IsProtected() => Tags.Any(x => x.Length >= 1 && x[0] == EventTag.Protected);

public string ToStringUnique()
Expand Down Expand Up @@ -76,6 +79,11 @@ public int GetDifficulty()
return GetTagValue(EventTag.Deduplication);
}

public IEnumerable<string> GetNormalizedRelayValues()
{
return GetTagValues(EventTag.Relay).Select(x => x.Contains("://") ? x.Split("://")[1].TrimEnd('/') : x);
}

public DateTimeOffset? GetExpirationValue()
{
if (long.TryParse(GetTagValue(EventTag.Expiration), out var exp) && exp > 0)
Expand All @@ -90,5 +98,12 @@ public int GetDifficulty()
{
return Tags.FirstOrDefault(x => x.Length > 1 && x.FirstOrDefault() == tag)?[1];
}

public IEnumerable<string> GetTagValues(string tag)
{
return Tags
.Where(x => x.Length > 1 && x.FirstOrDefault() == tag)
.Select(x => x[1]);
}
}
}
2 changes: 2 additions & 0 deletions src/Netstr/Messaging/Models/EventKind.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ public static class EventKind
{
public static int UserMetadata = 0;
public static int Delete = 5;
public static int RequestToVanish = 62;
public static int GiftWrap = 1059;
public static int Auth = 22242;
}
}
11 changes: 11 additions & 0 deletions src/Netstr/Messaging/Models/User.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Netstr.Messaging.Models
{
public record User
{
public required string PublicKey { get; init; }

public string? EventId { get; init; }

public DateTimeOffset? LastVanished { get; init; }
}
}
53 changes: 53 additions & 0 deletions src/Netstr/Messaging/UserCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using Netstr.Messaging.Models;
using System.Collections.Concurrent;

namespace Netstr.Messaging
{
public interface IUserCache
{
void Initialize(IEnumerable<User> users);

User SetFromEvent(Event e);

User? GetByPublicKey(string publicKey);

User Vanish(string publicKey, DateTimeOffset timestamp);
}

public class UserCache : IUserCache
{
// Use MemoryCache with CacheItemPolicy NotRemovable for users which vanished?
private readonly ConcurrentDictionary<string, User> users = new();

public User? GetByPublicKey(string publicKey)
{
this.users.TryGetValue(publicKey, out var user);

return user;
}

public void Initialize(IEnumerable<User> users)
{
foreach (var user in users)
{
this.users.TryAdd(user.PublicKey, user);
}
}

public User SetFromEvent(Event e)
{
return this.users.AddOrUpdate(
e.PublicKey,
key => new User { PublicKey = key, EventId = e.Id },
(key, user) => user with { EventId = e.Id });
}

public User Vanish(string publicKey, DateTimeOffset timestamp)
{
return this.users.AddOrUpdate(
publicKey,
key => new User { PublicKey = key, LastVanished = timestamp },
(key, user) => user with { LastVanished = timestamp });
}
}
}
Loading