diff --git a/app/Http/Middleware/EnforceLocalOnly.php b/app/Http/Middleware/EnforceLocalOnly.php index 15bfab85..3acbb1f3 100644 --- a/app/Http/Middleware/EnforceLocalOnly.php +++ b/app/Http/Middleware/EnforceLocalOnly.php @@ -15,18 +15,20 @@ class EnforceLocalOnly return $response; } + $staticOrigin = rtrim((string) config('app.asset_url'), '/'); + $response->headers->set('Content-Security-Policy', implode('; ', [ "default-src 'self'", "base-uri 'self'", "connect-src 'self'", - "font-src 'self' data:", + "font-src 'self' {$staticOrigin} data:", "form-action 'self'", "frame-src 'self'", - "img-src 'self' data: blob:", - "media-src 'self'", + "img-src 'self' {$staticOrigin} data: blob:", + "media-src 'self' {$staticOrigin}", "object-src 'none'", - "script-src 'self' 'unsafe-inline' 'unsafe-eval'", - "style-src 'self' 'unsafe-inline'", + "script-src 'self' {$staticOrigin} 'unsafe-inline' 'unsafe-eval'", + "style-src 'self' {$staticOrigin} 'unsafe-inline'", "navigate-to 'self'", ])); diff --git a/config/app.php b/config/app.php index 71ba6c27..7c9c6432 100644 --- a/config/app.php +++ b/config/app.php @@ -77,7 +77,11 @@ return [ 'url' => env('APP_URL', ''), - 'asset_url' => env('ASSET_URL'), + /* + * 项目静态资源统一由唯一的七牛云空间 caiwu-jiansun 提供。 + * 不允许通过环境变量切换到其他存储桶,避免不同环境引用不同资源。 + */ + 'asset_url' => 'https://caiwu.jiansun.vip', /* |-------------------------------------------------------------------------- diff --git a/deploy/Dockerfile b/deploy/Dockerfile index b2efe343..a85bdc1f 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -71,10 +71,14 @@ COPY deploy/apache-vhost.conf /etc/apache2/sites-available/000-default.conf COPY deploy/php.ini /usr/local/etc/php/conf.d/caiwu.ini COPY deploy/entrypoint.sh /usr/local/bin/caiwu-entrypoint COPY . /var/www/html +COPY --from=frontend /vendor /var/www/html/public/vendor RUN test -f public/js/auth/common.min.js \ && test -f public/css/app.css \ && test -f public/mix-manifest.json \ + && test -f public/vendor/apexcharts/apexcharts.min.js \ + && test -f public/vendor/js-cookie/js.cookie.js \ + && test -f public/vendor/quicksand/css/quicksand.css \ && chmod +x /usr/local/bin/caiwu-entrypoint \ && mkdir -p \ storage/app/public \ diff --git a/manifest.json b/manifest.json index 309d9d30..e078abcd 100644 --- a/manifest.json +++ b/manifest.json @@ -25,49 +25,48 @@ } ], "splash": { - "640x1136": "public/img/pwa/splash-640x1136.png", - "750x1334": "public/img/pwa/splash-750x1334.png", - "828x1792": "public/img/pwa/splash-828x1792.png", - "1125x2436": "public/img/pwa/splash-1125x2436.png", - "1242x2208": "public/img/pwa/splash-1242x2208.png", - "1242x2688": "public/img/pwa/splash-1242x2688.png", - "1536x2048": "public/img/pwa/splash-1536x2048.png", - "1668x2224": "public/img/pwa/splash-1668x2224.png", - "1668x2388": "public/img/pwa/splash-1668x2388.png", - "2048x2732": "public/img/pwa/splash-2048x2732.png" + "640x1136": "https://caiwu.jiansun.vip/public/img/pwa/splash-640x1136.png", + "750x1334": "https://caiwu.jiansun.vip/public/img/pwa/splash-750x1334.png", + "828x1792": "https://caiwu.jiansun.vip/public/img/pwa/splash-828x1792.png", + "1242x2208": "https://caiwu.jiansun.vip/public/img/pwa/splash-1242x2208.png", + "1242x2688": "https://caiwu.jiansun.vip/public/img/pwa/splash-1242x2688.png", + "1536x2048": "https://caiwu.jiansun.vip/public/img/pwa/splash-1536x2048.png", + "1668x2224": "https://caiwu.jiansun.vip/public/img/pwa/splash-1668x2224.png", + "1668x2388": "https://caiwu.jiansun.vip/public/img/pwa/splash-1668x2388.png", + "2048x2732": "https://caiwu.jiansun.vip/public/img/pwa/splash-2048x2732.png" }, "icons": [ { - "src": "public/img/pwa/icon-192x192.png", + "src": "https://caiwu.jiansun.vip/public/img/pwa/icon-192x192.png", "type": "image/png", "sizes": "192x192" }, { "purpose": "maskable", - "src": "public/img/pwa/icon-192x192-maskable.png", + "src": "https://caiwu.jiansun.vip/public/img/pwa/icon-192x192-maskable.png", "type": "image/png", "sizes": "192x192" }, { - "src": "public/img/pwa/icon-512x512.png", + "src": "https://caiwu.jiansun.vip/public/img/pwa/icon-512x512.png", "sizes": "512x512", "type": "image/png" }, { "purpose": "maskable", - "src": "public/img/pwa/icon-512x512-maskable.png", + "src": "https://caiwu.jiansun.vip/public/img/pwa/icon-512x512-maskable.png", "sizes": "512x512", "type": "image/png" } ], "screenshots": [ { - "src": "public/img/pwa/screenshot-dashboard.png", + "src": "https://caiwu.jiansun.vip/public/img/pwa/screenshot-dashboard.png", "sizes": "1932x1394", "type": "image/png" }, { - "src": "public/img/pwa/screenshot-invoice.png", + "src": "https://caiwu.jiansun.vip/public/img/pwa/screenshot-invoice.png", "sizes": "2748x1986", "type": "image/png" } @@ -80,7 +79,7 @@ "url": "sales/invoices/create?utm_source=pwa", "icons": [ { - "src": "public/img/pwa/icon-192x192.png", + "src": "https://caiwu.jiansun.vip/public/img/pwa/icon-192x192.png", "sizes": "192x192" } ] @@ -92,7 +91,7 @@ "url": "banking/transactions/create?type=income&utm_source=pwa", "icons": [ { - "src": "public/img/pwa/icon-192x192.png", + "src": "https://caiwu.jiansun.vip/public/img/pwa/icon-192x192.png", "sizes": "192x192" } ] @@ -104,7 +103,7 @@ "url": "purchases/bills/create?utm_source=pwa", "icons": [ { - "src": "public/img/pwa/icon-192x192.png", + "src": "https://caiwu.jiansun.vip/public/img/pwa/icon-192x192.png", "sizes": "192x192" } ] @@ -116,7 +115,7 @@ "url": "banking/transactions/create?type=expense&utm_source=pwa", "icons": [ { - "src": "public/img/pwa/icon-192x192.png", + "src": "https://caiwu.jiansun.vip/public/img/pwa/icon-192x192.png", "sizes": "192x192" } ] diff --git a/package-lock.json b/package-lock.json index 63595783..b360852f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "3.1.15", "dependencies": { "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@fontsource/quicksand": "5.3.0", "@fullcalendar/core": "^6.1.5", "@fullcalendar/daygrid": "^6.1.5", "@fullcalendar/interaction": "^6.1.5", @@ -19,6 +20,7 @@ "@sentry/vue": "^7.47.0", "@tailwindcss/forms": "^0.5.3", "@themesberg/flowbite": "^1.2.0", + "apexcharts": "6.5.0", "axios": "^1.3.5", "body-parser": "^1.20.3", "date-fns": "^2.29.3", @@ -29,6 +31,7 @@ "flatpickr": "^4.6.13", "fuse.js": "^6.6.2", "glightbox": "^3.2.0", + "js-cookie": "3.0.8", "json-schema": ">=0.4.0", "jsonwebtoken": "^9.0.2", "laravel-mix-tailwind": "^0.1.2", @@ -2937,6 +2940,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fontsource/quicksand": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/quicksand/-/quicksand-5.3.0.tgz", + "integrity": "sha512-epmMUyXI39f88bZSCyKlHTZqUWg4O7DqrTXOKqsqbVKMLshX9RtIYCUIw+G2nOfEwdIHIjYbeWdwHRcEzRXBRQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fullcalendar/core": { "version": "6.1.20", "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", @@ -5217,6 +5229,12 @@ "node": ">= 8" } }, + "node_modules/apexcharts": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-6.5.0.tgz", + "integrity": "sha512-yo04DuSIKA8cwCHEEcoI+MwBaPouTa5YnEmbT6R0cTN+v7OKdYxJl5BBVpzyPeNLD004rYyaT0EWeznDo1vztg==", + "license": "SEE LICENSE IN LICENSE" + }, "node_modules/app-root-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-2.1.0.tgz", @@ -11802,6 +11820,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "license": "MIT" + }, "node_modules/js-message": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz", diff --git a/package.json b/package.json index f8b28ea2..4ffe7d51 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@fontsource/quicksand": "5.3.0", "@fullcalendar/core": "^6.1.5", "@fullcalendar/daygrid": "^6.1.5", "@fullcalendar/interaction": "^6.1.5", @@ -23,6 +24,7 @@ "@sentry/vue": "^7.47.0", "@tailwindcss/forms": "^0.5.3", "@themesberg/flowbite": "^1.2.0", + "apexcharts": "6.5.0", "axios": "^1.3.5", "body-parser": "^1.20.3", "date-fns": "^2.29.3", @@ -33,6 +35,7 @@ "flatpickr": "^4.6.13", "fuse.js": "^6.6.2", "glightbox": "^3.2.0", + "js-cookie": "3.0.8", "json-schema": ">=0.4.0", "jsonwebtoken": "^9.0.2", "laravel-mix-tailwind": "^0.1.2", diff --git a/resources/views/components/layouts/admin/scripts.blade.php b/resources/views/components/layouts/admin/scripts.blade.php index 79264d0f..564e27f8 100644 --- a/resources/views/components/layouts/admin/scripts.blade.php +++ b/resources/views/components/layouts/admin/scripts.blade.php @@ -8,7 +8,7 @@ @stack('scripts_start') -@apexchartsScripts + @stack('charts') diff --git a/resources/views/components/layouts/auth/scripts.blade.php b/resources/views/components/layouts/auth/scripts.blade.php index 7cf21323..1cb58f29 100644 --- a/resources/views/components/layouts/auth/scripts.blade.php +++ b/resources/views/components/layouts/auth/scripts.blade.php @@ -12,6 +12,7 @@ @stack('body_scripts') - +{{-- 登录页没有 company_id,Livewire 运行脚本保持同源并直接读取容器内发布文件。 --}} + @stack('scripts_end') diff --git a/resources/views/components/layouts/portal/scripts.blade.php b/resources/views/components/layouts/portal/scripts.blade.php index 5036919d..79c7ba88 100644 --- a/resources/views/components/layouts/portal/scripts.blade.php +++ b/resources/views/components/layouts/portal/scripts.blade.php @@ -8,7 +8,7 @@ @stack('scripts_start') -@apexchartsScripts + @stack('charts') diff --git a/resources/views/components/layouts/print/scripts.blade.php b/resources/views/components/layouts/print/scripts.blade.php index 0aeb5cc4..8f70ce84 100644 --- a/resources/views/components/layouts/print/scripts.blade.php +++ b/resources/views/components/layouts/print/scripts.blade.php @@ -8,7 +8,7 @@ @stack('scripts_start') -@apexchartsScripts + @stack('charts') diff --git a/resources/views/components/layouts/pwa/head.blade.php b/resources/views/components/layouts/pwa/head.blade.php index c7cde72e..4d70bd4c 100644 --- a/resources/views/components/layouts/pwa/head.blade.php +++ b/resources/views/components/layouts/pwa/head.blade.php @@ -1,5 +1,6 @@ - + + @@ -31,6 +32,7 @@ diff --git a/scripts/build-frontend.ps1 b/scripts/build-frontend.ps1 index e6687677..ee4b4917 100644 --- a/scripts/build-frontend.ps1 +++ b/scripts/build-frontend.ps1 @@ -28,6 +28,7 @@ $cssFingerprintPath = Join-Path $frontendCacheRoot 'frontend-css.sha' $dependencyFingerprintPath = Join-Path $frontendCacheRoot 'node-modules.sha' $jsManifestCachePath = Join-Path $frontendCacheRoot 'manifest-js.json' $cssManifestCachePath = Join-Path $frontendCacheRoot 'manifest-css.json' +$cachedVendorPath = Join-Path $frontendOutputRoot 'vendor' function Invoke-Native { param( @@ -128,6 +129,7 @@ $jsFingerprintInputs = @( 'resources/assets/js', 'package.json', 'package-lock.json', + 'scripts/build-frontend.ps1', 'webpack.mix.js' ) $cssFingerprintInputs = @( @@ -153,6 +155,10 @@ $jsCacheValid = Test-CacheFingerprint ` (Join-Path $frontendOutputRoot 'js/auth/common.min.js'), (Join-Path $frontendOutputRoot 'js/manifest.js'), (Join-Path $frontendOutputRoot 'js/vendor.js'), + (Join-Path $cachedVendorPath 'apexcharts/apexcharts.min.js'), + (Join-Path $cachedVendorPath 'js-cookie/js.cookie.js'), + (Join-Path $cachedVendorPath 'quicksand/css/quicksand.css'), + (Join-Path $cachedVendorPath 'quicksand/files/quicksand-latin-400-normal.woff2'), $jsManifestCachePath ) $cssCacheValid = Test-CacheFingerprint ` @@ -270,6 +276,40 @@ try { } New-Item -ItemType Directory -Force -Path $frontendOutputRoot | Out-Null Copy-Item -LiteralPath $publicJsPath -Destination $frontendOutputRoot -Recurse + + if (Test-Path -LiteralPath $cachedVendorPath) { + Remove-Item -LiteralPath $cachedVendorPath -Recurse -Force + } + New-Item -ItemType Directory -Force -Path ` + (Join-Path $cachedVendorPath 'apexcharts'), ` + (Join-Path $cachedVendorPath 'js-cookie'), ` + (Join-Path $cachedVendorPath 'quicksand/css'), ` + (Join-Path $cachedVendorPath 'quicksand/files') | Out-Null + + Copy-Item ` + -LiteralPath (Join-Path $repoRoot 'node_modules/apexcharts/dist/apexcharts.min.js') ` + -Destination (Join-Path $cachedVendorPath 'apexcharts/apexcharts.min.js') ` + -Force + Copy-Item ` + -LiteralPath (Join-Path $repoRoot 'node_modules/js-cookie/dist/js.cookie.min.js') ` + -Destination (Join-Path $cachedVendorPath 'js-cookie/js.cookie.js') ` + -Force + Copy-Item ` + -LiteralPath (Join-Path $repoRoot 'node_modules/@fontsource/quicksand/files') ` + -Destination (Join-Path $cachedVendorPath 'quicksand') ` + -Recurse ` + -Force + + $quicksandCss = foreach ($weight in @(400, 500, 600, 700)) { + (Get-Content ` + -Raw ` + -LiteralPath (Join-Path $repoRoot "node_modules/@fontsource/quicksand/$weight.css") + ).Replace('url(./files/', 'url(../files/') + } + Write-Utf8NoBom ` + -Path (Join-Path $cachedVendorPath 'quicksand/css/quicksand.css') ` + -Value ($quicksandCss -join "`n") + Copy-Item -LiteralPath $publicManifestPath -Destination $jsManifestCachePath -Force Write-Utf8NoBom -Path $jsFingerprintPath -Value $jsFingerprint } diff --git a/scripts/deploy-from-workstation.ps1 b/scripts/deploy-from-workstation.ps1 index d98a849a..f6df8759 100644 --- a/scripts/deploy-from-workstation.ps1 +++ b/scripts/deploy-from-workstation.ps1 @@ -208,6 +208,10 @@ try { Invoke-Native { docker version | Out-Null } 'Docker Desktop 未启动或不可用。' & (Join-Path $PSScriptRoot 'build-frontend.ps1') + & (Join-Path $PSScriptRoot 'sync-static-assets-to-qiniu.ps1') + if ($LASTEXITCODE -ne 0) { + throw '七牛云静态资源同步失败,服务器未发生任何变更。' + } docker buildx inspect $BuilderName 2>$null | Out-Null if ($LASTEXITCODE -ne 0) { diff --git a/scripts/sync-static-assets-to-qiniu.ps1 b/scripts/sync-static-assets-to-qiniu.ps1 new file mode 100644 index 00000000..164fff8a --- /dev/null +++ b/scripts/sync-static-assets-to-qiniu.ps1 @@ -0,0 +1,441 @@ +[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)。" diff --git a/serviceworker.js b/serviceworker.js index bcca3fb2..c6a3c5f0 100644 --- a/serviceworker.js +++ b/serviceworker.js @@ -1,7 +1,7 @@ var staticCacheName = "pwa-v2"; var filesToCache = [ - '/public/img/pwa/icon-192x192.png', - '/public/img/pwa/icon-512x512.png' + 'https://caiwu.jiansun.vip/public/img/pwa/icon-192x192.png', + 'https://caiwu.jiansun.vip/public/img/pwa/icon-512x512.png' ]; // Cache on install diff --git a/tests/Unit/Http/Middleware/EnforceLocalOnlyTest.php b/tests/Unit/Http/Middleware/EnforceLocalOnlyTest.php new file mode 100644 index 00000000..4ba1bf71 --- /dev/null +++ b/tests/Unit/Http/Middleware/EnforceLocalOnlyTest.php @@ -0,0 +1,76 @@ +container = new Container(); + $this->container->instance('config', new Repository([ + 'app' => [ + 'offline' => true, + 'asset_url' => 'https://caiwu.jiansun.vip', + ], + ])); + + Container::setInstance($this->container); + } + + protected function tearDown(): void + { + Container::setInstance(null); + + parent::tearDown(); + } + + public function test_offline_policy_allows_the_fixed_qiniu_asset_origin(): void + { + $response = (new EnforceLocalOnly())->handle( + Request::create('/auth/login'), + fn () => new Response('ok') + ); + + $policy = (string) $response->headers->get('Content-Security-Policy'); + + $this->assertStringContainsString( + "script-src 'self' https://caiwu.jiansun.vip", + $policy + ); + $this->assertStringContainsString( + "style-src 'self' https://caiwu.jiansun.vip", + $policy + ); + $this->assertStringContainsString( + "font-src 'self' https://caiwu.jiansun.vip", + $policy + ); + $this->assertStringContainsString( + "img-src 'self' https://caiwu.jiansun.vip", + $policy + ); + } + + public function test_online_mode_does_not_add_an_offline_policy(): void + { + $this->container->make('config')->set('app.offline', false); + + $response = (new EnforceLocalOnly())->handle( + Request::create('/auth/login'), + fn () => new Response('ok') + ); + + $this->assertFalse($response->headers->has('Content-Security-Policy')); + } +}