Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m2s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 14s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 47s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
`GetChannelGuideHandler` interpolated `request.AccessToken` (HTTP-request- derived, from `?access_token=`) raw into the pre-built XMLTV cache fragments. A token containing `&`, `<`, `>`, or `"` would emit invalid XML and malform the entire guide. Escape it with `SecurityElement.Escape`, consistent with how #340 escaped `{RequestBase}`. The M3U path (`ChannelPlaylist.ToM3U`) also interpolates the token but M3U is not XML, so escaping there is neither needed nor correct — left unchanged. Regression test `Guide_xml_escapes_access_token` drives the real handler with a token containing all four XML-special chars and asserts the output is escaped (sibling to the #340 `Guide_xml_escapes_advertised_base_url` test). Verified non-vacuous: it fails with the escape reverted. fixes #376 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
129 lines
5.1 KiB
C#
129 lines
5.1 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 &), 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 value is HTTP-request-derived and interpolated raw into the pre-built XMLTV
|
|
// cache fragments, so it must be XML-escaped like {RequestBase} above — a token containing
|
|
// '&', '<', '>', or '"' would otherwise malform the whole guide. Opaque tokens are a no-op.
|
|
accessTokenUri += $"&access_token={SecurityElement.Escape(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();
|
|
}
|