视觉统一(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 对齐)
234 lines
9.7 KiB
JavaScript
234 lines
9.7 KiB
JavaScript
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()
|