Init
This commit is contained in:
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@ -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
|
19
.drone.yml
Normal file
19
.drone.yml
Normal file
@ -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
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
bin/
|
||||
obj/
|
||||
.idea/
|
16
NostrStreamer.sln
Normal file
16
NostrStreamer.sln
Normal file
@ -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
|
12
NostrStreamer/ApiModel/Account.cs
Normal file
12
NostrStreamer/ApiModel/Account.cs
Normal file
@ -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!;
|
||||
}
|
12
NostrStreamer/Config.cs
Normal file
12
NostrStreamer/Config.cs
Normal file
@ -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<string>();
|
||||
}
|
60
NostrStreamer/Controllers/AccountController.cs
Normal file
60
NostrStreamer/Controllers/AccountController.cs
Normal file
@ -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<ActionResult> 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<User?> 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;
|
||||
}
|
||||
}
|
95
NostrStreamer/Controllers/SRSController.cs
Normal file
95
NostrStreamer/Controllers/SRSController.cs
Normal file
@ -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<SrsController> _logger;
|
||||
private readonly Config _config;
|
||||
private readonly StreamManager _streamManager;
|
||||
|
||||
public SrsController(ILogger<SrsController> logger, Config config, StreamManager streamManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
_streamManager = streamManager;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<SrsHookReply> 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; }
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace NostrStreamer.Database.Configuration;
|
||||
|
||||
public class PaymentsConfiguration : IEntityTypeConfiguration<Payment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Payment> builder)
|
||||
{
|
||||
builder.HasKey(a => a.PubKey);
|
||||
builder.Property(a => a.Invoice)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(a => a.IsPaid)
|
||||
.IsRequired();
|
||||
}
|
||||
}
|
18
NostrStreamer/Database/Configuration/UserConfiguration.cs
Normal file
18
NostrStreamer/Database/Configuration/UserConfiguration.cs
Normal file
@ -0,0 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace NostrStreamer.Database.Configuration;
|
||||
|
||||
public class UserConfiguration : IEntityTypeConfiguration<User>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<User> builder)
|
||||
{
|
||||
builder.HasKey(a => a.PubKey);
|
||||
builder.Property(a => a.StreamKey)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(a => a.Event);
|
||||
builder.Property(a => a.Balance)
|
||||
.IsRequired();
|
||||
}
|
||||
}
|
10
NostrStreamer/Database/Payment.cs
Normal file
10
NostrStreamer/Database/Payment.cs
Normal file
@ -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; }
|
||||
}
|
26
NostrStreamer/Database/StreamerContext.cs
Normal file
26
NostrStreamer/Database/StreamerContext.cs
Normal file
@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace NostrStreamer.Database;
|
||||
|
||||
public class StreamerContext : DbContext
|
||||
{
|
||||
public StreamerContext()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public StreamerContext(DbContextOptions<StreamerContext> ctx) : base(ctx)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(StreamerContext).Assembly);
|
||||
}
|
||||
|
||||
public DbSet<User> Users => Set<User>();
|
||||
|
||||
public DbSet<Payment> Payments => Set<Payment>();
|
||||
}
|
41
NostrStreamer/Database/User.cs
Normal file
41
NostrStreamer/Database/User.cs
Normal file
@ -0,0 +1,41 @@
|
||||
namespace NostrStreamer.Database;
|
||||
|
||||
public class User
|
||||
{
|
||||
public string PubKey { get; init; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Stream key
|
||||
/// </summary>
|
||||
public string StreamKey { get; init; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Most recent nostr event published
|
||||
/// </summary>
|
||||
public string? Event { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sats balance
|
||||
/// </summary>
|
||||
public long Balance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stream title
|
||||
/// </summary>
|
||||
public string? Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stream summary
|
||||
/// </summary>
|
||||
public string? Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stream cover image
|
||||
/// </summary>
|
||||
public string? Image { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Comma seperated tags
|
||||
/// </summary>
|
||||
public string? Tags { get; init; }
|
||||
}
|
20
NostrStreamer/Dockerfile
Normal file
20
NostrStreamer/Dockerfile
Normal file
@ -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"]
|
8
NostrStreamer/Extensions.cs
Normal file
8
NostrStreamer/Extensions.cs
Normal file
@ -0,0 +1,8 @@
|
||||
using Nostr.Client.Messages;
|
||||
using NostrStreamer.Database;
|
||||
|
||||
namespace NostrStreamer;
|
||||
|
||||
public static class Extensions
|
||||
{
|
||||
}
|
78
NostrStreamer/Migrations/20230630094322_Init.Designer.cs
generated
Normal file
78
NostrStreamer/Migrations/20230630094322_Init.Designer.cs
generated
Normal file
@ -0,0 +1,78 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <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.Payment", b =>
|
||||
{
|
||||
b.Property<string>("PubKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Invoice")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsPaid")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("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>("Event")
|
||||
.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.HasKey("PubKey");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
55
NostrStreamer/Migrations/20230630094322_Init.cs
Normal file
55
NostrStreamer/Migrations/20230630094322_Init.cs
Normal file
@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace NostrStreamer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Init : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Payments",
|
||||
columns: table => new
|
||||
{
|
||||
PubKey = table.Column<string>(type: "text", nullable: false),
|
||||
Invoice = table.Column<string>(type: "text", nullable: false),
|
||||
IsPaid = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Payments", x => x.PubKey);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Users",
|
||||
columns: table => new
|
||||
{
|
||||
PubKey = table.Column<string>(type: "text", nullable: false),
|
||||
StreamKey = table.Column<string>(type: "text", nullable: false),
|
||||
Event = table.Column<string>(type: "text", nullable: true),
|
||||
Balance = table.Column<long>(type: "bigint", nullable: false),
|
||||
Title = table.Column<string>(type: "text", nullable: true),
|
||||
Summary = table.Column<string>(type: "text", nullable: true),
|
||||
Image = table.Column<string>(type: "text", nullable: true),
|
||||
Tags = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Users", x => x.PubKey);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Payments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Users");
|
||||
}
|
||||
}
|
||||
}
|
75
NostrStreamer/Migrations/StreamerContextModelSnapshot.cs
Normal file
75
NostrStreamer/Migrations/StreamerContextModelSnapshot.cs
Normal file
@ -0,0 +1,75 @@
|
||||
// <auto-generated />
|
||||
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<string>("PubKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Invoice")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsPaid")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("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>("Event")
|
||||
.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.HasKey("PubKey");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
90
NostrStreamer/NostrAuth.cs
Normal file
90
NostrStreamer/NostrAuth.cs
Normal file
@ -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<NostrAuthOptions>
|
||||
{
|
||||
public NostrAuthHandler(IOptionsMonitor<NostrAuthOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) :
|
||||
base(options, logger, encoder, clock)
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> 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<NostrEvent>(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));
|
||||
}
|
||||
}
|
35
NostrStreamer/NostrStreamer.csproj
Normal file
35
NostrStreamer/NostrStreamer.csproj
Normal file
@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
<Content Include="..\.drone.yml">
|
||||
<Link>.drone.yml</Link>
|
||||
</Content>
|
||||
<Content Include="..\docker-compose.yaml">
|
||||
<Link>docker-compose.yaml</Link>
|
||||
</Content>
|
||||
<Content Include="..\docker\srs.conf">
|
||||
<Link>srs.conf</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.8">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Nostr.Client" Version="1.4.2" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
61
NostrStreamer/Program.cs
Normal file
61
NostrStreamer/Program.cs
Normal file
@ -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<Config>();
|
||||
|
||||
ConfigureDb(services, builder.Configuration);
|
||||
services.AddControllers();
|
||||
services.AddSingleton(config);
|
||||
|
||||
// nostr services
|
||||
services.AddSingleton<NostrMultiWebsocketClient>();
|
||||
services.AddSingleton<INostrClient>(s => s.GetRequiredService<NostrMultiWebsocketClient>());
|
||||
services.AddSingleton<NostrListener>();
|
||||
services.AddHostedService<NostrListenerLifetime>();
|
||||
|
||||
// streaming services
|
||||
services.AddTransient<StreamManager>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<StreamerContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
await app.RunAsync();
|
||||
}
|
||||
|
||||
private static void ConfigureDb(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDbContext<StreamerContext>(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;
|
||||
}
|
||||
}
|
13
NostrStreamer/Properties/launchSettings.json
Normal file
13
NostrStreamer/Properties/launchSettings.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"profiles": {
|
||||
"NostrStreamer": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://*:5295",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
137
NostrStreamer/Services/NostrListener.cs
Normal file
137
NostrStreamer/Services/NostrListener.cs
Normal file
@ -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<NostrListener> _logger;
|
||||
|
||||
private readonly Dictionary<string, NostrFilter> _subscriptionToFilter = new();
|
||||
|
||||
public NostrListener(Config config, NostrMultiWebsocketClient client, ILogger<NostrListener> 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;
|
||||
}
|
||||
}
|
112
NostrStreamer/Services/StreamManager.cs
Normal file
112
NostrStreamer/Services/StreamManager.cs
Normal file
@ -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<StreamManager> _logger;
|
||||
private readonly StreamerContext _db;
|
||||
private readonly Config _config;
|
||||
private readonly INostrClient _nostr;
|
||||
|
||||
public StreamManager(ILogger<StreamManager> 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<NostrEventTag>()
|
||||
{
|
||||
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<string>())
|
||||
{
|
||||
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<User?> GetUserFromStreamKey(string streamKey)
|
||||
{
|
||||
return await _db.Users.SingleOrDefaultAsync(a => a.StreamKey == streamKey);
|
||||
}
|
||||
}
|
8
NostrStreamer/appsettings.Development.json
Normal file
8
NostrStreamer/appsettings.Development.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
20
NostrStreamer/appsettings.json
Normal file
20
NostrStreamer/appsettings.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
19
docker-compose.yaml
Normal file
19
docker-compose.yaml
Normal file
@ -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"
|
24
docker/srs.conf
Normal file
24
docker/srs.conf
Normal file
@ -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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user