feat: 静态资源统一使用七牛存储桶

This commit is contained in:
WuYongJie 2026-07-27 10:45:49 +08:00
parent ca54f4d338
commit 222eca0e2d
16 changed files with 634 additions and 34 deletions

View File

@ -15,18 +15,20 @@ class EnforceLocalOnly
return $response; return $response;
} }
$staticOrigin = rtrim((string) config('app.asset_url'), '/');
$response->headers->set('Content-Security-Policy', implode('; ', [ $response->headers->set('Content-Security-Policy', implode('; ', [
"default-src 'self'", "default-src 'self'",
"base-uri 'self'", "base-uri 'self'",
"connect-src 'self'", "connect-src 'self'",
"font-src 'self' data:", "font-src 'self' {$staticOrigin} data:",
"form-action 'self'", "form-action 'self'",
"frame-src 'self'", "frame-src 'self'",
"img-src 'self' data: blob:", "img-src 'self' {$staticOrigin} data: blob:",
"media-src 'self'", "media-src 'self' {$staticOrigin}",
"object-src 'none'", "object-src 'none'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", "script-src 'self' {$staticOrigin} 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'", "style-src 'self' {$staticOrigin} 'unsafe-inline'",
"navigate-to 'self'", "navigate-to 'self'",
])); ]));

View File

