[CmdletBinding()] param( [Parameter(Mandatory)] [string]$Archive, [switch]$Force ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $ProjectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) $Archive = [IO.Path]::GetFullPath($Archive) if (-not (Test-Path -LiteralPath $Archive -PathType Leaf)) { throw "The backup archive does not exist." } if (-not $Force) { $answer = (Read-Host "Restore will overwrite the current .env and runtime secrets. Continue? [y/N]").Trim().ToLowerInvariant() if ($answer -notin @("y", "yes")) { throw "Restore cancelled." } } Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [IO.Compression.ZipFile]::OpenRead($Archive) try { $allowedEntries = @( "config/environment.env", "secrets/calendar_ical_urls.json", "secrets/organizer_access_token.txt", "manifest.txt" ) $totalBytes = 0L foreach ($entry in $zip.Entries) { $entryName = $entry.FullName.Replace("\", "/").TrimEnd("/") if (-not $entryName) { continue } if ($entryName -notin $allowedEntries -and $entryName -notin @("config", "secrets")) { throw "The archive contains an unexpected path and will not be restored." } $totalBytes += $entry.Length } if ($totalBytes -gt 10MB) { throw "The configuration archive is unexpectedly large." } } finally { $zip.Dispose() } $stagingPath = Join-Path $ProjectRoot (".restore-staging-" + [Guid]::NewGuid().ToString("N")) $stagingFullPath = [IO.Path]::GetFullPath($stagingPath) $projectPrefix = $ProjectRoot.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar if (-not $stagingFullPath.StartsWith($projectPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Refusing to create a restore staging directory outside the application directory." } try { Expand-Archive -LiteralPath $Archive -DestinationPath $stagingPath $restoredEnvironment = Join-Path $stagingPath "config/environment.env" $restoredCalendars = Join-Path $stagingPath "secrets/calendar_ical_urls.json" $restoredToken = Join-Path $stagingPath "secrets/organizer_access_token.txt" foreach ($required in @($restoredEnvironment, $restoredCalendars, $restoredToken)) { if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "The archive is missing required configuration files." } } $secretsDirectory = Join-Path $ProjectRoot "secrets" New-Item -ItemType Directory -Path $secretsDirectory -Force | Out-Null Copy-Item -LiteralPath $restoredEnvironment -Destination (Join-Path $ProjectRoot ".env") -Force Copy-Item -LiteralPath $restoredCalendars -Destination (Join-Path $secretsDirectory "calendar_ical_urls.json") -Force Copy-Item -LiteralPath $restoredToken -Destination (Join-Path $secretsDirectory "organizer_access_token.txt") -Force $secretFiles = @( (Join-Path $secretsDirectory "calendar_ical_urls.json"), (Join-Path $secretsDirectory "organizer_access_token.txt") ) if ($PSVersionTable.PSEdition -eq "Desktop" -or [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::Windows)) { $userName = [Security.Principal.WindowsIdentity]::GetCurrent().Name foreach ($secretFile in $secretFiles) { & icacls.exe $secretFile "/inheritance:r" "/grant:r" "${userName}:(R,W)" | Out-Null if ($LASTEXITCODE -ne 0) { throw "Could not restrict restored secret permissions." } } } else { & chmod 600 -- @secretFiles if ($LASTEXITCODE -ne 0) { throw "Could not restrict restored secret permissions." } } } finally { if (Test-Path -LiteralPath $stagingFullPath) { Remove-Item -LiteralPath $stagingFullPath -Recurse -Force } } Write-Host "Configuration restored. Restart with: docker compose up -d --build"