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', '.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()