From bcaa32afb193fa821b418f967ad025e8075ba24e Mon Sep 17 00:00:00 2001 From: Kieran Date: Fri, 30 Jun 2023 14:08:15 +0100 Subject: [PATCH] Init --- .dockerignore | 25 ++++ .drone.yml | 19 +++ .gitignore | 3 + NostrStreamer.sln | 16 ++ NostrStreamer/ApiModel/Account.cs | 12 ++ NostrStreamer/Config.cs | 12 ++ .../Controllers/AccountController.cs | 60 ++++++++ NostrStreamer/Controllers/SRSController.cs | 95 ++++++++++++ .../Configuration/PaymentsConfiguration.cs | 17 +++ .../Configuration/UserConfiguration.cs | 18 +++ NostrStreamer/Database/Payment.cs | 10 ++ NostrStreamer/Database/StreamerContext.cs | 26 ++++ NostrStreamer/Database/User.cs | 41 ++++++ NostrStreamer/Dockerfile | 20 +++ NostrStreamer/Extensions.cs | 8 + .../20230630094322_Init.Designer.cs | 78 ++++++++++ .../Migrations/20230630094322_Init.cs | 55 +++++++ .../StreamerContextModelSnapshot.cs | 75 ++++++++++ NostrStreamer/NostrAuth.cs | 90 ++++++++++++ NostrStreamer/NostrStreamer.csproj | 35 +++++ NostrStreamer/Program.cs | 61 ++++++++ NostrStreamer/Properties/launchSettings.json | 13 ++ NostrStreamer/Services/NostrListener.cs | 137 ++++++++++++++++++ NostrStreamer/Services/StreamManager.cs | 112 ++++++++++++++ NostrStreamer/appsettings.Development.json | 8 + NostrStreamer/appsettings.json | 20 +++ docker-compose.yaml | 19 +++ docker/srs.conf | 24 +++ 28 files changed, 1109 insertions(+) create mode 100644 .dockerignore create mode 100644 .drone.yml create mode 100644 .gitignore create mode 100644 NostrStreamer.sln create mode 100644 NostrStreamer/ApiModel/Account.cs create mode 100644 NostrStreamer/Config.cs create mode 100644 NostrStreamer/Controllers/AccountController.cs create mode 100644 NostrStreamer/Controllers/SRSController.cs create mode 100644 NostrStreamer/Database/Configuration/PaymentsConfiguration.cs create mode 100644 NostrStreamer/Database/Configuration/UserConfiguration.cs create mode 100644 NostrStreamer/Database/Payment.cs create mode 100644 NostrStreamer/Database/StreamerContext.cs create mode 100644 NostrStreamer/Database/User.cs create mode 100644 NostrStreamer/Dockerfile create mode 100644 NostrStreamer/Extensions.cs create mode 100644 NostrStreamer/Migrations/20230630094322_Init.Designer.cs create mode 100644 NostrStreamer/Migrations/20230630094322_Init.cs create mode 100644 NostrStreamer/Migrations/StreamerContextModelSnapshot.cs create mode 100644 NostrStreamer/NostrAuth.cs create mode 100644 NostrStreamer/NostrStreamer.csproj create mode 100644 NostrStreamer/Program.cs create mode 100644 NostrStreamer/Properties/launchSettings.json create mode 100644 NostrStreamer/Services/NostrListener.cs create mode 100644 NostrStreamer/Services/StreamManager.cs create mode 100644 NostrStreamer/appsettings.Development.json create mode 100644 NostrStreamer/appsettings.json create mode 100644 docker-compose.yaml create mode 100644 docker/srs.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd967fc --- /dev/null +++ b/.dockerignore @@ -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 \ No newline at end of file diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..d6cb868 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,19 @@ +--- +kind: pipeline +type: kubernetes +name: default + +metadata: + namespace: git + +steps: + - name: build + image: r.j3ss.co/img + privileged: true + environment: + TOKEN: + from_secret: registry_token + commands: + - img login -u registry -p $TOKEN registry.v0l.io + - cd NostrStreamer && img build -t registry.v0l.io/nostr-streamer:latest . + - img push registry.v0l.io/nostr-streamer:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e945e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +.idea/ diff --git a/NostrStreamer.sln b/NostrStreamer.sln new file mode 100644 index 0000000..ff88036 --- /dev/null +++ b/NostrStreamer.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NostrStreamer", "NostrStreamer\NostrStreamer.csproj", "{883F6FF4-B1BA-48F7-82BF-9A0851051006}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {883F6FF4-B1BA-48F7-82BF-9A0851051006}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {883F6FF4-B1BA-48F7-82BF-9A0851051006}.Debug|Any CPU.Build.0 = Debug|Any CPU + {883F6FF4-B1BA-48F7-82BF-9A0851051006}.Release|Any CPU.ActiveCfg = Release|Any CPU + {883F6FF4-B1BA-48F7-82BF-9A0851051006}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/NostrStreamer/ApiModel/Account.cs b/NostrStreamer/ApiModel/Account.cs new file mode 100644 index 0000000..7ce7760 --- /dev/null +++ b/NostrStreamer/ApiModel/Account.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace NostrStreamer.ApiModel; + +public class Account +{ + [JsonProperty("url")] + public string Url { get; init; } = null!; + + [JsonProperty("key")] + public string Key { get; init; } = null!; +} diff --git a/NostrStreamer/Config.cs b/NostrStreamer/Config.cs new file mode 100644 index 0000000..d0b89eb --- /dev/null +++ b/NostrStreamer/Config.cs @@ -0,0 +1,12 @@ +namespace NostrStreamer; + +public class Config +{ + public Uri SrsPublicHost { get; init; } = null!; + public string App { get; init; } = null!; + + public Uri SrsApi { get; init; } = null!; + + public string PrivateKey { get; init; } = null!; + public string[] Relays { get; init; } = Array.Empty(); +} \ No newline at end of file diff --git a/NostrStreamer/Controllers/AccountController.cs b/NostrStreamer/Controllers/AccountController.cs new file mode 100644 index 0000000..64ecbf1 --- /dev/null +++ b/NostrStreamer/Controllers/AccountController.cs @@ -0,0 +1,60 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NostrStreamer.ApiModel; +using NostrStreamer.Database; + +namespace NostrStreamer.Controllers; + +[Authorize] +[Route("/api/account")] +public class AccountController : Controller +{ + private readonly StreamerContext _db; + private readonly Config _config; + + public AccountController(StreamerContext db, Config config) + { + _db = db; + _config = config; + } + + [HttpGet] + public async Task GetAccount() + { + var user = await GetUser(); + if (user == default) + { + var pk = GetPubKey(); + user = new() + { + PubKey = pk, + Balance = 0, + StreamKey = Guid.NewGuid().ToString() + }; + + _db.Users.Add(user); + + await _db.SaveChangesAsync(); + } + + return Json(new Account + { + Url = $"rtmp://{_config.SrsPublicHost.Host}/${_config.App}", + Key = user.StreamKey + }); + } + + private async Task GetUser() + { + var pk = GetPubKey(); + return await _db.Users.FirstOrDefaultAsync(a => a.PubKey == pk); + } + + private string GetPubKey() + { + var claim = HttpContext.User.Claims.FirstOrDefault(a => a.Type == ClaimTypes.Name); + return claim!.Value; + } +} diff --git a/NostrStreamer/Controllers/SRSController.cs b/NostrStreamer/Controllers/SRSController.cs new file mode 100644 index 0000000..f15213e --- /dev/null +++ b/NostrStreamer/Controllers/SRSController.cs @@ -0,0 +1,95 @@ +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using NostrStreamer.Services; + +namespace NostrStreamer.Controllers; + +[Route("/api/srs")] +public class SrsController : Controller +{ + private readonly ILogger _logger; + private readonly Config _config; + private readonly StreamManager _streamManager; + + public SrsController(ILogger logger, Config config, StreamManager streamManager) + { + _logger = logger; + _config = config; + _streamManager = streamManager; + } + + [HttpPost] + public async Task OnStream([FromBody] SrsHook req) + { + _logger.LogInformation("OnStream: {obj}", JsonConvert.SerializeObject(req)); + try + { + if (string.IsNullOrEmpty(req.Stream) || string.IsNullOrEmpty(req.App) || string.IsNullOrEmpty(req.Stream) || + !req.App.Equals(_config.App, StringComparison.InvariantCultureIgnoreCase)) + { + return new() + { + Code = 2 // invalid request + }; + } + + if (req.Action == "on_publish") + { + await _streamManager.StreamStarted(req.Stream); + return new(); + } + if (req.Action == "on_unpublish") + { + await _streamManager.StreamStopped(req.Stream); + return new(); + } + if (req.Action == "on_hls" && req.Duration.HasValue) + { + await _streamManager.ConsumeQuota(req.Stream, req.Duration.Value); + return new(); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to start stream"); + } + + return new() + { + Code = 1 // generic error + }; + } +} + +public class SrsHookReply +{ + [JsonProperty("code")] + public int Code { get; init; } +} + +public class SrsHook +{ + [JsonProperty("action")] + public string? Action { get; set; } + + [JsonProperty("client_id")] + public string? ClientId { get; set; } + + [JsonProperty("ip")] + public string? Ip { get; set; } + + [JsonProperty("vhost")] + public string? Vhost { get; set; } + + [JsonProperty("app")] + public string? App { get; set; } + + [JsonProperty("stream")] + public string? Stream { get; set; } + + [JsonProperty("param")] + public string? Param { get; init; } + + [JsonProperty("duration")] + public double? Duration { get; init; } +} diff --git a/NostrStreamer/Database/Configuration/PaymentsConfiguration.cs b/NostrStreamer/Database/Configuration/PaymentsConfiguration.cs new file mode 100644 index 0000000..803a19b --- /dev/null +++ b/NostrStreamer/Database/Configuration/PaymentsConfiguration.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace NostrStreamer.Database.Configuration; + +public class PaymentsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(a => a.PubKey); + builder.Property(a => a.Invoice) + .IsRequired(); + + builder.Property(a => a.IsPaid) + .IsRequired(); + } +} diff --git a/NostrStreamer/Database/Configuration/UserConfiguration.cs b/NostrStreamer/Database/Configuration/UserConfiguration.cs new file mode 100644 index 0000000..795b290 --- /dev/null +++ b/NostrStreamer/Database/Configuration/UserConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace NostrStreamer.Database.Configuration; + +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(a => a.PubKey); + builder.Property(a => a.StreamKey) + .IsRequired(); + + builder.Property(a => a.Event); + builder.Property(a => a.Balance) + .IsRequired(); + } +} diff --git a/NostrStreamer/Database/Payment.cs b/NostrStreamer/Database/Payment.cs new file mode 100644 index 0000000..4772f72 --- /dev/null +++ b/NostrStreamer/Database/Payment.cs @@ -0,0 +1,10 @@ +namespace NostrStreamer.Database; + +public class Payment +{ + public string PubKey { get; init; } = null!; + + public string Invoice { get; init; } = null!; + + public bool IsPaid { get; init; } +} diff --git a/NostrStreamer/Database/StreamerContext.cs b/NostrStreamer/Database/StreamerContext.cs new file mode 100644 index 0000000..aa8e753 --- /dev/null +++ b/NostrStreamer/Database/StreamerContext.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; + +namespace NostrStreamer.Database; + +public class StreamerContext : DbContext +{ + public StreamerContext() + { + + } + + public StreamerContext(DbContextOptions ctx) : base(ctx) + { + + } + + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(StreamerContext).Assembly); + } + + public DbSet Users => Set(); + + public DbSet Payments => Set(); +} diff --git a/NostrStreamer/Database/User.cs b/NostrStreamer/Database/User.cs new file mode 100644 index 0000000..4e1ea7a --- /dev/null +++ b/NostrStreamer/Database/User.cs @@ -0,0 +1,41 @@ +namespace NostrStreamer.Database; + +public class User +{ + public string PubKey { get; init; } = null!; + + /// + /// Stream key + /// + public string StreamKey { get; init; } = null!; + + /// + /// Most recent nostr event published + /// + public string? Event { get; init; } + + /// + /// Sats balance + /// + public long Balance { get; init; } + + /// + /// Stream title + /// + public string? Title { get; init; } + + /// + /// Stream summary + /// + public string? Summary { get; init; } + + /// + /// Stream cover image + /// + public string? Image { get; init; } + + /// + /// Comma seperated tags + /// + public string? Tags { get; init; } +} diff --git a/NostrStreamer/Dockerfile b/NostrStreamer/Dockerfile new file mode 100644 index 0000000..c19d75d --- /dev/null +++ b/NostrStreamer/Dockerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build +WORKDIR /src +COPY ["NostrStreamer/NostrStreamer.csproj", "NostrStreamer/"] +RUN dotnet restore "NostrStreamer/NostrStreamer.csproj" +COPY . . +WORKDIR "/src/NostrStreamer" +RUN dotnet build "NostrStreamer.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "NostrStreamer.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "NostrStreamer.dll"] diff --git a/NostrStreamer/Extensions.cs b/NostrStreamer/Extensions.cs new file mode 100644 index 0000000..1ec02ed --- /dev/null +++ b/NostrStreamer/Extensions.cs @@ -0,0 +1,8 @@ +using Nostr.Client.Messages; +using NostrStreamer.Database; + +namespace NostrStreamer; + +public static class Extensions +{ +} diff --git a/NostrStreamer/Migrations/20230630094322_Init.Designer.cs b/NostrStreamer/Migrations/20230630094322_Init.Designer.cs new file mode 100644 index 0000000..2a6a9f3 --- /dev/null +++ b/NostrStreamer/Migrations/20230630094322_Init.Designer.cs @@ -0,0 +1,78 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NostrStreamer.Database; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NostrStreamer.Migrations +{ + [DbContext(typeof(StreamerContext))] + [Migration("20230630094322_Init")] + partial class Init + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("NostrStreamer.Database.Payment", b => + { + b.Property("PubKey") + .HasColumnType("text"); + + b.Property("Invoice") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsPaid") + .HasColumnType("boolean"); + + b.HasKey("PubKey"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("NostrStreamer.Database.User", b => + { + b.Property("PubKey") + .HasColumnType("text"); + + b.Property("Balance") + .HasColumnType("bigint"); + + b.Property("Event") + .HasColumnType("text"); + + b.Property("Image") + .HasColumnType("text"); + + b.Property("StreamKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("PubKey"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/NostrStreamer/Migrations/20230630094322_Init.cs b/NostrStreamer/Migrations/20230630094322_Init.cs new file mode 100644 index 0000000..71094ba --- /dev/null +++ b/NostrStreamer/Migrations/20230630094322_Init.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NostrStreamer.Migrations +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Payments", + columns: table => new + { + PubKey = table.Column(type: "text", nullable: false), + Invoice = table.Column(type: "text", nullable: false), + IsPaid = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Payments", x => x.PubKey); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + PubKey = table.Column(type: "text", nullable: false), + StreamKey = table.Column(type: "text", nullable: false), + Event = table.Column(type: "text", nullable: true), + Balance = table.Column(type: "bigint", nullable: false), + Title = table.Column(type: "text", nullable: true), + Summary = table.Column(type: "text", nullable: true), + Image = table.Column(type: "text", nullable: true), + Tags = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.PubKey); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Payments"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs b/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs new file mode 100644 index 0000000..ecb10f9 --- /dev/null +++ b/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs @@ -0,0 +1,75 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NostrStreamer.Database; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NostrStreamer.Migrations +{ + [DbContext(typeof(StreamerContext))] + partial class StreamerContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("NostrStreamer.Database.Payment", b => + { + b.Property("PubKey") + .HasColumnType("text"); + + b.Property("Invoice") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsPaid") + .HasColumnType("boolean"); + + b.HasKey("PubKey"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("NostrStreamer.Database.User", b => + { + b.Property("PubKey") + .HasColumnType("text"); + + b.Property("Balance") + .HasColumnType("bigint"); + + b.Property("Event") + .HasColumnType("text"); + + b.Property("Image") + .HasColumnType("text"); + + b.Property("StreamKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("PubKey"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/NostrStreamer/NostrAuth.cs b/NostrStreamer/NostrAuth.cs new file mode 100644 index 0000000..125cacb --- /dev/null +++ b/NostrStreamer/NostrAuth.cs @@ -0,0 +1,90 @@ +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Nostr.Client.Messages; + +namespace NostrStreamer; + +public static class NostrAuth +{ + public const string Scheme = "Nostr"; +} + +public class NostrAuthOptions : AuthenticationSchemeOptions +{ +} + +public class NostrAuthHandler : AuthenticationHandler +{ + public NostrAuthHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : + base(options, logger, encoder, clock) + { + } + + protected override async Task HandleAuthenticateAsync() + { + var auth = Request.Headers.Authorization.FirstOrDefault()?.Trim(); + if (string.IsNullOrEmpty(auth)) + { + return AuthenticateResult.Fail("Missing Authorization header"); + } + + if (!auth.StartsWith(NostrAuth.Scheme)) + { + return AuthenticateResult.Fail("Invalid auth scheme"); + } + + var token = auth[6..]; + var bToken = Convert.FromBase64String(token); + if (string.IsNullOrEmpty(token) || bToken.Length == 0 || bToken[0] != '{') + { + return AuthenticateResult.Fail("Invalid token"); + } + + var ev = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(bToken)); + if (ev == default) + { + return AuthenticateResult.Fail("Invalid nostr event"); + } + + if (!ev.IsSignatureValid()) + { + return AuthenticateResult.Fail("Invalid nostr event, invalid sig"); + } + + if (ev.Kind != (NostrKind)27_235) + { + return AuthenticateResult.Fail("Invalid nostr event, wrong kind"); + } + + var diffTime = Math.Abs((ev.CreatedAt!.Value - DateTime.UtcNow).TotalSeconds); + if (diffTime > 60d) + { + return AuthenticateResult.Fail("Invalid nostr event, timestamp out of range"); + } + + var urlTag = ev.Tags!.FirstOrDefault(a => a.TagIdentifier == "url"); + var methodTag = ev.Tags!.FirstOrDefault(a => a.TagIdentifier == "method"); + if (string.IsNullOrEmpty(urlTag?.AdditionalData[0] as string) || + !new Uri((urlTag.AdditionalData[0] as string)!).AbsolutePath.Equals(Request.Path, StringComparison.InvariantCultureIgnoreCase)) + { + return AuthenticateResult.Fail("Invalid nostr event, url tag invalid"); + } + + if (string.IsNullOrEmpty(methodTag?.AdditionalData[0] as string) || + !((methodTag.AdditionalData[0] as string)?.Equals(Request.Method, StringComparison.InvariantCultureIgnoreCase) ?? false)) + { + return AuthenticateResult.Fail("Invalid nostr event, method tag invalid"); + } + + var principal = new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.Name, ev.Pubkey!) + }); + + return AuthenticateResult.Success(new(new ClaimsPrincipal(new[] {principal}), Scheme.Name)); + } +} diff --git a/NostrStreamer/NostrStreamer.csproj b/NostrStreamer/NostrStreamer.csproj new file mode 100644 index 0000000..d726993 --- /dev/null +++ b/NostrStreamer/NostrStreamer.csproj @@ -0,0 +1,35 @@ + + + + net6.0 + enable + enable + Linux + + + + + .dockerignore + + + .drone.yml + + + docker-compose.yaml + + + srs.conf + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/NostrStreamer/Program.cs b/NostrStreamer/Program.cs new file mode 100644 index 0000000..374dd18 --- /dev/null +++ b/NostrStreamer/Program.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore; +using Nostr.Client.Client; +using NostrStreamer.Database; +using NostrStreamer.Services; + +namespace NostrStreamer; + +internal static class Program +{ + public static async Task Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + var services = builder.Services; + var config = builder.Configuration.GetSection("Config").Get(); + + ConfigureDb(services, builder.Configuration); + services.AddControllers(); + services.AddSingleton(config); + + // nostr services + services.AddSingleton(); + services.AddSingleton(s => s.GetRequiredService()); + services.AddSingleton(); + services.AddHostedService(); + + // streaming services + services.AddTransient(); + + var app = builder.Build(); + + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + } + + app.MapControllers(); + + await app.RunAsync(); + } + + private static void ConfigureDb(IServiceCollection services, IConfiguration configuration) + { + services.AddDbContext(o => o.UseNpgsql(configuration.GetConnectionString("Database"))); + } + + /// + /// Dummy method for EF core migrations + /// + /// + /// + // 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; + } +} \ No newline at end of file diff --git a/NostrStreamer/Properties/launchSettings.json b/NostrStreamer/Properties/launchSettings.json new file mode 100644 index 0000000..7e077cf --- /dev/null +++ b/NostrStreamer/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "NostrStreamer": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://*:5295", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/NostrStreamer/Services/NostrListener.cs b/NostrStreamer/Services/NostrListener.cs new file mode 100644 index 0000000..ff3501c --- /dev/null +++ b/NostrStreamer/Services/NostrListener.cs @@ -0,0 +1,137 @@ +using System.Net.WebSockets; +using System.Reflection; +using Nostr.Client.Client; +using Nostr.Client.Communicator; +using Nostr.Client.Requests; +using Websocket.Client.Models; + +namespace NostrStreamer.Services; + +public class NostrListener : IDisposable +{ + private readonly Config _config; + private readonly NostrMultiWebsocketClient _client; + private readonly INostrCommunicator[] _communicators; + private readonly ILogger _logger; + + private readonly Dictionary _subscriptionToFilter = new(); + + public NostrListener(Config config, NostrMultiWebsocketClient client, ILogger logger) + { + _config = config; + _client = client; + _logger = logger; + + _communicators = CreateCommunicators(); + foreach (var communicator in _communicators) + _client.RegisterCommunicator(communicator); + } + + public NostrClientStreams Streams => _client.Streams; + + public void Dispose() + { + _client.Dispose(); + + foreach (var comm in _communicators) + { + comm.Dispose(); + } + } + + public void RegisterFilter(string subscription, NostrFilter filter) + { + _subscriptionToFilter[subscription] = filter; + } + + public void Start() + { + foreach (var comm in _communicators) + { + // fire and forget + _ = comm.Start(); + } + } + + public void Stop() + { + foreach (var comm in _communicators) + { + // fire and forget + _ = comm.Stop(WebSocketCloseStatus.NormalClosure, string.Empty); + } + } + + private INostrCommunicator[] CreateCommunicators() => + _config.Relays + .Select(x => CreateCommunicator(new Uri(x))) + .ToArray(); + + private INostrCommunicator CreateCommunicator(Uri uri) + { + var comm = new NostrWebsocketCommunicator(uri, () => + { + var client = new ClientWebSocket(); + client.Options.SetRequestHeader("Origin", "http://localhost"); + client.Options.SetRequestHeader("User-Agent", $"NostrStreamer ({Assembly.GetExecutingAssembly().GetName().Version})"); + return client; + }); + + comm.Name = uri.Host; + 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 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 : IHostedService +{ + private readonly NostrListener _nostrListener; + + public NostrListenerLifetime(NostrListener nostrListener) + { + _nostrListener = nostrListener; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _nostrListener.Start(); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _nostrListener.Dispose(); + return Task.CompletedTask; + } +} diff --git a/NostrStreamer/Services/StreamManager.cs b/NostrStreamer/Services/StreamManager.cs new file mode 100644 index 0000000..4853046 --- /dev/null +++ b/NostrStreamer/Services/StreamManager.cs @@ -0,0 +1,112 @@ +using Microsoft.EntityFrameworkCore; +using Nostr.Client.Client; +using Nostr.Client.Keys; +using Nostr.Client.Messages; +using Nostr.Client.Requests; +using NostrStreamer.Database; + +namespace NostrStreamer.Services; + +public class StreamManager +{ + private readonly ILogger _logger; + private readonly StreamerContext _db; + private readonly Config _config; + private readonly INostrClient _nostr; + + public StreamManager(ILogger logger, StreamerContext db, Config config, INostrClient nostr) + { + _logger = logger; + _db = db; + _config = config; + _nostr = nostr; + } + + public async Task StreamStarted(string streamKey) + { + var user = await GetUserFromStreamKey(streamKey); + if (user == default) throw new Exception("No stream key found"); + + _logger.LogInformation("Stream started for: {pubkey}", user.PubKey); + + if (user.Balance <= 0) + { + throw new Exception("User balance empty"); + } + var ev = CreateStreamEvent(user, "live"); + _nostr.Send(new NostrEventRequest(ev)); + } + + public async Task StreamStopped(string streamKey) + { + var user = await GetUserFromStreamKey(streamKey); + if (user == default) throw new Exception("No stream key found"); + + _logger.LogInformation("Stream stopped for: {pubkey}", user.PubKey); + + var ev = CreateStreamEvent(user, "ended"); + _nostr.Send(new NostrEventRequest(ev)); + } + + public async Task ConsumeQuota(string streamKey, double duration) + { + var user = await GetUserFromStreamKey(streamKey); + if (user == default) throw new Exception("No stream key found"); + + const double rate = 21.0d; + var cost = Math.Round(duration / 60d * rate); + await _db.Users + .Where(a => a.PubKey == user.PubKey) + .ExecuteUpdateAsync(o => o.SetProperty(v => v.Balance, v => v.Balance - cost)); + + _logger.LogInformation("Stream consumed {n} seconds for {pubkey} costing {cost} sats", duration, user.PubKey, cost); + if (user.Balance <= 0) + { + throw new Exception("User balance empty"); + } + } + + private NostrEvent CreateStreamEvent(User user, string state) + { + var tags = new List() + { + new("d", user.PubKey), + new("title", user.Title ?? ""), + new("summary", user.Summary ?? ""), + new("streaming", GetStreamUrl(user)), + new("image", user.Image ?? ""), + new("status", state), + new("p", user.PubKey, "", "host") + }; + + foreach (var tag in user.Tags?.Split(",") ?? Array.Empty()) + { + tags.Add(new("t", tag.Trim())); + } + + var ev = new NostrEvent + { + Kind = (NostrKind)30_311, + Content = "", + CreatedAt = DateTime.Now, + Tags = new NostrEventTags(tags) + }; + + return ev.Sign(NostrPrivateKey.FromBech32(_config.PrivateKey)); + } + + private string GetStreamUrl(User u) + { + var ub = new UriBuilder(_config.SrsPublicHost) + { + Path = $"/{_config.App}/${u.StreamKey}.m3u8" + }; + + return ub.Uri.ToString(); + } + + private async Task GetUserFromStreamKey(string streamKey) + { + return await _db.Users.SingleOrDefaultAsync(a => a.StreamKey == streamKey); + } +} diff --git a/NostrStreamer/appsettings.Development.json b/NostrStreamer/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/NostrStreamer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/NostrStreamer/appsettings.json b/NostrStreamer/appsettings.json new file mode 100644 index 0000000..f633289 --- /dev/null +++ b/NostrStreamer/appsettings.json @@ -0,0 +1,20 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Database": "User ID=postgres;Password=postgres;Database=streaming;Pooling=true;Host=127.0.0.1:5431" + }, + "Config": { + "SrsPublicHost": "http://localhost:8080", + "SrsApi": "http://localhost:1985", + "App": "test", + "Relays": ["ws://localhost:8081"], + "PrivateKey": "nsec1yqtv8s8y9krh6l8pwp09lk2jkulr9e0klu95tlk7dgus9cklr4ssdv3d88" + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..8b9ee99 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,19 @@ +services: + srs: + image: ossrs/srs + volumes: + - "./docker/srs.conf:/usr/local/srs/conf/docker.conf" + ports: + - "1935:1935" + - "1985:1985" + - "8080:8080" + nostr: + image: scsibug/nostr-rs-relay + ports: + - "8081:8080" + postgres: + image: postgres:15 + environment: + - "POSTGRES_HOST_AUTH_METHOD=trust" + ports: + - "5431:5432" \ No newline at end of file diff --git a/docker/srs.conf b/docker/srs.conf new file mode 100644 index 0000000..1343e7a --- /dev/null +++ b/docker/srs.conf @@ -0,0 +1,24 @@ +listen 1935; +max_connections 1000; +daemon off; +srs_log_tank console; +http_api { + enabled on; + listen 1985; +} +http_server { + enabled on; + listen 8080; +} +vhost __defaultVhost__ { + hls { + enabled on; + hls_dispose 30; + } + http_hooks { + enabled on; + on_publish http://10.100.2.226:5295/api/srs; + on_unpublish http://10.100.2.226:5295/api/srs; + on_hls http://10.100.2.226:5295/api/srs; + } +}