Files
MBN_STOCK_WEBVIEW/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs

654 lines
23 KiB
C#

using System.Text.RegularExpressions;
using System.Net;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
internal sealed record ValidatedPlayoutOptions(
PlayoutMode Mode,
string Host,
int Port,
int TcpMode,
int ClientPort,
int? OutputChannel,
int LayoutIndex,
string? SceneDirectory,
string? OperatorBackgroundDirectory,
Regex? TestProcessWindowTitleRegex,
IReadOnlySet<string> TestSceneAllowlist,
bool TrustedLiveOutputEnabled,
int QueueCapacity,
TimeSpan ConnectTimeout,
TimeSpan OperationTimeout,
TimeSpan DisconnectTimeout,
TimeSpan ProcessPollInterval,
TimeSpan ReconnectDelay,
int MaximumReconnectAttempts,
bool ReconnectEnabled)
{
public static ValidatedPlayoutOptions Create(PlayoutOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (!Enum.IsDefined(options.Mode))
{
throw Invalid(nameof(options.Mode));
}
var host = RequiredText(options.Host, nameof(options.Host));
RequireRange(options.Port, 1, 65_535, nameof(options.Port));
RequireRange(options.TcpMode, 0, 1, nameof(options.TcpMode));
if (options.Mode is PlayoutMode.Test or PlayoutMode.Live && options.TcpMode != 1)
{
throw Invalid(nameof(options.TcpMode));
}
RequireRange(options.ClientPort, 0, 65_535, nameof(options.ClientPort));
if (options.OutputChannel is { } outputChannel)
{
RequireRange(outputChannel, 0, int.MaxValue, nameof(options.OutputChannel));
}
if (options.LayoutIndex != K3dComConstants.LegacyLayoutIndex)
{
throw Invalid(nameof(options.LayoutIndex));
}
string? sceneDirectory = null;
if (options.Mode is PlayoutMode.Test or PlayoutMode.Live)
{
sceneDirectory = ValidateSceneDirectory(options.SceneDirectory);
}
var operatorBackgroundDirectory =
PlayoutSceneCompositionFactory.TryGetConfiguredBackgroundDirectory(
options,
out var configuredBackgroundDirectory)
? configuredBackgroundDirectory
: null;
RequireRange(options.QueueCapacity, 1, 4_096, nameof(options.QueueCapacity));
RequireRange(
options.ConnectTimeoutMilliseconds,
100,
300_000,
nameof(options.ConnectTimeoutMilliseconds));
RequireRange(
options.OperationTimeoutMilliseconds,
100,
300_000,
nameof(options.OperationTimeoutMilliseconds));
RequireRange(
options.DisconnectTimeoutMilliseconds,
100,
300_000,
nameof(options.DisconnectTimeoutMilliseconds));
RequireRange(
options.ProcessPollIntervalMilliseconds,
100,
60_000,
nameof(options.ProcessPollIntervalMilliseconds));
RequireRange(
options.ReconnectDelayMilliseconds,
0,
300_000,
nameof(options.ReconnectDelayMilliseconds));
RequireRange(
options.MaximumReconnectAttempts,
0,
1_000,
nameof(options.MaximumReconnectAttempts));
Regex? titleRegex = null;
var allowlist = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in options.TestSceneAllowlist ?? [])
{
var value = item?.Trim();
if (!IsSafeSceneCode(value))
{
throw Invalid(nameof(options.TestSceneAllowlist));
}
allowlist.Add(value!);
}
if (options.Mode is PlayoutMode.Test or PlayoutMode.Live && allowlist.Count == 0)
{
throw Invalid(nameof(options.TestSceneAllowlist));
}
if (options.Mode == PlayoutMode.Test)
{
if (!IsLiteralLoopback(host))
{
throw Invalid(nameof(options.Host));
}
if (options.OutputChannel is null)
{
throw Invalid(nameof(options.OutputChannel));
}
var pattern = RequiredText(
options.TestProcessWindowTitlePattern,
nameof(options.TestProcessWindowTitlePattern));
try
{
titleRegex = new Regex(
pattern,
RegexOptions.IgnoreCase |
RegexOptions.CultureInvariant |
RegexOptions.NonBacktracking,
TimeSpan.FromMilliseconds(250));
}
catch (ArgumentException exception)
{
throw new PlayoutConfigurationException(
"테스트 Tornado 창 제목 설정이 올바르지 않습니다.", exception);
}
if (!pattern.Contains("TEST", StringComparison.OrdinalIgnoreCase) ||
CanMatchProgramTitle(titleRegex))
{
throw new PlayoutConfigurationException(
"테스트 창 제목 규칙은 명시적인 TEST 표식이 필요하며 PROGRAM 또는 비식별 출력을 가리킬 수 없습니다.");
}
}
return new ValidatedPlayoutOptions(
options.Mode,
host,
options.Port,
options.TcpMode,
options.ClientPort,
options.OutputChannel,
options.LayoutIndex,
sceneDirectory,
operatorBackgroundDirectory,
titleRegex,
allowlist,
options.TrustedLiveOutputEnabled,
options.QueueCapacity,
TimeSpan.FromMilliseconds(options.ConnectTimeoutMilliseconds),
TimeSpan.FromMilliseconds(options.OperationTimeoutMilliseconds),
TimeSpan.FromMilliseconds(options.DisconnectTimeoutMilliseconds),
TimeSpan.FromMilliseconds(options.ProcessPollIntervalMilliseconds),
TimeSpan.FromMilliseconds(options.ReconnectDelayMilliseconds),
options.MaximumReconnectAttempts,
options.ReconnectEnabled);
}
public bool IsTestSceneAllowed(PlayoutCue cue)
=> IsOutputSceneAllowed(cue);
/// <summary>
/// Closed scene allowlist applied to every real Test or Live mutation command.
/// The legacy property name is retained for local-config compatibility.
/// </summary>
public bool IsOutputSceneAllowed(PlayoutCue cue)
{
var sceneName = cue.SceneName?.Trim();
return !string.IsNullOrEmpty(sceneName) && TestSceneAllowlist.Contains(sceneName);
}
public PlayoutCue ResolveCue(PlayoutCue cue)
{
ArgumentNullException.ThrowIfNull(cue);
var sceneName = cue.SceneName?.Trim();
if (!IsSafeSceneCode(sceneName))
{
throw new PlayoutRequestException("장면 이름이 올바르지 않습니다.");
}
if (cue.FadeDuration is < 0 or > 60)
{
throw new PlayoutRequestException("장면 전환 시간이 올바르지 않습니다.");
}
foreach (var field in cue.Fields ?? [])
{
if (string.IsNullOrWhiteSpace(field.ObjectName) ||
field.ObjectName.Length > 128 ||
field.ObjectName.Any(char.IsControl) ||
(field.Value?.Length ?? 0) > 16_384)
{
throw new PlayoutRequestException("장면 필드 값이 올바르지 않습니다.");
}
}
var mutations = ResolveMutations(cue.Mutations);
var relativePath = cue.SceneFile?.Trim();
if (string.IsNullOrEmpty(relativePath) || Path.IsPathRooted(relativePath))
{
throw new PlayoutRequestException("장면 파일 이름이 올바르지 않습니다.");
}
if (!string.Equals(relativePath, Path.GetFileName(relativePath), StringComparison.Ordinal) ||
relativePath.Contains("..", StringComparison.Ordinal))
{
throw new PlayoutRequestException("장면 파일 이름이 올바르지 않습니다.");
}
if (!string.Equals(Path.GetExtension(relativePath), ".t2s", StringComparison.OrdinalIgnoreCase))
{
throw new PlayoutRequestException("장면 파일 형식이 올바르지 않습니다.");
}
var fileSceneName = Path.GetFileNameWithoutExtension(relativePath);
if (!string.Equals(fileSceneName, sceneName, StringComparison.OrdinalIgnoreCase))
{
throw new PlayoutRequestException("장면 이름과 장면 파일이 일치하지 않습니다.");
}
if (SceneDirectory is null)
{
throw new PlayoutRequestException("장면 폴더가 설정되지 않았습니다.");
}
var candidate = Path.GetFullPath(Path.Combine(SceneDirectory, relativePath));
var rootPrefix = SceneDirectory.EndsWith(Path.DirectorySeparatorChar)
? SceneDirectory
: SceneDirectory + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
!File.Exists(candidate) ||
HasReparsePointBetween(SceneDirectory, candidate))
{
throw new PlayoutRequestException("허용된 장면 파일을 찾을 수 없습니다.");
}
return cue with
{
SceneFile = candidate,
SceneName = sceneName!,
Mutations = mutations
};
}
private IReadOnlyList<PlayoutMutation>? ResolveMutations(
IReadOnlyList<PlayoutMutation>? mutations)
{
if (mutations is null)
{
return null;
}
if (mutations.Count > 100_000)
{
throw new PlayoutRequestException("장면 변경 항목이 너무 많습니다.");
}
var resolved = new PlayoutMutation[mutations.Count];
for (var index = 0; index < mutations.Count; index++)
{
resolved[index] = ResolveMutation(mutations[index]);
}
return resolved;
}
private PlayoutMutation ResolveMutation(PlayoutMutation? mutation)
{
if (mutation is null)
{
throw new PlayoutRequestException("장면 변경 항목이 올바르지 않습니다.");
}
switch (mutation)
{
case PlayoutSetValue value:
ValidateObjectName(value.ObjectName);
ValidateText(value.Value);
return value;
case PlayoutSetAssetValue asset:
ValidateObjectName(asset.ObjectName);
return asset with { AssetPath = ResolveAssetPath(asset.AssetPath) };
case PlayoutSetVisible visible:
ValidateObjectName(visible.ObjectName);
return visible;
case PlayoutSetFaceColor color:
ValidateObjectName(color.ObjectName);
if (!IsByte(color.Red) || !IsByte(color.Green) ||
!IsByte(color.Blue) || !IsByte(color.Alpha))
{
throw new PlayoutRequestException("장면 색상 값이 올바르지 않습니다.");
}
return color;
case PlayoutSetPosition position:
ValidateObjectName(position.ObjectName);
ValidatePoint(position.X, position.Y, position.Z);
ValidateVectorComponents(position.Components);
return position;
case PlayoutSetPositionKey positionKey:
ValidateObjectName(positionKey.ObjectName);
ValidateKeyIndex(positionKey.KeyIndex);
ValidatePoint(positionKey.X, positionKey.Y, positionKey.Z);
ValidateVectorComponents(positionKey.Components);
return positionKey;
case PlayoutSetScale scale:
ValidateObjectName(scale.ObjectName);
ValidatePoint(scale.X, scale.Y, scale.Z);
ValidateVectorComponents(scale.Components);
return scale;
case PlayoutSetCropKey crop:
ValidateObjectName(crop.ObjectName);
ValidateKeyIndex(crop.KeyIndex);
ValidatePoint(crop.Left, crop.Top, crop.Right);
if (!float.IsFinite(crop.Bottom) ||
!Enum.IsDefined(crop.Edges) || crop.Edges == 0)
{
throw new PlayoutRequestException("장면 Crop 값이 올바르지 않습니다.");
}
return crop;
case PlayoutSetCircleAngleKey angle:
ValidateObjectName(angle.ObjectName);
ValidateKeyIndex(angle.KeyIndex);
if (!float.IsFinite(angle.Start) || !float.IsFinite(angle.End) ||
!Enum.IsDefined(angle.Components) || angle.Components == 0)
{
throw new PlayoutRequestException("장면 각도 값이 올바르지 않습니다.");
}
return angle;
case PlayoutSetPathPoints path:
ValidateObjectName(path.ObjectName);
ValidatePoints(path.Points);
return path;
case PlayoutSetPathShapePoints shape:
ValidateObjectName(shape.ObjectName);
ValidatePoints(shape.Points);
return shape;
case PlayoutUseBackground background:
ValidateMutationTiming(background.Timing);
return background;
case PlayoutSetBackgroundTexture texture:
ValidateMutationTiming(texture.Timing);
ValidateAssetRoot(texture.AssetRoot);
return texture with
{
AssetPath = ResolveAssetPath(texture.AssetPath, texture.AssetRoot)
};
case PlayoutSetBackgroundVideo video:
ValidateMutationTiming(video.Timing);
ValidateAssetRoot(video.AssetRoot);
// The legacy MainForm intentionally uses 2004 for the common studio
// background. Keep the input bounded while retaining that vendor value.
if (video.LoopCount is < 0 or > 10_000)
{
throw new PlayoutRequestException("장면 배경 영상 반복 값이 올바르지 않습니다.");
}
return video with
{
AssetPath = ResolveAssetPath(video.AssetPath, video.AssetRoot)
};
default:
throw new PlayoutRequestException("지원하지 않는 장면 변경 항목입니다.");
}
}
private string ResolveAssetPath(string? relativePath)
=> ResolveAssetPath(relativePath, PlayoutAssetRoot.SceneDirectory);
private string ResolveAssetPath(string? relativePath, PlayoutAssetRoot assetRoot)
{
relativePath = relativePath?.Trim();
var root = assetRoot switch
{
PlayoutAssetRoot.SceneDirectory => SceneDirectory,
PlayoutAssetRoot.OperatorBackgroundDirectory => OperatorBackgroundDirectory,
_ => null
};
if (root is null || string.IsNullOrEmpty(relativePath) ||
Path.IsPathRooted(relativePath) || relativePath.Contains("..", StringComparison.Ordinal) ||
relativePath.Any(char.IsControl))
{
throw new PlayoutRequestException("장면 자산 경로가 올바르지 않습니다.");
}
var extension = Path.GetExtension(relativePath);
string[] allowedExtensions =
[
".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds",
".vrv", ".avi", ".mp4", ".mov"
];
if (!allowedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
throw new PlayoutRequestException("지원하지 않는 장면 자산 형식입니다.");
}
if (assetRoot == PlayoutAssetRoot.OperatorBackgroundDirectory)
{
try
{
return TrustedPlayoutAssetPath.ResolveRelativeFile(
root,
relativePath,
new HashSet<string>(allowedExtensions, StringComparer.OrdinalIgnoreCase));
}
catch (TrustedPlayoutAssetException)
{
throw new PlayoutRequestException(
"신뢰 배경 파일을 확인할 수 없습니다.");
}
}
var candidate = Path.GetFullPath(Path.Combine(root, relativePath));
var rootPrefix = root.EndsWith(Path.DirectorySeparatorChar)
? root
: root + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
!File.Exists(candidate) || HasReparsePointBetween(root, candidate))
{
throw new PlayoutRequestException("허용된 장면 자산을 찾을 수 없습니다.");
}
return candidate;
}
private static void ValidateObjectName(string? objectName)
{
if (string.IsNullOrWhiteSpace(objectName) || objectName.Length > 128 ||
objectName.Any(char.IsControl))
{
throw new PlayoutRequestException("장면 오브젝트 이름이 올바르지 않습니다.");
}
}
private static void ValidateText(string? value)
{
if (value is null || value.Length > 16_384 || value.Any(character =>
character is '\0' or '\u0001' or '\u0002' or '\u0003'))
{
throw new PlayoutRequestException("장면 텍스트 값이 올바르지 않습니다.");
}
}
private static void ValidatePoint(float x, float y, float z)
{
if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z))
{
throw new PlayoutRequestException("장면 좌표 값이 올바르지 않습니다.");
}
}
private static void ValidatePoints(IReadOnlyList<PlayoutPoint>? points)
{
if (points is null || points.Count > 10_000)
{
throw new PlayoutRequestException("장면 그래프 점 목록이 올바르지 않습니다.");
}
foreach (var point in points)
{
ValidatePoint(point.X, point.Y, point.Z);
}
}
private static void ValidateVectorComponents(PlayoutVectorComponents components)
{
if (!Enum.IsDefined(components) || components == 0)
{
throw new PlayoutRequestException("장면 벡터 축 값이 올바르지 않습니다.");
}
}
private static void ValidateKeyIndex(int keyIndex)
{
if (keyIndex is < 0 or > 10_000)
{
throw new PlayoutRequestException("장면 키 인덱스가 올바르지 않습니다.");
}
}
private static void ValidateMutationTiming(PlayoutMutationTiming timing)
{
if (!Enum.IsDefined(timing))
{
throw new PlayoutRequestException("장면 변경 시점이 올바르지 않습니다.");
}
}
private static void ValidateAssetRoot(PlayoutAssetRoot assetRoot)
{
if (!Enum.IsDefined(assetRoot))
{
throw new PlayoutRequestException("배경 자산 루트가 올바르지 않습니다.");
}
}
private static bool IsByte(int value) => value is >= byte.MinValue and <= byte.MaxValue;
private static bool CanMatchProgramTitle(Regex regex)
{
string[] protectedTitles =
[
string.Empty,
" ",
"Tornado2",
"PGM",
"PROGRAM",
"Tornado2 PGM",
"Tornado2 PROGRAM",
"PGM OUTPUT",
"PROGRAM OUTPUT"
];
return protectedTitles.Any(regex.IsMatch);
}
private static bool ContainsPathSyntax(string value) =>
value.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar, ':']) >= 0;
private static bool IsSafeSceneCode(string? value)
{
if (string.IsNullOrEmpty(value) || value.Length > 64 || ContainsPathSyntax(value))
{
return false;
}
foreach (var character in value)
{
if (!((character >= 'A' && character <= 'Z') ||
(character >= 'a' && character <= 'z') ||
(character >= '0' && character <= '9') ||
character is '_' or '-'))
{
return false;
}
}
return true;
}
private static bool IsLiteralLoopback(string host) =>
string.Equals(host, IPAddress.Loopback.ToString(), StringComparison.Ordinal) ||
string.Equals(host, IPAddress.IPv6Loopback.ToString(), StringComparison.Ordinal);
private static string ValidateSceneDirectory(string? value)
{
value = value?.Trim();
if (string.IsNullOrEmpty(value) || !Path.IsPathFullyQualified(value))
{
throw Invalid(nameof(PlayoutOptions.SceneDirectory));
}
var fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(value));
if (!Directory.Exists(fullPath) || IsReparsePoint(fullPath))
{
throw Invalid(nameof(PlayoutOptions.SceneDirectory));
}
return fullPath;
}
private static bool HasReparsePointBetween(string root, string file)
{
if (IsReparsePoint(file))
{
return true;
}
var directory = Path.GetDirectoryName(file);
while (!string.IsNullOrEmpty(directory) &&
!string.Equals(directory, root, StringComparison.OrdinalIgnoreCase))
{
if (IsReparsePoint(directory))
{
return true;
}
directory = Path.GetDirectoryName(directory);
}
return !string.Equals(directory, root, StringComparison.OrdinalIgnoreCase);
}
private static bool IsReparsePoint(string path)
{
try
{
return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
}
catch (IOException)
{
return true;
}
catch (UnauthorizedAccessException)
{
return true;
}
}
private static string RequiredText(string? value, string name)
{
value = value?.Trim();
if (string.IsNullOrEmpty(value) || value.Length > 512)
{
throw Invalid(name);
}
return value;
}
private static void RequireRange(int value, int minimum, int maximum, string name)
{
if (value < minimum || value > maximum)
{
throw Invalid(name);
}
}
private static PlayoutConfigurationException Invalid(string name) =>
new($"송출 설정 {name} 값이 올바르지 않습니다.");
}
internal sealed class PlayoutRequestException : Exception
{
public PlayoutRequestException(string message)
: base(message)
{
}
}