Files
ersatztv/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs
T
timothyandtimothy 0c063c23fb
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m45s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m27s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m10s
harden(421,559): percent-encode access_token in IPTV URLs, redact from logs, no-store on tokened manifests (#574)
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-23 16:30:59 +00:00

130 lines
5.2 KiB
C#

using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Security;
using System.Text;
using System.Text.RegularExpressions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.IO;
namespace ErsatzTV.Application.Channels;
public partial class GetChannelGuideHandler(
IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
{
public async Task<Either<BaseError, ChannelGuide>> Handle(
GetChannelGuide request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<string> maybeBaseUrl =
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
maybeBaseUrl.IfNone(string.Empty),
request.Scheme,
request.Host,
request.BaseUrl);
// The cache fragments are pre-built XML written raw (like {AccessTokenUri}, which is already
// emitted as &amp;), so the substituted base must be XML-escaped. A path prefix can legally
// contain '&' (Uri keeps it out of the query), which would otherwise emit a bare '&' and
// malform the whole guide. Normal URLs have no special chars, so this is a no-op for them.
string requestBase = SecurityElement.Escape($"{scheme}://{host}{baseUrl}");
var hiddenChannelNumbers = dbContext.Channels
.Where(c => c.ShowInEpg == false)
.Select(c => c.Number)
.AsEnumerable()
.Select(n => $"{n}.xml")
.ToImmutableHashSet();
string channelsFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!fileSystem.File.Exists(channelsFile))
{
return BaseError.New($"Required file {channelsFile} is missing");
}
long mtime = fileSystem.File.GetLastWriteTime(channelsFile).Ticks;
var accessTokenUri = $"?v={mtime}";
if (!string.IsNullOrWhiteSpace(request.AccessToken))
{
// The token lands in a URL query value inside an XMLTV attribute, so it needs BOTH layers:
// percent-encode first (#421 — a token with '&' would otherwise split the query and truncate
// the token once the consumer URL-decodes the attribute; mirrors the M3U fix), then XML-escape
// the result so it can't malform the guide (#376). Both are no-ops for an opaque base64url token.
accessTokenUri += $"&amp;access_token={SecurityElement.Escape(Uri.EscapeDataString(request.AccessToken))}";
}
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
// TODO: is regex faster?
channelsFragment = channelsFragment
.Replace("{RequestBase}", requestBase)
.Replace("{AccessTokenUri}", accessTokenUri);
var channelDataFragments = new Dictionary<string, string>();
foreach (string fileName in localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
{
if (fileName.Contains("channels"))
{
continue;
}
if (hiddenChannelNumbers.Contains(fileSystem.Path.GetFileName(fileName)))
{
continue;
}
try
{
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
channelDataFragment = channelDataFragment
.Replace("{RequestBase}", requestBase)
.Replace("{AccessTokenUri}", accessTokenUri);
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
channelDataFragments.Add(fileSystem.Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
}
catch (FileNotFoundException)
{
// ignore this channel fragment
}
catch (IOException)
{
// ignore this channel fragment
}
}
return new ChannelGuide(recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
}
private async Task<string> ReadAllTextShared(string fileName, CancellationToken cancellationToken)
{
await using var stream = fileSystem.FileStream.New(
fileName,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
return await reader.ReadToEndAsync(cancellationToken);
}
[GeneratedRegex(@"<etv:[^>]+?>.*?<\/etv:[^>]+?>|<etv:[^>]+?\/>", RegexOptions.Singleline)]
private static partial Regex EtvTagRegex();
}