654 lines
23 KiB
C#
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)
|
|
{
|
|
}
|
|
}
|