Init
This commit is contained in:
commit
53b187d250
|
@ -0,0 +1,25 @@
|
|||
**/.dockerignore
|
||||
**/.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/.project
|
||||
**/.settings
|
||||
**/.toolstarget
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/.idea
|
||||
**/*.*proj.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/azds.yaml
|
||||
**/bin
|
||||
**/charts
|
||||
**/docker-compose*
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
LICENSE
|
||||
README.md
|
|
@ -0,0 +1,5 @@
|
|||
bin/
|
||||
obj/
|
||||
/packages/
|
||||
riderModule.iml
|
||||
/_ReSharper.Caches/
|
|
@ -0,0 +1,21 @@
|
|||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NostrServices", "NostrServices\NostrServices.csproj", "{51CD83E4-2CEC-4852-B285-2327EF86C6E7}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{52604625-FC24-4D1E-A152-637973A967F9}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
docker-compose.yml = docker-compose.yml
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{51CD83E4-2CEC-4852-B285-2327EF86C6E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{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
|
||||
EndGlobalSection
|
||||
EndGlobal
|
|
@ -0,0 +1,6 @@
|
|||
namespace NostrServices;
|
||||
|
||||
public class Config
|
||||
{
|
||||
public string Redis { get; init; } = null!;
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nostr.Client.Messages;
|
||||
using NostrServices.Services;
|
||||
|
||||
namespace NostrServices.Controllers;
|
||||
|
||||
[Route("/api/v1/import")]
|
||||
public class ImportController : Controller
|
||||
{
|
||||
private readonly RedisStore _redisStore;
|
||||
|
||||
public ImportController(RedisStore redisStore)
|
||||
{
|
||||
_redisStore = redisStore;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Consumes("text/json", "application/json")]
|
||||
public async Task<IActionResult> ImportEvent([FromBody] NostrEvent ev)
|
||||
{
|
||||
if (await _redisStore.StoreEvent(CompactEvent.FromNostrEvent(ev)))
|
||||
{
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using AngleSharp;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Newtonsoft.Json;
|
||||
using ProtoBuf;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace NostrServices.Controllers;
|
||||
|
||||
[Route("/api/v1/preview")]
|
||||
public class LinkPreviewController : Controller
|
||||
{
|
||||
private readonly IDatabase _database;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly HttpClient _client;
|
||||
private readonly ILogger<LinkPreviewController> _logger;
|
||||
|
||||
public LinkPreviewController(HttpClient client, ILogger<LinkPreviewController> logger, IDatabase database, IMemoryCache memoryCache)
|
||||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
_database = database;
|
||||
_memoryCache = memoryCache;
|
||||
_client.DefaultRequestHeaders.Add("user-agent",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36 Snort/1.0 (LinkPreview; https://snort.social)");
|
||||
|
||||
_client.Timeout = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ResponseCache(Duration = 21600, VaryByQueryKeys = new[] {"url"}, Location = ResponseCacheLocation.Any)]
|
||||
public async Task<LinkPreviewData?> GetPreview([FromQuery] string url)
|
||||
{
|
||||
var urlHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(url.ToLower())));
|
||||
var key = $"link-preview:{urlHash}";
|
||||
var keyEmpty = $"{key}:empty";
|
||||
if (_memoryCache.Get<bool>(keyEmpty))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var cached = await _database.GetAsync<LinkPreviewData>(key);
|
||||
if (cached != default)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var cts = HttpContext.RequestAborted;
|
||||
try
|
||||
{
|
||||
var rsp = await _client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cts);
|
||||
if (rsp.IsSuccessStatusCode && rsp.Content.Headers.ContentType?.MediaType == "text/html")
|
||||
{
|
||||
var body = await rsp.Content.ReadAsStringAsync(cts);
|
||||
var config = Configuration.Default;
|
||||
var context = BrowsingContext.New(config);
|
||||
var doc = await context.OpenAsync(c => c.Content(body), cts);
|
||||
var ogTags = doc.Head?.QuerySelectorAll("meta[property*='og']").Select(a =>
|
||||
{
|
||||
var k = a.Attributes["property"]?.Value;
|
||||
var v = a.Attributes["content"]?.Value;
|
||||
if (!string.IsNullOrEmpty(k) && !string.IsNullOrEmpty(v))
|
||||
{
|
||||
return new KeyValuePair<string, string>(k, v);
|
||||
}
|
||||
|
||||
return default;
|
||||
}).Where(a => !string.IsNullOrEmpty(a.Key)).ToArray();
|
||||
|
||||
var obj = new LinkPreviewData
|
||||
{
|
||||
OgTags = ogTags?.Select(a => new KeyValuePair<string, string>(a.Key, a.Value)).ToList() ?? new(),
|
||||
Title = ogTags?.FirstOrDefault(a => a.Key.Equals("og:title")).Value
|
||||
?? doc.Head?.QuerySelector("title")?.TextContent,
|
||||
Description = ogTags?.FirstOrDefault(a => a.Key.Equals("og:description")).Value
|
||||
?? doc.Head?.QuerySelector("meta[name='description']")?.Attributes["content"]?.Value,
|
||||
Image = ogTags?.FirstOrDefault(a => a.Key.Equals("og:image")).Value
|
||||
};
|
||||
|
||||
await _database.SetAsync(key, obj, TimeSpan.FromDays(7));
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException hx)
|
||||
{
|
||||
_logger.LogWarning("{url} returned {status}", url, hx.StatusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch content at {url}", url);
|
||||
}
|
||||
|
||||
// cache no result in memory to avoid wasting time on repeated attempts
|
||||
_memoryCache.Set(keyEmpty, true, TimeSpan.FromMinutes(10));
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
[ProtoContract]
|
||||
public class LinkPreviewData
|
||||
{
|
||||
[ProtoMember(1)]
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public List<KeyValuePair<string, string>> OgTags { get; init; } = new();
|
||||
|
||||
[ProtoIgnore]
|
||||
[JsonProperty("og_tags")]
|
||||
public List<string[]> OgTagsJson => OgTags.Select(a => new[] {a.Key, a.Value}).ToList();
|
||||
|
||||
[ProtoMember(2)]
|
||||
[JsonProperty("title")]
|
||||
public string? Title { get; init; }
|
||||
|
||||
[ProtoMember(3)]
|
||||
[JsonProperty("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[ProtoMember(4)]
|
||||
[JsonProperty("image")]
|
||||
public string? Image { get; init; }
|
||||
}
|
|
@ -0,0 +1,235 @@
|
|||
using AngleSharp;
|
||||
using AngleSharp.Dom;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nostr.Client.Identifiers;
|
||||
using Nostr.Client.Messages;
|
||||
using Nostr.Client.Utils;
|
||||
using NostrServices.Services;
|
||||
using ProtoBuf;
|
||||
|
||||
namespace NostrServices.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Add OpenGraph tags to html documents
|
||||
/// </summary>
|
||||
[Route("/api/v1/opengraph")]
|
||||
public class OpenGraphController : Controller
|
||||
{
|
||||
private readonly ILogger<OpenGraphController> _logger;
|
||||
private readonly RedisStore _redisStore;
|
||||
|
||||
public OpenGraphController(ILogger<OpenGraphController> logger, RedisStore redisStore)
|
||||
{
|
||||
_logger = logger;
|
||||
_redisStore = redisStore;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inject opengraph tags into provided html
|
||||
/// </summary>
|
||||
/// <param name="id">Nostr identifier npub/note/nevent/naddr/nprofile</param>
|
||||
/// <param name="canonical">Url format for canonical tag <code>https://example.com/%s</code></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("{id}")]
|
||||
[Consumes("text/html")]
|
||||
[Produces("text/html")]
|
||||
public async Task<IActionResult> TagPage([FromRoute] string id, [FromQuery] string? canonical)
|
||||
{
|
||||
var cts = HttpContext.RequestAborted;
|
||||
using var sr = new StreamReader(Request.Body);
|
||||
var html = await sr.ReadToEndAsync(cts);
|
||||
|
||||
void AddCanonical(List<HeadElement> tags)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(canonical) && canonical.Contains("%s"))
|
||||
{
|
||||
var uc = new Uri(canonical.Replace("%s", id));
|
||||
tags.Add(new HeadElement("link", [
|
||||
new("rel", "canonical"),
|
||||
new("href", uc.ToString())
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
if (NostrIdentifierParser.TryParse(id, out var nid))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (nid!.Hrp is "nevent" or "note")
|
||||
{
|
||||
var ev = await _redisStore.GetEvent(nid);
|
||||
if (ev != default)
|
||||
{
|
||||
var tags = MetaTagsToElements(await GetEventTags(ev));
|
||||
AddCanonical(tags);
|
||||
|
||||
var doc = await InjectTags(html, tags);
|
||||
return Content(doc?.ToHtml() ?? html, "text/html");
|
||||
}
|
||||
}
|
||||
else if (nid.Hrp is "nprofile" or "npub")
|
||||
{
|
||||
var meta = await GetProfileMeta(id);
|
||||
if (meta != default)
|
||||
{
|
||||
var tags = MetaTagsToElements([
|
||||
new("og:type", "profile"),
|
||||
new("og:title", meta.Title ?? ""),
|
||||
new("og:description", meta.Description ?? ""),
|
||||
new("og:image", meta.Image ?? ""),
|
||||
new("og:profile:username", meta.Profile?.Name ?? "")
|
||||
]);
|
||||
|
||||
AddCanonical(tags);
|
||||
var doc = await InjectTags(html, tags);
|
||||
|
||||
return Content(doc?.ToHtml() ?? html, "text/html");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not TaskCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Failed to inject event tags: {Message}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return Content(html, "text/html");
|
||||
}
|
||||
|
||||
private async Task<List<KeyValuePair<string, string>>> GetEventTags(CompactEvent ev)
|
||||
{
|
||||
var ret = new List<KeyValuePair<string, string>>();
|
||||
|
||||
var profile = await _redisStore.GetProfile(ev.PubKey.ToHex());
|
||||
var name = profile?.Name ?? "Nostrich";
|
||||
switch (ev.Kind)
|
||||
{
|
||||
case (long)NostrKind.LiveEvent:
|
||||
{
|
||||
var host = ev.Tags.FirstOrDefault(a => a.Key is "p" && a.Values[3] is "host")?.Values[1] ?? ev.PubKey.ToHex();
|
||||
var hostProfile = await _redisStore.GetProfile(host);
|
||||
var hostName = hostProfile?.Name ?? profile?.Name ?? "Nostrich";
|
||||
var stream = ev.GetFirstTagValue("streaming") ?? ev.GetFirstTagValue("recording") ?? "";
|
||||
ret.AddRange(new KeyValuePair<string, string>[]
|
||||
{
|
||||
new("og:type", "video.other"),
|
||||
new("og:title", $"{hostName} is streaming"),
|
||||
new("og:description", ev.GetFirstTagValue("title") ?? ""),
|
||||
new("og:image",
|
||||
ev.GetFirstTagValue("image") ?? hostProfile?.Picture ?? $"https://robohash.v0l.io/{ev.PubKey.ToHex()}.png"),
|
||||
new("og:video", stream),
|
||||
new("og:video:secure_url", stream),
|
||||
new("og:video:type", "application/vnd.apple.mpegurl"),
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case 1_313:
|
||||
{
|
||||
var stream = ev.GetFirstTagValue("r")!;
|
||||
ret.AddRange(new KeyValuePair<string, string>[]
|
||||
{
|
||||
new("og:type", "video.other"),
|
||||
new("og:title", $"{name} created a clip"),
|
||||
new("og:description", ev.GetFirstTagValue("title") ?? ""),
|
||||
new("og:image",
|
||||
ev.GetFirstTagValue("image") ?? profile?.Picture ?? $"https://robohash.v0l.io/{ev.PubKey.ToHex()}.png"),
|
||||
new("og:video", stream),
|
||||
new("og:video:secure_url", stream),
|
||||
new("og:video:type", "video/mp4"),
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
const int maxLen = 160;
|
||||
var trimmedContent = ev.Content.Length > maxLen ? ev.Content[..maxLen] : ev.Content;
|
||||
var titleContent = $"{profile}: {trimmedContent}";
|
||||
ret.AddRange(new KeyValuePair<string, string>[]
|
||||
{
|
||||
new("og:type", "article"),
|
||||
new("og:title", titleContent),
|
||||
new("og:description", ""),
|
||||
new("og:image", profile?.Picture ?? $"https://robohash.v0l.io/{ev.PubKey.ToHex()}.png"),
|
||||
new("og:article:published_time", ev.Created.ToString("o")),
|
||||
new("og:article:author:username", profile?.Name ?? ""),
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
private async Task<CachedMeta?> GetProfileMeta(string id)
|
||||
{
|
||||
var profile = await _redisStore.GetProfile(id);
|
||||
var titleContent = $"Snort - {profile?.Name ?? "Nostrich"}'s Profile";
|
||||
var aboutContent = profile?.About?.Length > 160 ? profile.About[..160] : profile?.About ?? "";
|
||||
var imageContent = profile?.Picture ?? "https://snort.social/nostrich_512.png";
|
||||
return new CachedMeta
|
||||
{
|
||||
Title = titleContent,
|
||||
Description = aboutContent,
|
||||
Image = imageContent,
|
||||
Profile = profile
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IDocument?> InjectTags(string html, List<HeadElement> tags)
|
||||
{
|
||||
var config = Configuration.Default;
|
||||
var context = BrowsingContext.New(config);
|
||||
var doc = await context.OpenAsync(c => c.Content(html));
|
||||
|
||||
foreach (var ex in tags)
|
||||
{
|
||||
var tag = doc.CreateElement(ex.Element);
|
||||
foreach (var attr in ex.Attributes)
|
||||
{
|
||||
tag.SetAttribute(attr.Key, attr.Value);
|
||||
}
|
||||
|
||||
doc.Head?.AppendChild(tag);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
private List<HeadElement> MetaTagsToElements(List<KeyValuePair<string, string>> tags)
|
||||
{
|
||||
var ret = new List<HeadElement>();
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
ret.Add(new("meta", [
|
||||
new("property", tag.Key),
|
||||
new("content", tag.Value)
|
||||
]));
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
record HeadElement(string Element, List<KeyValuePair<string, string>> Attributes);
|
||||
}
|
||||
|
||||
[ProtoContract]
|
||||
class CachedMeta
|
||||
{
|
||||
[ProtoMember(1)]
|
||||
public string? Title { get; init; }
|
||||
|
||||
[ProtoMember(2)]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[ProtoMember(3)]
|
||||
public string? Image { get; init; }
|
||||
|
||||
[ProtoMember(4)]
|
||||
public CompactProfile? Profile { get; init; }
|
||||
|
||||
[ProtoMember(5)]
|
||||
public CompactEvent? Event { get; init; }
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace NostrServices.Database.Configuration;
|
||||
|
||||
public class RelayConfiguration : IEntityTypeConfiguration<Relay>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Relay> builder)
|
||||
{
|
||||
builder.HasKey(a => a.Id);
|
||||
builder.Property(a => a.Url)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(a => a.FirstSeen)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(a => a.LastSeen)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(a => a.IsAnyCast)
|
||||
.IsRequired();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace NostrServices.Database;
|
||||
|
||||
public class NostrServicesContext : DbContext
|
||||
{
|
||||
public NostrServicesContext()
|
||||
{
|
||||
}
|
||||
|
||||
public NostrServicesContext(DbContextOptions<NostrServicesContext> ctx) : base(ctx)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<Relay> Relays => Set<Relay>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(NostrServicesContext).Assembly);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
namespace NostrServices.Database;
|
||||
|
||||
public class Relay
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
public Uri Url { get; init; } = null!;
|
||||
|
||||
public DateTime FirstSeen { get; init; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime LastSeen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// If this relay uses any cast IPs which will naturally obscure its location
|
||||
/// </summary>
|
||||
public bool IsAnyCast { get; init; }
|
||||
}
|
|
@ -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 ["NostrServices/NostrServices.csproj", "NostrServices/"]
|
||||
RUN dotnet restore "NostrServices/NostrServices.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/NostrServices"
|
||||
RUN dotnet build "NostrServices.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "NostrServices.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "NostrServices.dll"]
|
|
@ -0,0 +1,54 @@
|
|||
using Nostr.Client.Identifiers;
|
||||
using Nostr.Client.Messages;
|
||||
using Nostr.Client.Utils;
|
||||
using NostrServices.Services;
|
||||
using ProtoBuf;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace NostrServices;
|
||||
|
||||
public static class Extensions
|
||||
{
|
||||
public static string? GetFirstTagValue(this CompactEvent ev, string key)
|
||||
{
|
||||
return ev.Tags.FirstOrDefault(a => a.Key == key)?.Values[0];
|
||||
}
|
||||
|
||||
public static async Task<bool> SetAsync<T>(this IDatabase db, RedisKey key, T val, TimeSpan? expire = null)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
Serializer.Serialize(ms, val);
|
||||
return await db.StringSetAsync(key, ms.ToArray(), expire);
|
||||
}
|
||||
|
||||
public static async Task<T?> GetAsync<T>(this IDatabase db, RedisKey key) where T : class
|
||||
{
|
||||
var data = await db.StringGetAsync(key);
|
||||
if (data is {HasValue: true, IsNullOrEmpty: false})
|
||||
{
|
||||
return Serializer.Deserialize<T>(((byte[])data!).AsSpan());
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
public static NostrIdentifier ToIdentifier(this NostrEvent ev)
|
||||
{
|
||||
if ((long)ev.Kind is >= 30_000 and < 40_000)
|
||||
{
|
||||
return new NostrAddressIdentifier(ev.Tags!.FindFirstTagValue("d")!, ev.Pubkey!, [], ev.Kind);
|
||||
}
|
||||
|
||||
return new NostrEventIdentifier(ev.Id!, ev.Pubkey, [], ev.Kind);
|
||||
}
|
||||
|
||||
public static NostrIdentifier ToIdentifier(this CompactEvent ev)
|
||||
{
|
||||
if (ev.Kind is >= 30_000 and < 40_000)
|
||||
{
|
||||
return new NostrAddressIdentifier(ev.GetFirstTagValue("d")!, ev.PubKey.ToHex(), [], (NostrKind)ev.Kind);
|
||||
}
|
||||
|
||||
return new NostrEventIdentifier(ev.Id.ToHex(), ev.PubKey.ToHex(), [], (NostrKind)ev.Kind);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NostrServices.Database;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NostrServices.Migrations
|
||||
{
|
||||
[DbContext(typeof(NostrServicesContext))]
|
||||
[Migration("20240110094022_Relay")]
|
||||
partial class Relay
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.1")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("NostrServices.Database.Relay", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("FirstSeen")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsAnyCast")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime>("LastSeen")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Relays");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NostrServices.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Relay : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Relays",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Url = table.Column<string>(type: "text", nullable: false),
|
||||
FirstSeen = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
LastSeen = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
IsAnyCast = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Relays", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Relays");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
<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>
|
||||
<PackageReference Include="AngleSharp" Version="1.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Nostr.Client" Version="2.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
|
||||
<PackageReference Include="protobuf-net" Version="3.2.30" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.7.10" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
|
@ -0,0 +1,99 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using Nostr.Client.Client;
|
||||
using NostrServices.Database;
|
||||
using NostrServices.Services;
|
||||
using NostrServices.Services.EventHandlers;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace NostrServices;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var config = builder.Configuration.GetSection("Config").Get<Config>()!;
|
||||
|
||||
var cx = await ConnectionMultiplexer.ConnectAsync(config.Redis);
|
||||
builder.Services.AddSingleton(cx);
|
||||
|
||||
//builder.Services.AddSingleton<IGeoIP2DatabaseReader>(_ => new DatabaseReader(cfg.GeoIpDatabase));
|
||||
builder.Services.AddSingleton<NostrMultiWebsocketClient>();
|
||||
builder.Services.AddSingleton<INostrClient>(s => s.GetRequiredService<NostrMultiWebsocketClient>());
|
||||
builder.Services.AddSingleton<NostrListener>();
|
||||
|
||||
builder.Services.AddTransient<IDatabase>(svc => svc.GetRequiredService<ConnectionMultiplexer>().GetDatabase());
|
||||
builder.Services.AddTransient<ISubscriber>(svc => svc.GetRequiredService<ConnectionMultiplexer>().GetSubscriber());
|
||||
builder.Services.AddTransient<RedisStore>();
|
||||
builder.Services.AddNostrEventHandlers();
|
||||
|
||||
builder.Services.AddControllers().AddNewtonsoftJson(opt =>
|
||||
{
|
||||
opt.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
|
||||
opt.SerializerSettings.Formatting = Formatting.None;
|
||||
opt.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
|
||||
opt.SerializerSettings.ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor;
|
||||
opt.SerializerSettings.Converters =
|
||||
[
|
||||
new UnixDateTimeConverter()
|
||||
];
|
||||
|
||||
opt.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
|
||||
});
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddResponseCaching();
|
||||
|
||||
builder.Services.AddHostedService<NostrListener.NostrListenerLifetime>();
|
||||
|
||||
ConfigureDb(builder.Services, builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<NostrServicesContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
app.UseResponseCaching();
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
app.UseCors(o =>
|
||||
{
|
||||
o.AllowAnyOrigin();
|
||||
o.AllowAnyHeader();
|
||||
o.AllowAnyMethod();
|
||||
});
|
||||
|
||||
app.UseRouting();
|
||||
app.MapControllers();
|
||||
|
||||
await app.RunAsync();
|
||||
}
|
||||
|
||||
private static void ConfigureDb(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDbContext<NostrServicesContext>(o =>
|
||||
o.UseNpgsql(configuration.GetConnectionString("Database")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dummy method for EF core migrations
|
||||
/// </summary>
|
||||
/// <param name="args"></param>
|
||||
/// <returns></returns>
|
||||
// ReSharper disable once UnusedMember.Global
|
||||
public static IHostBuilder CreateHostBuilder(string[] args)
|
||||
{
|
||||
var dummyHost = Host.CreateDefaultBuilder(args);
|
||||
dummyHost.ConfigureServices((ctx, svc) => { ConfigureDb(svc, ctx.Configuration); });
|
||||
|
||||
return dummyHost;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5228",
|
||||
"launchUrl": "http://localhost:5228/swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
using Nostr.Client.Messages;
|
||||
|
||||
namespace NostrServices.Services.EventHandlers;
|
||||
|
||||
public interface IEventHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Handle an event from the network
|
||||
/// </summary>
|
||||
/// <param name="ev"></param>
|
||||
/// <returns></returns>
|
||||
ValueTask HandleEvent(NostrEvent ev);
|
||||
}
|
||||
|
||||
|
||||
public static class EventHandlerStartup
|
||||
{
|
||||
public static void AddNostrEventHandlers(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IEventHandler, RedisEventCache>();
|
||||
services.AddSingleton<IEventHandler, RedisStreamPublisher>();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
using Nostr.Client.Messages;
|
||||
|
||||
namespace NostrServices.Services.EventHandlers;
|
||||
|
||||
public class RedisEventCache : IEventHandler
|
||||
{
|
||||
private readonly RedisStore _redisStore;
|
||||
|
||||
public RedisEventCache(RedisStore redisStore)
|
||||
{
|
||||
_redisStore = redisStore;
|
||||
}
|
||||
|
||||
public async ValueTask HandleEvent(NostrEvent ev)
|
||||
{
|
||||
if (ev.Kind is NostrKind.Metadata)
|
||||
{
|
||||
var p = CompactProfile.FromNostrEvent(ev);
|
||||
if (p != default)
|
||||
{
|
||||
await _redisStore.StoreProfile(p);
|
||||
}
|
||||
}
|
||||
else if (ev.Kind is NostrKind.ShortTextNote or NostrKind.LongFormContent or NostrKind.LiveEvent or NostrKind.LiveChatMessage)
|
||||
{
|
||||
await _redisStore.StoreEvent(CompactEvent.FromNostrEvent(ev));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
using Newtonsoft.Json;
|
||||
using Nostr.Client.Json;
|
||||
using Nostr.Client.Messages;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace NostrServices.Services.EventHandlers;
|
||||
|
||||
public class RedisStreamPublisher : IEventHandler
|
||||
{
|
||||
private readonly IDatabase _redis;
|
||||
|
||||
public RedisStreamPublisher(IDatabase redis)
|
||||
{
|
||||
_redis = redis;
|
||||
}
|
||||
|
||||
public async ValueTask HandleEvent(NostrEvent ev)
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(ev, NostrSerializer.Settings);
|
||||
await _redis.PublishAsync("event-stream", json);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
using System.Net.WebSockets;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Nostr.Client.Client;
|
||||
using Nostr.Client.Communicator;
|
||||
using Nostr.Client.Requests;
|
||||
using NostrServices.Database;
|
||||
using Websocket.Client.Models;
|
||||
|
||||
namespace NostrServices.Services;
|
||||
|
||||
public class NostrListener : IDisposable
|
||||
{
|
||||
private readonly NostrMultiWebsocketClient _client;
|
||||
private readonly ILogger<NostrListener> _logger;
|
||||
|
||||
private readonly Dictionary<string, NostrFilter> _subscriptionToFilter = new();
|
||||
|
||||
public NostrListener(NostrMultiWebsocketClient client, ILogger<NostrListener> logger)
|
||||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public NostrClientStreams Streams => _client.Streams;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
|
||||
public void RegisterFilter(string subscription, NostrFilter filter)
|
||||
{
|
||||
_subscriptionToFilter[subscription] = filter;
|
||||
_client.Send(new NostrRequest(subscription, filter));
|
||||
}
|
||||
|
||||
private void Stop()
|
||||
{
|
||||
foreach (var nostrWebsocketClient in _client.Clients)
|
||||
{
|
||||
_ = nostrWebsocketClient.Communicator.Stop(WebSocketCloseStatus.NormalClosure, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddCommunicators(List<Uri> relays)
|
||||
{
|
||||
var missing = relays.Where(a => _client.FindClient(ClientName(a)) == null);
|
||||
var newComm = missing.Select(CreateCommunicator);
|
||||
foreach (var comm in newComm)
|
||||
{
|
||||
_client.RegisterCommunicator(comm);
|
||||
comm.Start();
|
||||
}
|
||||
}
|
||||
|
||||
private INostrCommunicator CreateCommunicator(Uri uri)
|
||||
{
|
||||
var comm = new NostrWebsocketCommunicator(uri, () =>
|
||||
{
|
||||
var client = new ClientWebSocket();
|
||||
client.Options.SetRequestHeader("Origin", "https://nostr-api.v0l.io/swagger");
|
||||
client.Options.SetRequestHeader("User-Agent", $"v0l/NostrServices ({Assembly.GetExecutingAssembly().GetName().Version})");
|
||||
return client;
|
||||
});
|
||||
|
||||
comm.Name = ClientName(uri);
|
||||
comm.ReconnectTimeout = null; //TimeSpan.FromSeconds(30);
|
||||
comm.ErrorReconnectTimeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
comm.ReconnectionHappened.Subscribe(info => OnCommunicatorReconnection(info, comm.Name));
|
||||
comm.DisconnectionHappened.Subscribe(info =>
|
||||
_logger.LogWarning("[{relay}] Disconnected, type: {type}, reason: {reason}", comm.Name, info.Type, info.CloseStatus));
|
||||
|
||||
return comm;
|
||||
}
|
||||
|
||||
private string ClientName(Uri u) => $"{u.Host}{u.AbsolutePath}";
|
||||
|
||||
private void OnCommunicatorReconnection(ReconnectionInfo info, string communicatorName)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("[{relay}] Reconnected, sending Nostr filters ({filterCount})", communicatorName,
|
||||
_subscriptionToFilter.Count);
|
||||
|
||||
var client = _client.FindClient(communicatorName);
|
||||
if (client == null)
|
||||
{
|
||||
_logger.LogWarning("[{relay}] Cannot find client", communicatorName);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (sub, filter) in _subscriptionToFilter)
|
||||
{
|
||||
client.Send(new NostrRequest(sub, filter));
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "[{relay}] Failed to process reconnection, error: {error}", communicatorName, e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public class NostrListenerLifetime : BackgroundService
|
||||
{
|
||||
private readonly NostrListener _nostrListener;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
|
||||
public NostrListenerLifetime(NostrListener nostrListener, IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
_nostrListener = nostrListener;
|
||||
_scopeFactory = scopeFactory;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
await using var db = scope.ServiceProvider.GetRequiredService<NostrServicesContext>();
|
||||
|
||||
var relays = await db.Relays.Select(a => a.Url).ToListAsync(cancellationToken: stoppingToken);
|
||||
_nostrListener.AddCommunicators(relays);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(10), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.StopAsync(cancellationToken);
|
||||
_nostrListener.Stop();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,155 @@
|
|||
using NBitcoin;
|
||||
using Nostr.Client.Identifiers;
|
||||
using Nostr.Client.Json;
|
||||
using Nostr.Client.Messages;
|
||||
using Nostr.Client.Messages.Metadata;
|
||||
using Nostr.Client.Utils;
|
||||
using ProtoBuf;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace NostrServices.Services;
|
||||
|
||||
public class RedisStore
|
||||
{
|
||||
private static readonly TimeSpan DefaultExpire = TimeSpan.FromDays(30);
|
||||
private readonly IDatabase _database;
|
||||
|
||||
public RedisStore(IDatabase database)
|
||||
{
|
||||
_database = database;
|
||||
}
|
||||
|
||||
public async Task<bool> StoreEvent(CompactEvent ev, TimeSpan? expiry = null)
|
||||
{
|
||||
return await _database.SetAsync(EventKey(ev.ToIdentifier()), ev, expiry ?? DefaultExpire);
|
||||
}
|
||||
|
||||
public async Task<CompactEvent?> GetEvent(NostrIdentifier id)
|
||||
{
|
||||
return await _database.GetAsync<CompactEvent>(EventKey(id));
|
||||
}
|
||||
|
||||
public async Task StoreProfile(CompactProfile meta, TimeSpan? expiry = null)
|
||||
{
|
||||
await _database.SetAsync(ProfileKey(meta.PubKey.ToHex()), meta, expiry ?? DefaultExpire);
|
||||
}
|
||||
|
||||
public async Task<CompactProfile?> GetProfile(string id)
|
||||
{
|
||||
return await _database.GetAsync<CompactProfile>(ProfileKey(id));
|
||||
}
|
||||
|
||||
private string EventKey(NostrIdentifier id) => $"event:{id}";
|
||||
private string ProfileKey(string id) => $"profile:{id}";
|
||||
}
|
||||
|
||||
[ProtoContract]
|
||||
public class CompactEventTag
|
||||
{
|
||||
[ProtoMember(1)]
|
||||
public string Key { get; init; } = null!;
|
||||
|
||||
[ProtoMember(2)]
|
||||
public List<string> Values { get; init; } = null!;
|
||||
}
|
||||
|
||||
[ProtoContract]
|
||||
public class CompactEvent
|
||||
{
|
||||
[ProtoMember(1)]
|
||||
public byte[] Id { get; init; } = null!;
|
||||
|
||||
[ProtoMember(2)]
|
||||
public byte[] PubKey { get; init; } = null!;
|
||||
|
||||
[ProtoMember(3)]
|
||||
public long Created { get; init; }
|
||||
|
||||
[ProtoMember(4)]
|
||||
public string Content { get; init; } = null!;
|
||||
|
||||
[ProtoMember(5)]
|
||||
public List<CompactEventTag> Tags { get; init; } = new();
|
||||
|
||||
[ProtoMember(6)]
|
||||
public byte[] Sig { get; init; } = null!;
|
||||
|
||||
[ProtoMember(7)]
|
||||
public long Kind { get; init; }
|
||||
|
||||
public static CompactEvent FromNostrEvent(NostrEvent ev)
|
||||
{
|
||||
return new CompactEvent
|
||||
{
|
||||
Id = Convert.FromHexString(ev.Id!),
|
||||
PubKey = Convert.FromHexString(ev.Pubkey!),
|
||||
Kind = (long)ev.Kind,
|
||||
Created = ev.CreatedAt!.Value.ToUnixTimestamp(),
|
||||
Content = ev.Content!,
|
||||
Tags = ev.Tags!.Select(a => new CompactEventTag
|
||||
{
|
||||
Key = a.TagIdentifier!,
|
||||
Values = a.AdditionalData.Cast<string>().ToList()
|
||||
}).ToList(),
|
||||
Sig = Convert.FromHexString(ev.Sig!)
|
||||
};
|
||||
}
|
||||
|
||||
public NostrEvent ToNostrEvent()
|
||||
{
|
||||
return new NostrEvent()
|
||||
{
|
||||
Id = Id.ToHex(),
|
||||
Pubkey = PubKey.ToHex(),
|
||||
CreatedAt = DateTimeOffset.FromUnixTimeSeconds(Created).UtcDateTime,
|
||||
Content = Content,
|
||||
Tags = new(Tags.Select(a => new NostrEventTag(a.Key, a.Values.ToArray()))),
|
||||
Sig = Sig.ToHex()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
[ProtoContract]
|
||||
public class CompactProfile
|
||||
{
|
||||
[ProtoMember(1)]
|
||||
public byte[] PubKey { get; init; } = null!;
|
||||
|
||||
[ProtoMember(2)]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[ProtoMember(3)]
|
||||
public string? About { get; init; }
|
||||
|
||||
[ProtoMember(4)]
|
||||
public string? Picture { get; init; }
|
||||
|
||||
[ProtoMember(5)]
|
||||
public string? Nip05 { get; init; }
|
||||
|
||||
[ProtoMember(6)]
|
||||
public string? Lud16 { get; init; }
|
||||
|
||||
[ProtoMember(7)]
|
||||
public string? Banner { get; init; }
|
||||
|
||||
public static CompactProfile? FromNostrEvent(NostrEvent ev)
|
||||
{
|
||||
var meta = NostrJson.Deserialize<NostrMetadata>(ev.Content);
|
||||
if (meta != default)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
PubKey = Convert.FromHexString(ev.Pubkey!),
|
||||
Name = meta.Name,
|
||||
About = meta.About,
|
||||
Picture = meta.Picture,
|
||||
Nip05 = meta.Nip05,
|
||||
Lud16 = meta.Lud16,
|
||||
Banner = meta.Banner
|
||||
};
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
using System.Threading.Tasks.Dataflow;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Newtonsoft.Json;
|
||||
using Nostr.Client.Json;
|
||||
using Nostr.Client.Messages;
|
||||
using Nostr.Client.Requests;
|
||||
using NostrServices.Services.EventHandlers;
|
||||
|
||||
namespace NostrServices.Services;
|
||||
|
||||
public class RelayListener : IHostedService
|
||||
{
|
||||
private readonly NostrListener _nostr;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly BufferBlock<NostrEvent> _queue = new();
|
||||
private readonly ILogger<RelayListener> _logger;
|
||||
private readonly IEnumerable<IEventHandler> _handlers;
|
||||
|
||||
public RelayListener(NostrListener nostr, IMemoryCache cache, ILogger<RelayListener> logger, IEnumerable<IEventHandler> handlers)
|
||||
{
|
||||
_nostr = nostr;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
_handlers = handlers;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
const string subId = "notifications";
|
||||
var evs = _nostr.Streams.EventStream.Subscribe(a =>
|
||||
{
|
||||
if (a.Subscription == subId)
|
||||
{
|
||||
_queue.Post(a.Event!);
|
||||
}
|
||||
});
|
||||
|
||||
_nostr.RegisterFilter(subId, new NostrFilter()
|
||||
{
|
||||
Kinds = new[]
|
||||
{
|
||||
NostrKind.Metadata,
|
||||
NostrKind.Reaction,
|
||||
NostrKind.Zap,
|
||||
NostrKind.EncryptedDm,
|
||||
NostrKind.GenericRepost,
|
||||
NostrKind.LiveEvent,
|
||||
NostrKind.LiveChatMessage,
|
||||
NostrKind.ShortTextNote,
|
||||
NostrKind.LongFormContent
|
||||
},
|
||||
Limit = 0
|
||||
});
|
||||
|
||||
_cts.Token.Register(() => { evs.Dispose(); });
|
||||
_ = HandleEvents();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_cts.Cancel();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task HandleEvents()
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var msg = await _queue.ReceiveAsync();
|
||||
if (Math.Abs((DateTime.UtcNow - msg.CreatedAt!.Value).TotalMinutes) > 10)
|
||||
{
|
||||
// skip events older/newer than 10mins from now
|
||||
continue;
|
||||
}
|
||||
|
||||
var seenKey = $"seen-{msg.Id}";
|
||||
var hasSeen = _cache.Get(seenKey);
|
||||
if (hasSeen != default) continue;
|
||||
|
||||
_logger.LogDebug(JsonConvert.SerializeObject(msg, NostrSerializer.Settings));
|
||||
_cache.Set(seenKey, true, TimeSpan.FromMinutes(10));
|
||||
|
||||
foreach (var eventHandler in _handlers)
|
||||
{
|
||||
try
|
||||
{
|
||||
await eventHandler.HandleEvent(msg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Handler failed {msg}", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in handle events {msg}", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"Database": "User ID=postgres;Password=postgres;Database=nostr-services;Pooling=true;Host=127.0.0.1:25438"
|
||||
},
|
||||
"Config": {
|
||||
"Redis": "localhost:6377"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
services:
|
||||
postgres:
|
||||
image: "postgres:15"
|
||||
ports:
|
||||
- "25438:5432"
|
||||
environment:
|
||||
- "POSTGRES_DB=nostr-services"
|
||||
- "POSTGRES_HOST_AUTH_METHOD=trust"
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- "6377:6379"
|
Loading…
Reference in New Issue