FASTER relay
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
This commit is contained in:
parent
a0e59757b5
commit
a22122e824
|
@ -0,0 +1,24 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0"/>
|
||||
<PackageReference Include="NUnit" Version="3.13.3"/>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1"/>
|
||||
<PackageReference Include="NUnit.Analyzers" Version="3.6.1"/>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NostrRelay\NostrRelay.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
|
@ -0,0 +1 @@
|
|||
global using NUnit.Framework;
|
|
@ -0,0 +1,44 @@
|
|||
using Newtonsoft.Json;
|
||||
using Nostr.Client.Json;
|
||||
using Nostr.Client.Messages;
|
||||
using NostrRelay;
|
||||
|
||||
namespace BasicTests;
|
||||
|
||||
public class Tests
|
||||
{
|
||||
private NostrEvent _testEvent = null!;
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
var json =
|
||||
"{\"id\":\"0552df8c6617c8919f1872ce8a0b2834f3f28100e4bf07be8b4aa262e9d59321\",\"pubkey\":\"27154fb873badf69c3ea83a0da6e65d6a150d2bf8f7320fc3314248d74645c64\",\"created_at\":1705062495,\"kind\":1,\"tags\":[[\"r\",\"https://www.coindesk.com/business/2024/01/12/bitcoin-miner-outflows-hit-six-year-highs-ahead-of-halving-sparking-mixed-signals/\"]],\"content\":\"‚Miner outflow has hit a multi-year high as tens of thousands of bitcoin (BTC), worth over $1 billion, have been sent to exchanges.‘\\n\\nhttps://www.coindesk.com/business/2024/01/12/bitcoin-miner-outflows-hit-six-year-highs-ahead-of-halving-sparking-mixed-signals/\",\"sig\":\"ba2523600bea47637bcced3b75ec446253dca184783a98ec807bf0a4fc90031152c225e7a39392bb3af23ceafc64aa67d9c10874e8ec6d2c1af268235a554d93\"}";
|
||||
|
||||
_testEvent = JsonConvert.DeserializeObject<NostrEvent>(json, NostrSerializer.Settings)!;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TestNostrBuf()
|
||||
{
|
||||
var data = NostrBuf.Encode(_testEvent);
|
||||
Assert.That(data.Length, Is.EqualTo(NostrBuf.CalculateSize(_testEvent)));
|
||||
|
||||
var ev = NostrBuf.Decode(data);
|
||||
Assert.That(ev.Id, Is.EqualTo(_testEvent.Id));
|
||||
Assert.That(ev.Pubkey, Is.EqualTo(_testEvent.Pubkey));
|
||||
Assert.That(ev.Sig, Is.EqualTo(_testEvent.Sig));
|
||||
Assert.That(ev.CreatedAt, Is.EqualTo(_testEvent.CreatedAt));
|
||||
Assert.That(ev.Kind, Is.EqualTo(_testEvent.Kind));
|
||||
Assert.That(ev.Tags!.Count, Is.EqualTo(_testEvent.Tags!.Count));
|
||||
for (var tx = 0; tx < ev.Tags.Count; tx++)
|
||||
{
|
||||
Assert.That(ev.Tags[tx].TagIdentifier, Is.EqualTo(_testEvent.Tags[tx].TagIdentifier));
|
||||
Assert.That(ev.Tags[tx].AdditionalData.Length, Is.EqualTo(_testEvent.Tags[tx].AdditionalData.Length));
|
||||
for (var ty = 0; ty < ev.Tags[tx].AdditionalData.Length; ty++)
|
||||
{
|
||||
Assert.That(ev.Tags[tx].AdditionalData[ty], Is.EqualTo(_testEvent.Tags[tx].AdditionalData[ty]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
data/
|
|
@ -0,0 +1,23 @@
|
|||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["FASTERlay/FASTERlay.csproj", "FASTERlay/"]
|
||||
RUN dotnet restore "FASTERlay/FASTERlay.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/FASTERlay"
|
||||
RUN dotnet build "FASTERlay.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "FASTERlay.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "FASTERlay.dll"]
|
|
@ -0,0 +1,5 @@
|
|||
namespace FASTERlay;
|
||||
|
||||
public static class Extensions
|
||||
{
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
using FASTER.core;
|
||||
using Nostr.Client.Messages;
|
||||
using Nostr.Client.Requests;
|
||||
using NostrRelay;
|
||||
|
||||
namespace FASTERlay;
|
||||
|
||||
public class FasterRelay : INostrRelay, IDisposable
|
||||
{
|
||||
private readonly ClientSession<byte[], byte[], byte[], byte[], Empty, SimpleFunctions<byte[], byte[]>> _session;
|
||||
private readonly ClientSession<string, byte[], byte[], byte[], Empty, TagSetFunctions> _tagsSession;
|
||||
|
||||
public FasterRelay(NostrStore store)
|
||||
{
|
||||
_session = store.MainStore.For(new SimpleFunctions<byte[], byte[]>()).NewSession<SimpleFunctions<byte[], byte[]>>();
|
||||
_tagsSession = store.TagStore.For(new TagSetFunctions()).NewSession<TagSetFunctions>();
|
||||
}
|
||||
|
||||
public ValueTask<bool> AcceptConnection(NostrClientContext context)
|
||||
{
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<NostrEvent> HandleRequest(NostrClientContext context, NostrRequest req)
|
||||
{
|
||||
var iter = _session.Iterate();
|
||||
var filter = req.NostrFilter;
|
||||
while (iter.GetNext(out _, out _, out var bytes))
|
||||
{
|
||||
var ev = NostrBuf.Decode(bytes);
|
||||
if (ev != default)
|
||||
{
|
||||
if (filter.Since.HasValue && ev.CreatedAt <= filter.Since)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filter.Until.HasValue && ev.CreatedAt > filter.Until)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filter.Ids != default && !filter.Ids.Contains(ev.Id!))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filter.Authors != default && !filter.Authors.Contains(ev.Pubkey!))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filter.Kinds != default && !filter.Kinds.Contains(ev.Kind))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return ev;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<HandleEventResponse> HandleEvent(NostrClientContext context, NostrEvent ev)
|
||||
{
|
||||
await SaveEvent(ev);
|
||||
return new(true, null);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_session.Dispose();
|
||||
_tagsSession.Dispose();
|
||||
}
|
||||
|
||||
private async Task SaveEvent(NostrEvent ev)
|
||||
{
|
||||
var idBytes = Convert.FromHexString(ev.Id!);
|
||||
var eventBytes = NostrBuf.Encode(ev);
|
||||
|
||||
await _session.UpsertAsync(ref idBytes, ref eventBytes);
|
||||
|
||||
var indexTags = ev.Tags!.Where(a => a.TagIdentifier.Length == 1);
|
||||
foreach (var tag in indexTags)
|
||||
{
|
||||
var kIndex = $"{tag.TagIdentifier}.{tag.AdditionalData[0]}";
|
||||
await _tagsSession.RMWAsync(ref kIndex, ref idBytes);
|
||||
}
|
||||
|
||||
await _session.CompletePendingAsync();
|
||||
await _tagsSession.CompletePendingAsync();
|
||||
}
|
||||
|
||||
private async Task<NostrEvent?> GetEvent(string id)
|
||||
{
|
||||
var idBytes = Convert.FromHexString(id);
|
||||
var read = await _session.ReadAsync(ref idBytes);
|
||||
if (read.Status.Found)
|
||||
{
|
||||
var data = read.Complete().output;
|
||||
return NostrBuf.Decode(data);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
private class TagSetFunctions : SimpleFunctions<string, byte[]>
|
||||
{
|
||||
public TagSetFunctions() : base((l, r) =>
|
||||
{
|
||||
var output = new byte[l.Length + r.Length];
|
||||
Buffer.BlockCopy(l, 0, output, 0, l.Length);
|
||||
Buffer.BlockCopy(r, 0, output, l.Length, r.Length);
|
||||
return output;
|
||||
})
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NostrRelay\NostrRelay.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.FASTER.Core" Version="2.6.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
|
@ -0,0 +1,58 @@
|
|||
using FASTER.core;
|
||||
|
||||
namespace FASTERlay;
|
||||
|
||||
public class NostrStore : IDisposable
|
||||
{
|
||||
private readonly ILogger<NostrStore> _logger;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly FasterKVSettings<byte[], byte[]> _storeSettings;
|
||||
private readonly FasterKV<byte[], byte[]> _store;
|
||||
private readonly FasterKVSettings<string, byte[]> _tagStoreSettings;
|
||||
private readonly FasterKV<string, byte[]> _tagStore;
|
||||
|
||||
private readonly Task _checkpointer;
|
||||
|
||||
public NostrStore(string dir, ILogger<NostrStore> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_storeSettings = new FasterKVSettings<byte[], byte[]>(Path.Combine(dir, "main"), false, _logger);
|
||||
_store = new(_storeSettings);
|
||||
var version = _store.GetLatestCheckpointVersion();
|
||||
if (version != -1)
|
||||
{
|
||||
_store.Recover();
|
||||
}
|
||||
|
||||
_tagStoreSettings = new FasterKVSettings<string, byte[]>(Path.Combine(dir, "tags"), false, _logger);
|
||||
_tagStore = new(_tagStoreSettings);
|
||||
var keyVersion = _tagStore.GetLatestCheckpointVersion();
|
||||
if (keyVersion != -1)
|
||||
{
|
||||
_tagStore.Recover();
|
||||
}
|
||||
|
||||
_checkpointer = Task.Factory.StartNew(async () =>
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(10_000);
|
||||
await _store.TakeHybridLogCheckpointAsync(CheckpointType.Snapshot, true);
|
||||
await _tagStore.TakeHybridLogCheckpointAsync(CheckpointType.Snapshot, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
public FasterKV<byte[], byte[]> MainStore => _store;
|
||||
public FasterKV<string, byte[]> TagStore => _tagStore;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_checkpointer.ConfigureAwait(false).GetAwaiter().GetResult();
|
||||
|
||||
_store.Dispose();
|
||||
_storeSettings.Dispose();
|
||||
_tagStore.Dispose();
|
||||
_tagStoreSettings.Dispose();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
using System.Net.WebSockets;
|
||||
using FASTERlay;
|
||||
using Newtonsoft.Json;
|
||||
using Nostr.Client.Json;
|
||||
using NostrRelay;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddSingleton<NostrStore>(svc =>
|
||||
{
|
||||
var logger = svc.GetRequiredService<ILogger<NostrStore>>();
|
||||
return new NostrStore("./data", logger);
|
||||
});
|
||||
|
||||
builder.Services.AddTransient<FasterRelay>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
app.UseWebSockets();
|
||||
app.MapGet("/", async ctx =>
|
||||
{
|
||||
if (ctx.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
var logger = app.Services.GetRequiredService<ILogger<NostrRelay<FasterRelay>>>();
|
||||
using var handler = app.Services.GetRequiredService<FasterRelay>();
|
||||
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<FasterRelay>(handler, wsCtx, ctx.RequestAborted, logger);
|
||||
|
||||
await nostrRelay.Read();
|
||||
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, ctx.RequestAborted);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex.Message);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var isRelayDocRequest = ctx.Request.Headers.Accept.FirstOrDefault()
|
||||
?.Equals("application/nostr+json", StringComparison.InvariantCultureIgnoreCase) ?? false;
|
||||
|
||||
if (isRelayDocRequest)
|
||||
{
|
||||
var doc = new RelayDocument
|
||||
{
|
||||
Name = "",
|
||||
Description = "C# FASTER relay",
|
||||
Pubkey = "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed",
|
||||
SupportedNips = [1],
|
||||
Software = "NostrServices",
|
||||
Icon = "https://void.cat/d/CtXsF5EvqNLG3K85TgezCt.webp",
|
||||
Limitation = new()
|
||||
{
|
||||
RestrictedWrites = false,
|
||||
AuthRequired = false,
|
||||
PaymentRequired = false
|
||||
}
|
||||
};
|
||||
|
||||
ctx.Response.ContentType = "application/json";
|
||||
await ctx.Response.WriteAsync(JsonConvert.SerializeObject(doc, NostrSerializer.Settings));
|
||||
}
|
||||
else
|
||||
{
|
||||
await ctx.Response.WriteAsync("Hey");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.Run();
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5042",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"NostrRelay": "Debug",
|
||||
"FASTER": "Debug"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
using System.Text;
|
||||
using Nostr.Client.Messages;
|
||||
|
||||
namespace NostrRelay;
|
||||
|
||||
/// <summary>
|
||||
/// Simple fixed length nostr event storage
|
||||
/// </summary>
|
||||
public static class NostrBuf
|
||||
{
|
||||
public static byte[] Encode(NostrEvent ev)
|
||||
{
|
||||
var buf = new byte[CalculateSize(ev)];
|
||||
var span = buf.AsSpan();
|
||||
span[0] = 0x01; // version 1
|
||||
Convert.FromHexString(ev.Id!).CopyTo(span[1..33]);
|
||||
Convert.FromHexString(ev.Pubkey!).CopyTo(span[33..65]);
|
||||
Convert.FromHexString(ev.Sig!).CopyTo(span[65..129]);
|
||||
BitConverter.GetBytes((ulong)ToUnixTime(ev.CreatedAt!.Value)).CopyTo(span[129..137]);
|
||||
BitConverter.GetBytes((uint)ev.Kind).CopyTo(span[137..141]);
|
||||
var contentBytes = Encoding.UTF8.GetBytes(ev.Content!);
|
||||
BitConverter.GetBytes((uint)contentBytes.Length).CopyTo(span[141..145]);
|
||||
var pos = 145;
|
||||
contentBytes.CopyTo(span[pos..(pos += contentBytes.Length)]);
|
||||
BitConverter.GetBytes((ushort)ev.Tags!.Count).CopyTo(span[pos..(pos += 2)]);
|
||||
|
||||
foreach (var tag in ev.Tags)
|
||||
{
|
||||
var tagKey = Encoding.UTF8.GetBytes(tag.TagIdentifier);
|
||||
span[pos++] = (byte)tagKey.Length;
|
||||
tagKey.CopyTo(span[pos..(pos += tagKey.Length)]);
|
||||
|
||||
span[pos++] = (byte)tag.AdditionalData.Length;
|
||||
foreach (var tagAdd in tag.AdditionalData)
|
||||
{
|
||||
var tagAddBytes = Encoding.UTF8.GetBytes(tagAdd);
|
||||
BitConverter.GetBytes((ushort)tagAddBytes.Length).CopyTo(span[pos..(pos += 2)]);
|
||||
tagAddBytes.CopyTo(span[pos..(pos += tagAddBytes.Length)]);
|
||||
}
|
||||
}
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
public static NostrEvent? Decode(Span<byte> data)
|
||||
{
|
||||
var version = data[0];
|
||||
if (version != 0x01) throw new Exception("Version not supported");
|
||||
|
||||
var id = Convert.ToHexString(data[1..33]).ToLower();
|
||||
var pubkey = Convert.ToHexString(data[33..65]).ToLower();
|
||||
var sig = Convert.ToHexString(data[65..129]).ToLower();
|
||||
var createdAt = BitConverter.ToUInt64(data[129..137]);
|
||||
var kind = BitConverter.ToUInt32(data[137..141]);
|
||||
var contentLen = BitConverter.ToUInt32(data[141..145]);
|
||||
var pos = 145;
|
||||
var content = Encoding.UTF8.GetString(data[pos..(pos += (int)contentLen)]);
|
||||
var nTags = BitConverter.ToUInt16(data[pos..(pos += 2)]);
|
||||
|
||||
var tags = new List<NostrEventTag>();
|
||||
for (var x = 0; x < nTags; x++)
|
||||
{
|
||||
var keyLen = data[pos++];
|
||||
var keyString = Encoding.UTF8.GetString(data[pos..(pos += keyLen)]);
|
||||
|
||||
var nElements = data[pos++];
|
||||
var elms = new string[nElements];
|
||||
for (var y = 0; y < nElements; y++)
|
||||
{
|
||||
var elmLen = BitConverter.ToUInt16(data[pos..(pos += 2)]);
|
||||
var elmString = Encoding.UTF8.GetString(data[pos..(pos += elmLen)]);
|
||||
elms[y] = elmString;
|
||||
}
|
||||
|
||||
tags.Add(new(keyString, elms));
|
||||
}
|
||||
|
||||
return new NostrEvent()
|
||||
{
|
||||
Id = id,
|
||||
Pubkey = pubkey,
|
||||
Sig = sig,
|
||||
CreatedAt = DateTimeOffset.FromUnixTimeSeconds((long)createdAt).UtcDateTime,
|
||||
Kind = (NostrKind)kind,
|
||||
Content = content,
|
||||
Tags = new NostrEventTags(tags)
|
||||
};
|
||||
}
|
||||
|
||||
public static long CalculateSize(NostrEvent ev)
|
||||
{
|
||||
const long fixedPart =
|
||||
1 // version byte
|
||||
+ 32 // id
|
||||
+ 32 // pubkey
|
||||
+ 64 // sig
|
||||
+ 8 // created_at (uint64)
|
||||
+ 4 // kind (uint32)
|
||||
+ 4 // len(content) (uint32)
|
||||
+ 2; // len(tags) (uint16)
|
||||
|
||||
var variableLength =
|
||||
Encoding.UTF8.GetByteCount(ev.Content!)
|
||||
+ ev.Tags!.Sum(a =>
|
||||
1 + // 1 byte for length of first tag element (key)
|
||||
Encoding.UTF8.GetByteCount(a.TagIdentifier) + // length of the tag key
|
||||
a.AdditionalData.Length + // 1 byte - number of elements
|
||||
a.AdditionalData.Sum(b => Encoding.UTF8.GetByteCount(b)) + // length of the content of the items in the tag
|
||||
(a.AdditionalData.Length * 2)); // 2 bytes per tag element for length prefix
|
||||
|
||||
return fixedPart + variableLength;
|
||||
}
|
||||
|
||||
private static long ToUnixTime(DateTime dt)
|
||||
{
|
||||
return dt.ToUniversalTime().Ticks / 10000000L - 62135596800L;
|
||||
}
|
||||
}
|
|
@ -27,7 +27,7 @@ public class NostrRelay<THandler> where THandler : INostrRelay
|
|||
private async Task WriteResponse<T>(T obj)
|
||||
{
|
||||
var rspJson = JsonConvert.SerializeObject(obj, NostrSerializer.Settings);
|
||||
_logger.LogDebug("Sending {msg}", rspJson);
|
||||
_logger.LogTrace("Sending {msg}", rspJson);
|
||||
await _ctx.WebSocket.SendAsync(Encoding.UTF8.GetBytes(rspJson), WebSocketMessageType.Text, true, _cts.Token);
|
||||
}
|
||||
|
||||
|
@ -36,10 +36,11 @@ public class NostrRelay<THandler> where THandler : INostrRelay
|
|||
if (!await _handler.AcceptConnection(_ctx)) return;
|
||||
|
||||
var offset = 0;
|
||||
var mem = MemoryPool<byte>.Shared.Rent(1024 * 1024);
|
||||
var mem = MemoryPool<byte>.Shared.Rent(1024 * 1024 * 32);
|
||||
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)];
|
||||
|
@ -47,49 +48,100 @@ public class NostrRelay<THandler> where THandler : INostrRelay
|
|||
if (!msg.EndOfMessage) continue;
|
||||
|
||||
var str = Encoding.UTF8.GetString(buff.Span);
|
||||
_logger.LogDebug("Got msg {msg}", str);
|
||||
if (str.StartsWith("[\"REQ\""))
|
||||
_logger.LogTrace("Got msg {msg}", str);
|
||||
try
|
||||
{
|
||||
var req = JsonConvert.DeserializeObject<NostrRequest>(str, NostrSerializer.Settings);
|
||||
if (req != default)
|
||||
if (str.StartsWith("[\"REQ\""))
|
||||
{
|
||||
await foreach (var ev in _handler.HandleRequest(_ctx, req))
|
||||
var req = JsonConvert.DeserializeObject<NostrInboundRequest>(str, NostrSerializer.Settings);
|
||||
if (req != default)
|
||||
{
|
||||
await WriteResponse(new NostrEventResponse()
|
||||
await foreach (var ev in _handler.HandleRequest(_ctx, req))
|
||||
{
|
||||
MessageType = "EVENT",
|
||||
Subscription = req.Subscription,
|
||||
Event = ev
|
||||
});
|
||||
await WriteResponse(new NostrEventResponse()
|
||||
{
|
||||
MessageType = "EVENT",
|
||||
Subscription = req.Subscription,
|
||||
Event = ev
|
||||
});
|
||||
}
|
||||
|
||||
var rsp = new NostrEoseResponse
|
||||
{
|
||||
MessageType = "EOSE",
|
||||
Subscription = req.Subscription
|
||||
};
|
||||
|
||||
await WriteResponse(rsp);
|
||||
}
|
||||
|
||||
var rsp = new NostrEoseResponse
|
||||
}
|
||||
else if (str.StartsWith("[\"EVENT\""))
|
||||
{
|
||||
var req = JsonConvert.DeserializeObject<NostrEventRequest>(str, NostrSerializer.Settings);
|
||||
if (req != default)
|
||||
{
|
||||
MessageType = "EOSE",
|
||||
Subscription = req.Subscription
|
||||
};
|
||||
if (!req.Event.IsSignatureValid())
|
||||
{
|
||||
var rsp = new NostrOkResponse()
|
||||
{
|
||||
MessageType = "OK",
|
||||
EventId = req.Event.Id,
|
||||
Accepted = false,
|
||||
Message = "invalid: sig check failed"
|
||||
};
|
||||
|
||||
await WriteResponse(rsp);
|
||||
await WriteResponse(rsp);
|
||||
}
|
||||
else
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (str.StartsWith("[\"EVENT\""))
|
||||
catch (Exception ex)
|
||||
{
|
||||
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);
|
||||
}
|
||||
_logger.LogWarning("Failed to process msg {error}", ex.Message);
|
||||
offset = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(ArrayConverter))]
|
||||
public class NostrInboundRequest
|
||||
{
|
||||
public NostrInboundRequest()
|
||||
{
|
||||
}
|
||||
|
||||
public NostrInboundRequest(string subscription, NostrFilter nostrFilter)
|
||||
{
|
||||
this.Subscription = subscription;
|
||||
this.NostrFilter = nostrFilter;
|
||||
}
|
||||
|
||||
public static implicit operator NostrRequest(NostrInboundRequest req)
|
||||
{
|
||||
return new(req.Subscription, req.NostrFilter);
|
||||
}
|
||||
|
||||
[ArrayProperty(0)]
|
||||
public string Type { get; init; } = "REQ";
|
||||
|
||||
[ArrayProperty(1)]
|
||||
public string Subscription { get; init; }
|
||||
|
||||
[ArrayProperty(2)]
|
||||
public NostrFilter NostrFilter { get; init; }
|
||||
}
|
||||
|
|
|
@ -0,0 +1,103 @@
|
|||
using Newtonsoft.Json;
|
||||
|
||||
namespace NostrRelay;
|
||||
|
||||
public class Fees
|
||||
{
|
||||
[JsonProperty("subscription")]
|
||||
public List<Subscription> Subscription { get; set; }
|
||||
}
|
||||
|
||||
public class Limitation
|
||||
{
|
||||
[JsonProperty("payment_required")]
|
||||
public bool? PaymentRequired { get; set; } = false;
|
||||
|
||||
[JsonProperty("max_message_length")]
|
||||
public int? MaxMessageLength { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of filter values in each subscription. Must be one or higher.
|
||||
/// </summary>
|
||||
[JsonProperty("max_filters")]
|
||||
public int? MaxFilters { get; init; } = 1;
|
||||
|
||||
[JsonProperty("max_event_tags")]
|
||||
public int? MaxEventTags { get; set; }
|
||||
|
||||
[JsonProperty("max_subscriptions")]
|
||||
public int? MaxSubscriptions { get; set; }
|
||||
|
||||
[JsonProperty("auth_required")]
|
||||
public bool? AuthRequired { get; set; }
|
||||
|
||||
[JsonProperty("restricted_writes")]
|
||||
public bool? RestrictedWrites { get; set; }
|
||||
}
|
||||
|
||||
public class RelayDocument
|
||||
{
|
||||
[JsonProperty("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonProperty("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonProperty("pubkey")]
|
||||
public string? Pubkey { get; set; }
|
||||
|
||||
[JsonProperty("software")]
|
||||
public string? Software { get; set; }
|
||||
|
||||
|
||||
[JsonProperty("supported_nips")]
|
||||
public List<int>? SupportedNips { get; set; }
|
||||
|
||||
[JsonProperty("version")]
|
||||
public string? Version { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A URL pointing to an image to be used as an icon for the relay
|
||||
/// </summary>
|
||||
[JsonProperty("icon")]
|
||||
public string? Icon { get; init; }
|
||||
|
||||
[JsonProperty("limitation")]
|
||||
public Limitation? Limitation { get; set; }
|
||||
|
||||
[JsonProperty("payments_url")]
|
||||
public string? PaymentsUrl { get; set; }
|
||||
|
||||
[JsonProperty("fees")]
|
||||
public Fees? Fees { get; set; }
|
||||
|
||||
[JsonProperty("relay_countries")]
|
||||
public List<string>? RelayCountries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of IETF language tags indicating the major languages spoken on the relay.
|
||||
/// </summary>
|
||||
[JsonProperty("language_tags")]
|
||||
public List<string>? LanguageTags { get; init; }
|
||||
|
||||
[JsonProperty("tags")]
|
||||
public List<string>? Tags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Link to a human-readable page which specifies the community policies for the relay
|
||||
/// </summary>
|
||||
[JsonProperty("posting_policy")]
|
||||
public string? PostingPolicy { get; set; }
|
||||
}
|
||||
|
||||
public class Subscription
|
||||
{
|
||||
[JsonProperty("amount")]
|
||||
public long? Amount { get; set; }
|
||||
|
||||
[JsonProperty("unit")]
|
||||
public string Unit { get; set; } = "msats";
|
||||
|
||||
[JsonProperty("period")]
|
||||
public int? Period { get; set; }
|
||||
}
|
|
@ -11,6 +11,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NostrRelay", "NostrRelay\NostrRelay.csproj", "{FBCF209E-9C58-45EB-BC59-99569B28D811}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FASTERlay", "FASTERlay\FASTERlay.csproj", "{A9BD2D9D-9BAA-463C-8FC1-027D86A17E91}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BasicTests", "BasicTests\BasicTests.csproj", "{1F9E8BD2-38B6-4FA4-9FAA-7376759C2CBB}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
@ -25,5 +29,13 @@ Global
|
|||
{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
|
||||
{A9BD2D9D-9BAA-463C-8FC1-027D86A17E91}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A9BD2D9D-9BAA-463C-8FC1-027D86A17E91}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A9BD2D9D-9BAA-463C-8FC1-027D86A17E91}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A9BD2D9D-9BAA-463C-8FC1-027D86A17E91}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1F9E8BD2-38B6-4FA4-9FAA-7376759C2CBB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1F9E8BD2-38B6-4FA4-9FAA-7376759C2CBB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1F9E8BD2-38B6-4FA4-9FAA-7376759C2CBB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1F9E8BD2-38B6-4FA4-9FAA-7376759C2CBB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
Loading…
Reference in New Issue