Simple relay import
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Kieran 2024-01-11 13:05:31 +00:00
parent 17df7e73d1
commit a0e59757b5
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
9 changed files with 232 additions and 0 deletions

35
NostrRelay/INostrRelay.cs Normal file
View File

@ -0,0 +1,35 @@
using System.Net;
using System.Net.WebSockets;
using Nostr.Client.Messages;
using Nostr.Client.Requests;
namespace NostrRelay;
public record NostrClientContext(WebSocket WebSocket, IPAddress Ip, string UserAgent);
public record HandleEventResponse(bool Ok, string? Message);
public interface INostrRelay
{
/// <summary>
/// If we should handle this connection
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
ValueTask<bool> AcceptConnection(NostrClientContext context);
/// <summary>
/// Respond to a request for content
/// </summary>
/// <param name="context"></param>
/// <param name="req"></param>
/// <returns></returns>
IAsyncEnumerable<NostrEvent> HandleRequest(NostrClientContext context, NostrRequest req);
/// <summary>
/// Handle new event publish
/// </summary>
/// <param name="context"></param>
/// <param name="ev"></param>
/// <returns></returns>
ValueTask<HandleEventResponse> HandleEvent(NostrClientContext context, NostrEvent ev);
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Nostr.Client" Version="2.0.0" />
</ItemGroup>
</Project>

95
NostrRelay/Relay.cs Normal file
View File

@ -0,0 +1,95 @@
using System.Buffers;
using System.Net.WebSockets;
using System.Text;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Nostr.Client.Json;
using Nostr.Client.Requests;
using Nostr.Client.Responses;
namespace NostrRelay;
public class NostrRelay<THandler> where THandler : INostrRelay
{
private readonly ILogger<NostrRelay<THandler>> _logger;
private readonly CancellationTokenSource _cts = new();
private readonly THandler _handler;
private readonly NostrClientContext _ctx;
public NostrRelay(THandler handler, NostrClientContext ctx, CancellationToken ct, ILogger<NostrRelay<THandler>> logger)
{
_handler = handler;
_ctx = ctx;
_logger = logger;
ct.Register(() => _cts.Cancel());
}
private async Task WriteResponse<T>(T obj)
{
var rspJson = JsonConvert.SerializeObject(obj, NostrSerializer.Settings);
_logger.LogDebug("Sending {msg}", rspJson);
await _ctx.WebSocket.SendAsync(Encoding.UTF8.GetBytes(rspJson), WebSocketMessageType.Text, true, _cts.Token);
}
public async Task Read()
{
if (!await _handler.AcceptConnection(_ctx)) return;
var offset = 0;
var mem = MemoryPool<byte>.Shared.Rent(1024 * 1024);
while (!_cts.IsCancellationRequested)
{
var msg = await _ctx.WebSocket.ReceiveAsync(mem.Memory[offset..], _cts.Token);
if (msg.MessageType is WebSocketMessageType.Text)
{
var buff = mem.Memory[..(offset + msg.Count)];
offset = !msg.EndOfMessage ? offset + msg.Count : 0;
if (!msg.EndOfMessage) continue;
var str = Encoding.UTF8.GetString(buff.Span);
_logger.LogDebug("Got msg {msg}", str);
if (str.StartsWith("[\"REQ\""))
{
var req = JsonConvert.DeserializeObject<NostrRequest>(str, NostrSerializer.Settings);
if (req != default)
{
await foreach (var ev in _handler.HandleRequest(_ctx, req))
{
await WriteResponse(new NostrEventResponse()
{
MessageType = "EVENT",
Subscription = req.Subscription,
Event = ev
});
}
var rsp = new NostrEoseResponse
{
MessageType = "EOSE",
Subscription = req.Subscription
};
await WriteResponse(rsp);
}
}
else if (str.StartsWith("[\"EVENT\""))
{
var req = JsonConvert.DeserializeObject<NostrEventRequest>(str, NostrSerializer.Settings);
if (req != default)
{
var result = await _handler.HandleEvent(_ctx, req.Event);
var rsp = new NostrOkResponse()
{
MessageType = "OK",
EventId = req.Event.Id,
Accepted = result.Ok,
Message = result.Message ?? ""
};
await WriteResponse(rsp);
}
}
}
}
}
}

View File

@ -9,6 +9,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
README.md = README.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NostrRelay", "NostrRelay\NostrRelay.csproj", "{FBCF209E-9C58-45EB-BC59-99569B28D811}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -19,5 +21,9 @@ Global
{51CD83E4-2CEC-4852-B285-2327EF86C6E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{51CD83E4-2CEC-4852-B285-2327EF86C6E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{51CD83E4-2CEC-4852-B285-2327EF86C6E7}.Release|Any CPU.Build.0 = Release|Any CPU
{FBCF209E-9C58-45EB-BC59-99569B28D811}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FBCF209E-9C58-45EB-BC59-99569B28D811}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FBCF209E-9C58-45EB-BC59-99569B28D811}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FBCF209E-9C58-45EB-BC59-99569B28D811}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -25,6 +25,10 @@ public class ImportController : Controller
/// POST events in ND-JSON format here to add them to the cache which is used in <see cref="OpenGraphController"/>
///
/// `nak req -k 1 -l 100 wss://nos.lol | curl -v -X POST --data-binary @- https://nostr.api.v0l.io/api/v1/import`
///
/// You can also write events directly to this API as nostr websocket
///
/// `nak req -k 1 -l 100 wss://nos.lol | nak event --envelope | websocat -tn wss://nostr.api.v0l.io/`
/// </remarks>
/// <returns></returns>
[HttpPost]

