442 lines
14 KiB
PowerShell
442 lines
14 KiB
PowerShell
[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)。"
|