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'));
+ }
+}