gongxiangp2/scripts/check-legacy-storage-boundaries.mjs
chenpei 6c6c282203 feat: 全站视觉统一白皮书语言 + 个人档案改造 + 顶栏平台化
视觉统一(Zen Minimal × Future Organic 白皮书语言,全站浅色单主题):
- 登录页:品牌→由壹点·品牌中心,白纸底+发丝线+柔光光晕,黑按钮
- 作品过稿:深灰头→白皮书,返回链接→返回创意资产库
- 绩效考核:桌面+移动双头部换白皮书,标题改绩效考核,返回改链接
- 组织架构:BoardShell+页面 76 处调色板全量替换(#eef1f4→#fafafa 等)
- admin 后台:MaterialsHeader(面包屑治理与设置),Tab 栏墨色高亮
- 深色主题废弃:globals.css 移除媒体查询,tailwind darkMode:'class' 不挂 .dark
- 设计文档同步更新:AGENTS.md UI 基线/DESIGN.md/PRODUCT.md/README.md/design-qa.md

个人档案(原个人中心):
- API 合并权威人员档案(getEmployeeByUserId → 花名/工号/部门/职位/M-P等级/主管/状态)
- 页面白皮书化:权威档案卡(只读 HR 字段)+ 账号区(头像上传)+ 素材/收藏

顶栏平台化:
- MaterialsHeader 双模式:首页(五板块导航+全套工具+菜单)/板块(面包屑)
- 菜单重排:我的档案/绩效考核/作品过稿看板‖更新记录/提交反馈‖治理与设置‖退出
- 菜单头身份行:花名+职位·部门(权威档案)
- 首页排序:01 创意资产库(全员)→ 02 组织管理(管理员)→ 03-05 规划中
- 素材库改名创意资产库,全站同步(索引/导航/卡片/按钮/页脚)
- 排行榜换白皮书(白底发丝线+琥珀前三名)

构建修复:
- tsconfig 排除 output/(本地审计临时文件不再卡类型检查)
- 存储边界门禁豁免 output/(与 .dockerignore 对齐)
2026-08-15 14:16:20 +08:00

234 lines
9.7 KiB
JavaScript
Raw Permalink 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',
'.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()