@ -77,7 +77,11 @@ return [
'url' => env('APP_URL', ''), 'url' => env('APP_URL', ''),
'asset_url' => env('ASSET_URL'), /*
* 项目静态资源统一由唯一的七牛云空间 caiwu-jiansun 提供。
* 不允许通过环境变量切换到其他存储桶,避免不同环境引用不同资源。
*/
'asset_url' => 'https://caiwu.jiansun.vip',
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@ -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/php.ini /usr/local/etc/php/conf.d/caiwu.ini
COPY deploy/entrypoint.sh /usr/local/bin/caiwu-entrypoint COPY deploy/entrypoint.sh /usr/local/bin/caiwu-entrypoint
COPY . /var/www/html COPY . /var/www/html
COPY --from=frontend /vendor /var/www/html/public/vendor
RUN test -f public/js/auth/common.min.js \ RUN test -f public/js/auth/common.min.js \
&& test -f public/css/app.css \ && test -f public/css/app.css \
&& test -f public/mix-manifest.json \ && 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 \ && chmod +x /usr/local/bin/caiwu-entrypoint \
&& mkdir -p \ && mkdir -p \
storage/app/public \ storage/app/public \

View File

@ -25,49 +25,48 @@
} }
], ],
"splash": { "splash": {
"640x1136": "public/img/pwa/splash-640x1136.png", "640x1136": "https://caiwu.jiansun.vip/public/img/pwa/splash-640x1136.png",
"750x1334": "public/img/pwa/splash-750x1334.png", "750x1334": "https://caiwu.jiansun.vip/public/img/pwa/splash-750x1334.png",
"828x1792": "public/img/pwa/splash-828x1792.png", "828x1792": "https://caiwu.jiansun.vip/public/img/pwa/splash-828x1792.png",
"1125x2436": "public/img/pwa/splash-1125x2436.png", "1242x2208": "https://caiwu.jiansun.vip/public/img/pwa/splash-1242x2208.png",
"1242x2208": "public/img/pwa/splash-1242x2208.png", "1242x2688": "https://caiwu.jiansun.vip/public/img/pwa/splash-1242x2688.png",
"1242x2688": "public/img/pwa/splash-1242x2688.png", "1536x2048": "https://caiwu.jiansun.vip/public/img/pwa/splash-1536x2048.png",
"1536x2048": "public/img/pwa/splash-1536x2048.png", "1668x2224": "https://caiwu.jiansun.vip/public/img/pwa/splash-1668x2224.png",
"1668x2224": "public/img/pwa/splash-1668x2224.png", "1668x2388": "https://caiwu.jiansun.vip/public/img/pwa/splash-1668x2388.png",
"1668x2388": "public/img/pwa/splash-1668x2388.png", "2048x2732": "https://caiwu.jiansun.vip/public/img/pwa/splash-2048x2732.png"
"2048x2732": "public/img/pwa/splash-2048x2732.png"
}, },
"icons": [ "icons": [
{ {
"src": "public/img/pwa/icon-192x192.png", "src": "https://caiwu.jiansun.vip/public/img/pwa/icon-192x192.png",
"type": "image/png", "type": "image/png",
"sizes": "192x192" "sizes": "192x192"
}, },
{ {
"purpose": "maskable", "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", "type": "image/png",
"sizes": "192x192" "sizes": "192x192"
}, },
{ {
"src": "public/img/pwa/icon-512x512.png", "src": "https://caiwu.jiansun.vip/public/img/pwa/icon-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
}, },
{ {
"purpose": "maskable", "purpose": "maskable",
"src": "public/img/pwa/icon-512x512-maskable.png", "src": "https://caiwu.jiansun.vip/public/img/pwa/icon-512x512-maskable.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
} }
], ],
"screenshots": [ "screenshots": [
{ {
"src": "public/img/pwa/screenshot-dashboard.png", "src": "https://caiwu.jiansun.vip/public/img/pwa/screenshot-dashboard.png",
"sizes": "1932x1394", "sizes": "1932x1394",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "public/img/pwa/screenshot-invoice.png", "src": "https://caiwu.jiansun.vip/public/img/pwa/screenshot-invoice.png",
"sizes": "2748x1986", "sizes": "2748x1986",
"type": "image/png" "type": "image/png"
} }
@ -80,7 +79,7 @@
"url": "sales/invoices/create?utm_source=pwa", "url": "sales/invoices/create?utm_source=pwa",
"icons": [ "icons": [
{ {
"src": "public/img/pwa/icon-192x192.png", "src": "https://caiwu.jiansun.vip/public/img/pwa/icon-192x192.png",
"sizes": "192x192" "sizes": "192x192"
} }
] ]
@ -92,7 +91,7 @@
"url": "banking/transactions/create?type=income&utm_source=pwa", "url": "banking/transactions/create?type=income&utm_source=pwa",
"icons": [ "icons": [
{ {
"src": "public/img/pwa/icon-192x192.png", "src": "https://caiwu.jiansun.vip/public/img/pwa/icon-192x192.png",
"sizes": "192x192" "sizes": "192x192"
} }
] ]
@ -104,7 +103,7 @@
"url": "purchases/bills/create?utm_source=pwa", "url": "purchases/bills/create?utm_source=pwa",
"icons": [ "icons": [
{ {
"src": "public/img/pwa/icon-192x192.png", "src": "https://caiwu.jiansun.vip/public/img/pwa/icon-192x192.png",
"sizes": "192x192" "sizes": "192x192"
} }
] ]
@ -116,7 +115,7 @@
"url": "banking/transactions/create?type=expense&utm_source=pwa", "url": "banking/transactions/create?type=expense&utm_source=pwa",
"icons": [ "icons": [
{ {
"src": "public/img/pwa/icon-192x192.png", "src": "https://caiwu.jiansun.vip/public/img/pwa/icon-192x192.png",
"sizes": "192x192" "sizes": "192x192"
} }
] ]

24
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "3.1.15", "version": "3.1.15",
"dependencies": { "dependencies": {
"@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@fontsource/quicksand": "5.3.0",
"@fullcalendar/core": "^6.1.5", "@fullcalendar/core": "^6.1.5",
"@fullcalendar/daygrid": "^6.1.5", "@fullcalendar/daygrid": "^6.1.5",
"@fullcalendar/interaction": "^6.1.5", "@fullcalendar/interaction": "^6.1.5",
@ -19,6 +20,7 @@
"@sentry/vue": "^7.47.0", "@sentry/vue": "^7.47.0",
"@tailwindcss/forms": "^0.5.3", "@tailwindcss/forms": "^0.5.3",
"@themesberg/flowbite": "^1.2.0", "@themesberg/flowbite": "^1.2.0",
"apexcharts": "6.5.0",
"axios": "^1.3.5", "axios": "^1.3.5",
"body-parser": "^1.20.3", "body-parser": "^1.20.3",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
@ -29,6 +31,7 @@
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"fuse.js": "^6.6.2", "fuse.js": "^6.6.2",
"glightbox": "^3.2.0", "glightbox": "^3.2.0",
"js-cookie": "3.0.8",
"json-schema": ">=0.4.0", "json-schema": ">=0.4.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"laravel-mix-tailwind": "^0.1.2", "laravel-mix-tailwind": "^0.1.2",
@ -2937,6 +2940,15 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "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": { "node_modules/@fullcalendar/core": {
"version": "6.1.20", "version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
@ -5217,6 +5229,12 @@
"node": ">= 8" "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": { "node_modules/app-root-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-2.1.0.tgz",
@ -11802,6 +11820,12 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "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": { "node_modules/js-message": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz", "resolved": "https://registry.npmjs.org/js-message/-/js-message-1.0.7.tgz",

View File

@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@fontsource/quicksand": "5.3.0",
"@fullcalendar/core": "^6.1.5", "@fullcalendar/core": "^6.1.5",
"@fullcalendar/daygrid": "^6.1.5", "@fullcalendar/daygrid": "^6.1.5",
"@fullcalendar/interaction": "^6.1.5", "@fullcalendar/interaction": "^6.1.5",
@ -23,6 +24,7 @@
"@sentry/vue": "^7.47.0", "@sentry/vue": "^7.47.0",
"@tailwindcss/forms": "^0.5.3", "@tailwindcss/forms": "^0.5.3",
"@themesberg/flowbite": "^1.2.0", "@themesberg/flowbite": "^1.2.0",
"apexcharts": "6.5.0",
"axios": "^1.3.5", "axios": "^1.3.5",
"body-parser": "^1.20.3", "body-parser": "^1.20.3",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
@ -33,6 +35,7 @@
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
"fuse.js": "^6.6.2", "fuse.js": "^6.6.2",
"glightbox": "^3.2.0", "glightbox": "^3.2.0",
"js-cookie": "3.0.8",
"json-schema": ">=0.4.0", "json-schema": ">=0.4.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"laravel-mix-tailwind": "^0.1.2", "laravel-mix-tailwind": "^0.1.2",

View File

@ -8,7 +8,7 @@
@stack('scripts_start') @stack('scripts_start')
@apexchartsScripts <script src="{{ asset('public/vendor/apexcharts/apexcharts.min.js') }}"></script>
@stack('charts') @stack('charts')

View File

@ -12,6 +12,7 @@
@stack('body_scripts') @stack('body_scripts')
<script type="text/javascript" src="{{ asset('vendor/livewire/livewire/dist/livewire.min.js') }}" defer></script> {{-- 登录页没有 company_id,Livewire 运行脚本保持同源并直接读取容器内发布文件。 --}}
<script type="text/javascript" src="{{ url('vendor/livewire/livewire/dist/livewire.min.js') }}" defer></script>
@stack('scripts_end') @stack('scripts_end')

View File

@ -8,7 +8,7 @@
@stack('scripts_start') @stack('scripts_start')
@apexchartsScripts <script src="{{ asset('public/vendor/apexcharts/apexcharts.min.js') }}"></script>
@stack('charts') @stack('charts')

View File

@ -8,7 +8,7 @@
@stack('scripts_start') @stack('scripts_start')
@apexchartsScripts <script src="{{ asset('public/vendor/apexcharts/apexcharts.min.js') }}"></script>
@stack('charts') @stack('charts')

View File

@ -1,5 +1,6 @@
<!-- Web Application Manifest --> <!-- Web Application Manifest -->
<link rel="manifest" href="{{ asset('manifest.json') }}"> <!-- Web App Manifest 与 Service Worker 一样需要保持应用同源。 -->
<link rel="manifest" href="{{ url('manifest.json') }}">
<!-- Chrome for Android theme color --> <!-- Chrome for Android theme color -->
<meta name="theme-color" content="#ffffff"> <meta name="theme-color" content="#ffffff">
@ -31,6 +32,7 @@
<script type="text/javascript"> <script type="text/javascript">
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.register("{{ asset('serviceworker.js') }}"); // Service Worker 受浏览器安全策略限制,必须与当前页面保持同源。
navigator.serviceWorker.register("{{ url('serviceworker.js') }}");
} }
</script> </script>

View File

@ -28,6 +28,7 @@ $cssFingerprintPath = Join-Path $frontendCacheRoot 'frontend-css.sha'
$dependencyFingerprintPath = Join-Path $frontendCacheRoot 'node-modules.sha' $dependencyFingerprintPath = Join-Path $frontendCacheRoot 'node-modules.sha'
$jsManifestCachePath = Join-Path $frontendCacheRoot 'manifest-js.json' $jsManifestCachePath = Join-Path $frontendCacheRoot 'manifest-js.json'
$cssManifestCachePath = Join-Path $frontendCacheRoot 'manifest-css.json' $cssManifestCachePath = Join-Path $frontendCacheRoot 'manifest-css.json'
$cachedVendorPath = Join-Path $frontendOutputRoot 'vendor'
function Invoke-Native { function Invoke-Native {
param( param(
@ -128,6 +129,7 @@ $jsFingerprintInputs = @(
'resources/assets/js', 'resources/assets/js',
'package.json', 'package.json',
'package-lock.json', 'package-lock.json',
'scripts/build-frontend.ps1',
'webpack.mix.js' 'webpack.mix.js'
) )
$cssFingerprintInputs = @( $cssFingerprintInputs = @(
@ -153,6 +155,10 @@ $jsCacheValid = Test-CacheFingerprint `
(Join-Path $frontendOutputRoot 'js/auth/common.min.js'), (Join-Path $frontendOutputRoot 'js/auth/common.min.js'),
(Join-Path $frontendOutputRoot 'js/manifest.js'), (Join-Path $frontendOutputRoot 'js/manifest.js'),
(Join-Path $frontendOutputRoot 'js/vendor.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 $jsManifestCachePath
) )
$cssCacheValid = Test-CacheFingerprint ` $cssCacheValid = Test-CacheFingerprint `
@ -270,6 +276,40 @@ try {
} }
New-Item -ItemType Directory -Force -Path $frontendOutputRoot | Out-Null New-Item -ItemType Directory -Force -Path $frontendOutputRoot | Out-Null
Copy-Item -LiteralPath $publicJsPath -Destination $frontendOutputRoot -Recurse 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 Copy-Item -LiteralPath $publicManifestPath -Destination $jsManifestCachePath -Force
Write-Utf8NoBom -Path $jsFingerprintPath -Value $jsFingerprint Write-Utf8NoBom -Path $jsFingerprintPath -Value $jsFingerprint
} }

View File

@ -208,6 +208,10 @@ try {
Invoke-Native { docker version | Out-Null } 'Docker Desktop 未启动或不可用。' Invoke-Native { docker version | Out-Null } 'Docker Desktop 未启动或不可用。'
& (Join-Path $PSScriptRoot 'build-frontend.ps1') & (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 docker buildx inspect $BuilderName 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {

View File

@ -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)。"

4
serviceworker.js vendored
View File

@ -1,7 +1,7 @@
var staticCacheName = "pwa-v2"; var staticCacheName = "pwa-v2";
var filesToCache = [ var filesToCache = [
'/public/img/pwa/icon-192x192.png', 'https://caiwu.jiansun.vip/public/img/pwa/icon-192x192.png',
'/public/img/pwa/icon-512x512.png' 'https://caiwu.jiansun.vip/public/img/pwa/icon-512x512.png'
]; ];
// Cache on install // Cache on install

View File

@ -0,0 +1,76 @@
<?php
namespace Tests\Unit\Http\Middleware;
use App\Http\Middleware\EnforceLocalOnly;
use Illuminate\Config\Repository;
use Illuminate\Container\Container;
use Illuminate\Http\Request;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Response;
class EnforceLocalOnlyTest extends TestCase
{
private Container $container;
protected function setUp(): void
{
parent::setUp();
$this->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'));
}
}