Migrate remaining legacy operator workflows
This commit is contained in:
@@ -26,7 +26,7 @@ public sealed class PlayoutOptions
|
||||
|
||||
/// <summary>
|
||||
/// Trusted MainForm-level background selection. The asset is always a path relative
|
||||
/// to SceneDirectory and is never accepted from Web content.
|
||||
/// to LegacyBackgroundDirectory and is never accepted from Web content.
|
||||
/// </summary>
|
||||
public LegacySceneBackgroundKind LegacySceneBackgroundKind { get; set; } =
|
||||
LegacySceneBackgroundKind.None;
|
||||
@@ -37,6 +37,14 @@ public sealed class PlayoutOptions
|
||||
|
||||
public bool LegacySceneBackgroundVideoLoopInfinite { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// External absolute root containing operator-selectable MainForm backgrounds.
|
||||
/// When omitted, the original layout is retained by deriving a sibling "배경"
|
||||
/// directory from SceneDirectory (Cuts\..\배경). This root is never merged with
|
||||
/// the trusted scene/Cuts root.
|
||||
/// </summary>
|
||||
public string? LegacyBackgroundDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// External absolute root containing vendor .t2s scenes. Required in Test and Live modes.
|
||||
/// </summary>
|
||||
|
||||
@@ -98,6 +98,9 @@ public static class PlayoutOptionsLoader
|
||||
ApplyBool(
|
||||
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE",
|
||||
value => options.LegacySceneBackgroundVideoLoopInfinite = value);
|
||||
ApplyString(
|
||||
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY",
|
||||
value => options.LegacyBackgroundDirectory = value);
|
||||
ApplyString("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", value => options.SceneDirectory = value);
|
||||
ApplyString(
|
||||
"MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
@@ -8,6 +9,8 @@ namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
/// </summary>
|
||||
public static class PlayoutSceneCompositionFactory
|
||||
{
|
||||
public const string LegacyDefaultBackgroundFileName = "기본.vrv";
|
||||
|
||||
private static readonly IReadOnlySet<string> AllowedExtensions = new HashSet<string>(
|
||||
[
|
||||
".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds",
|
||||
@@ -17,12 +20,7 @@ public static class PlayoutSceneCompositionFactory
|
||||
public static LegacySceneCueCompositionOptions Create(PlayoutOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (options.LegacySceneFadeDuration is < 0 or > 60 ||
|
||||
!Enum.IsDefined(options.LegacySceneBackgroundKind) ||
|
||||
options.LegacySceneBackgroundVideoLoopCount is < 0 or > 10_000)
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
ValidateCommon(options.LegacySceneFadeDuration, options);
|
||||
|
||||
if (options.LegacySceneBackgroundKind == LegacySceneBackgroundKind.None)
|
||||
{
|
||||
@@ -36,74 +34,180 @@ public static class PlayoutSceneCompositionFactory
|
||||
LegacySceneBackgroundKind.None);
|
||||
}
|
||||
|
||||
var rootText = options.SceneDirectory?.Trim();
|
||||
var relativeText = options.LegacySceneBackgroundAssetPath?.Trim();
|
||||
if (string.IsNullOrEmpty(rootText) || !Path.IsPathFullyQualified(rootText) ||
|
||||
string.IsNullOrEmpty(relativeText) || Path.IsPathRooted(relativeText) ||
|
||||
relativeText.Contains("..", StringComparison.Ordinal) ||
|
||||
relativeText.Contains(':') || relativeText.Any(char.IsControl) ||
|
||||
!AllowedExtensions.Contains(Path.GetExtension(relativeText)))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
|
||||
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootText));
|
||||
if (!Directory.Exists(root) || IsReparsePoint(root))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
|
||||
var candidate = Path.GetFullPath(Path.Combine(root, relativeText));
|
||||
var rootPrefix = root + Path.DirectorySeparatorChar;
|
||||
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(candidate) || HasReparsePointBetween(root, candidate))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
|
||||
var normalizedRelative = Path.GetRelativePath(root, candidate);
|
||||
return new LegacySceneCueCompositionOptions(
|
||||
options.LegacySceneFadeDuration,
|
||||
options.LegacySceneBackgroundKind,
|
||||
normalizedRelative,
|
||||
options.LegacySceneBackgroundVideoLoopCount,
|
||||
options.LegacySceneBackgroundVideoLoopInfinite);
|
||||
}
|
||||
|
||||
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 false;
|
||||
}
|
||||
|
||||
private static bool IsReparsePoint(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
|
||||
var root = RequireTrustedBackgroundDirectory(options);
|
||||
var candidate = TrustedPlayoutAssetPath.ResolveRelativeFile(
|
||||
root,
|
||||
options.LegacySceneBackgroundAssetPath ?? string.Empty,
|
||||
AllowedExtensions);
|
||||
return CreateResult(
|
||||
options,
|
||||
options.LegacySceneFadeDuration,
|
||||
options.LegacySceneBackgroundKind,
|
||||
Path.GetRelativePath(root, candidate));
|
||||
}
|
||||
catch (TrustedPlayoutAssetException)
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates one F2 picker result against the separate background root. An
|
||||
/// absolute path is accepted only at this native boundary and is converted back
|
||||
/// to a closed relative path before it reaches the scene builder.
|
||||
/// </summary>
|
||||
public static LegacySceneCueCompositionOptions CreateOperatorSelection(
|
||||
PlayoutOptions options,
|
||||
int fadeDuration,
|
||||
string selectedAbsolutePath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ValidateCommon(fadeDuration, options);
|
||||
|
||||
try
|
||||
{
|
||||
var root = RequireTrustedBackgroundDirectory(options);
|
||||
var candidate = TrustedPlayoutAssetPath.ResolveAbsoluteFile(
|
||||
root,
|
||||
selectedAbsolutePath,
|
||||
AllowedExtensions);
|
||||
var extension = Path.GetExtension(candidate);
|
||||
var kind = string.Equals(extension, ".vrv", StringComparison.OrdinalIgnoreCase)
|
||||
? LegacySceneBackgroundKind.Video
|
||||
: LegacySceneBackgroundKind.Texture;
|
||||
return CreateResult(
|
||||
options,
|
||||
fadeDuration,
|
||||
kind,
|
||||
Path.GetRelativePath(root, candidate));
|
||||
}
|
||||
catch (TrustedPlayoutAssetException)
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Restores the original F3-on default, 배경\기본.vrv.</summary>
|
||||
public static LegacySceneCueCompositionOptions CreateLegacyDefault(
|
||||
PlayoutOptions options,
|
||||
int fadeDuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ValidateCommon(fadeDuration, options);
|
||||
|
||||
try
|
||||
{
|
||||
var root = RequireTrustedBackgroundDirectory(options);
|
||||
var candidate = TrustedPlayoutAssetPath.ResolveRelativeFile(
|
||||
root,
|
||||
LegacyDefaultBackgroundFileName,
|
||||
AllowedExtensions);
|
||||
return CreateResult(
|
||||
options,
|
||||
fadeDuration,
|
||||
LegacySceneBackgroundKind.Video,
|
||||
Path.GetRelativePath(root, candidate));
|
||||
}
|
||||
catch (TrustedPlayoutAssetException)
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetTrustedBackgroundDirectory(
|
||||
PlayoutOptions options,
|
||||
out string directory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
directory = string.Empty;
|
||||
try
|
||||
{
|
||||
directory = TrustedPlayoutAssetPath.ValidateRoot(
|
||||
RequireConfiguredBackgroundDirectory(options));
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
directory = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryGetConfiguredBackgroundDirectory(
|
||||
PlayoutOptions options,
|
||||
out string directory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
directory = string.Empty;
|
||||
try
|
||||
{
|
||||
directory = RequireConfiguredBackgroundDirectory(options);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
directory = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RequireTrustedBackgroundDirectory(PlayoutOptions options) =>
|
||||
TrustedPlayoutAssetPath.ValidateRoot(RequireConfiguredBackgroundDirectory(options));
|
||||
|
||||
private static string RequireConfiguredBackgroundDirectory(PlayoutOptions options)
|
||||
{
|
||||
var configured = options.LegacyBackgroundDirectory?.Trim();
|
||||
if (string.IsNullOrEmpty(configured))
|
||||
{
|
||||
var sceneDirectory = options.SceneDirectory?.Trim();
|
||||
if (string.IsNullOrEmpty(sceneDirectory) ||
|
||||
!Path.IsPathFullyQualified(sceneDirectory))
|
||||
{
|
||||
throw new TrustedPlayoutAssetException();
|
||||
}
|
||||
|
||||
var cuts = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sceneDirectory));
|
||||
var parent = Path.GetDirectoryName(cuts);
|
||||
if (string.IsNullOrEmpty(parent))
|
||||
{
|
||||
throw new TrustedPlayoutAssetException();
|
||||
}
|
||||
|
||||
configured = Path.Combine(parent, "배경");
|
||||
}
|
||||
|
||||
if (!Path.IsPathFullyQualified(configured) || configured.Any(char.IsControl))
|
||||
{
|
||||
throw new TrustedPlayoutAssetException();
|
||||
}
|
||||
|
||||
return Path.TrimEndingDirectorySeparator(Path.GetFullPath(configured));
|
||||
}
|
||||
|
||||
private static LegacySceneCueCompositionOptions CreateResult(
|
||||
PlayoutOptions options,
|
||||
int fadeDuration,
|
||||
LegacySceneBackgroundKind kind,
|
||||
string relativePath) => new(
|
||||
fadeDuration,
|
||||
kind,
|
||||
relativePath,
|
||||
options.LegacySceneBackgroundVideoLoopCount,
|
||||
options.LegacySceneBackgroundVideoLoopInfinite,
|
||||
PlayoutAssetRoot.OperatorBackgroundDirectory);
|
||||
|
||||
private static void ValidateCommon(int fadeDuration, PlayoutOptions options)
|
||||
{
|
||||
if (fadeDuration is < 0 or > 60 ||
|
||||
!Enum.IsDefined(options.LegacySceneBackgroundKind) ||
|
||||
options.LegacySceneBackgroundVideoLoopCount is < 0 or > 10_000)
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
}
|
||||
|
||||
private static PlayoutConfigurationException Invalid() => new(
|
||||
"공통 장면 배경 설정이 올바르지 않거나 허용된 scene 폴더의 자산을 찾을 수 없습니다.");
|
||||
"신뢰 배경 루트 안의 일반 배경 파일을 확인할 수 없습니다.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a read-only vendor asset from one already-trusted root. Windows handles
|
||||
/// are used for the directory ancestry and the file itself so that path checks do not
|
||||
/// silently follow a reparse point or accept a hard-linked file.
|
||||
/// </summary>
|
||||
internal static class TrustedPlayoutAssetPath
|
||||
{
|
||||
public static string ValidateRoot(string root)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root) || root.IndexOf('\0') >= 0 ||
|
||||
!Path.IsPathFullyQualified(root))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
|
||||
var fullRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root));
|
||||
if (!Directory.Exists(fullRoot))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
|
||||
using var lease = TrustedReadOnlyDirectoryLease.Acquire(fullRoot);
|
||||
return fullRoot;
|
||||
}
|
||||
|
||||
public static string ResolveRelativeFile(
|
||||
string root,
|
||||
string relativePath,
|
||||
IReadOnlySet<string> allowedExtensions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(allowedExtensions);
|
||||
relativePath = relativePath?.Trim() ?? string.Empty;
|
||||
if (relativePath.Length == 0 || relativePath.Length > 512 ||
|
||||
Path.IsPathRooted(relativePath) ||
|
||||
relativePath.Contains("..", StringComparison.Ordinal) ||
|
||||
relativePath.Contains(':') || relativePath.Any(char.IsControl) ||
|
||||
!allowedExtensions.Contains(Path.GetExtension(relativePath)))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
|
||||
var fullRoot = ValidateRoot(root);
|
||||
var candidate = Path.GetFullPath(Path.Combine(fullRoot, relativePath));
|
||||
return ResolveCanonicalFile(fullRoot, candidate);
|
||||
}
|
||||
|
||||
public static string ResolveAbsoluteFile(
|
||||
string root,
|
||||
string absolutePath,
|
||||
IReadOnlySet<string> allowedExtensions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(allowedExtensions);
|
||||
if (string.IsNullOrWhiteSpace(absolutePath) || absolutePath.Length > 32_767 ||
|
||||
absolutePath.Any(char.IsControl) || !Path.IsPathFullyQualified(absolutePath) ||
|
||||
!allowedExtensions.Contains(Path.GetExtension(absolutePath)))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
|
||||
var fullRoot = ValidateRoot(root);
|
||||
var candidate = Path.GetFullPath(absolutePath);
|
||||
return ResolveCanonicalFile(fullRoot, candidate);
|
||||
}
|
||||
|
||||
internal static void EnsureOpenedRegularFile(
|
||||
SafeFileHandle handle,
|
||||
string expectedPath)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
var attributes = File.GetAttributes(expectedPath);
|
||||
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (handle.IsInvalid ||
|
||||
!GetFileInformationByHandle(handle, out var information) ||
|
||||
(information.FileAttributes &
|
||||
(FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
|
||||
information.NumberOfLinks != 1 ||
|
||||
(((long)information.FileSizeHigh << 32) | information.FileSizeLow) <= 0 ||
|
||||
!string.Equals(
|
||||
GetFinalPath(handle),
|
||||
Path.GetFullPath(expectedPath),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveCanonicalFile(string fullRoot, string candidate)
|
||||
{
|
||||
var rootPrefix = fullRoot + Path.DirectorySeparatorChar;
|
||||
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(candidate))
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// The lease excludes FILE_SHARE_DELETE for every ancestor while the file
|
||||
// handle is checked. The file handle itself is also bound to the canonical
|
||||
// final path and must have exactly one link.
|
||||
using var lease = TrustedReadOnlyDirectoryLease.Acquire(
|
||||
Path.GetDirectoryName(candidate) ?? throw Invalid());
|
||||
using var stream = new FileStream(
|
||||
candidate,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 1,
|
||||
FileOptions.RandomAccess);
|
||||
EnsureOpenedRegularFile(stream.SafeFileHandle, candidate);
|
||||
return candidate;
|
||||
}
|
||||
catch (TrustedPlayoutAssetException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw Invalid();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetFinalPath(SafeFileHandle handle)
|
||||
{
|
||||
var capacity = 512;
|
||||
while (capacity <= 32_768)
|
||||
{
|
||||
var buffer = new StringBuilder(capacity);
|
||||
var length = GetFinalPathNameByHandleW(
|
||||
handle,
|
||||
buffer,
|
||||
(uint)buffer.Capacity,
|
||||
FileNameNormalized);
|
||||
if (length == 0)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
if (length < buffer.Capacity)
|
||||
{
|
||||
var path = buffer.ToString();
|
||||
if (path.StartsWith("\\\\?\\UNC\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
path = "\\\\" + path[8..];
|
||||
}
|
||||
else if (path.StartsWith("\\\\?\\", StringComparison.Ordinal))
|
||||
{
|
||||
path = path[4..];
|
||||
}
|
||||
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
|
||||
capacity = checked((int)length + 1);
|
||||
}
|
||||
|
||||
throw Invalid();
|
||||
}
|
||||
|
||||
private static TrustedPlayoutAssetException Invalid() => new();
|
||||
|
||||
private const uint FileNameNormalized = 0;
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetFileInformationByHandle(
|
||||
SafeFileHandle file,
|
||||
out ByHandleFileInformation information);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern uint GetFinalPathNameByHandleW(
|
||||
SafeFileHandle file,
|
||||
StringBuilder path,
|
||||
uint pathLength,
|
||||
uint flags);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct ByHandleFileInformation
|
||||
{
|
||||
public FileAttributes FileAttributes;
|
||||
public FileTime CreationTime;
|
||||
public FileTime LastAccessTime;
|
||||
public FileTime LastWriteTime;
|
||||
public uint VolumeSerialNumber;
|
||||
public uint FileSizeHigh;
|
||||
public uint FileSizeLow;
|
||||
public uint NumberOfLinks;
|
||||
public uint FileIndexHigh;
|
||||
public uint FileIndexLow;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct FileTime
|
||||
{
|
||||
public uint Low;
|
||||
public uint High;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TrustedReadOnlyDirectoryLease : IDisposable
|
||||
{
|
||||
private const uint GenericRead = 0x80000000;
|
||||
private const uint OpenExisting = 3;
|
||||
private const uint FileFlagBackupSemantics = 0x02000000;
|
||||
private const uint FileFlagOpenReparsePoint = 0x00200000;
|
||||
private const uint FileNameNormalized = 0;
|
||||
|
||||
private readonly IReadOnlyList<SafeFileHandle> _handles;
|
||||
|
||||
private TrustedReadOnlyDirectoryLease(IReadOnlyList<SafeFileHandle> handles)
|
||||
{
|
||||
_handles = handles;
|
||||
}
|
||||
|
||||
public static TrustedReadOnlyDirectoryLease Acquire(string directory)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
var attributes = File.GetAttributes(directory);
|
||||
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) !=
|
||||
FileAttributes.Directory)
|
||||
{
|
||||
throw new TrustedPlayoutAssetException();
|
||||
}
|
||||
|
||||
return new TrustedReadOnlyDirectoryLease(Array.Empty<SafeFileHandle>());
|
||||
}
|
||||
|
||||
var ancestors = new List<string>();
|
||||
for (var current = new DirectoryInfo(directory); current is not null; current = current.Parent)
|
||||
{
|
||||
ancestors.Add(current.FullName);
|
||||
}
|
||||
ancestors.Reverse();
|
||||
|
||||
var handles = new List<SafeFileHandle>(ancestors.Count);
|
||||
try
|
||||
{
|
||||
foreach (var ancestor in ancestors)
|
||||
{
|
||||
var handle = CreateFileW(
|
||||
ancestor,
|
||||
GenericRead,
|
||||
FileShare.Read | FileShare.Write,
|
||||
IntPtr.Zero,
|
||||
OpenExisting,
|
||||
FileFlagBackupSemantics | FileFlagOpenReparsePoint,
|
||||
IntPtr.Zero);
|
||||
if (handle.IsInvalid)
|
||||
{
|
||||
handle.Dispose();
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EnsureDirectDirectory(handle, ancestor);
|
||||
}
|
||||
catch
|
||||
{
|
||||
handle.Dispose();
|
||||
throw;
|
||||
}
|
||||
handles.Add(handle);
|
||||
}
|
||||
|
||||
return new TrustedReadOnlyDirectoryLease(handles.AsReadOnly());
|
||||
}
|
||||
catch
|
||||
{
|
||||
foreach (var handle in handles)
|
||||
{
|
||||
handle.Dispose();
|
||||
}
|
||||
|
||||
throw new TrustedPlayoutAssetException();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var handle in _handles.Reverse())
|
||||
{
|
||||
handle.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureDirectDirectory(SafeFileHandle handle, string expectedPath)
|
||||
{
|
||||
if (!GetFileInformationByHandle(handle, out var information) ||
|
||||
(information.FileAttributes & FileAttributes.Directory) == 0 ||
|
||||
(information.FileAttributes & FileAttributes.ReparsePoint) != 0 ||
|
||||
!string.Equals(
|
||||
GetFinalPath(handle),
|
||||
Path.GetFullPath(expectedPath),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new TrustedPlayoutAssetException();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetFinalPath(SafeFileHandle handle)
|
||||
{
|
||||
var capacity = 512;
|
||||
while (capacity <= 32_768)
|
||||
{
|
||||
var buffer = new StringBuilder(capacity);
|
||||
var length = GetFinalPathNameByHandleW(
|
||||
handle,
|
||||
buffer,
|
||||
(uint)buffer.Capacity,
|
||||
FileNameNormalized);
|
||||
if (length == 0)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
if (length < buffer.Capacity)
|
||||
{
|
||||
var path = buffer.ToString();
|
||||
if (path.StartsWith("\\\\?\\UNC\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
path = "\\\\" + path[8..];
|
||||
}
|
||||
else if (path.StartsWith("\\\\?\\", StringComparison.Ordinal))
|
||||
{
|
||||
path = path[4..];
|
||||
}
|
||||
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
|
||||
capacity = checked((int)length + 1);
|
||||
}
|
||||
|
||||
throw new TrustedPlayoutAssetException();
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern SafeFileHandle CreateFileW(
|
||||
string fileName,
|
||||
uint desiredAccess,
|
||||
FileShare shareMode,
|
||||
IntPtr securityAttributes,
|
||||
uint creationDisposition,
|
||||
uint flagsAndAttributes,
|
||||
IntPtr templateFile);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetFileInformationByHandle(
|
||||
SafeFileHandle file,
|
||||
out ByHandleFileInformation information);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern uint GetFinalPathNameByHandleW(
|
||||
SafeFileHandle file,
|
||||
StringBuilder path,
|
||||
uint pathLength,
|
||||
uint flags);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct ByHandleFileInformation
|
||||
{
|
||||
public FileAttributes FileAttributes;
|
||||
public FileTime CreationTime;
|
||||
public FileTime LastAccessTime;
|
||||
public FileTime LastWriteTime;
|
||||
public uint VolumeSerialNumber;
|
||||
public uint FileSizeHigh;
|
||||
public uint FileSizeLow;
|
||||
public uint NumberOfLinks;
|
||||
public uint FileIndexHigh;
|
||||
public uint FileIndexLow;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct FileTime
|
||||
{
|
||||
public uint Low;
|
||||
public uint High;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TrustedPlayoutAssetException : Exception;
|
||||
@@ -15,6 +15,7 @@ internal sealed record ValidatedPlayoutOptions(
|
||||
int? OutputChannel,
|
||||
int LayoutIndex,
|
||||
string? SceneDirectory,
|
||||
string? OperatorBackgroundDirectory,
|
||||
Regex? TestProcessWindowTitleRegex,
|
||||
IReadOnlySet<string> TestSceneAllowlist,
|
||||
bool TrustedLiveOutputEnabled,
|
||||
@@ -59,6 +60,13 @@ internal sealed record ValidatedPlayoutOptions(
|
||||
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,
|
||||
@@ -156,6 +164,7 @@ internal sealed record ValidatedPlayoutOptions(
|
||||
options.OutputChannel,
|
||||
options.LayoutIndex,
|
||||
sceneDirectory,
|
||||
operatorBackgroundDirectory,
|
||||
titleRegex,
|
||||
allowlist,
|
||||
options.TrustedLiveOutputEnabled,
|
||||
@@ -356,9 +365,14 @@ internal sealed record ValidatedPlayoutOptions(
|
||||
return background;
|
||||
case PlayoutSetBackgroundTexture texture:
|
||||
ValidateMutationTiming(texture.Timing);
|
||||
return texture with { AssetPath = ResolveAssetPath(texture.AssetPath) };
|
||||
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)
|
||||
@@ -366,16 +380,28 @@ internal sealed record ValidatedPlayoutOptions(
|
||||
throw new PlayoutRequestException("장면 배경 영상 반복 값이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
return video with { AssetPath = ResolveAssetPath(video.AssetPath) };
|
||||
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();
|
||||
if (SceneDirectory is null || string.IsNullOrEmpty(relativePath) ||
|
||||
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))
|
||||
{
|
||||
@@ -393,12 +419,28 @@ internal sealed record ValidatedPlayoutOptions(
|
||||
throw new PlayoutRequestException("지원하지 않는 장면 자산 형식입니다.");
|
||||
}
|
||||
|
||||
var candidate = Path.GetFullPath(Path.Combine(SceneDirectory, relativePath));
|
||||
var rootPrefix = SceneDirectory.EndsWith(Path.DirectorySeparatorChar)
|
||||
? SceneDirectory
|
||||
: SceneDirectory + Path.DirectorySeparatorChar;
|
||||
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(SceneDirectory, candidate))
|
||||
!File.Exists(candidate) || HasReparsePointBetween(root, candidate))
|
||||
{
|
||||
throw new PlayoutRequestException("허용된 장면 자산을 찾을 수 없습니다.");
|
||||
}
|
||||
@@ -469,6 +511,14 @@ internal sealed record ValidatedPlayoutOptions(
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user