Set-StrictMode -Version Latest . "$PSScriptRoot\helpers.ps1" function Test-ApprovedZipFileName { param( [string]$ZipPath, [hashtable]$Settings ) $name = Split-Path $ZipPath -Leaf $regex = [string]$Settings.StrictZipFileNameRegex if ([string]::IsNullOrWhiteSpace($regex)) { $regex = '^index_beta_\d{4}-\d{2}-\d{2}-v\d{3}\.zip$' } return ($name -match $regex) } function Get-ApprovedZipEntries { param( [string]$ZipPath, [object]$BuildInfo ) Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [System.IO.Compression.ZipFile]::OpenRead($ZipPath) try { $entries = @() foreach ($entry in $zip.Entries) { if ([string]::IsNullOrWhiteSpace($entry.FullName)) { continue } if ($entry.FullName.EndsWith("/")) { continue } $name = $entry.FullName.Replace("\", "/") # Reject path traversal and absolute paths. if ($name -match '(^/|^[A-Za-z]:|(^|/)\.\.(/|$))') { throw "Rejected unsafe ZIP path: $name" } $entries += [pscustomobject]@{ Name = $name Length = $entry.Length } } } finally { $zip.Dispose() } $date = [regex]::Escape($BuildInfo.Date) $version = [regex]::Escape($BuildInfo.Version) $html = "^index_beta_$date-v$version\.html$" $defaults = "^_MyAnythingList-defaults\.txt$" $docs = "^docs/$date/v$version/en-us/controllable-parameters\.html$" $approved = @() $rejected = @() foreach ($e in $entries) { if ($e.Name -match $html -or $e.Name -match $defaults -or $e.Name -match $docs) { $approved += $e } else { $rejected += $e } } $hasHtml = $approved | Where-Object { $_.Name -match $html } $hasDefaults = $approved | Where-Object { $_.Name -match $defaults } return [pscustomobject]@{ Entries = $entries Approved = $approved Rejected = $rejected HasHtml = ($null -ne $hasHtml) HasDefaults = ($null -ne $hasDefaults) } } function Expand-ApprovedPayloadOnly { param( [string]$ZipPath, [string]$Destination, [object[]]$ApprovedEntries ) Add-Type -AssemblyName System.IO.Compression.FileSystem New-Item -ItemType Directory -Force -Path $Destination | Out-Null $zip = [System.IO.Compression.ZipFile]::OpenRead($ZipPath) try { $approvedNames = @{} foreach ($e in $ApprovedEntries) { $approvedNames[$e.Name] = $true } foreach ($entry in $zip.Entries) { if ([string]::IsNullOrWhiteSpace($entry.FullName) -or $entry.FullName.EndsWith("/")) { continue } $name = $entry.FullName.Replace("\", "/") if (!$approvedNames.ContainsKey($name)) { continue } $outPath = Join-Path $Destination ($name -replace '/', [System.IO.Path]::DirectorySeparatorChar) $outDir = Split-Path $outPath New-Item -ItemType Directory -Force -Path $outDir | Out-Null [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $outPath, $true) } } finally { $zip.Dispose() } } function Touch-ApprovedPayloadToNow { param( [string]$Destination, [object[]]$ApprovedEntries, [hashtable]$Settings ) $now = Get-Date if ([bool]$Settings.TouchExtractedPayloadToNow) { foreach ($e in $ApprovedEntries) { $relative = ($e.Name -replace '/', [System.IO.Path]::DirectorySeparatorChar) $path = Join-Path $Destination $relative if (Test-Path $path) { $item = Get-Item $path $item.LastWriteTime = $now $item.CreationTime = $now $item.LastAccessTime = $now } } } if ([bool]$Settings.TouchLocalDateFolderToNow) { if (Test-Path $Destination) { Get-ChildItem -Path $Destination -Directory -Recurse -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | ForEach-Object { $_.LastWriteTime = $now $_.CreationTime = $now $_.LastAccessTime = $now } $root = Get-Item $Destination $root.LastWriteTime = $now $root.CreationTime = $now $root.LastAccessTime = $now } } Write-Log "Touched approved extracted payload timestamps to current local time for File Explorer sorting." } function Invoke-DailyBuildZip { param([string]$ZipPath) $Settings = Import-Settings if (!(Test-ApprovedZipFileName -ZipPath $ZipPath -Settings $Settings)) { Write-Log "Ignoring non-MyAnythingList ZIP/download: $ZipPath" return } $info = Get-BuildInfoFromZipName -Path $ZipPath if ($null -eq $info) { Write-Log "Skipping non-matching ZIP: $ZipPath" return } if (!(Wait-FileStable -Path $ZipPath -StableSeconds ([int]$Settings.FileStableSeconds))) { Write-Log "ZIP did not become stable: $ZipPath" "WARN" return } $validation = Get-ApprovedZipEntries -ZipPath $ZipPath -BuildInfo $info if ([bool]$Settings.RequireApprovedZipContentsOnly) { if ($validation.Rejected.Count -gt 0) { Write-Log "Rejected ZIP because it contains unexpected files: $ZipPath" "ERROR" foreach ($bad in $validation.Rejected) { Write-Log "Unexpected ZIP entry: $($bad.Name)" "ERROR" } return } if (-not $validation.HasHtml) { Write-Log "Rejected ZIP because matching app HTML is missing: $ZipPath" "ERROR" return } if (-not $validation.HasDefaults) { Write-Log "Rejected ZIP because _MyAnythingList-defaults.txt is missing: $ZipPath" "ERROR" return } } $state = Read-ProcessedState $sig = Get-ZipSignature -Path $ZipPath $stateKey = (Resolve-Path $ZipPath).Path if ($state.ContainsKey($stateKey)) { if (($state[$stateKey].Signature -eq $sig) -or (-not [bool]$Settings.AllowReprocessChangedZip)) { Write-Log "Already processed unchanged ZIP: $ZipPath" return } } $date = $info.Date $localDateDir = Join-Path $Settings.LocalDailyBuildsRoot $date New-Item -ItemType Directory -Force -Path $localDateDir | Out-Null Write-Log "Processing approved MyAnythingList build: $($info.FileName)" Write-Log "Extracting approved payload only to $localDateDir" Expand-ApprovedPayloadOnly -ZipPath $ZipPath -Destination $localDateDir -ApprovedEntries $validation.Approved Touch-ApprovedPayloadToNow -Destination $localDateDir -ApprovedEntries $validation.Approved -Settings $Settings $bucket = $Settings.S3Bucket if ([string]::IsNullOrWhiteSpace($bucket) -or $bucket -eq "CHANGE-ME-BUCKET") { Write-Log "S3Bucket is not configured. Edit config\settings.ps1 first." "ERROR" return } $prefix = $Settings.S3PrefixTemplate.Replace("{date}", $date).TrimStart("/") $s3Uri = "s3://$bucket/$prefix" $awsArgs = @("s3", "sync", $localDateDir, $s3Uri) if ([bool]$Settings.DeleteRemoteRemovedFiles) { $awsArgs += "--delete" } if ([bool]$Settings.DryRun) { $awsArgs += "--dryrun" Write-Log "DRY RUN: aws $($awsArgs -join ' ')" } else { Write-Log "Mirroring approved local date folder to S3: aws $($awsArgs -join ' ')" } $aws = Get-Command aws -ErrorAction SilentlyContinue if ($null -eq $aws) { Write-Log "AWS CLI not found. Install AWS CLI v2 first." "ERROR" return } & aws @awsArgs 2>&1 | ForEach-Object { Write-Log "$_" } if ($LASTEXITCODE -ne 0) { Write-Log "AWS sync failed with exit code $LASTEXITCODE" "ERROR" return } $publicUrl = $Settings.PublicUrlTemplate.Replace("{date}", $date) Write-Log "Done. Test URL: $publicUrl" $state[$stateKey] = [pscustomobject]@{ FileName = $info.FileName Date = $info.Date Version = $info.Version Signature = $sig ProcessedAt = (Get-Date).ToString("o") LocalDateDir = $localDateDir S3Uri = $s3Uri DryRun = [bool]$Settings.DryRun ApprovedEntryCount = $validation.Approved.Count RejectedEntryCount = $validation.Rejected.Count } Write-ProcessedState -State $state } if ($MyInvocation.InvocationName -ne ".") { if ($args.Count -lt 1) { throw "Usage: process-zip.ps1 " } Invoke-DailyBuildZip -ZipPath $args[0] }