diff --git a/NostrStreamer/ApiModel/Account.cs b/NostrStreamer/ApiModel/Account.cs index 1411343..2a5f57d 100644 --- a/NostrStreamer/ApiModel/Account.cs +++ b/NostrStreamer/ApiModel/Account.cs @@ -12,9 +12,12 @@ public class Account [JsonProperty("balance")] public long Balance { get; init; } - + [JsonProperty("tos")] - public AccountTos Tos { get; init; } + public AccountTos Tos { get; init; } = null!; + + [JsonProperty("forwards")] + public List Forwards { get; init; } = new(); } public class AccountEndpoint @@ -51,4 +54,13 @@ public class AccountTos [JsonProperty("link")] public Uri Link { get; init; } = null!; +} + +public class ForwardDest +{ + [JsonProperty("id")] + public Guid Id { get;init; } + + [JsonProperty("name")] + public string Name { get; init; } = null!; } \ No newline at end of file diff --git a/NostrStreamer/ApiModel/NewForwardRequest.cs b/NostrStreamer/ApiModel/NewForwardRequest.cs new file mode 100644 index 0000000..a64efbc --- /dev/null +++ b/NostrStreamer/ApiModel/NewForwardRequest.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace NostrStreamer.ApiModel; + +public class NewForwardRequest +{ + [JsonProperty("name")] + public string Name { get; init; } = null!; + + [JsonProperty("target")] + public string Target { get; init; } = null!; +} diff --git a/NostrStreamer/Config.cs b/NostrStreamer/Config.cs index eadba70..9e592c1 100644 --- a/NostrStreamer/Config.cs +++ b/NostrStreamer/Config.cs @@ -46,6 +46,8 @@ public class Config public List Edges { get; init; } = new(); public TwitchApi Twitch { get; init; } = null!; + + public string DataProtectionKeyPath { get; init; } = null!; } public class TwitchApi diff --git a/NostrStreamer/Controllers/NostrController.cs b/NostrStreamer/Controllers/NostrController.cs index 280da27..dfaf8e3 100644 --- a/NostrStreamer/Controllers/NostrController.cs +++ b/NostrStreamer/Controllers/NostrController.cs @@ -72,7 +72,12 @@ public class NostrController : Controller { Accepted = user.TosAccepted >= _config.TosDate, Link = new Uri(_config.ApiHost, "/tos") - } + }, + Forwards = user.Forwards.Select(a => new ForwardDest() + { + Id = a.Id, + Name = a.Name + }).ToList() }; return Content(JsonConvert.SerializeObject(account, NostrSerializer.Settings), "application/json"); @@ -128,6 +133,34 @@ public class NostrController : Controller return Accepted(); } + [HttpPost("account/forward")] + public async Task AddForward([FromBody] NewForwardRequest req) + { + var user = await GetUser(); + if (user == default) + { + return NotFound(); + } + + await _userService.AddForward(user.PubKey, req.Name, req.Target); + + return Accepted(); + } + + [HttpDelete("account/forward/{id:guid}")] + public async Task DeleteForward([FromRoute] Guid id) + { + var user = await GetUser(); + if (user == default) + { + return NotFound(); + } + + await _userService.RemoveForward(user.PubKey, id); + + return Ok(); + } + private async Task GetUser() { var pk = GetPubKey(); diff --git a/NostrStreamer/Database/Configuration/UserStreamForwardsConfiguration.cs b/NostrStreamer/Database/Configuration/UserStreamForwardsConfiguration.cs new file mode 100644 index 0000000..77fb5ca --- /dev/null +++ b/NostrStreamer/Database/Configuration/UserStreamForwardsConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace NostrStreamer.Database.Configuration; + +public class UserStreamForwardsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(a => a.Id); + builder.Property(a => a.Name); + builder.Property(a => a.Target); + + builder.HasOne(a => a.User) + .WithMany(a => a.Forwards) + .HasForeignKey(a => a.UserPubkey) + .HasPrincipalKey(a => a.PubKey); + } +} diff --git a/NostrStreamer/Database/StreamerContext.cs b/NostrStreamer/Database/StreamerContext.cs index 0506f25..b577266 100644 --- a/NostrStreamer/Database/StreamerContext.cs +++ b/NostrStreamer/Database/StreamerContext.cs @@ -29,4 +29,6 @@ public class StreamerContext : DbContext public DbSet Endpoints => Set(); public DbSet Recordings => Set(); + + public DbSet Forwards => Set(); } diff --git a/NostrStreamer/Database/User.cs b/NostrStreamer/Database/User.cs index d176abf..5bd0566 100644 --- a/NostrStreamer/Database/User.cs +++ b/NostrStreamer/Database/User.cs @@ -56,4 +56,5 @@ public class User public List Payments { get; init; } = new(); public List Streams { get; init; } = new(); + public List Forwards { get; init; } = new(); } diff --git a/NostrStreamer/Database/UserStreamForwards.cs b/NostrStreamer/Database/UserStreamForwards.cs new file mode 100644 index 0000000..6c7fd4d --- /dev/null +++ b/NostrStreamer/Database/UserStreamForwards.cs @@ -0,0 +1,13 @@ +namespace NostrStreamer.Database; + +public class UserStreamForwards +{ + public Guid Id { get; init; } = Guid.NewGuid(); + + public string UserPubkey { get; init; } = null!; + public User User { get; init; } = null!; + + public string Name { get; init; } = null!; + + public string Target { get; init; } = null!; +} diff --git a/NostrStreamer/Migrations/20231207193950_Forwards.Designer.cs b/NostrStreamer/Migrations/20231207193950_Forwards.Designer.cs new file mode 100644 index 0000000..559e4d6 --- /dev/null +++ b/NostrStreamer/Migrations/20231207193950_Forwards.Designer.cs @@ -0,0 +1,356 @@ +// +using System; +using System.Collections.Generic; +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("20231207193950_Forwards")] + partial class Forwards + { + /// + 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.IngestEndpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("App") + .IsRequired() + .HasColumnType("text"); + + b.Property>("Capabilities") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Cost") + .HasColumnType("integer"); + + b.Property("Forward") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("App") + .IsUnique(); + + b.ToTable("Endpoints"); + }); + + modelBuilder.Entity("NostrStreamer.Database.Payment", b => + { + b.Property("PaymentHash") + .HasColumnType("text"); + + b.Property("Amount") + .HasColumnType("numeric(20,0)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Invoice") + .HasColumnType("text"); + + b.Property("IsPaid") + .HasColumnType("boolean"); + + b.Property("Nostr") + .HasColumnType("text"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("PaymentHash"); + + b.HasIndex("PubKey"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("NostrStreamer.Database.User", b => + { + b.Property("PubKey") + .HasColumnType("text"); + + b.Property("Balance") + .HasColumnType("bigint"); + + b.Property("ContentWarning") + .HasColumnType("text"); + + b.Property("Goal") + .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.Property("TosAccepted") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("PubKey"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EdgeIp") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndpointId") + .HasColumnType("uuid"); + + b.Property("Ends") + .HasColumnType("timestamp with time zone"); + + b.Property("Event") + .IsRequired() + .HasColumnType("text"); + + b.Property("ForwardClientId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastSegment") + .HasColumnType("timestamp with time zone"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Starts") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("StreamId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Thumbnail") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EndpointId"); + + b.HasIndex("PubKey"); + + b.ToTable("Streams"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamForwards", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Target") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserPubkey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserPubkey"); + + b.ToTable("Forwards"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamGuest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Relay") + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("text"); + + b.Property("Sig") + .HasColumnType("text"); + + b.Property("StreamId") + .HasColumnType("uuid"); + + b.Property("ZapSplit") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("StreamId", "PubKey") + .IsUnique(); + + b.ToTable("Guests"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamRecording", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Duration") + .HasColumnType("double precision"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserStreamId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserStreamId"); + + b.ToTable("Recordings"); + }); + + modelBuilder.Entity("NostrStreamer.Database.Payment", b => + { + b.HasOne("NostrStreamer.Database.User", "User") + .WithMany("Payments") + .HasForeignKey("PubKey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStream", b => + { + b.HasOne("NostrStreamer.Database.IngestEndpoint", "Endpoint") + .WithMany() + .HasForeignKey("EndpointId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NostrStreamer.Database.User", "User") + .WithMany("Streams") + .HasForeignKey("PubKey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Endpoint"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamForwards", b => + { + b.HasOne("NostrStreamer.Database.User", "User") + .WithMany("Forwards") + .HasForeignKey("UserPubkey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamGuest", b => + { + b.HasOne("NostrStreamer.Database.UserStream", "Stream") + .WithMany("Guests") + .HasForeignKey("StreamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Stream"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamRecording", b => + { + b.HasOne("NostrStreamer.Database.UserStream", "Stream") + .WithMany() + .HasForeignKey("UserStreamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Stream"); + }); + + modelBuilder.Entity("NostrStreamer.Database.User", b => + { + b.Navigation("Forwards"); + + b.Navigation("Payments"); + + b.Navigation("Streams"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStream", b => + { + b.Navigation("Guests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/NostrStreamer/Migrations/20231207193950_Forwards.cs b/NostrStreamer/Migrations/20231207193950_Forwards.cs new file mode 100644 index 0000000..d262492 --- /dev/null +++ b/NostrStreamer/Migrations/20231207193950_Forwards.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NostrStreamer.Migrations +{ + /// + public partial class Forwards : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Forwards", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserPubkey = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Target = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Forwards", x => x.Id); + table.ForeignKey( + name: "FK_Forwards_Users_UserPubkey", + column: x => x.UserPubkey, + principalTable: "Users", + principalColumn: "PubKey", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Forwards_UserPubkey", + table: "Forwards", + column: "UserPubkey"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Forwards"); + } + } +} diff --git a/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs b/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs index 67b2241..fa4d194 100644 --- a/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs +++ b/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs @@ -187,6 +187,31 @@ namespace NostrStreamer.Migrations b.ToTable("Streams"); }); + modelBuilder.Entity("NostrStreamer.Database.UserStreamForwards", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Target") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserPubkey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserPubkey"); + + b.ToTable("Forwards"); + }); + modelBuilder.Entity("NostrStreamer.Database.UserStreamGuest", b => { b.Property("Id") @@ -276,6 +301,17 @@ namespace NostrStreamer.Migrations b.Navigation("User"); }); + modelBuilder.Entity("NostrStreamer.Database.UserStreamForwards", b => + { + b.HasOne("NostrStreamer.Database.User", "User") + .WithMany("Forwards") + .HasForeignKey("UserPubkey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("NostrStreamer.Database.UserStreamGuest", b => { b.HasOne("NostrStreamer.Database.UserStream", "Stream") @@ -300,6 +336,8 @@ namespace NostrStreamer.Migrations modelBuilder.Entity("NostrStreamer.Database.User", b => { + b.Navigation("Forwards"); + b.Navigation("Payments"); b.Navigation("Streams"); diff --git a/NostrStreamer/NostrStreamer.csproj b/NostrStreamer/NostrStreamer.csproj index cbece53..9dec796 100644 --- a/NostrStreamer/NostrStreamer.csproj +++ b/NostrStreamer/NostrStreamer.csproj @@ -41,6 +41,7 @@ + diff --git a/NostrStreamer/Program.cs b/NostrStreamer/Program.cs index 2bdd215..ed80e87 100644 --- a/NostrStreamer/Program.cs +++ b/NostrStreamer/Program.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using MaxMind.GeoIP2; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Infrastructure; +using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Nostr.Client.Client; using NostrStreamer.Database; @@ -51,6 +52,9 @@ internal static class Program }, new[] {NostrAuth.Scheme}); }); + services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(config.DataProtectionKeyPath)); + // nostr services services.AddSingleton(); services.AddSingleton(s => s.GetRequiredService()); @@ -74,10 +78,10 @@ internal static class Program // lnd services services.AddSingleton(); services.AddHostedService(); - + // game services services.AddSingleton(); - + var app = builder.Build(); using (var scope = app.Services.CreateScope()) diff --git a/NostrStreamer/Services/StreamManager/NostrStreamManager.cs b/NostrStreamer/Services/StreamManager/NostrStreamManager.cs index 578b8c6..6ee7b0d 100644 --- a/NostrStreamer/Services/StreamManager/NostrStreamManager.cs +++ b/NostrStreamer/Services/StreamManager/NostrStreamManager.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using Nostr.Client.Json; @@ -13,6 +14,7 @@ public class NostrStreamManager : IStreamManager private readonly StreamEventBuilder _eventBuilder; private readonly IDvrStore _dvrStore; private readonly Config _config; + private readonly IDataProtectionProvider _dataProtectionProvider; public NostrStreamManager(ILogger logger, StreamManagerContext context, IServiceProvider serviceProvider) { @@ -21,6 +23,7 @@ public class NostrStreamManager : IStreamManager _eventBuilder = serviceProvider.GetRequiredService(); _dvrStore = serviceProvider.GetRequiredService(); _config = serviceProvider.GetRequiredService(); + _dataProtectionProvider = serviceProvider.GetRequiredService(); } public UserStream GetStream() @@ -44,10 +47,26 @@ public class NostrStreamManager : IStreamManager public Task> OnForward() { TestCanStream(); - return Task.FromResult(new List + var fwds = new List { $"rtmp://127.0.0.1:1935/{_context.UserStream.Endpoint.App}/{_context.User.StreamKey}?vhost={_context.UserStream.Endpoint.Forward}" - }); + }; + + var dataProtector = _dataProtectionProvider.CreateProtector("forward-targets"); + foreach (var f in _context.User.Forwards) + { + try + { + var target = dataProtector.Unprotect(f.Target); + fwds.Add(target); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to decrypt forward target {id} {msg}", f.Id, ex.Message); + } + } + + return Task.FromResult(fwds); } public async Task StreamStarted() diff --git a/NostrStreamer/Services/StreamManager/StreamManagerFactory.cs b/NostrStreamer/Services/StreamManager/StreamManagerFactory.cs index ab8bbd7..e096856 100644 --- a/NostrStreamer/Services/StreamManager/StreamManagerFactory.cs +++ b/NostrStreamer/Services/StreamManager/StreamManagerFactory.cs @@ -27,6 +27,7 @@ public class StreamManagerFactory { var user = await _db.Users .AsNoTracking() + .Include(a => a.Forwards) .SingleOrDefaultAsync(a => a.StreamKey.Equals(info.StreamKey)); if (user == default) throw new Exception("No user found"); diff --git a/NostrStreamer/Services/UserService.cs b/NostrStreamer/Services/UserService.cs index 6c24a2f..a5b0de1 100644 --- a/NostrStreamer/Services/UserService.cs +++ b/NostrStreamer/Services/UserService.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Text; +using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Nostr.Client.Utils; using NostrStreamer.ApiModel; @@ -11,11 +12,13 @@ public class UserService { private readonly StreamerContext _db; private readonly LndNode _lnd; + private readonly IDataProtectionProvider _dataProtector; - public UserService(StreamerContext db, LndNode lnd) + public UserService(StreamerContext db, LndNode lnd, IDataProtectionProvider dataProtector) { _db = db; _lnd = lnd; + _dataProtector = dataProtector; } /// @@ -41,7 +44,7 @@ public class UserService Amount = (ulong)user.Balance / 1000, PaymentHash = SHA256.HashData(Encoding.UTF8.GetBytes($"{pubkey}-init-credit")).ToHex() }); - + await _db.SaveChangesAsync(); return user; } @@ -79,6 +82,7 @@ public class UserService public async Task GetUser(string pubkey) { return await _db.Users.AsNoTracking() + .Include(a => a.Forwards) .SingleOrDefaultAsync(a => a.PubKey.Equals(pubkey)); } @@ -90,6 +94,25 @@ public class UserService if (change != 1) throw new Exception($"Failed to accept TOS, {change} rows updated."); } + public async Task AddForward(string pubkey, string name, string dest) + { + var protector = _dataProtector.CreateProtector("forward-targets"); + _db.Forwards.Add(new() + { + UserPubkey = pubkey, + Name = name, + Target = protector.Protect(dest) + }); + + await _db.SaveChangesAsync(); + } + + public async Task RemoveForward(string pubkey, Guid id) + { + await _db.Forwards.Where(a => a.UserPubkey.Equals(pubkey) && a.Id == id) + .ExecuteDeleteAsync(); + } + public async Task UpdateStreamInfo(string pubkey, PatchEvent req) { await _db.Users diff --git a/NostrStreamer/appsettings.json b/NostrStreamer/appsettings.json index db531aa..ffa81d0 100644 --- a/NostrStreamer/appsettings.json +++ b/NostrStreamer/appsettings.json @@ -51,6 +51,7 @@ "Twitch": { "ClientId": "123", "ClientSecret": "aaa" - } + }, + "DataProtectionKeyPath": "./keys" } }