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 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(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); /// /// Closed scene allowlist applied to every real Test or Live mutation command. /// The legacy property name is retained for local-config compatibility. /// 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? ResolveMutations( IReadOnlyList? 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(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? 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) { } }