Stream forwarding

This commit is contained in:
2023-12-07 22:46:36 +00:00
parent 1ad5186aff
commit cef1b845bc
17 changed files with 594 additions and 10 deletions

View File

@ -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<ForwardDest> 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!;
}

View File

@ -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!;
}

View File

@ -46,6 +46,8 @@ public class Config
public List<EdgeLocation> Edges { get; init; } = new();
public TwitchApi Twitch { get; init; } = null!;
public string DataProtectionKeyPath { get; init; } = null!;
}
public class TwitchApi

View File

@ -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<IActionResult> 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<IActionResult> DeleteForward([FromRoute] Guid id)
{
var user = await GetUser();
if (user == default)
{
return NotFound();
}
await _userService.RemoveForward(user.PubKey, id);
return Ok();
}
private async Task<User?> GetUser()
{
var pk = GetPubKey();

View File

@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace NostrStreamer.Database.Configuration;
public class UserStreamForwardsConfiguration : IEntityTypeConfiguration<UserStreamForwards>
{
public void Configure(EntityTypeBuilder<UserStreamForwards> 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);
}
}

View File

@ -29,4 +29,6 @@ public class StreamerContext : DbContext
public DbSet<IngestEndpoint> Endpoints => Set<IngestEndpoint>();
public DbSet<UserStreamRecording> Recordings => Set<UserStreamRecording>();
public DbSet<UserStreamForwards> Forwards => Set<UserStreamForwards>();
}

View File

@ -56,4 +56,5 @@ public class User
public List<Payment> Payments { get; init; } = new();
public List<UserStream> Streams { get; init; } = new();
public List<UserStreamForwards> Forwards { get; init; } = new();
}

View File

@ -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!;
}

View File

@ -0,0 +1,356 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("App")
.IsRequired()
.HasColumnType("text");
b.Property<List<string>>("Capabilities")
.IsRequired()
.HasColumnType("text[]");
b.Property<int>("Cost")
.HasColumnType("integer");
b.Property<string>("Forward")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("App")
.IsUnique();
b.ToTable("Endpoints");
});
modelBuilder.Entity("NostrStreamer.Database.Payment", b =>
{
b.Property<string>("PaymentHash")
.HasColumnType("text");
b.Property<decimal>("Amount")
.HasColumnType("numeric(20,0)");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<string>("Invoice")
.HasColumnType("text");
b.Property<bool>("IsPaid")
.HasColumnType("boolean");
b.Property<string>("Nostr")
.HasColumnType("text");
b.Property<string>("PubKey")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("PaymentHash");
b.HasIndex("PubKey");
b.ToTable("Payments");
});
modelBuilder.Entity("NostrStreamer.Database.User", b =>
{
b.Property<string>("PubKey")
.HasColumnType("text");
b.Property<long>("Balance")
.HasColumnType("bigint");
b.Property<string>("ContentWarning")
.HasColumnType("text");
b.Property<string>("Goal")
.HasColumnType("text");
b.Property<string>("Image")
.HasColumnType("text");
b.Property<string>("StreamKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Summary")
.HasColumnType("text");
b.Property<string>("Tags")
.HasColumnType("text");
b.Property<string>("Title")
.HasColumnType("text");
b.Property<DateTime?>("TosAccepted")
.HasColumnType("timestamp with time zone");
b.Property<uint>("Version")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.HasKey("PubKey");
b.ToTable("Users");
});
modelBuilder.Entity("NostrStreamer.Database.UserStream", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("EdgeIp")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("EndpointId")
.HasColumnType("uuid");
b.Property<DateTime?>("Ends")
.HasColumnType("timestamp with time zone");
b.Property<string>("Event")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ForwardClientId")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("LastSegment")
.HasColumnType("timestamp with time zone");
b.Property<string>("PubKey")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("Starts")
.HasColumnType("timestamp with time zone");
b.Property<int>("State")
.HasColumnType("integer");
b.Property<string>("StreamId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Thumbnail")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("EndpointId");
b.HasIndex("PubKey");
b.ToTable("Streams");
});
modelBuilder.Entity("NostrStreamer.Database.UserStreamForwards", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Target")
.IsRequired()
.HasColumnType("text");
b.Property<string>("UserPubkey")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserPubkey");
b.ToTable("Forwards");
});
modelBuilder.Entity("NostrStreamer.Database.UserStreamGuest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("PubKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Relay")
.HasColumnType("text");
b.Property<string>("Role")
.HasColumnType("text");
b.Property<string>("Sig")
.HasColumnType("text");
b.Property<Guid>("StreamId")
.HasColumnType("uuid");
b.Property<decimal>("ZapSplit")
.HasColumnType("numeric");
b.HasKey("Id");
b.HasIndex("StreamId", "PubKey")
.IsUnique();
b.ToTable("Guests");
});
modelBuilder.Entity("NostrStreamer.Database.UserStreamRecording", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<double>("Duration")
.HasColumnType("double precision");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("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
}
}
}

View File

@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NostrStreamer.Migrations
{
/// <inheritdoc />
public partial class Forwards : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Forwards",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserPubkey = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Target = table.Column<string>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Forwards");
}
}
}

View File

@ -187,6 +187,31 @@ namespace NostrStreamer.Migrations
b.ToTable("Streams");
});
modelBuilder.Entity("NostrStreamer.Database.UserStreamForwards", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Target")
.IsRequired()
.HasColumnType("text");
b.Property<string>("UserPubkey")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserPubkey");
b.ToTable("Forwards");
});
modelBuilder.Entity("NostrStreamer.Database.UserStreamGuest", b =>
{
b.Property<Guid>("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");

View File

@ -41,6 +41,7 @@
<PackageReference Include="LNURL" Version="0.0.30" />
<PackageReference Include="MaxMind.GeoIP2" Version="5.1.0" />
<PackageReference Include="MediaFormatLibrary.Lib" Version="1.0.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.19" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.8">

View File

@ -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<NostrMultiWebsocketClient>();
services.AddSingleton<INostrClient>(s => s.GetRequiredService<NostrMultiWebsocketClient>());
@ -74,10 +78,10 @@ internal static class Program
// lnd services
services.AddSingleton<LndNode>();
services.AddHostedService<LndInvoicesStream>();
// game services
services.AddSingleton<GameDb>();
var app = builder.Build();
using (var scope = app.Services.CreateScope())

View File

@ -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<NostrStreamManager> logger, StreamManagerContext context, IServiceProvider serviceProvider)
{
@ -21,6 +23,7 @@ public class NostrStreamManager : IStreamManager
_eventBuilder = serviceProvider.GetRequiredService<StreamEventBuilder>();
_dvrStore = serviceProvider.GetRequiredService<IDvrStore>();
_config = serviceProvider.GetRequiredService<Config>();
_dataProtectionProvider = serviceProvider.GetRequiredService<IDataProtectionProvider>();
}
public UserStream GetStream()
@ -44,10 +47,26 @@ public class NostrStreamManager : IStreamManager
public Task<List<string>> OnForward()
{
TestCanStream();
return Task.FromResult(new List<string>
var fwds = new List<string>
{
$"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()

View File

@ -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");

View File

@ -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;
}
/// <summary>
@ -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<User?> 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

View File

@ -51,6 +51,7 @@
"Twitch": {
"ClientId": "123",
"ClientSecret": "aaa"
}
},
"DataProtectionKeyPath": "./keys"
}
}