param( [Parameter(Mandatory=$true)][ValidateSet('Check','Plan','Prepare','Publish','Rollback')][string]$Action, [Parameter(Mandatory=$true)][string]$RequestBase64, [string]$StateRoot = '' ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' [Console]::OutputEncoding = New-Object Text.UTF8Encoding($false) $request = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($RequestBase64)) | ConvertFrom-Json function Hash([string]$Path) { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return '' } return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() } function Under([string]$Root, [string]$Relative) { $full = [IO.Path]::GetFullPath((Join-Path $Root $Relative)) $prefix = $Root.TrimEnd('\','/') + [IO.Path]::DirectorySeparatorChar if (-not $full.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Path escapes release root: $Relative" } return $full } function Resolve-WebRoot([string]$Path) { if (-not [IO.Path]::IsPathRooted($Path)) { throw 'Website root must be an absolute path' } $resolved = (Resolve-Path -LiteralPath $Path).Path.TrimEnd('\','/') if ($resolved.Length -le 3 -or -not (Test-Path -LiteralPath $resolved -PathType Container)) { throw 'Invalid website root' } if (-not (Test-Path -LiteralPath (Join-Path $resolved 'cdn/version.json')) -and -not (Get-ChildItem -LiteralPath $resolved -Filter '*.webgl.data.unityweb.bin.txt' -File | Select-Object -First 1)) { throw 'Target is not an existing WebGL CDN website (no version or startup data)' } return $resolved } function Validate-Files($Files) { $seen = @{} foreach ($item in @($Files)) { $name = [string]$item.path if ($name -ne 'cdn/version.json' -and $name -notmatch '^yoo/(Art|DefaultPackage)/[A-Za-z0-9_.-]+$' -and $name -cnotmatch '^[a-f0-9]{16}\.webgl\.data\.unityweb\.bin\.txt$') { throw "Unsupported release path: $name" } if ($name.Split('/') -contains '..' -or $seen.ContainsKey($name)) { throw "Unsafe or duplicate path: $name" } if ($item.sha256 -notmatch '^[a-f0-9]{64}$' -or [long]$item.size -lt 0) { throw "Invalid file metadata: $name" } $seen[$name] = $item } if (-not $seen.ContainsKey('cdn/version.json')) { throw 'Release has no global version.json' } return $seen } function Atomic-Copy([string]$Source, [string]$Target) { New-Item -ItemType Directory -Path (Split-Path -Parent $Target) -Force | Out-Null $temporary = "$Target.upload-$([Guid]::NewGuid().ToString('N')).tmp" try { [IO.File]::Copy($Source, $temporary, $false) if (Test-Path -LiteralPath $Target) { [IO.File]::Replace($temporary, $Target, [System.Management.Automation.Language.NullString]::Value) } else { [IO.File]::Move($temporary, $Target) } } finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary } } } try { $root = Resolve-WebRoot $request.root $current = Join-Path $root 'cdn/version.json' $base = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'AIGameCdnPublish' if ($StateRoot) { $base = [IO.Path]::GetFullPath($StateRoot) } if ($Action -eq 'Check') { @{ connected=$true; user=[Environment]::UserName; root=$root; currentVersionSha256=(Hash $current) } | ConvertTo-Json -Compress exit 0 } if ($request.releaseId -notmatch '^[a-f0-9]{32}$') { throw 'Invalid release ID' } $folder = Join-Path $base $request.releaseId $archivePath = Join-Path $folder 'release.zip' if ($Action -eq 'Plan') { $files = Validate-Files $request.files $changed = @($request.files | Where-Object { $target = Under $root $_.path -not (Test-Path -LiteralPath $target -PathType Leaf) -or (Get-Item -LiteralPath $target).Length -ne [long]$_.size -or (Hash $target) -ne $_.sha256 } | ForEach-Object { $_.path }) @{ changed=$changed; currentVersionSha256=(Hash $current); root=$root } | ConvertTo-Json -Compress exit 0 } New-Item -ItemType Directory -Path $base -Force | Out-Null if ($Action -eq 'Prepare') { $null = Validate-Files $request.files if (Test-Path -LiteralPath $folder) { throw 'Release staging directory already exists' } New-Item -ItemType Directory -Path $folder | Out-Null @{ archive=$archivePath } | ConvertTo-Json -Compress exit 0 } # The lock is outside the public website and covers both publishing and rollback. $sha = [Security.Cryptography.SHA256]::Create() try { $rootId = [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($root.ToLowerInvariant()))).Replace('-','') } finally { $sha.Dispose() } $lockPath = Join-Path $base "$rootId.lock" $lock = [IO.File]::Open($lockPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) try { if ($Action -eq 'Rollback') { $receipt = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $folder 'receipt.json') | ConvertFrom-Json if ($receipt.root -ne $root -or (Hash $current) -ne $receipt.versionSha256) { throw 'A different version is live; rollback refused' } $previous = Join-Path $folder 'previous.version.json' if (-not (Test-Path -LiteralPath $previous)) { throw 'This release has no previous version to restore' } $version = Get-Content -Raw -Encoding UTF8 -LiteralPath $previous | ConvertFrom-Json foreach ($package in @($version.packages)) { if ($package -notin @('Art','DefaultPackage')) { throw 'Unexpected previous package' } $versionId = if ($package -eq 'Art') { $version.resVersion } else { $version.packVersion } if ($versionId -notmatch '^[a-f0-9]{32}$') { throw 'Invalid previous manifest version' } $index = Under $root "yoo/$package/${package}_$versionId.bytes" if (-not (Test-Path -LiteralPath $index) -or (Get-FileHash -LiteralPath $index -Algorithm MD5).Hash.ToLowerInvariant() -ne $versionId) { throw "Previous manifest missing or corrupt: $package" } } Atomic-Copy $previous $current @{ rolledBack=$true; releaseId=$request.releaseId; version=$version } | ConvertTo-Json -Depth 20 -Compress exit 0 } $files = Validate-Files $request.files if ((Hash $current) -ne $request.expectedVersionSha256) { throw 'Live version changed since planning; make a fresh plan' } if (Test-Path -LiteralPath (Join-Path $folder 'receipt.json')) { throw 'Release was already committed; inspect its receipt' } $unpacked = Join-Path $folder 'files' if (Test-Path -LiteralPath $unpacked) { throw 'Release already extracted; start a fresh release' } New-Item -ItemType Directory -Path $unpacked | Out-Null Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [IO.Compression.ZipFile]::OpenRead($archivePath) $seen = @{} try { foreach ($entry in $zip.Entries) { $name = $entry.FullName if (-not $files.ContainsKey($name) -or $seen.ContainsKey($name) -or $entry.Length -ne [long]$files[$name].size) { throw "Unexpected ZIP entry: $name" } $destination = Under $unpacked $name New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null $inputStream = $entry.Open() $outputStream = [IO.File]::Open($destination, [IO.FileMode]::CreateNew) try { $inputStream.CopyTo($outputStream) } finally { $inputStream.Dispose(); $outputStream.Dispose() } if ((Hash $destination) -ne $files[$name].sha256) { throw "Uploaded SHA256 mismatch: $name" } $seen[$name] = $true } } finally { $zip.Dispose() } # Validate the entire release, including unchanged server files, before any website writes. foreach ($item in @($request.files)) { $source = if ($seen.ContainsKey($item.path)) { Under $unpacked $item.path } else { Under $root $item.path } if (-not (Test-Path -LiteralPath $source -PathType Leaf) -or (Get-Item -LiteralPath $source).Length -ne [long]$item.size -or (Hash $source) -ne $item.sha256) { throw "Missing or corrupt release file: $($item.path)" } } $versionSource = if ($seen.ContainsKey('cdn/version.json')) { Under $unpacked 'cdn/version.json' } else { $current } $version = Get-Content -Raw -Encoding UTF8 -LiteralPath $versionSource | ConvertFrom-Json if ($version.cdnRoot -ne $request.version.cdnRoot -or $version.packVersion -ne $request.version.packVersion -or $version.resVersion -ne $request.version.resVersion -or (@($version.packages) -join ',') -ne (@($request.version.packages) -join ',')) { throw 'Global version payload does not match the request' } foreach ($package in @($version.packages)) { if ($package -notin @('Art','DefaultPackage')) { throw 'Unexpected package name' } $versionId = if ($package -eq 'Art') { $version.resVersion } else { $version.packVersion } if ($versionId -notmatch '^[a-f0-9]{32}$') { throw 'Invalid package version' } $name = "yoo/$package/${package}_$versionId.bytes" if (-not $files.ContainsKey($name)) { throw "Manifest absent from release: $name" } $source = if ($seen.ContainsKey($name)) { Under $unpacked $name } else { Under $root $name } if ((Get-FileHash -LiteralPath $source -Algorithm MD5).Hash.ToLowerInvariant() -ne $versionId) { throw "Manifest MD5 mismatch: $package" } } if (Test-Path -LiteralPath $current) { [IO.File]::Copy($current, (Join-Path $folder 'previous.version.json'), $false) } foreach ($name in @($seen.Keys | Where-Object { $_ -ne 'cdn/version.json' } | Sort-Object)) { Atomic-Copy (Under $unpacked $name) (Under $root $name) } # Commit marker is always last. All older content-addressed resources remain available. if ($seen.ContainsKey('cdn/version.json')) { Atomic-Copy (Under $unpacked 'cdn/version.json') $current } $receipt = @{ published=$true; releaseId=$request.releaseId; root=$root; version=$version; changedFiles=$seen.Count; versionSha256=(Hash $current); publishedUtc=[DateTime]::UtcNow.ToString('o') } $receiptJson = $receipt | ConvertTo-Json -Depth 20 -Compress [IO.File]::WriteAllText((Join-Path $folder 'receipt.json'), $receiptJson, (New-Object Text.UTF8Encoding($false))) Write-Output $receiptJson } finally { $lock.Dispose() } } catch { [Console]::Error.WriteLine($_.Exception.Message) exit 1 }