CaiWuXiTong/scripts/sync-static-assets-to-qiniu.ps1

442 lines
14 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

[CmdletBinding()]
param(
[switch]$Force
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$bucket = 'caiwu-jiansun'
$assetDomain = 'https://caiwu.jiansun.vip'
$credentialFile = Join-Path $repoRoot 'runtime/qiniu-static.env'
$frontendOutputRoot = Join-Path $repoRoot '.caiwu-build/public'
function ConvertTo-QiniuBase64 {
param(
[Parameter(Mandatory = $true)]
[byte[]]$Bytes,
[switch]$RemovePadding
)
$encoded = [Convert]::ToBase64String($Bytes).Replace('+', '-').Replace('/', '_')
if ($RemovePadding) {
return $encoded.TrimEnd('=')
}
return $encoded
}
function Get-HmacSha1Signature {
param(
[Parameter(Mandatory = $true)]
[string]$SecretKey,
[Parameter(Mandatory = $true)]
[string]$Value,
[switch]$RemovePadding
)
$hmac = [Security.Cryptography.HMACSHA1]::new([Text.Encoding]::UTF8.GetBytes($SecretKey))
try {
$signature = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($Value))
return ConvertTo-QiniuBase64 -Bytes $signature -RemovePadding:$RemovePadding
}
finally {
$hmac.Dispose()
}
}
function Import-PrivateCredentialFile {
if (-not (Test-Path -LiteralPath $credentialFile -PathType Leaf)) {
return
}
foreach ($line in Get-Content -LiteralPath $credentialFile -Encoding UTF8) {
if ($line -match '^\s*(QINIU_ACCESS_KEY|QINIU_SECRET_KEY)\s*=\s*(.+?)\s*$') {
$name = $matches[1]
$value = $matches[2].Trim().Trim('"').Trim("'")
if (-not [Environment]::GetEnvironmentVariable($name, 'Process')) {
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
}
}
}
}
function Get-QiniuManagementHeaders {
param(
[Parameter(Mandatory = $true)]
[string]$Method,
[Parameter(Mandatory = $true)]
[Uri]$Uri,
[Parameter(Mandatory = $true)]
[string]$AccessKey,
[Parameter(Mandatory = $true)]
[string]$SecretKey
)
$timestamp = [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ')
$pathAndQuery = $Uri.PathAndQuery
$signingData = "$Method $pathAndQuery`nHost: $($Uri.Host)`nContent-Type: application/x-www-form-urlencoded`nX-Qiniu-Date: $timestamp`n`n"
$signature = Get-HmacSha1Signature -SecretKey $SecretKey -Value $signingData
return @{
Authorization = "Qiniu ${AccessKey}:$signature"
'X-Qiniu-Date' = $timestamp
}
}
function Get-QiniuBucketNames {
param(
[Parameter(Mandatory = $true)]
[string]$AccessKey,
[Parameter(Mandatory = $true)]
[string]$SecretKey
)
$uri = [Uri]'https://uc.qiniuapi.com/buckets'
$headers = Get-QiniuManagementHeaders `
-Method 'GET' `
-Uri $uri `
-AccessKey $AccessKey `
-SecretKey $SecretKey
return @(
Invoke-RestMethod `
-Uri $uri `
-Method Get `
-Headers $headers `
-ContentType 'application/x-www-form-urlencoded'
)
}
function Set-QiniuBucketPublic {
param(
[Parameter(Mandatory = $true)]
[string]$AccessKey,
[Parameter(Mandatory = $true)]
[string]$SecretKey
)
$uri = [Uri]"https://uc.qiniuapi.com/private?bucket=$([Uri]::EscapeDataString($bucket))&private=0"
$headers = Get-QiniuManagementHeaders `
-Method 'POST' `
-Uri $uri `
-AccessKey $AccessKey `
-SecretKey $SecretKey
$response = Invoke-WebRequest `
-Uri $uri `
-Method Post `
-Headers $headers `
-ContentType 'application/x-www-form-urlencoded' `
-SkipHttpErrorCheck
if ($response.StatusCode -notin @(200, 204)) {
throw "无法将项目静态资源桶设置为公开读取:HTTP $($response.StatusCode)。"
}
}
function Get-QiniuRemoteFiles {
param(
[Parameter(Mandatory = $true)]
[string]$AccessKey,
[Parameter(Mandatory = $true)]
[string]$SecretKey
)
$remoteFiles = @{}
$marker = ''
do {
$query = "bucket=$([Uri]::EscapeDataString($bucket))&limit=1000"
if ($marker) {
$query += "&marker=$([Uri]::EscapeDataString($marker))"
}
$uri = [Uri]"https://rsf.qiniuapi.com/list?$query"
$signature = Get-HmacSha1Signature `
-SecretKey $SecretKey `
-Value "$($uri.PathAndQuery)`n"
$response = Invoke-RestMethod `
-Uri $uri `
-Method Get `
-Headers @{ Authorization = "QBox ${AccessKey}:$signature" }
foreach ($item in @($response.items)) {
$remoteFiles[$item.key] = $item
}
$marker = [string]$response.marker
} while ($marker)
return $remoteFiles
}
function Get-QiniuUploadHost {
param([Parameter(Mandatory = $true)][string]$AccessKey)
$queryUri = "https://api.qiniu.com/v4/query?ak=$([Uri]::EscapeDataString($AccessKey))&bucket=$([Uri]::EscapeDataString($bucket))"
$response = Invoke-RestMethod -Uri $queryUri -Method Get
$domains = @($response.hosts[0].up.acc_domains) + @($response.hosts[0].up.domains)
$domain = $domains | Where-Object { $_ } | Select-Object -First 1
if (-not $domain) {
throw '七牛云没有返回可用的上传域名。'
}
if ($domain -match '^https?://') {
return $domain.TrimEnd('/')
}
return "https://$($domain.TrimEnd('/'))"
}
function New-QiniuUploadToken {
param(
[Parameter(Mandatory = $true)]
[string]$AccessKey,
[Parameter(Mandatory = $true)]
[string]$SecretKey,
[Parameter(Mandatory = $true)]
[string]$Key
)
$policy = [ordered]@{
scope = "${bucket}:$Key"
deadline = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + 3600
insertOnly = 0
returnBody = '{"key":$(key),"hash":$(etag),"fsize":$(fsize)}'
}
$policyJson = $policy | ConvertTo-Json -Compress
$encodedPolicy = ConvertTo-QiniuBase64 `
-Bytes ([Text.Encoding]::UTF8.GetBytes($policyJson))
$encodedSignature = Get-HmacSha1Signature `
-SecretKey $SecretKey `
-Value $encodedPolicy
return "${AccessKey}:$encodedSignature`:$encodedPolicy"
}
function Get-StaticAssetSources {
if (-not (Test-Path -LiteralPath $frontendOutputRoot -PathType Container)) {
throw '缺少 .caiwu-build/public,请先运行 scripts/build-frontend.ps1。'
}
$sources = @{}
$trackedPublicFiles = @(& git -C $repoRoot ls-files -- public)
if ($LASTEXITCODE -ne 0) {
throw '无法读取 Git 静态资源清单。'
}
foreach ($relativePath in $trackedPublicFiles) {
if (
$relativePath -in @('public/.htaccess', 'public/index.php', 'public/public') -or
$relativePath.EndsWith('.php', [StringComparison]::OrdinalIgnoreCase)
) {
continue
}
$fullPath = Join-Path $repoRoot $relativePath
if (Test-Path -LiteralPath $fullPath -PathType Leaf) {
$sources[$relativePath.Replace('\', '/')] = $fullPath
}
}
$trackedModuleFiles = @(& git -C $repoRoot ls-files -- modules)
if ($LASTEXITCODE -ne 0) {
throw '无法读取 Git 模块静态资源清单。'
}
foreach ($relativePath in $trackedModuleFiles) {
$normalizedPath = $relativePath.Replace('\', '/')
if (
$normalizedPath -notmatch '/Resources/assets/' -or
$normalizedPath -notmatch '\.(css|js|png|jpe?g|gif|svg|webp|ico|woff2?|ttf|otf|eot|pdf|json|xlsx?)$'
) {
continue
}
$fullPath = Join-Path $repoRoot $relativePath
if (Test-Path -LiteralPath $fullPath -PathType Leaf) {
$sources[$normalizedPath] = $fullPath
}
}
$manifestPath = Join-Path $repoRoot 'manifest.json'
if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
throw '缺少 manifest.json。'
}
$sources['manifest.json'] = $manifestPath
foreach ($file in Get-ChildItem -LiteralPath $frontendOutputRoot -Recurse -File) {
$generatedRelativePath = $file.FullName.Substring($frontendOutputRoot.Length).TrimStart('\', '/')
$key = "public/$($generatedRelativePath.Replace('\', '/'))"
$sources[$key] = $file.FullName
}
return $sources
}
function Get-MimeType {
param([Parameter(Mandatory = $true)][string]$Path)
switch ([IO.Path]::GetExtension($Path).ToLowerInvariant()) {
'.css' { return 'text/css; charset=utf-8' }
'.js' { return 'application/javascript; charset=utf-8' }
'.json' { return 'application/json; charset=utf-8' }
'.svg' { return 'image/svg+xml' }
'.png' { return 'image/png' }
'.jpg' { return 'image/jpeg' }
'.jpeg' { return 'image/jpeg' }
'.gif' { return 'image/gif' }
'.webp' { return 'image/webp' }
'.ico' { return 'image/x-icon' }
'.woff' { return 'font/woff' }
'.woff2' { return 'font/woff2' }
'.ttf' { return 'font/ttf' }
'.otf' { return 'font/otf' }
'.eot' { return 'application/vnd.ms-fontobject' }
'.pdf' { return 'application/pdf' }
'.xls' { return 'application/vnd.ms-excel' }
'.xlsx' { return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }
default { return 'application/octet-stream' }
}
}
function Get-CacheControl {
param([Parameter(Mandatory = $true)][string]$Path)
# 代码和配置可能原键覆盖,缩短浏览器缓存,避免发布后长期命中旧版本。
if ([IO.Path]::GetExtension($Path).ToLowerInvariant() -in @('.css', '.js', '.json')) {
return 'public, max-age=300, must-revalidate'
}
# 图片、字体和导入模板很少原键替换,长期缓存可显著减少重复下载。
return 'public, max-age=31536000, immutable'
}
function Send-QiniuFile {
param(
[Parameter(Mandatory = $true)]
[Net.Http.HttpClient]$Client,
[Parameter(Mandatory = $true)]
[string]$UploadHost,
[Parameter(Mandatory = $true)]
[string]$AccessKey,
[Parameter(Mandatory = $true)]
[string]$SecretKey,
[Parameter(Mandatory = $true)]
[string]$Key,
[Parameter(Mandatory = $true)]
[string]$SourcePath
)
$form = [Net.Http.MultipartFormDataContent]::new()
$stream = $null
$fileContent = $null
try {
$form.Add([Net.Http.StringContent]::new(
(New-QiniuUploadToken -AccessKey $AccessKey -SecretKey $SecretKey -Key $Key)
), 'token')
$form.Add([Net.Http.StringContent]::new($Key), 'key')
$form.Add([Net.Http.StringContent]::new(
(Get-CacheControl -Path $SourcePath)
), 'cacheControl')
$stream = [IO.File]::OpenRead($SourcePath)
$fileContent = [Net.Http.StreamContent]::new($stream)
$fileContent.Headers.ContentType = [Net.Http.Headers.MediaTypeHeaderValue]::Parse(
(Get-MimeType -Path $SourcePath)
)
$form.Add($fileContent, 'file', [IO.Path]::GetFileName($SourcePath))
$response = $Client.PostAsync($UploadHost, $form).GetAwaiter().GetResult()
$body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
if (-not $response.IsSuccessStatusCode) {
throw "七牛云上传失败:key=$Key,HTTP $([int]$response.StatusCode),$body"
}
}
finally {
if ($fileContent) { $fileContent.Dispose() }
if ($stream) { $stream.Dispose() }
$form.Dispose()
}
}
Import-PrivateCredentialFile
$accessKey = [Environment]::GetEnvironmentVariable('QINIU_ACCESS_KEY', 'Process')
$secretKey = [Environment]::GetEnvironmentVariable('QINIU_SECRET_KEY', 'Process')
if (-not $accessKey) {
$accessKey = [Environment]::GetEnvironmentVariable('QINIU_ACCESS_KEY', 'User')
}
if (-not $secretKey) {
$secretKey = [Environment]::GetEnvironmentVariable('QINIU_SECRET_KEY', 'User')
}
if (-not $accessKey -or -not $secretKey) {
throw '缺少七牛云凭据。请在进程环境变量或 runtime/qiniu-static.env 中设置 QINIU_ACCESS_KEY 和 QINIU_SECRET_KEY。'
}
$bucketNames = Get-QiniuBucketNames -AccessKey $accessKey -SecretKey $secretKey
if ($bucketNames -notcontains $bucket) {
throw "当前七牛云账号中不存在项目唯一存储桶 $bucket。"
}
Set-QiniuBucketPublic -AccessKey $accessKey -SecretKey $secretKey
$sources = Get-StaticAssetSources
$remoteFiles = Get-QiniuRemoteFiles -AccessKey $accessKey -SecretKey $secretKey
$uploadHost = Get-QiniuUploadHost -AccessKey $accessKey
$client = [Net.Http.HttpClient]::new()
$client.Timeout = [TimeSpan]::FromMinutes(5)
$uploaded = 0
$skipped = 0
try {
foreach ($key in @($sources.Keys | Sort-Object)) {
$sourcePath = $sources[$key]
$localMd5 = (Get-FileHash -LiteralPath $sourcePath -Algorithm MD5).Hash.ToLowerInvariant()
$remote = $remoteFiles[$key]
if (-not $Force -and $remote -and $remote.md5 -eq $localMd5) {
$skipped++
continue
}
Write-Host "上传静态资源:$key"
Send-QiniuFile `
-Client $client `
-UploadHost $uploadHost `
-AccessKey $accessKey `
-SecretKey $secretKey `
-Key $key `
-SourcePath $sourcePath
$uploaded++
}
}
finally {
$client.Dispose()
}
$probeKey = 'public/img/favicon.ico'
if (-not $sources.ContainsKey($probeKey)) {
throw "静态资源清单缺少验收文件 $probeKey。"
}
$probeHash = (Get-FileHash -LiteralPath $sources[$probeKey] -Algorithm MD5).Hash.ToLowerInvariant()
$probeUri = "${assetDomain}/${probeKey}?check=${probeHash}"
$probeStatus = 0
foreach ($attempt in 1..10) {
$probeResponse = Invoke-WebRequest `
-Uri $probeUri `
-Method Head `
-TimeoutSec 20 `
-SkipHttpErrorCheck
$probeStatus = $probeResponse.StatusCode
if ($probeStatus -eq 200) {
break
}
Start-Sleep -Seconds 2
}
if ($probeStatus -ne 200) {
throw "七牛云资源无法通过正式 HTTPS 域名读取:HTTP $probeStatus。请检查唯一存储桶 $bucket 的公开权限和域名证书。"
}
Write-Host "七牛云静态资源同步完成:桶=$bucket,上传=$uploaded,跳过=$skipped,总数=$($sources.Count)。"