Browse Source

fix: GHSA-h3r4-r2f2-qf59

pull/2992/head
Jason Dove 3 weeks ago
parent
commit
ef5094aa16
No known key found for this signature in database
  1. 15
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/Plex/PlexConnectionParametersViewModel.cs
  3. 4
      ErsatzTV.Application/Plex/Queries/GetPlexConnectionParametersHandler.cs
  4. 44
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs
  5. 25
      ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs
  6. 7
      ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSampleHandler.cs
  7. 24
      ErsatzTV.Application/Troubleshooting/Commands/TroubleshootingHandlerBase.cs
  8. 23
      ErsatzTV.Core.Tests/Security/InternalUrlSignerTests.cs
  9. 6
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  10. 40
      ErsatzTV.Core/Security/InternalUrlSigner.cs
  11. 10
      ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs
  12. 26
      ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItemService.cs
  13. 23
      ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs
  14. 15
      ErsatzTV.Infrastructure/Streaming/ExternalJsonPlayoutItemProvider.cs
  15. 2
      ErsatzTV/Controllers/ArtworkController.cs
  16. 71
      ErsatzTV/Controllers/InternalController.cs
  17. 42
      ErsatzTV/Services/SchedulerService.cs

15
CHANGELOG.md

@ -5,6 +5,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -5,6 +5,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Security
- Fix GHSA-h3r4-r2f2-qf59 (CVE-PENDING)
- All users should upgrade as soon as possible
- After upgrading, it is highly recommended to rotate your Plex token and Emby API key
- To rotate Plex token:
- In **Media Sources** > **Plex** click **Re-Authenticate With Plex** and complete the sign in to generate a new token
- In https://app.plex.tv/ **Account Settings** > **Authorized Devices** delete the old ErsatzTV authorized device
- Restart the Plex server to immediately invalidate the token that the old authorized device used
- To rotate Emby API key:
- Generate new API key in Emby's **Dashboard** > **Advanced** > **API Keys**
- Delete the old API key in that same screen
- In **Media Sources** > **Emby** click **Edit Emby Connection**, paste the new API key and click **Save Changes**
- Fix case where specifically-crafted requests could access management UI over streaming port
### Added
- Add `Re-authenticate with Plex` button to the Plex media sources page
- Use this to replace the credentials ErsatzTV uses (after a Plex password reset, or after signing out of all Plex devices) without removing media sources or synchronized content
@ -24,7 +38,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -24,7 +38,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Troubleshooting playback endpoints are requested directly by the browser, so they authorize with the management UI session instead of the API key
### Fixed
- Fix case where specifically-crafted requests could access management UI over streaming port
- Fix Plex page staying disabled until restart when a sign-in is not completed within two minutes, or when plex.tv cannot be reached
- Fix Plex page showing no indication that ErsatzTV has been signed out of Plex
- Fix mirror channels falling out of sync when using the next streaming engine

2
ErsatzTV.Application/Plex/PlexConnectionParametersViewModel.cs

@ -1,3 +1,3 @@ @@ -1,3 +1,3 @@
namespace ErsatzTV.Application.Plex;
public record PlexConnectionParametersViewModel(Uri Uri, string AuthToken);
public record PlexConnectionParametersViewModel(string Address, string AuthToken);

4
ErsatzTV.Application/Plex/Queries/GetPlexConnectionParametersHandler.cs

@ -58,7 +58,7 @@ public class GetPlexConnectionParametersHandler : PlexBaseConnectionHandler, @@ -58,7 +58,7 @@ public class GetPlexConnectionParametersHandler : PlexBaseConnectionHandler,
if (await _plexServerApiClient.Ping(activeConnection, token, cancellationToken))
{
_logger.LogDebug("Plex connection is still active at {Uri}", activeConnection.Uri);
var p = new PlexConnectionParametersViewModel(new Uri(activeConnection.Uri), token.AuthToken);
var p = new PlexConnectionParametersViewModel(activeConnection.Uri, token.AuthToken);
_memoryCache.Set(request, p, TimeSpan.FromSeconds(30));
return p;
}
@ -70,7 +70,7 @@ public class GetPlexConnectionParametersHandler : PlexBaseConnectionHandler, @@ -70,7 +70,7 @@ public class GetPlexConnectionParametersHandler : PlexBaseConnectionHandler,
Option<PlexConnection> maybeConnection = await FindConnectionToActivate(mediaSource, token);
foreach (PlexConnection connection in maybeConnection)
{
var p = new PlexConnectionParametersViewModel(new Uri(connection.Uri), token.AuthToken);
var p = new PlexConnectionParametersViewModel(connection.Uri, token.AuthToken);
_memoryCache.Set(request, p, TimeSpan.FromMinutes(30));
return p;
}