View File

@ -1,6 +1,8 @@
using System.Net.WebSockets;
using Nostr.Client.Identifiers;
using Nostr.Client.Messages;
using Nostr.Client.Utils;
using NostrRelay;
using NostrServices.Services;
using ProtoBuf;
using StackExchange.Redis;
@ -56,4 +58,32 @@ public static class Extensions
{
return new NostrProfileIdentifier(px.PubKey.ToHex(), []);
}
public static void MapNostrRelay<THandler>(this IEndpointRouteBuilder app, string path) where THandler : INostrRelay
{
app.MapGet(path, async ctx =>
{
if (ctx.WebSockets.IsWebSocketRequest)
{
var logger = app.ServiceProvider.GetRequiredService<ILogger<NostrRelay<THandler>>>();
var handler = app.ServiceProvider.GetRequiredService<THandler>();
try
{
var ws = await ctx.WebSockets.AcceptWebSocketAsync();
var wsCtx = new NostrClientContext(ws, ctx.Connection.RemoteIpAddress!,
ctx.Request.Headers.UserAgent.FirstOrDefault() ?? string.Empty);
var nostrRelay = new NostrRelay<THandler>(handler, wsCtx, ctx.RequestAborted, logger);
await nostrRelay.Read();
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, ctx.RequestAborted);
}
catch (Exception ex)
{
logger.LogDebug(ex.Message);
}
}
});
}
}

View File

@ -33,4 +33,8 @@
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NostrRelay\NostrRelay.csproj" />
</ItemGroup>
</Project>

View File

@ -67,9 +67,11 @@ public static class Program
Url = new Uri("https://git.v0l.io/Kieran/NostrServices")
}
});
var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
opt.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename));
});
builder.Services.AddHttpClient();
builder.Services.AddMemoryCache();
builder.Services.AddResponseCaching();
@ -77,6 +79,7 @@ public static class Program
builder.Services.AddHealthChecks();
builder.Services.AddHostedService<NostrListener.NostrListenerLifetime>();
builder.Services.AddTransient<CacheRelay>();
ConfigureDb(builder.Services, builder.Configuration);
@ -92,6 +95,7 @@ public static class Program
app.UseSwaggerUI();
app.UseHttpMetrics();
app.UseHealthChecks("/healthz");
app.UseWebSockets();
app.UseCors(o =>
{
o.AllowAnyOrigin();
@ -102,6 +106,7 @@ public static class Program
app.UseRouting();
app.MapControllers();
app.MapMetrics();
app.MapNostrRelay<CacheRelay>("/");
await app.RunAsync();
}

View File

@ -0,0 +1,40 @@
using Nostr.Client.Messages;
using Nostr.Client.Requests;
using NostrRelay;
namespace NostrServices.Services;
public class CacheRelay : INostrRelay
{
private readonly ILogger<CacheRelay> _logger;
private readonly RedisStore _redisStore;
public CacheRelay(RedisStore redisStore, ILogger<CacheRelay> logger)
{
_redisStore = redisStore;
_logger = logger;
}
public ValueTask<bool> AcceptConnection(NostrClientContext context)
{
_logger.LogInformation("New connection {ip} {ua}", context.Ip, context.UserAgent);
return ValueTask.FromResult(true);
}
public async IAsyncEnumerable<NostrEvent> HandleRequest(NostrClientContext context, NostrRequest req)
{
//no results yet
yield break;
}
public async ValueTask<HandleEventResponse> HandleEvent(NostrClientContext context, NostrEvent ev)
{
if (RelayListener.AcceptedKinds.Contains(ev.Kind))
{
await _redisStore.StoreEvent(CompactEvent.FromNostrEvent(ev));
return new(true, null);
}
return new(false, "blocked: kind not accepted");
}
}