[CmdletBinding()] param( [ValidateSet("trusted-lan", "bearer")] [string]$Mode, [switch]$RotateToken ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $ProjectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) $SecretsDirectory = Join-Path $ProjectRoot "secrets" $CalendarFile = Join-Path $SecretsDirectory "calendar_ical_urls.json" $TokenFile = Join-Path $SecretsDirectory "organizer_access_token.txt" $EnvironmentFile = Join-Path $ProjectRoot ".env" $EnvironmentExample = Join-Path $ProjectRoot ".env.example" $Utf8NoBom = New-Object System.Text.UTF8Encoding($false) function Test-IsWindows { if ($PSVersionTable.PSEdition -eq "Desktop") { return $true } return [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( [Runtime.InteropServices.OSPlatform]::Windows ) } function Read-HiddenText { param([Parameter(Mandatory)][string]$Prompt) $secureValue = Read-Host $Prompt -AsSecureString $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue) try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) } } function Read-YesNo { param( [Parameter(Mandatory)][string]$Prompt, [bool]$Default = $false ) $answer = (Read-Host $Prompt).Trim().ToLowerInvariant() if (-not $answer) { return $Default } return $answer -in @("y", "yes") } function Test-PrivateGoogleCalendarUrl { param([Parameter(Mandatory)][string]$Value) $uri = $null if (-not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$uri)) { return $false } if ($uri.Scheme -ne "https" -or $uri.Host -ne "calendar.google.com") { return $false } if (-not $uri.IsDefaultPort -or $uri.UserInfo -or $uri.Fragment) { return $false } return $uri.AbsolutePath -match "^/calendar/ical/[^/]+/(?:private-[^/]+|public)/basic[.]ics$" } function Protect-SecretFile { param([Parameter(Mandatory)][string]$Path) if (Test-IsWindows) { $userName = [Security.Principal.WindowsIdentity]::GetCurrent().Name & icacls.exe $Path "/inheritance:r" "/grant:r" "${userName}:(R,W)" | Out-Null if ($LASTEXITCODE -ne 0) { throw "Could not restrict permissions on a secret file." } } else { & chmod 600 -- $Path if ($LASTEXITCODE -ne 0) { throw "Could not restrict permissions on a secret file." } } } function Write-SecretFile { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][AllowEmptyString()][string]$Content ) $directory = Split-Path -Parent $Path $temporaryPath = Join-Path $directory (".configure-" + [Guid]::NewGuid().ToString("N") + ".tmp") try { [IO.File]::WriteAllText($temporaryPath, $Content, $Utf8NoBom) Protect-SecretFile -Path $temporaryPath Move-Item -LiteralPath $temporaryPath -Destination $Path -Force Protect-SecretFile -Path $Path } finally { if (Test-Path -LiteralPath $temporaryPath) { Remove-Item -LiteralPath $temporaryPath -Force } } } function New-AccessToken { $bytes = New-Object byte[] 48 $generator = [Security.Cryptography.RandomNumberGenerator]::Create() try { $generator.GetBytes($bytes) } finally { $generator.Dispose() } return [Convert]::ToBase64String($bytes).TrimEnd("=").Replace("+", "-").Replace("/", "_") } if (-not (Test-Path -LiteralPath $EnvironmentExample -PathType Leaf)) { throw "Missing .env.example. Run this script from an intact release bundle." } New-Item -ItemType Directory -Path $SecretsDirectory -Force | Out-Null if (-not (Test-IsWindows)) { & chmod 700 -- $SecretsDirectory if ($LASTEXITCODE -ne 0) { throw "Could not restrict the secrets directory permissions." } } if (-not (Test-Path -LiteralPath $EnvironmentFile)) { Copy-Item -LiteralPath $EnvironmentExample -Destination $EnvironmentFile Write-Host "Created .env from .env.example." } else { Write-Host "Preserved the existing .env file." } $writeCalendars = -not (Test-Path -LiteralPath $CalendarFile -PathType Leaf) if (-not $writeCalendars) { $writeCalendars = Read-YesNo "A calendar secret already exists. Replace it? [y/N]" } if ($writeCalendars) { $calendarUrls = [Collections.Generic.List[string]]::new() Write-Host "Enter private Google Calendar iCal URLs. Input is hidden; submit a blank entry when finished." while ($true) { $calendarUrl = Read-HiddenText ("Calendar URL #{0}" -f ($calendarUrls.Count + 1)) if ([string]::IsNullOrWhiteSpace($calendarUrl)) { break } if (-not (Test-PrivateGoogleCalendarUrl -Value $calendarUrl.Trim())) { Write-Warning "That entry is not a supported private Google Calendar basic.ics HTTPS URL; it was not saved." continue } $calendarUrls.Add($calendarUrl.Trim()) } if ($calendarUrls.Count -eq 0) { throw "At least one calendar URL is required when creating the calendar secret." } $calendarJson = ConvertTo-Json -InputObject @($calendarUrls.ToArray()) -Compress Write-SecretFile -Path $CalendarFile -Content ($calendarJson + [Environment]::NewLine) Write-Host ("Saved {0} calendar source(s) without displaying their URLs." -f $calendarUrls.Count) } else { Protect-SecretFile -Path $CalendarFile Write-Host "Preserved the existing calendar secret." } $hasExistingToken = (Test-Path -LiteralPath $TokenFile -PathType Leaf) -and ((Get-Item -LiteralPath $TokenFile).Length -gt 0) if (-not $Mode) { $defaultMode = if ($hasExistingToken) { "bearer" } else { "trusted-lan" } $selectedMode = (Read-Host "Access mode [trusted-lan/bearer] (default: $defaultMode)").Trim().ToLowerInvariant() $Mode = if ($selectedMode) { $selectedMode } else { $defaultMode } if ($Mode -notin @("trusted-lan", "bearer")) { throw "Access mode must be trusted-lan or bearer." } } if ($Mode -eq "trusted-lan") { if ($hasExistingToken -and -not (Read-YesNo "This will erase the current access token and disable bearer authentication. Continue? [y/N]")) { throw "Configuration cancelled before changing the access mode." } Write-SecretFile -Path $TokenFile -Content "" Write-Host "Configured trusted-LAN mode with an empty token file." } elseif ($hasExistingToken -and -not $RotateToken) { Protect-SecretFile -Path $TokenFile Write-Host "Preserved the existing bearer token. Use -RotateToken to replace it." } else { $accessToken = New-AccessToken try { Write-SecretFile -Path $TokenFile -Content ($accessToken + [Environment]::NewLine) } finally { $accessToken = $null } Write-Host "Generated and stored a new bearer token. The token was not displayed." } Write-Host "Configuration is ready. Review .env, then run: docker compose up -d --build"