170 lines
11 KiB
PowerShell
170 lines
11 KiB
PowerShell
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
|
|
}
|