44
ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs

@ -10,6 +10,7 @@ using Dapper; @@ -10,6 +10,7 @@ using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Security;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Humanizer;
@ -291,6 +292,8 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem @@ -291,6 +292,8 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem
private static async Task<string> GetMediaItemPath(TvContext dbContext, MediaItem mediaItem)
{
DateTimeOffset exp = DateTimeOffset.Now + TimeSpan.FromHours(1);
MediaVersion version = mediaItem.GetHeadVersion();
MediaFile file = version.MediaFiles.Head();
@ -307,22 +310,43 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem @@ -307,22 +310,43 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem
foreach (int plexMediaSourceId in maybeId)
{
return $"http://localhost:{Settings.StreamingPort}/internal/media/plex/{plexMediaSourceId}/{pmf.Key}";
string sig = InternalUrlSigner.Sign(
exp,
"plex",
$"{plexMediaSourceId}",
$"{pmf.Key}");
return $"http://localhost:{Settings.StreamingPort}/internal/media/plex/{plexMediaSourceId}/{pmf.Key}?exp={exp.ToUnixTimeSeconds()}&sig={sig}";
}
break;
}
return mediaItem switch
switch (mediaItem)
{
JellyfinMovie jellyfinMovie =>
$"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{jellyfinMovie.ItemId}",
JellyfinEpisode jellyfinEpisode =>
$"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{jellyfinEpisode.ItemId}",
EmbyMovie embyMovie => $"http://localhost:{Settings.StreamingPort}/internal/media/emby/{embyMovie.ItemId}",
EmbyEpisode embyEpisode => $"http://localhost:{Settings.StreamingPort}/internal/media/emby/{embyEpisode.ItemId}",
_ => file.Path
};
case JellyfinMovie jellyfinMovie:
{
string sig = InternalUrlSigner.Sign(exp, "jellyfin", $"{jellyfinMovie.ItemId}");
return $"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{jellyfinMovie.ItemId}?exp={exp.ToUnixTimeSeconds()}&sig={sig}";
}
case JellyfinEpisode jellyfinEpisode:
{
string sig = InternalUrlSigner.Sign(exp, "jellyfin", $"{jellyfinEpisode.ItemId}");
return $"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{jellyfinEpisode.ItemId}?exp={exp.ToUnixTimeSeconds()}&sig={sig}";
}
case EmbyMovie embyMovie:
{
string sig = InternalUrlSigner.Sign(exp, "emby", $"{embyMovie.ItemId}");
return $"http://localhost:{Settings.StreamingPort}/internal/media/emby/{embyMovie.ItemId}?exp={exp.ToUnixTimeSeconds()}&sig={sig}";
}
case EmbyEpisode embyEpisode:
{
string sig = InternalUrlSigner.Sign(exp, "emby", $"{embyEpisode.ItemId}");
return $"http://localhost:{Settings.StreamingPort}/internal/media/emby/{embyEpisode.ItemId}?exp={exp.ToUnixTimeSeconds()}&sig={sig}";
}
default:
return file.Path;
}
}
private bool FileDoesntExist(int mediaItemId, Subtitle subtitle)

25
ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs

