feat: 静态资源统一使用七牛存储桶
This commit is contained in:
parent
ca54f4d338
commit
222eca0e2d
@ -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'",
|
||||
]));
|
||||
|
||||
|
||||
@ -77,7 +77,11 @@ return [
|
||||
|
||||
'url' => env('APP_URL', ''),
|
||||
|
||||
'asset_url' => env('ASSET_URL'),
|
||||
/*
|
||||
* 项目静态资源统一由唯一的七牛云空间 caiwu-jiansun 提供。
|
||||
* 不允许通过环境变量切换到其他存储桶,避免不同环境引用不同资源。
|
||||
*/
|
||||
'asset_url' => 'https://caiwu.jiansun.vip',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
24
package-lock.json
generated
24
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
@stack('scripts_start')
|
||||
|
||||
@apexchartsScripts
|
||||
<script src="{{ asset('public/vendor/apexcharts/apexcharts.min.js') }}"></script>
|
||||
|
||||
@stack('charts')
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
|
||||
@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')
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
@stack('scripts_start')
|
||||
|
||||
@apexchartsScripts
|
||||
<script src="{{ asset('public/vendor/apexcharts/apexcharts.min.js') }}"></script>
|
||||
|
||||
@stack('charts')
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
@stack('scripts_start')
|
||||
|
||||
@apexchartsScripts
|
||||
<script src="{{ asset('public/vendor/apexcharts/apexcharts.min.js') }}"></script>
|
||||
|
||||
@stack('charts')
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<!-- 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 -->
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
@ -31,6 +32,7 @@
|
||||
|
||||
<script type="text/javascript">
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register("{{ asset('serviceworker.js') }}");
|
||||
// Service Worker 受浏览器安全策略限制,必须与当前页面保持同源。
|
||||
navigator.serviceWorker.register("{{ url('serviceworker.js') }}");
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
441
scripts/sync-static-assets-to-qiniu.ps1
Normal file
441
scripts/sync-static-assets-to-qiniu.ps1
Normal 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
4
serviceworker.js
vendored
@ -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
|
||||
|
||||
76
tests/Unit/Http/Middleware/EnforceLocalOnlyTest.php
Normal file
76
tests/Unit/Http/Middleware/EnforceLocalOnlyTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user