gongxiangp2/scripts/check-legacy-storage-boundaries.mjs

235 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { promises as fs } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
export const LEGACY_DOMAINS = ['s3.1080p.hk', 's3.yydd.ai']
const SOURCE_EXTENSIONS = new Set([
'.ts', '.tsx', '.js', '.mjs', '.cjs', '.prisma',
'.jsx', '.mts', '.cts', '.css', '.scss', '.html', '.htm',
'.json', '.yml', '.yaml', '.toml', '.ps1', '.sh', '.sql', '.conf', '.ini',
])
const ALWAYS_SKIPPED_DIRECTORIES = new Set([
'.git', '.next', 'node_modules', 'coverage',
'.next-salary-local',
'.build-cache', '.docker-cache',
])
// 根级本地数据目录:不进构建上下文(.dockerignore 已排除),扫描时同样豁免
const ROOT_DATA_DIRECTORIES = new Set(['uploads', 'backups', 'output'])
const LEGACY_READ_ONLY_MARKER = 'LEGACY_READ_ONLY'
const LEGACY_DATA_REMEDIATION_MARKER = 'LEGACY_DATA_REMEDIATION'
const DEFAULT_COMPOSE_FILES = new Set([
'compose.yml',
'compose.yaml',
'docker-compose.yml',
'docker-compose.yaml',
])
// 精确计数是有意的:兼容文件里即使只新增一次旧域名,也必须更新门禁并接受评审。
const LEGACY_READ_ONLY_POLICY = new Map([
['app/api/proxy-media/route.ts', { old: 1, retired: 1 }],
['app/performance-survey/[[...path]]/page.tsx', { old: 1, retired: 1 }],
['components/work-review/WorkReviewDialogs.tsx', { old: 1, retired: 1 }],
['components/work-review/WorkReviewImageGallery.tsx', { old: 1, retired: 1 }],
['components/work-review/WorkReviewProjectCard.tsx', { old: 1, retired: 1 }],
['components/work-review/WorkReviewReviewerWorkspace.tsx', { old: 1, retired: 1 }],
['lib/network-detection.ts', { old: 1, retired: 1 }],
['lib/proxy-media-security.ts', { old: 1, retired: 2 }],
['lib/url-rewrite.ts', { old: 11, retired: 6 }],
['lib/utils.ts', { old: 1, retired: 1 }],
['next.config.js', { old: 2, retired: 2 }],
['scripts/migrate-history-preview-assets.js', { old: 1, retired: 0 }],
])
const IMMUTABLE_HISTORICAL_POLICY = new Map([
['prisma/migrations/20251026015433_add_material_type/migration.sql', { old: 1, retired: 0 }],
])
// 这不是一般 allowlist。唯一允许的写场景是:把品牌列中已确认退役的旧域名
// 精确改成本地内置资源。文件、次数和 SQL 形状任一变化都必须重新评审。
const LEGACY_DATA_REMEDIATION_POLICY = new Map([
['prisma/migrations/20260811_update_brand_logo_defaults/migration.sql', { old: 2, retired: 0 }],
])
function normalize(relativePath) {
return relativePath.split(path.sep).join('/')
}
function countOccurrences(content, search) {
const normalizedContent = content.toLowerCase()
const normalizedSearch = search.toLowerCase()
let count = 0
let offset = 0
while ((offset = normalizedContent.indexOf(normalizedSearch, offset)) !== -1) {
count += 1
offset += normalizedSearch.length
}
return count
}
function countDomainReferences(content, domain) {
return countOccurrences(content, domain)
+ countOccurrences(content, domain.replaceAll('.', '\\.'))
+ countOccurrences(content, domain.replaceAll('.', '[.]'))
}
function isCompatibilityTest(relativePath) {
return relativePath.includes('/__tests__/')
|| /\.test\.[cm]?[jt]sx?$/.test(relativePath)
}
function shouldScanFile(entryName) {
return entryName === 'Dockerfile'
|| entryName.startsWith('Dockerfile.')
|| entryName === 'Containerfile'
|| entryName.startsWith('Containerfile.')
|| entryName.startsWith('.env')
|| SOURCE_EXTENSIONS.has(path.extname(entryName).toLowerCase())
}
function isComposeFile(relativePath) {
const fileName = path.posix.basename(relativePath).toLowerCase()
return /^(?:docker-)?compose(?:[.-][a-z0-9_-]+)?\.ya?ml$/.test(fileName)
}
function isExplicitNonProductionCompose(relativePath) {
const fileName = path.posix.basename(relativePath).toLowerCase()
return /(?:^|[.-])(?:dev|development|local|test)(?:[.-]|$)/.test(fileName)
}
function findComposeUploadsBoundaryViolations(relativePath, content) {
if (!isComposeFile(relativePath)) return []
const reasons = []
const fileName = path.posix.basename(relativePath).toLowerCase()
const uncommentedContent = content
.split(/\r?\n/)
.map(line => line.replace(/\s+#.*$/, ''))
.join('\n')
const relativeUploadsBind = (
/source\s*:\s*[^\r\n]*\.\/uploads(?=[\/\s'"}]|$)/i.test(uncommentedContent)
|| /[-]\s*[^\r\n]*\.\/uploads(?=[\/\s'"}:]|$)[^\r\n]*:\s*\/app\/uploads/i.test(uncommentedContent)
)
if (relativeUploadsBind && !isExplicitNonProductionCompose(relativePath)) {
reasons.push('relative uploads bind is only allowed in an explicitly dev/local/test compose file')
}
if (!DEFAULT_COMPOSE_FILES.has(fileName)) return reasons
if (!/(?:^|\n)\s*(?:-\s*)?UPLOAD_DIR\s*(?::|=)\s*['"]?\/app\/uploads['"]?\s*(?:#.*)?(?:\r?\n|$)/m.test(content)) {
reasons.push('default compose must set container UPLOAD_DIR=/app/uploads')
}
if (!/source\s*:\s*['"]?\$\{GONGXIANGP2_UPLOADS_MOUNT:\?[^}\r\n]+\}['"]?/i.test(content)) {
reasons.push('default compose must require GONGXIANGP2_UPLOADS_MOUNT as the host bind source')
}
if (!/target\s*:\s*['"]?\/app\/uploads['"]?/i.test(content)) {
reasons.push('default compose must bind the uploads source to /app/uploads')
}
if (!/create_host_path\s*:\s*false\b/i.test(content)) {
reasons.push('default compose must disable automatic host-path creation')
}
return reasons
}
async function collectSourceFiles(rootDirectory, directory = rootDirectory, result = []) {
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
const isRootDataDirectory = directory === rootDirectory
&& ROOT_DATA_DIRECTORIES.has(entry.name)
if (
entry.isDirectory()
&& (ALWAYS_SKIPPED_DIRECTORIES.has(entry.name) || isRootDataDirectory)
) {
continue
}
const absolutePath = path.join(directory, entry.name)
if (entry.isDirectory()) {
await collectSourceFiles(rootDirectory, absolutePath, result)
continue
}
if (shouldScanFile(entry.name)) result.push(absolutePath)
}
return result
}
function matchesExactPolicy(content, expected) {
return countDomainReferences(content, LEGACY_DOMAINS[0]) === expected.old
&& countDomainReferences(content, LEGACY_DOMAINS[1]) === expected.retired
}
function matchesBrandLogoRemediation(content, expected) {
if (!content.includes(LEGACY_DATA_REMEDIATION_MARKER)) return false
if (!matchesExactPolicy(content, expected)) return false
if (/\b(?:INSERT|DELETE|TRUNCATE)\b/i.test(content)) return false
if (/\bDEFAULT\b[^;\n]*(?:s3\.1080p\.hk|s3\.yydd\.ai)/i.test(content)) return false
if ((content.match(/UPDATE\s+"system_settings"/gi) || []).length !== 2) return false
const headerRepair = /UPDATE\s+"system_settings"\s+SET\s+"headerLogoUrl"\s*=\s*'\/brand\/header-logo\.png'\s+WHERE\s+"headerLogoUrl"\s+~\*\s*'\^https\?:\/\/s3\\\.1080p\\\.hk\(\?:\/\|\$\)'\s*;/i
const loginRepair = /UPDATE\s+"system_settings"\s+SET\s+"loginLogoUrl"\s*=\s*'\/brand\/login-logo\.png'\s+WHERE\s+"loginLogoUrl"\s+~\*\s*'\^https\?:\/\/s3\\\.1080p\\\.hk\(\?:\/\|\$\)'\s*;/i
return headerRepair.test(content) && loginRepair.test(content)
}
export async function scanLegacyStorageBoundaries(rootDirectory) {
const violations = []
for (const absolutePath of await collectSourceFiles(rootDirectory)) {
const relativePath = normalize(path.relative(rootDirectory, absolutePath))
if (relativePath === 'scripts/check-legacy-storage-boundaries.mjs') continue
const content = await fs.readFile(absolutePath, 'utf8')
const boundaryReasons = findComposeUploadsBoundaryViolations(relativePath, content)
const matchedDomains = LEGACY_DOMAINS.filter(domain => countDomainReferences(content, domain) > 0)
if (matchedDomains.length > 0 && !isCompatibilityTest(relativePath)) {
const immutableExpected = IMMUTABLE_HISTORICAL_POLICY.get(relativePath)
const remediationExpected = LEGACY_DATA_REMEDIATION_POLICY.get(relativePath)
const readOnlyExpected = LEGACY_READ_ONLY_POLICY.get(relativePath)
const allowedLegacyReference = (
immutableExpected && matchesExactPolicy(content, immutableExpected)
) || (
remediationExpected && matchesBrandLogoRemediation(content, remediationExpected)
) || (
readOnlyExpected
&& content.includes(LEGACY_READ_ONLY_MARKER)
&& matchesExactPolicy(content, readOnlyExpected)
)
if (!allowedLegacyReference) {
boundaryReasons.push(`legacy storage domain is outside an exact compatibility policy: ${matchedDomains.join(', ')}`)
}
}
if (boundaryReasons.length > 0) {
violations.push({ relativePath, matchedDomains, reasons: boundaryReasons })
}
}
return violations
}
async function runCli() {
const rootDirectory = process.cwd()
const violations = await scanLegacyStorageBoundaries(rootDirectory)
if (violations.length > 0) {
console.error('检测到存储边界违规(旧域名越界或 uploads 挂载配置不安全):')
for (const violation of violations) {
console.error(`- ${violation.relativePath}: ${violation.reasons.join('; ')}`)
}
console.error('请改用当前存储域名/本地品牌资源;默认 Compose 必须显式绑定已核验的 uploads 宿主机挂载,历史兼容和数据修复必须匹配受控语义。')
process.exitCode = 1
return
}
console.log(`存储边界检查通过(${LEGACY_READ_ONLY_POLICY.size} 个精确只读兼容文件,默认 Compose uploads 挂载已受控)。`)
}
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''
if (invokedPath === fileURLToPath(import.meta.url)) {
await runCli()
}
// 避免某些 Node 版本在仅 import 测试时把 os 标为未使用;同时保留平台路径语义测试入口。
export const TEMP_ROOT = os.tmpdir()