@ -2,6 +2,7 @@ using Dapper; @@ -2,6 +2,7 @@ using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Security;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -85,7 +86,15 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory<TvContext> dbContextFa @@ -85,7 +86,15 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory<TvContext> dbContextFa
{
foreach (string subtitlePath in maybeSubtitle.Map(s => s.Path))
{
return $"http://localhost:{Settings.StreamingPort}/internal/media/plex/{plexMediaSourceId}/{subtitlePath}";
DateTimeOffset exp = DateTimeOffset.Now + TimeSpan.FromHours(1);
string sig = InternalUrlSigner.Sign(
exp,
"plex",
$"{plexMediaSourceId}",
subtitlePath);
return $"http://localhost:{Settings.StreamingPort}/internal/media/plex/{plexMediaSourceId}/{subtitlePath}?exp={exp.ToUnixTimeSeconds()}&sig={sig}";
}
}
@ -126,7 +135,12 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory<TvContext> dbContextFa @@ -126,7 +135,12 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory<TvContext> dbContextFa
string extension = Subtitle.ExtensionForCodec(subtitle.Codec);
var subtitlePath =
$"Videos/{jellyfinItemId}/{jellyfinItemId}/Subtitles/{index}/{index}/Stream.{extension}";
return $"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{subtitlePath}";
DateTimeOffset exp = DateTimeOffset.Now + TimeSpan.FromHours(1);
string sig = InternalUrlSigner.Sign(exp, "jellyfin", subtitlePath);
return $"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{subtitlePath}?exp={exp.ToUnixTimeSeconds()}&sig={sig}";
}
}
@ -166,7 +180,12 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory<TvContext> dbContextFa @@ -166,7 +180,12 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory<TvContext> dbContextFa
string extension = Subtitle.ExtensionForCodec(subtitle.Codec);
var subtitlePath =
$"Videos/{embyItemId}/{subtitle.Path}/Subtitles/{subtitle.StreamIndex}/Stream.{extension}";
return $"http://localhost:{Settings.StreamingPort}/internal/media/emby/{subtitlePath}";
DateTimeOffset exp = DateTimeOffset.Now + TimeSpan.FromHours(1);
string sig = InternalUrlSigner.Sign(exp, "emby", subtitlePath);
return $"http://localhost:{Settings.StreamingPort}/internal/media/emby/{subtitlePath}?exp={exp.ToUnixTimeSeconds()}&sig={sig}";
}
}

7
ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSampleHandler.cs

@ -114,6 +114,13 @@ public class ArchiveMediaSampleHandler( @@ -114,6 +114,13 @@ public class ArchiveMediaSampleHandler(
}
}
bool isRemoteUri = Uri.TryCreate(mediaItemPath, UriKind.Absolute, out Uri uriResult)
&& (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps);
if (isRemoteUri && extension.Contains('?'))
{
extension = extension.Split('?')[0];
}
string tempPath = Path.GetTempPath();
string fileName = Path.ChangeExtension(Guid.NewGuid().ToString(), extension);
string outputPath = Path.Combine(tempPath, fileName);

24
ErsatzTV.Application/Troubleshooting/Commands/TroubleshootingHandlerBase.cs

@ -8,6 +8,7 @@ using ErsatzTV.Core.Extensions; @@ -8,6 +8,7 @@ using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Security;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -108,14 +109,21 @@ public abstract class TroubleshootingHandlerBase( @@ -108,14 +109,21 @@ public abstract class TroubleshootingHandlerBase(
{
string path = await GetLocalPath(mediaItem, cancellationToken);
DateTimeOffset exp = DateTimeOffset.Now + TimeSpan.FromMinutes(15);
// check filesystem first
if (_fileSystem.File.Exists(path))
{
if (mediaItem is RemoteStream remoteStream)
{
string sig = InternalUrlSigner.Sign(
exp,
"remote-stream",
$"{remoteStream.Id}");
path = !string.IsNullOrWhiteSpace(remoteStream.Url)
? remoteStream.Url
: $"http://localhost:{Settings.StreamingPort}/internal/ffmpeg/remote-stream/{remoteStream.Id}";
: $"http://localhost:{Settings.StreamingPort}/internal/ffmpeg/remote-stream/{remoteStream.Id}?exp={exp.ToUnixTimeSeconds()}&sig={sig}";
}
return path;
@ -136,7 +144,13 @@ public abstract class TroubleshootingHandlerBase( @@ -136,7 +144,13 @@ public abstract class TroubleshootingHandlerBase(
foreach (int plexMediaSourceId in maybeId)
{
return $"http://localhost:{Settings.StreamingPort}/internal/media/plex/{plexMediaSourceId}/{pmf.Key}";
string sig = InternalUrlSigner.Sign(
exp,
"plex",
$"{plexMediaSourceId}",
$"{pmf.Key}");
return $"http://localhost:{Settings.StreamingPort}/internal/media/plex/{plexMediaSourceId}/{pmf.Key}?exp={exp.ToUnixTimeSeconds()}&sig={sig}";
}
break;
@ -152,7 +166,8 @@ public abstract class TroubleshootingHandlerBase( @@ -152,7 +166,8 @@ public abstract class TroubleshootingHandlerBase(
foreach (string itemId in jellyfinItemId)
{
return $"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{itemId}";
string sig = InternalUrlSigner.Sign(exp, "jellyfin", $"{itemId}");
return $"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{itemId}?exp={exp.ToUnixTimeSeconds()}&sig={sig}";
}
// attempt to remotely stream emby
@ -165,7 +180,8 @@ public abstract class TroubleshootingHandlerBase( @@ -165,7 +180,8 @@ public abstract class TroubleshootingHandlerBase(
foreach (string itemId in embyItemId)
{
return $"http://localhost:{Settings.StreamingPort}/internal/media/emby/{itemId}";
string sig = InternalUrlSigner.Sign(exp, "emby", $"{itemId}");
return $"http://localhost:{Settings.StreamingPort}/internal/media/emby/{itemId}?exp={exp.ToUnixTimeSeconds()}&sig={sig}";
}
return null;

23
ErsatzTV.Core.Tests/Security/InternalUrlSignerTests.cs

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
using ErsatzTV.Core.Security;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Security;
[TestFixture]
public class InternalUrlSignerTests
{
[Test]
public void Verify_Alpha_Exp_Should_Not_Throw()
{
bool actual = InternalUrlSigner.Verify("abc", "sig");
actual.ShouldBeFalse();
}
[Test]
public void Verify_Long_Exp_Should_Not_Throw()
{
bool actual = InternalUrlSigner.Verify("99999999999999", "sig");
actual.ShouldBeFalse();
}
}

6
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -9,6 +9,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg; @@ -9,6 +9,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Security;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Format;
@ -179,8 +180,11 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -179,8 +180,11 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
if (subtitle.SubtitleKind == SubtitleKind.Sidecar || subtitle is
{ SubtitleKind: SubtitleKind.Embedded, IsImage: false, IsExtracted: true })
{
DateTimeOffset exp = finish + TimeSpan.FromHours(2);
string sig = InternalUrlSigner.Sign(exp, "subtitle", $"{subtitle.Id}");
// proxy to avoid dealing with escaping
subtitle.Path = $"http://localhost:{Settings.StreamingPort}/internal/media/subtitle/{subtitle.Id}";
subtitle.Path = $"http://localhost:{Settings.StreamingPort}/internal/media/subtitle/{subtitle.Id}?exp={exp.ToUnixTimeSeconds()}&sig={sig}";
foreach (TimeSpan seek in playbackSettings.StreamSeek)
{

40
ErsatzTV.Core/Security/InternalUrlSigner.cs

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace ErsatzTV.Core.Security;
public static class InternalUrlSigner
{
private static readonly byte[] Key = RandomNumberGenerator.GetBytes(32);
public static string Sign(DateTimeOffset expires, params string[] parts)
{
string canonical = string.Join('\0', parts) + '\0' + expires.ToUnixTimeSeconds();
byte[] bytes = Encoding.UTF8.GetBytes(canonical);
using HMACSHA256 hmac = new HMACSHA256(Key);
byte[] hash = hmac.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static bool Verify(string exp, string sig, params string[] parts)
{
if (!long.TryParse(exp, CultureInfo.InvariantCulture, out long num))
{
return false;
}
try
{
DateTimeOffset expires = DateTimeOffset.FromUnixTimeSeconds(num);
string expected = Sign(expires, parts);
byte[] expectedBytes = Encoding.UTF8.GetBytes(expected);
byte[] actualBytes = Encoding.UTF8.GetBytes(sig);
return DateTimeOffset.Now < expires && CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes);
}
catch (ArgumentOutOfRangeException)
{
return false;
}
}
}

10
ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs

@ -11,6 +11,7 @@ using ErsatzTV.Core.Domain; @@ -11,6 +11,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Security;
using ErsatzTV.FFmpeg.Capabilities;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
@ -680,9 +681,16 @@ public partial class LocalStatisticsProvider : ILocalStatisticsProvider @@ -680,9 +681,16 @@ public partial class LocalStatisticsProvider : ILocalStatisticsProvider
if (mediaItem is RemoteStream remoteStream)
{
DateTimeOffset exp = DateTimeOffset.Now + TimeSpan.FromMinutes(15);
string sig = InternalUrlSigner.Sign(
exp,
"remote-stream",
$"{remoteStream.Id}");
path = !string.IsNullOrWhiteSpace(remoteStream.Url)
? remoteStream.Url
: $"http://localhost:{Settings.StreamingPort}/internal/ffmpeg/remote-stream/{remoteStream.Id}";
: $"http://localhost:{Settings.StreamingPort}/internal/ffmpeg/remote-stream/{remoteStream.Id}?exp={exp.ToUnixTimeSeconds()}&sig={sig}";
}
return Task.FromResult(path);

26
ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItemService.cs

@ -12,6 +12,7 @@ using ErsatzTV.Core.Interfaces.Jellyfin; @@ -12,6 +12,7 @@ using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Security;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -230,14 +231,21 @@ public class DynamicPlayoutItemService( @@ -230,14 +231,21 @@ public class DynamicPlayoutItemService(
return new PlayoutItemWithPath(playoutItem, path);
}
DateTimeOffset exp = playoutItem.FinishOffset + TimeSpan.FromHours(2);
// check filesystem first
if (fileSystem.File.Exists(path))
{
if (playoutItem.MediaItem is RemoteStream remoteStream)
{
string sig = InternalUrlSigner.Sign(
exp,
"remote-stream",
$"{remoteStream.Id}");
path = !string.IsNullOrWhiteSpace(remoteStream.Url)
? remoteStream.Url
: $"http://localhost:{Settings.StreamingPort}/internal/ffmpeg/remote-stream/{remoteStream.Id}";
: $"http://localhost:{Settings.StreamingPort}/internal/ffmpeg/remote-stream/{remoteStream.Id}?exp={exp.ToUnixTimeSeconds()}&sig={sig}";
}
return new PlayoutItemWithPath(playoutItem, path);
@ -258,9 +266,15 @@ public class DynamicPlayoutItemService( @@ -258,9 +266,15 @@ public class DynamicPlayoutItemService(
foreach (int plexMediaSourceId in maybeId)
{
string sig = InternalUrlSigner.Sign(
exp,
"plex",
$"{plexMediaSourceId}",
$"{pmf.Key}");
return new PlayoutItemWithPath(
playoutItem,
$"http://localhost:{Settings.StreamingPort}/internal/media/plex/{plexMediaSourceId}/{pmf.Key}");
$"http://localhost:{Settings.StreamingPort}/internal/media/plex/{plexMediaSourceId}/{pmf.Key}?exp={exp.ToUnixTimeSeconds()}&sig={sig}");
}
break;
@ -276,9 +290,11 @@ public class DynamicPlayoutItemService( @@ -276,9 +290,11 @@ public class DynamicPlayoutItemService(
foreach (string itemId in jellyfinItemId)
{
string sig = InternalUrlSigner.Sign(exp, "jellyfin", $"{itemId}");
return new PlayoutItemWithPath(
playoutItem,
$"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{itemId}");
$"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{itemId}?exp={exp.ToUnixTimeSeconds()}&sig={sig}");
}
// attempt to remotely stream emby
@ -291,9 +307,11 @@ public class DynamicPlayoutItemService( @@ -291,9 +307,11 @@ public class DynamicPlayoutItemService(
foreach (string itemId in embyItemId)
{
string sig = InternalUrlSigner.Sign(exp, "emby", $"{itemId}");
return new PlayoutItemWithPath(
playoutItem,
$"http://localhost:{Settings.StreamingPort}/internal/media/emby/{itemId}");
$"http://localhost:{Settings.StreamingPort}/internal/media/emby/{itemId}?exp={exp.ToUnixTimeSeconds()}&sig={sig}");
}
return new PlayoutItemDoesNotExistOnDisk(path);

23
ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs

@ -12,6 +12,7 @@ using ErsatzTV.Core.Interfaces.Jellyfin; @@ -12,6 +12,7 @@ using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Security;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Data;
@ -240,12 +241,16 @@ public class PlayoutItemConverter( @@ -240,12 +241,16 @@ public class PlayoutItemConverter(
PlayoutItem playoutItem,
CancellationToken cancellationToken)
{
DateTimeOffset exp = playoutItem.FinishOffset + TimeSpan.FromHours(2);
if (playoutItem is DynamicPlayoutItem)
{
string sig = InternalUrlSigner.Sign(exp, "fallback");
return new Core.Next.Source
{
SourceType = Core.Next.SourceType.Dynamic,
Uri = $"http://localhost:{Settings.StreamingPort}/internal/media/fallback"
Uri = $"http://localhost:{Settings.StreamingPort}/internal/media/fallback?exp={exp.ToUnixTimeSeconds()}&sig={sig}"
};
}
@ -317,10 +322,16 @@ public class PlayoutItemConverter( @@ -317,10 +322,16 @@ public class PlayoutItemConverter(
int mediaSourceId = playoutItem.MediaItem.LibraryPath.Library.MediaSourceId;
if (file is PlexMediaFile pmf)
{
string sig = InternalUrlSigner.Sign(
exp,
"plex",
$"{mediaSourceId}",
$"{pmf.Key}");
return new Core.Next.Source
{
SourceType = Core.Next.SourceType.Http,
Uri = $"http://localhost:{Settings.StreamingPort}/internal/media/plex/{mediaSourceId}/{pmf.Key}",
Uri = $"http://localhost:{Settings.StreamingPort}/internal/media/plex/{mediaSourceId}/{pmf.Key}?exp={exp.ToUnixTimeSeconds()}&sig={sig}",
KeepAlive = false,
Reconnect = true
};
@ -335,10 +346,12 @@ public class PlayoutItemConverter( @@ -335,10 +346,12 @@ public class PlayoutItemConverter(
foreach (string itemId in jellyfinItemId)
{
string sig = InternalUrlSigner.Sign(exp, "jellyfin", $"{itemId}");
return new Core.Next.Source
{
SourceType = Core.Next.SourceType.Http,
Uri = $"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{itemId}",
Uri = $"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{itemId}?exp={exp.ToUnixTimeSeconds()}&sig={sig}",
KeepAlive = false,
Reconnect = true
};
@ -354,10 +367,12 @@ public class PlayoutItemConverter( @@ -354,10 +367,12 @@ public class PlayoutItemConverter(
foreach (string itemId in embyItemId)
{
string sig = InternalUrlSigner.Sign(exp, "emby", $"{itemId}");
return new Core.Next.Source
{
SourceType = Core.Next.SourceType.Http,
Uri = $"http://localhost:{Settings.StreamingPort}/internal/media/emby/{itemId}",
Uri = $"http://localhost:{Settings.StreamingPort}/internal/media/emby/{itemId}?exp={exp.ToUnixTimeSeconds()}&sig={sig}",
KeepAlive = false,
Reconnect = true
};

15
ErsatzTV.Infrastructure/Streaming/ExternalJsonPlayoutItemProvider.cs

@ -8,6 +8,7 @@ using ErsatzTV.Core.Interfaces.Metadata; @@ -8,6 +8,7 @@ using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Plex;
using ErsatzTV.Core.Security;
using ErsatzTV.Core.Streaming;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@ -223,9 +224,19 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider @@ -223,9 +224,19 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
_ => await GetPlexMovie(server, connection, token, program)
};
PlayoutItem playoutItem = GetPlayoutItem(startTime, mediaItem, program);
DateTimeOffset exp = playoutItem.FinishOffset + TimeSpan.FromHours(2);
string sig = InternalUrlSigner.Sign(
exp,
"plex",
$"{server.Id}",
$"{program.PlexFile}");
return new PlayoutItemWithPath(
GetPlayoutItem(startTime, mediaItem, program),
$"http://localhost:{Settings.StreamingPort}/internal/media/plex/{server.Id}/{program.PlexFile}");
playoutItem,
$"http://localhost:{Settings.StreamingPort}/internal/media/plex/{server.Id}/{program.PlexFile}?exp={exp.ToUnixTimeSeconds()}&sig={sig}");
}
}
}

2
ErsatzTV/Controllers/ArtworkController.cs

@ -196,7 +196,7 @@ public class ArtworkController : ControllerBase @@ -196,7 +196,7 @@ public class ArtworkController : ControllerBase
HttpContext.Response.RegisterForDispose(client);
client.DefaultRequestHeaders.Add("X-Plex-Token", r.AuthToken);
var fullPath = new Uri(r.Uri, transcodePath);
var fullPath = new Uri(new Uri(r.Address), transcodePath);
HttpResponseMessage response = await client.GetAsync(
fullPath,
HttpCompletionOption.ResponseHeadersRead,

71
ErsatzTV/Controllers/InternalController.cs

@ -14,6 +14,7 @@ using ErsatzTV.Core.Domain; @@ -14,6 +14,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Security;
using ErsatzTV.Extensions;
using ErsatzTV.FFmpeg;
using ErsatzTV.Infrastructure.Data;
@ -81,8 +82,20 @@ public class InternalController : StreamingControllerBase @@ -81,8 +82,20 @@ public class InternalController : StreamingControllerBase
}
[HttpGet("ffmpeg/remote-stream/{remoteStreamId}")]
public async Task<IActionResult> GetRemoteStream(int remoteStreamId, CancellationToken cancellationToken)
public async Task<IActionResult> GetRemoteStream(
int remoteStreamId,
[FromQuery]
string exp,
[FromQuery]
string sig,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(exp) || string.IsNullOrWhiteSpace(sig) ||
!InternalUrlSigner.Verify(exp, sig, "remote-stream", $"{remoteStreamId}"))
{
return NotFound();
}
Option<RemoteStreamViewModel> maybeRemoteStream =
await _mediator.Send(new GetRemoteStreamById(remoteStreamId), cancellationToken);
@ -139,8 +152,16 @@ public class InternalController : StreamingControllerBase @@ -139,8 +152,16 @@ public class InternalController : StreamingControllerBase
public async Task<IActionResult> GetPlexMedia(
int plexMediaSourceId,
string path,
[FromQuery] string exp,
[FromQuery] string sig,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(exp) || string.IsNullOrWhiteSpace(sig) ||
!InternalUrlSigner.Verify(exp, sig, "plex", $"{plexMediaSourceId}", path))
{
return NotFound();
}
#if DEBUG_NO_SYNC
await Task.Delay(100, cancellationToken);
return NotFound();
@ -152,15 +173,28 @@ public class InternalController : StreamingControllerBase @@ -152,15 +173,28 @@ public class InternalController : StreamingControllerBase
Left: _ => new NotFoundResult(),
Right: r =>
{
Url fullPath = new Uri(r.Uri, path).SetQueryParam("X-Plex-Token", r.AuthToken);
Url fullPath = Flurl.Url.Parse(r.Address)
.AppendPathSegment(path)
.SetQueryParam("X-Plex-Token", r.AuthToken);
return new RedirectResult(fullPath.ToString());
});
#endif
}
[HttpGet("media/jellyfin/{*path}")]
public async Task<IActionResult> GetJellyfinMedia(string path, CancellationToken cancellationToken)
public async Task<IActionResult> GetJellyfinMedia(
string path,
[FromQuery] string exp,
[FromQuery] string sig,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(exp) || string.IsNullOrWhiteSpace(sig) ||
!InternalUrlSigner.Verify(exp, sig, "jellyfin", path))
{
return NotFound();
}
Either<BaseError, JellyfinConnectionParametersViewModel> connectionParameters =
await _mediator.Send(new GetJellyfinConnectionParameters(), cancellationToken);
@ -189,8 +223,18 @@ public class InternalController : StreamingControllerBase @@ -189,8 +223,18 @@ public class InternalController : StreamingControllerBase
}
[HttpGet("media/emby/{*path}")]
public async Task<IActionResult> GetEmbyMedia(string path, CancellationToken cancellationToken)
public async Task<IActionResult> GetEmbyMedia(
string path,
[FromQuery] string exp,
[FromQuery] string sig,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(exp) || string.IsNullOrWhiteSpace(sig) ||
!InternalUrlSigner.Verify(exp, sig, "emby", path))
{
return NotFound();
}
Either<BaseError, EmbyConnectionParametersViewModel> connectionParameters =
await _mediator.Send(new GetEmbyConnectionParameters(), cancellationToken);
@ -223,9 +267,17 @@ public class InternalController : StreamingControllerBase @@ -223,9 +267,17 @@ public class InternalController : StreamingControllerBase
[HttpGet("media/subtitle/{id:int}")]
public async Task<IActionResult> GetSubtitle(
int id,
[FromQuery] string exp,
[FromQuery] string sig,
[FromQuery] long? seekToMs,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(exp) || string.IsNullOrWhiteSpace(sig) ||
!InternalUrlSigner.Verify(exp, sig, "subtitle", $"{id}"))
{
return NotFound();
}
Either<BaseError, SubtitlePathAndCodec> maybePath = await _mediator.Send(
new GetSubtitlePathById(id),
cancellationToken);
@ -301,8 +353,17 @@ public class InternalController : StreamingControllerBase @@ -301,8 +353,17 @@ public class InternalController : StreamingControllerBase
}
[HttpGet("media/fallback")]
public async Task<IActionResult> GetFallbackPlayoutJson(CancellationToken cancellationToken)
public async Task<IActionResult> GetFallbackPlayoutJson(
[FromQuery] string exp,
[FromQuery] string sig,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(exp) || string.IsNullOrWhiteSpace(sig) ||
!InternalUrlSigner.Verify(exp, sig, "fallback"))
{
return NotFound();
}
if (!Request.Headers.TryGetValue("x-etv-channel", out StringValues channelNumber) || channelNumber.Count != 1)
{
return BadRequest();

42
ErsatzTV/Services/SchedulerService.cs

@ -50,18 +50,26 @@ public class SchedulerService : BackgroundService @@ -50,18 +50,26 @@ public class SchedulerService : BackgroundService
{
await Task.Yield();
await _systemStartup.WaitForSearchIndex(stoppingToken);
if (stoppingToken.IsCancellationRequested)
{
return;
}
try
{
await _systemStartup.WaitForDatabase(stoppingToken);
if (stoppingToken.IsCancellationRequested)
{
return;
}
_logger.LogInformation("Scheduler service started");
DateTime firstRun = DateTime.Now;
await SyncAllNextPlayouts(stoppingToken);
await _systemStartup.WaitForSearchIndex(stoppingToken);
if (stoppingToken.IsCancellationRequested)
{
return;
}
// run once immediately at startup
if (!stoppingToken.IsCancellationRequested)
{
@ -386,4 +394,26 @@ public class SchedulerService : BackgroundService @@ -386,4 +394,26 @@ public class SchedulerService : BackgroundService
private ValueTask QueueFFmpegCapabilitiesRefresh(CancellationToken cancellationToken) =>
_workerChannel.WriteAsync(new RefreshFFmpegCapabilities(), cancellationToken);
private async Task SyncAllNextPlayouts(CancellationToken cancellationToken)
{
try
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
List<Core.Domain.Channel> channels = await dbContext.Channels
.AsNoTracking()
.ToListAsync(cancellationToken);
foreach (Core.Domain.Channel channel in channels)
{
await _workerChannel.WriteAsync(new SyncNextPlayout(channel.Number), cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error synchronizing all next playouts");
}
}
}

Loading…
Cancel
Save