diff --git a/Dockerfile b/Dockerfile index a2c0d6f..eca1a2c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,13 +14,17 @@ ENV NEXT_TELEMETRY_DISABLED=1 RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs +# 将 nextjs 加入 docker 组(Alpine: -g 而非 --gid),允许访问 docker.sock +RUN addgroup -g 988 docker 2>/dev/null; addgroup nextjs docker COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static -# Install docker-cli for container management -RUN apk add --no-cache docker-cli +# docker-cli: 密码修改等写操作仍需 docker exec lldap(LLDAP 非 WAL 模式,不可并发写) +# sqlite: 直连只读查询 LLDAP/asstes/issue 数据库 +# bcryptjs: OIDC callback 中 JWT 签发需要 +RUN apk add --no-cache docker-cli sqlite && npm install bcryptjs USER nextjs diff --git a/docker-compose.yml b/docker-compose.yml index b73322f..3b6d7fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,8 @@ services: volumes: - ./.next:/app/.next - /var/run/docker.sock:/var/run/docker.sock - # 挂载整个数据目录(非单个文件),确保 SQLite WAL 文件共享 + # 挂载外部数据目录(非单个文件),确保 SQLite WAL 文件共享 + - /root/docker/ldap-ai/data/lldap:/data/other-sites/lldap - /var/lib/docker/volumes/assets-ai_assets-data/_data:/data/other-sites/assets - /var/lib/docker/volumes/issue-ai_issue-data/_data:/data/other-sites/issue networks: diff --git a/next.config.ts b/next.config.ts index 11618f1..cbeafe1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,9 @@ import type { NextConfig } from 'next' const config: NextConfig = { output: 'standalone', + outputFileTracingIncludes: { + '/api/**': ['./node_modules/bcryptjs/**/*'], + }, } export default config diff --git a/package-lock.json b/package-lock.json index c7b2e40..3e32055 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "oa-ai", "version": "1.0.0", "dependencies": { + "bcryptjs": "^3.0.3", "ldapts": "^6.0.0", "next": "^15.0.0", "openid-client": "^5.7.1", @@ -17,6 +18,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.3.1", + "@types/bcryptjs": "^2.4.6", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "autoprefixer": "^10.5.2", @@ -1065,6 +1067,13 @@ "@types/node": "*" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.18", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", @@ -1149,6 +1158,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/browserslist": { "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", diff --git a/package.json b/package.json index 54e96c3..eefa090 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "next start" }, "dependencies": { + "bcryptjs": "^3.0.3", "ldapts": "^6.0.0", "next": "^15.0.0", "openid-client": "^5.7.1", @@ -17,6 +18,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.3.1", + "@types/bcryptjs": "^2.4.6", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "autoprefixer": "^10.5.2", diff --git a/src/app/api/admin/create-user/route.ts b/src/app/api/admin/create-user/route.ts index 76ad81f..6b952b2 100644 --- a/src/app/api/admin/create-user/route.ts +++ b/src/app/api/admin/create-user/route.ts @@ -1,13 +1,12 @@ import { NextResponse } from 'next/server' import { cookies } from 'next/headers' -import { exec } from 'child_process' -import { promisify } from 'util' +import { execFileSync } from 'child_process' import { verifySharedJwt } from '@/lib/jwt' import { isLldapAdmin } from '@/lib/ldap' import { sendSetupLinkEmail } from '@/lib/email' import { signSetupToken } from '@/lib/setup-token' - -const execAsync = promisify(exec) +import { execLldapWrite, lldapChangePassword, esc, getAdminPassword } from '@/lib/lldap-db' +import bcrypt from 'bcryptjs' const INTERNAL_KEY = 'oa-internal-key-tlyq-2026' @@ -19,10 +18,8 @@ function generatePassword(): string { const all = upper + lower + digits + special const crypto = globalThis.crypto const pick = (s: string) => s[crypto.getRandomValues(new Uint32Array(1))[0] % s.length] - // 确保每种类型至少一个,其余随机填充到 12 位 let pwd = pick(upper) + pick(lower) + pick(digits) + pick(special) for (let i = 4; i < 12; i++) pwd += pick(all) - // 打乱顺序 return pwd.split('').sort(() => crypto.getRandomValues(new Uint32Array(1))[0] - 0x80000000).join('') } @@ -34,28 +31,18 @@ async function fetchRoles(siteUrl: string): Promise { }) const data = await res.json() return (data.roles || []).map((r: { name: string }) => r.name) - } catch { - return [] - } + } catch { return [] } } async function syncToSite(siteUrl: string, username: string, password: string): Promise { try { const res = await fetch(`${siteUrl}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), signal: AbortSignal.timeout(10000), }) return res.ok - } catch { - return false - } -} - -// 直接更新站点 SQLite 数据库中的用户角色 -function setRoleSQL(dbPath: string, username: string, role: string): string { - return `sqlite3 "${dbPath}" "UPDATE users SET role = '${role}', updated_at = datetime('now', '+8 hours') WHERE username = '${username}';"` + } catch { return false } } export async function POST(request: Request) { @@ -73,36 +60,25 @@ export async function POST(request: Request) { if (!/^[a-z][a-z0-9_.@-]*$/i.test(username)) return NextResponse.json({ error: '用户名格式不合法' }, { status: 400 }) const password = generatePassword() - - // 从各站点实时获取可用角色列表 const [assetsRoles, issueRoles] = await Promise.all([ - fetchRoles('http://localhost:6177'), - fetchRoles('http://localhost:6176'), + fetchRoles('http://localhost:6177'), fetchRoles('http://localhost:6176'), ]) const ar = (assetsRole && assetsRoles.includes(assetsRole)) ? assetsRole : 'viewer' const ir = (issueRole && issueRoles.includes(issueRole)) ? issueRole : 'viewer' - const safeName = (displayName || username).replace(/'/g, "'\\''") - const safeUser = username.replace(/'/g, "'\\''") + const safeName = esc(displayName || username) + const safeUser = esc(username) const lldapEmail = email || '' const d = new Date() const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}` const userUuid = crypto.randomUUID() - // 1. LLDAP SQLite 插入用户 - const insertSQL = `INSERT OR IGNORE INTO users (user_id, email, display_name, creation_date, uuid, lowercase_email, modified_date, password_modified_date) VALUES ('${username}', '${lldapEmail}', '${safeName}', '${now}', '${userUuid}', LOWER('${lldapEmail}'), '${now}', '${now}');` - await execAsync(`docker exec lldap /bin/sh -c "cat > /tmp/iu.sql <<'EOSQL'\n${insertSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/iu.sql"`, { timeout: 5000 }) + // 1. docker exec lldap 插入用户(LLDAP DELETE 模式不可并发写) + execLldapWrite(`INSERT OR IGNORE INTO users (user_id, email, display_name, creation_date, uuid, lowercase_email, modified_date, password_modified_date) VALUES ('${safeUser}', '${esc(lldapEmail)}', '${safeName}', '${now}', '${userUuid}', LOWER('${esc(lldapEmail)}'), '${now}', '${now}')`) - // 2. 从 LLDAP 容器动态获取 admin 密码(不硬编码,admin 改密码后无需改 OA 配置) - const { stdout: adminPassOut } = await execAsync('docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 }) - const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''") - - // 3. LLDAP 设置密码 —— 通过 base64 传输避免 shell 特殊字符问题 - const b64Pass = Buffer.from(password).toString('base64') - await execAsync(`docker exec lldap /bin/sh -c "echo '${b64Pass}' | base64 -d > /tmp/userpwd.txt"`, { timeout: 3000 }) - const pwdCmd = `LLDAP_USER_PASSWORD=$(cat /tmp/userpwd.txt) ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}'` - await execAsync(`docker exec lldap /bin/sh -c '${pwdCmd}'`, { timeout: 10000 }) + // 2. bcryptjs 直写 LLDAP 密码(替代 docker exec lldap_set_password) + lldapChangePassword(username, password) // 3. 自动登录各站点触发用户同步 const [assetsOk, issueOk] = await Promise.all([ @@ -110,28 +86,21 @@ export async function POST(request: Request) { syncToSite('http://localhost:6176', username, password), ]) - // 4. 直接更新各站点 SQLite 的角色(覆盖 viewer 默认值) - const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db' - const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db' + // 4. 直接更新各站点角色 + const assetsDb = process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db' + const issueDb = process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db' const roleResults = { assets: false, issue: false } - if (assetsOk) { - try { await execAsync(setRoleSQL(assetsDb, username, ar), { timeout: 3000 }); roleResults.assets = true } catch {} - } - if (issueOk) { - try { await execAsync(setRoleSQL(issueDb, username, ir), { timeout: 3000 }); roleResults.issue = true } catch {} - } + if (assetsOk) try { execFileSync('sqlite3', [assetsDb], { input: `UPDATE users SET role = '${ar}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';`, timeout: 3000 }); roleResults.assets = true } catch {} + if (issueOk) try { execFileSync('sqlite3', [issueDb], { input: `UPDATE users SET role = '${ir}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';`, timeout: 3000 }); roleResults.issue = true } catch {} - // 5. 如果提供了邮箱,发送密码设置链接(不再在邮件中发送明文密码) + // 5. 如果提供了邮箱,发送密码设置链接 let emailSent = false if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { try { const setupToken = signSetupToken(username) - const setupUrl = `https://oa.tlyq.ai/setup-password?token=${setupToken}` - await sendSetupLinkEmail(email, username, setupUrl, displayName || username) + await sendSetupLinkEmail(email, username, `https://oa.tlyq.ai/setup-password?token=${setupToken}`, displayName || username) emailSent = true - } catch (e) { - console.error('发送邮件失败:', e) - } + } catch (e) { console.error('发送邮件失败:', e) } } return NextResponse.json({ @@ -140,9 +109,7 @@ export async function POST(request: Request) { synced: { assets: assetsOk, issue: issueOk }, roles: { assets: ar, issue: ir, applied: roleResults }, emailSent, - message: emailSent - ? `用户已创建,密码设置链接已发送至 ${email}` - : '用户已创建并同步至所有站点', + message: emailSent ? `用户已创建,密码设置链接已发送至 ${email}` : '用户已创建并同步至所有站点', }) } catch (e) { const msg = e instanceof Error ? e.message : '创建失败' diff --git "a/src/app/api/admin/create-user/route.ts\"\"" "b/src/app/api/admin/create-user/route.ts\"\"" index b6d3ad6..76ad81f 100644 --- "a/src/app/api/admin/create-user/route.ts\"\"" +++ "b/src/app/api/admin/create-user/route.ts\"\"" @@ -76,8 +76,8 @@ export async function POST(request: Request) { // 从各站点实时获取可用角色列表 const [assetsRoles, issueRoles] = await Promise.all([ - fetchRoles('http://assets-ai:3000'), - fetchRoles('http://issue-ai:3000'), + fetchRoles('http://localhost:6177'), + fetchRoles('http://localhost:6176'), ]) const ar = (assetsRole && assetsRoles.includes(assetsRole)) ? assetsRole : 'viewer' @@ -106,8 +106,8 @@ export async function POST(request: Request) { // 3. 自动登录各站点触发用户同步 const [assetsOk, issueOk] = await Promise.all([ - syncToSite('http://assets-ai:3000', username, password), - syncToSite('http://issue-ai:3000', username, password), + syncToSite('http://localhost:6177', username, password), + syncToSite('http://localhost:6176', username, password), ]) // 4. 直接更新各站点 SQLite 的角色(覆盖 viewer 默认值) diff --git "a/src/app/api/admin/roles/route.ts\"\"" "b/src/app/api/admin/roles/route.ts\"\"" index ae6c4f5..6061f7b 100644 --- "a/src/app/api/admin/roles/route.ts\"\"" +++ "b/src/app/api/admin/roles/route.ts\"\"" @@ -24,8 +24,8 @@ export async function GET() { if (!session || !(await isLldapAdmin(session.username))) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) const [assetsRoles, issueRoles] = await Promise.all([ - fetchRoles('http://assets-ai:3000'), - fetchRoles('http://issue-ai:3000'), + fetchRoles('http://localhost:6177'), + fetchRoles('http://localhost:6176'), ]) return NextResponse.json({ assets: assetsRoles, issue: issueRoles }) diff --git a/src/app/api/admin/sync-emails/route.ts b/src/app/api/admin/sync-emails/route.ts index eb7cab9..32b9d48 100644 --- a/src/app/api/admin/sync-emails/route.ts +++ b/src/app/api/admin/sync-emails/route.ts @@ -1,13 +1,12 @@ import { NextResponse } from 'next/server' import { cookies } from 'next/headers' -import { exec } from 'child_process' -import { promisify } from 'util' +import { execFileSync } from 'child_process' import { verifySharedJwt } from '@/lib/jwt' import { isLldapAdmin } from '@/lib/ldap' +import { queryLldap, esc } from '@/lib/lldap-db' -const execAsync = promisify(exec) -const ASSETS_DB = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db' -const ISSUE_DB = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db' +const ASSETS_DB = process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db' +const ISSUE_DB = process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db' export async function POST() { try { @@ -19,23 +18,20 @@ export async function POST() { return NextResponse.json({ error: '仅管理员可操作' }, { status: 403 }) } - const { stdout } = await execAsync( - `docker exec lldap sqlite3 /data/users.db "SELECT user_id, email FROM users WHERE email != '';"`, - { timeout: 5000 } - ) - const lines = stdout.trim().split('\n').filter(Boolean) + const out = queryLldap(`SELECT user_id, email FROM users WHERE email != ''`) + const lines = out.split('\n').filter(Boolean) let synced = 0 for (const line of lines) { const [user, mail] = line.split('|') - const su = user.replace(/'/g, "''") - const sm = (mail || '').replace(/'/g, "''") + const su = esc(user) + const sm = esc(mail || '') for (const db of [ASSETS_DB, ISSUE_DB]) { try { - await execAsync( - `sqlite3 "${db}" "UPDATE users SET email = '${sm}', updated_at = datetime('now', '+8 hours') WHERE username = '${su}';"`, - { timeout: 3000 } - ) + execFileSync('sqlite3', [db], { + input: `UPDATE users SET email = '${sm}', updated_at = datetime('now', '+8 hours') WHERE username = '${su}';`, + timeout: 3000, + }) } catch {} } synced++ diff --git a/src/app/api/admin/user-roles/route.ts b/src/app/api/admin/user-roles/route.ts index 3b14859..8fcce08 100644 --- a/src/app/api/admin/user-roles/route.ts +++ b/src/app/api/admin/user-roles/route.ts @@ -1,11 +1,10 @@ import { NextResponse } from 'next/server' import { cookies } from 'next/headers' -import { exec } from 'child_process' -import { promisify } from 'util' +import { execFileSync } from 'child_process' import { verifySharedJwt } from '@/lib/jwt' import { isLldapAdmin } from '@/lib/ldap' +import { queryLldap } from '@/lib/lldap-db' -const execAsync = promisify(exec) const INTERNAL_KEY = 'oa-internal-key-tlyq-2026' async function fetchRoles(siteUrl: string): Promise { @@ -19,13 +18,13 @@ async function fetchRoles(siteUrl: string): Promise { } catch { return [] } } -function queryDb(dbPath: string, sql: string): Promise { - return execAsync(`sqlite3 "${dbPath}" "${sql.replace(/"/g, '\\"')}"`, { timeout: 3000 }).then(r => r.stdout).catch(() => '') +function queryDb(dbPath: string, sql: string): string { + try { return execFileSync('sqlite3', [dbPath, sql], { timeout: 3000, encoding: 'utf8' }).trim() } catch { return '' } } async function getSiteUsers(dbPath: string, roles: string[]): Promise<{ username: string; display_name: string; role: string }[]> { - const out = await queryDb(dbPath, 'SELECT username, display_name, role FROM users WHERE is_active=1 ORDER BY username;') - return out.trim().split('\n').filter(Boolean).map(line => { + const out = queryDb(dbPath, 'SELECT username, display_name, role FROM users WHERE is_active=1 ORDER BY username;') + return out.split('\n').filter(Boolean).map(line => { const [username, display_name, role] = line.split('|') return { username, display_name: display_name || username, role: roles.includes(role) ? role : 'viewer' } }) @@ -50,30 +49,22 @@ export async function GET() { ]) const [assetsUsers, issueUsers] = await Promise.all([ - getSiteUsers(process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db', assetsRoles), - getSiteUsers(process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db', issueRoles), + getSiteUsers(process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db', assetsRoles), + getSiteUsers(process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db', issueRoles), ]) - // 从 LLDAP 获取所有用户邮箱 + // 直连 LLDAP SQLite 获取邮箱 let emails: Record = {} try { - const { stdout } = await execAsync( - `docker exec lldap /bin/sh -c "echo 'SELECT user_id, email FROM users;' | sqlite3 /data/users.db"`, - { timeout: 3000 } - ) - stdout.trim().split('\n').filter(Boolean).forEach(line => { + const out = queryLldap(`SELECT user_id, email FROM users`) + out.split('\n').filter(Boolean).forEach(line => { const [uid, e] = line.split('|') emails[uid] = e || '' }) } catch {} - return NextResponse.json({ - assetsRoles, - issueRoles, - users: { assets: assetsUsers, issue: issueUsers }, - emails, - }) - } catch (e) { + return NextResponse.json({ assetsRoles, issueRoles, users: { assets: assetsUsers, issue: issueUsers }, emails }) + } catch { return NextResponse.json({ error: '查询失败' }, { status: 500 }) } } @@ -88,17 +79,19 @@ export async function PUT(request: Request) { if (username === 'admin' || username === 'localadmin') return NextResponse.json({ error: '不能修改系统保留用户角色' }, { status: 400 }) const dbPath = site === 'assets' - ? (process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db') - : (process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db') + ? (process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db') + : (process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db') - // 验证角色合法性 const roles = await fetchRoles(`http://localhost:${site === 'assets' ? 6177 : 6176}`) if (!roles.includes(role)) return NextResponse.json({ error: '无效的角色' }, { status: 400 }) - await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET role='${role}', updated_at=datetime('now', '+8 hours') WHERE username='${username}';"`, { timeout: 3000 }) + execFileSync('sqlite3', [dbPath], { + input: `UPDATE users SET role='${role}', updated_at=datetime('now', '+8 hours') WHERE username='${username}';`, + timeout: 3000, + }) return NextResponse.json({ success: true }) - } catch (e) { + } catch { return NextResponse.json({ error: '更新失败' }, { status: 500 }) } } diff --git "a/src/app/api/admin/user-roles/route.ts\"\"" "b/src/app/api/admin/user-roles/route.ts\"\"" index a0ff2e4..3b14859 100644 --- "a/src/app/api/admin/user-roles/route.ts\"\"" +++ "b/src/app/api/admin/user-roles/route.ts\"\"" @@ -45,8 +45,8 @@ export async function GET() { try { const [assetsRoles, issueRoles] = await Promise.all([ - fetchRoles('http://assets-ai:3000'), - fetchRoles('http://issue-ai:3000'), + fetchRoles('http://localhost:6177'), + fetchRoles('http://localhost:6176'), ]) const [assetsUsers, issueUsers] = await Promise.all([ @@ -92,7 +92,7 @@ export async function PUT(request: Request) { : (process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db') // 验证角色合法性 - const roles = await fetchRoles(`http://${site}-ai:3000`) + const roles = await fetchRoles(`http://localhost:${site === 'assets' ? 6177 : 6176}`) if (!roles.includes(role)) return NextResponse.json({ error: '无效的角色' }, { status: 400 }) await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET role='${role}', updated_at=datetime('now', '+8 hours') WHERE username='${username}';"`, { timeout: 3000 }) diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 913b070..9e86d3f 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -1,11 +1,9 @@ import { NextResponse } from 'next/server' import { cookies } from 'next/headers' -import { exec } from 'child_process' -import { promisify } from 'util' +import { execFileSync } from 'child_process' import { verifySharedJwt } from '@/lib/jwt' import { isLldapAdmin } from '@/lib/ldap' - -const execAsync = promisify(exec) +import { queryLldap, execLldapWrite, esc } from '@/lib/lldap-db' function checkAdmin() { return async () => { @@ -17,22 +15,28 @@ function checkAdmin() { } } +function siteSQL(dbPath: string, sql: string): void { + try { execFileSync('sqlite3', [dbPath], { input: sql, timeout: 3000 }) } catch {} +} + +function nowStr(): string { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}` +} + // GET — 列出 LLDAP 中所有用户 export async function GET() { const isAdmin = await checkAdmin()() if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) try { - const { stdout } = await execAsync( - `docker exec lldap /bin/sh -c "echo 'SELECT user_id, email, display_name, creation_date FROM users ORDER BY creation_date DESC;' | sqlite3 /data/users.db"`, - { timeout: 5000 } - ) - const users = stdout.trim().split('\n').filter(Boolean).map(line => { + const out = queryLldap(`SELECT user_id, email, display_name, creation_date FROM users ORDER BY creation_date DESC`) + const users = out.split('\n').filter(Boolean).map(line => { const [user_id, email, display_name, creation_date] = line.split('|') return { username: user_id, email, displayName: display_name || user_id, createdAt: creation_date } }) return NextResponse.json({ users }) - } catch (e) { + } catch { return NextResponse.json({ error: '查询失败' }, { status: 500 }) } } @@ -49,34 +53,24 @@ export async function DELETE(request: Request) { return NextResponse.json({ error: '不能删除系统保留用户' }, { status: 400 }) } - const safeUser = username.replace(/'/g, "''") + const safeUser = esc(username) + execLldapWrite(`DELETE FROM users WHERE user_id='${safeUser}'`) - // 删除 LLDAP 用户 - const lldapSQL = `DELETE FROM users WHERE user_id='${safeUser}';` - await execAsync( - `docker exec lldap /bin/sh -c "cat > /tmp/del.sql <<'EOSQL'\n${lldapSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/del.sql"`, - { timeout: 5000 } - ) - - // 删除各站点本地用户 const results: Record = {} for (const [site, dbPath] of Object.entries({ - assets: process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db', - issue: process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db', + assets: process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db', + issue: process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db', })) { - try { - await execAsync(`sqlite3 "${dbPath}" "DELETE FROM users WHERE username='${safeUser}';"`, { timeout: 3000 }) - results[site] = true - } catch { results[site] = false } + try { siteSQL(dbPath, `DELETE FROM users WHERE username='${safeUser}'`); results[site] = true } catch { results[site] = false } } return NextResponse.json({ success: true, deleted: results }) - } catch (e) { + } catch { return NextResponse.json({ error: '删除失败' }, { status: 500 }) } } -// PATCH — 修改用户信息(admin 权限) +// PATCH — 修改用户信息 export async function PATCH(request: Request) { const isAdmin = await checkAdmin()() if (!isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) @@ -91,42 +85,30 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 }) } - const safeUser = username.replace(/'/g, "''") - const d = new Date() - const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}` - - // 更新 LLDAP - let lldapSets: string[] = [] - let siteSets: string[] = [] + const safeUser = esc(username) + let lldapSets: string[] = [], siteSets: string[] = [] if (email !== undefined) { - const safeEmail = (email || '').replace(/'/g, "''") + const safeEmail = esc(email || '') lldapSets.push(`email = '${safeEmail}'`, `lowercase_email = LOWER('${safeEmail}')`) siteSets.push(`email = '${safeEmail}'`) } if (displayName !== undefined) { - const safeName = displayName.replace(/'/g, "''") + const safeName = esc(displayName) lldapSets.push(`display_name = '${safeName}'`) siteSets.push(`display_name = '${safeName}'`) } - lldapSets.push(`modified_date = '${now}'`) + lldapSets.push(`modified_date = '${nowStr()}'`) siteSets.push(`updated_at = datetime('now', '+8 hours')`) - const lldapSQL = `UPDATE users SET ${lldapSets.join(', ')} WHERE user_id = '${safeUser}';` - await execAsync( - `docker exec lldap /bin/sh -c "cat > /tmp/up.sql <<'EOSQL'\n${lldapSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/up.sql"`, - { timeout: 5000 } - ) + execLldapWrite(`UPDATE users SET ${lldapSets.join(', ')} WHERE user_id = '${safeUser}'`) - // 同步更新 assets / issue - const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db' - const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db' - const siteSQL = `UPDATE users SET ${siteSets.join(', ')} WHERE username = '${safeUser}';` - for (const dbPath of [assetsDb, issueDb]) { - try { await execAsync(`sqlite3 "${dbPath}" "${siteSQL}"`, { timeout: 3000 }) } catch {} + const siteSql = `UPDATE users SET ${siteSets.join(', ')} WHERE username = '${safeUser}'` + for (const dbPath of [process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db', process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db']) { + siteSQL(dbPath, siteSql) } return NextResponse.json({ success: true, username, email, displayName }) - } catch (e) { + } catch { return NextResponse.json({ error: '修改失败' }, { status: 500 }) } } diff --git "a/src/app/api/auth/callback/route.ts\"\"" "b/src/app/api/auth/callback/route.ts\"\"" index 10ce534..15e6f81 100644 --- "a/src/app/api/auth/callback/route.ts\"\"" +++ "b/src/app/api/auth/callback/route.ts\"\"" @@ -1,99 +1,27 @@ -import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' -import { getOidcClient } from '@/lib/oidc' -import { signSharedJwt, sharedCookieConfig } from '@/lib/jwt' +// GET /api/auth/callback — OIDC callback(V2:OA 签发 tlyq_session) +import { NextRequest } from 'next/server' +import { handleOidcCallback } from '@shared/lib/auth/handle-callback' +import { ldapUserExists } from '@/lib/ldap' -// 从 OIDC_REDIRECT_URI 提取 base URL(避免 request.url 使用 localhost) -function getBaseUrl(): string { - const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6179/api/auth/callback' - const url = new URL(redirectUri) - return `${url.protocol}//${url.host}` -} - -export async function GET(request: Request) { - const { searchParams } = new URL(request.url) - const code = searchParams.get('code') - const state = searchParams.get('state') - const error = searchParams.get('error') - const baseUrl = getBaseUrl() - - const cookieStore = await cookies() - - // 1. 错误处理 - if (error) { - return NextResponse.redirect(new URL(`/login?error=${error}`, baseUrl)) - } - - // 2. 验证 state - const savedState = cookieStore.get('oidc_state')?.value - if (!savedState || savedState !== state) { - return NextResponse.redirect(new URL('/login?error=state_mismatch', baseUrl)) - } - - // 3. 取出 code_verifier - const codeVerifier = cookieStore.get('oidc_code_verifier')?.value - if (!codeVerifier) { - return NextResponse.redirect(new URL('/login?error=missing_verifier', baseUrl)) - } - - // 4. 验证 nonce - const savedNonce = cookieStore.get('oidc_nonce')?.value - - try { - // 5. 换取 token - const client = await getOidcClient() - const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://127.0.0.1:6179/api/auth/callback' - const params = { code, state, iss: searchParams.get('iss') } - const checks = { - code_verifier: codeVerifier, - nonce: savedNonce, - state: savedState, - } - - const tokenSet = await client.callback(redirectUri, params, checks) - - // 6. 验证 nonce - if (savedNonce && tokenSet.claims) { - const claims = tokenSet.claims() - if (claims.nonce !== savedNonce) { - return NextResponse.redirect(new URL('/login?error=nonce_mismatch', baseUrl)) - } - } - - // 7. 获取 userinfo - const userinfo = await client.userinfo(tokenSet.access_token!) - - // 8. 使用 preferred_username 作为用户名(sub 可能是 UUID) - const username = (userinfo as any).preferred_username || userinfo.sub! - const displayName = userinfo.name || username - - // 9. 签发 tlyq_session cookie - const sharedToken = signSharedJwt({ username: username as string, displayName: displayName as string }) - const cfg = sharedCookieConfig() - - const response = NextResponse.redirect(new URL('/', baseUrl)) - response.cookies.set(cfg.name, sharedToken, cfg) - - // 10. 存储 id_token 用于登出 - if (tokenSet.id_token) { - response.cookies.set('oidc_id_token', tokenSet.id_token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 86400, - path: '/', - }) - } - - // 11. 清理 OIDC 临时 cookie - response.cookies.delete('oidc_state') - response.cookies.delete('oidc_nonce') - response.cookies.delete('oidc_code_verifier') - - return response - } catch (e) { - const errorMsg = e instanceof Error ? e.message : String(e) - console.error('OIDC callback error:', errorMsg) - return NextResponse.redirect(new URL(`/login?error=callback_error&detail=${encodeURIComponent(errorMsg)}`, baseUrl)) - } +const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai' +const oidcClientId = process.env.OIDC_CLIENT_ID || 'oa-oidc' +const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || '' +const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://oa.tlyq.ai/api/auth/callback' +const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026' +const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai' + +export async function GET(request: NextRequest) { + return handleOidcCallback(request, { + oidc: { autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri }, + jwtSecret, + cookieDomain, + + // 强制验证 LLDAP 存在性(违反 §2.3 的旧行为已修正) + getUser: async (username) => { + const exists = await ldapUserExists(username) + return exists ? { id: -1, role: 'admin' } : null + }, + + // OA 不通过 OIDC 创建/更新用户 + }) } diff --git a/src/app/api/auth/change-password/route.ts b/src/app/api/auth/change-password/route.ts index 420b7c9..8a615b4 100644 --- a/src/app/api/auth/change-password/route.ts +++ b/src/app/api/auth/change-password/route.ts @@ -1,10 +1,7 @@ import { NextResponse } from 'next/server' import { cookies } from 'next/headers' -import { exec } from 'child_process' -import { promisify } from 'util' import { verifySharedJwt } from '@/lib/jwt' - -const execAsync = promisify(exec) +import { lldapChangePassword, getAdminPassword } from '@/lib/lldap-db' export async function POST(request: Request) { try { @@ -22,7 +19,6 @@ export async function POST(request: Request) { if (newPassword.length < 8) { return NextResponse.json({ error: '新密码至少 8 位' }, { status: 400 }) } - // 密码复杂度:大写/小写/数字/特殊字符 4选3 const hasUpper = /[A-Z]/.test(newPassword) const hasLower = /[a-z]/.test(newPassword) const hasDigit = /[0-9]/.test(newPassword) @@ -32,25 +28,12 @@ export async function POST(request: Request) { return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 }) } - // 从 LLDAP 容器动态获取 admin 密码(不硬编码,admin 改密码后无需改 OA 配置) - const { stdout: adminPassOut } = await execAsync('docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 }) - const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''") - - const safeUser = session.username.replace(/'/g, "'\\''") - const safePass = newPassword.replace(/'/g, "'\\''") - const cmd = `docker exec lldap ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}' --password '${safePass}'` - - const { stdout, stderr } = await execAsync(cmd, { timeout: 10000 }) - if (stderr && !stderr.includes('Successfully')) { - return NextResponse.json({ error: stderr.trim() || '修改失败' }, { status: 500 }) - } + // 通过 bcryptjs + 直连 LLDAP SQLite 修改密码(不再 docker exec) + lldapChangePassword(session.username, newPassword) return NextResponse.json({ success: true }) } catch (err) { const msg = err instanceof Error ? err.message : '修改失败' - if (msg.includes('command not found') || msg.includes('No such container')) { - return NextResponse.json({ error: '密码服务不可用' }, { status: 503 }) - } return NextResponse.json({ error: msg }, { status: 500 }) } } diff --git "a/src/app/api/auth/login/oidc/route.ts\"\"" "b/src/app/api/auth/login/oidc/route.ts\"\"" index 9171a98..ac98a8b 100644 --- "a/src/app/api/auth/login/oidc/route.ts\"\"" +++ "b/src/app/api/auth/login/oidc/route.ts\"\"" @@ -1,81 +1,13 @@ -import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' -import { getOidcClient, generatePKCE, generateState, generateNonce } from '@/lib/oidc' -import { verifySharedJwt } from '@/lib/jwt' +// GET /api/auth/login/oidc — OIDC SSO 重定向(V2:使用 shared handleOidcLogin 工厂) +import { handleOidcLogin } from '@shared/lib/auth/handle-login' + +const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai' +const oidcClientId = process.env.OIDC_CLIENT_ID || 'oa-oidc' +const oidcClientSecret = process.env.OIDC_CLIENT_SECRET || '' +const oidcRedirectUri = process.env.OIDC_REDIRECT_URI || 'https://oa.tlyq.ai/api/auth/callback' export async function GET(request: Request) { - const cookieStore = await cookies() - const existingSession = cookieStore.get('tlyq_session')?.value const url = new URL(request.url) const switchUser = url.searchParams.get('switch') === '1' - - // 检查是否已有登录用户 - if (existingSession && !switchUser) { - const existing = verifySharedJwt(existingSession) - if (existing) { - return NextResponse.json({ - conflict: true, - currentUser: existing.username, - displayName: existing.displayName, - }) - } - } - - // 预检 Authelia 健康状态 - const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai' - try { - const healthRes = await fetch(`${autheliaUrl}/api/health`, { - signal: AbortSignal.timeout(5000), - }) - if (!healthRes.ok) { - return NextResponse.json({ fallback: 'ldap', error: 'Authelia 不可用' }, { status: 503 }) - } - } catch { - return NextResponse.json({ fallback: 'ldap', error: 'Authelia 不可达' }, { status: 503 }) - } - - // 生成 PKCE 参数 - const { codeVerifier, codeChallenge } = generatePKCE() - const state = generateState() - const nonce = generateNonce() - - // 构建授权 URL - const client = await getOidcClient() - const authorizationUrl = client.authorizationUrl({ - scope: 'openid profile email', - state, - nonce, - code_challenge: codeChallenge, - code_challenge_method: 'S256', - ...(switchUser && { prompt: 'login' }), - }) - - // 存储到 httpOnly cookie(5 分钟过期) - const response = NextResponse.redirect(authorizationUrl) - - response.cookies.set('oidc_code_verifier', codeVerifier, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 300, - path: '/', - }) - - response.cookies.set('oidc_state', state, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 300, - path: '/', - }) - - response.cookies.set('oidc_nonce', nonce, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 300, - path: '/', - }) - - return response + return handleOidcLogin({ autheliaUrl, clientId: oidcClientId, clientSecret: oidcClientSecret, redirectUri: oidcRedirectUri, switchUser }) } diff --git "a/src/app/api/auth/logout/route.ts\"\"" "b/src/app/api/auth/logout/route.ts\"\"" index 8a73281..9fe8b6e 100644 --- "a/src/app/api/auth/logout/route.ts\"\"" +++ "b/src/app/api/auth/logout/route.ts\"\"" @@ -1,16 +1,30 @@ +// POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login) import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' -export async function POST() { - const cookieStore = await cookies() - const domain = process.env.COOKIE_DOMAIN || '' +const autheliaUrl = process.env.AUTHELIA_URL || 'https://sso.tlyq.ai' +const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai' - // 清除所有相关 cookie(必须指定 domain 以清除跨域 cookie) - cookieStore.set('tlyq_session', '', { maxAge: 0, path: '/', domain }) - cookieStore.set('session', '', { maxAge: 0, path: '/', domain }) - cookieStore.set('oidc_id_token', '', { maxAge: 0, path: '/', domain }) - - // Authelia 4.38 不支持 end_session_endpoint,直接跳转登录页 - // Authelia session 会在 cookie 过期后自动清除 - return NextResponse.redirect(new URL('/login', process.env.NEXT_PUBLIC_URL || 'http://127.0.0.1:6179')) +// GET: 浏览器导航 → 302 到 Authelia end_session(带 rd 回跳 /login) +export async function GET() { + const returnUrl = `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:6179'}/login` + const response = NextResponse.redirect( + `${autheliaUrl}/api/oidc/end_session?rd=${encodeURIComponent(returnUrl)}` + ) + response.cookies.set('tlyq_session', '', { + httpOnly: true, secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0, + }) + response.cookies.set('session', '', { path: '/', maxAge: 0 }) + return response +} + +// POST: fetch 调用 → 清除 cookie + 返回 200 JSON +export async function POST() { + const response = NextResponse.json({ success: true }) + response.cookies.set('tlyq_session', '', { + httpOnly: true, secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0, + }) + response.cookies.set('session', '', { path: '/', maxAge: 0 }) + return response } diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 642e4a8..7fd92d4 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -1,20 +1,15 @@ import { NextResponse } from 'next/server' import { cookies } from 'next/headers' -import { exec } from 'child_process' -import { promisify } from 'util' import { verifySharedJwt } from '@/lib/jwt' import { isLldapAdmin } from '@/lib/ldap' - -const execAsync = promisify(exec) +import { queryLldap, execLldapWrite, esc } from '@/lib/lldap-db' +import { execFileSync } from 'child_process' async function getLldapInfo(username: string): Promise<{ email: string; displayName: string }> { try { - const safeUser = username.replace(/'/g, "''") - const { stdout } = await execAsync( - `docker exec lldap /bin/sh -c "echo 'SELECT email, display_name FROM users WHERE user_id='\\''${safeUser}'\\'';' | sqlite3 /data/users.db"`, - { timeout: 3000 } - ) - const parts = stdout.trim().split('|') + const safe = esc(username) + const out = queryLldap(`SELECT email, display_name FROM users WHERE user_id = '${safe}'`) + const parts = out.split('|') return { email: parts[0] || '', displayName: parts[1] || username } } catch { return { email: '', displayName: username } } } @@ -55,23 +50,23 @@ export async function PUT(request: Request) { return NextResponse.json({ error: '邮箱格式不合法' }, { status: 400 }) } - const safeUser = payload.username.replace(/'/g, "''") - const safeEmail = (email || '').replace(/'/g, "''") + const safeUser = esc(payload.username) + const safeEmail = esc(email || '') const d = new Date() const now = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}` - const updateSQL = `UPDATE users SET email = '${safeEmail}', lowercase_email = LOWER('${safeEmail}'), modified_date = '${now}' WHERE user_id = '${safeUser}';` - await execAsync( - `docker exec lldap /bin/sh -c "cat > /tmp/ue.sql <<'EOSQL'\n${updateSQL}\nEOSQL\nsqlite3 /data/users.db < /tmp/ue.sql"`, - { timeout: 5000 } - ) + // docker exec lldap 更新邮箱(LLDAP DELETE 模式不可并发写) + execLldapWrite(`UPDATE users SET email = '${safeEmail}', lowercase_email = LOWER('${safeEmail}'), modified_date = '${now}' WHERE user_id = '${safeUser}'`) // 同步更新 assets / issue 本地用户表 - const assetsDb = process.env.ASSETS_DB_PATH || '/Users/niuniu/programs/docker/assets-ai/data/assets.db' - const issueDb = process.env.ISSUE_DB_PATH || '/Users/niuniu/programs/docker/issue-ai/data/issue.db' + const assetsDb = process.env.ASSETS_DB_PATH || '/data/other-sites/assets/assets.db' + const issueDb = process.env.ISSUE_DB_PATH || '/data/other-sites/issue/issue.db' for (const dbPath of [assetsDb, issueDb]) { try { - await execAsync(`sqlite3 "${dbPath}" "UPDATE users SET email = '${safeEmail}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';"`, { timeout: 3000 }) + execFileSync('sqlite3', [dbPath], { + input: `UPDATE users SET email = '${safeEmail}', updated_at = datetime('now', '+8 hours') WHERE username = '${safeUser}';`, + timeout: 3000, + }) } catch {} } diff --git a/src/app/api/auth/setup-password/route.ts b/src/app/api/auth/setup-password/route.ts index 312dfe2..19f66a8 100644 --- a/src/app/api/auth/setup-password/route.ts +++ b/src/app/api/auth/setup-password/route.ts @@ -1,9 +1,6 @@ import { NextResponse } from 'next/server' -import { exec } from 'child_process' -import { promisify } from 'util' import { verifySetupToken } from '@/lib/setup-token' - -const execAsync = promisify(exec) +import { lldapChangePassword } from '@/lib/lldap-db' export async function POST(request: Request) { try { @@ -29,26 +26,12 @@ export async function POST(request: Request) { return NextResponse.json({ error: '密码需包含大写字母、小写字母、数字、特殊字符中至少 3 种' }, { status: 400 }) } - const { stdout: adminPassOut } = await execAsync( - 'docker exec lldap printenv LLDAP_ADMIN_PASSWORD', { timeout: 3000 } - ) - const adminPass = (adminPassOut.trim() || 'admin123').replace(/'/g, "'\\''") - - const safeUser = payload.username.replace(/'/g, "'\\''") - const safePass = password.replace(/'/g, "'\\''") - const cmd = `docker exec lldap ./lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password '${adminPass}' --username '${safeUser}' --password '${safePass}'` - - const { stderr } = await execAsync(cmd, { timeout: 10000 }) - if (stderr && !stderr.includes('Successfully')) { - return NextResponse.json({ error: stderr.trim() || '设置失败' }, { status: 500 }) - } + // 通过 bcryptjs + 直连 LLDAP SQLite 修改密码(不再 docker exec) + lldapChangePassword(payload.username, password) return NextResponse.json({ success: true }) } catch (err) { const msg = err instanceof Error ? err.message : '设置失败' - if (msg.includes('command not found') || msg.includes('No such container')) { - return NextResponse.json({ error: '密码服务不可用' }, { status: 503 }) - } return NextResponse.json({ error: msg }, { status: 500 }) } } diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..1a4d458 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + return NextResponse.json({ status: 'OK' }) +} diff --git "a/src/app/api/health/route.ts\"\"" "b/src/app/api/health/route.ts\"\"" new file mode 100644 index 0000000..1a4d458 --- /dev/null +++ "b/src/app/api/health/route.ts\"\"" @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + return NextResponse.json({ status: 'OK' }) +} diff --git "a/src/app/page.tsx\"\"" "b/src/app/page.tsx\"\"" deleted file mode 100644 index b1d70e4..0000000 --- "a/src/app/page.tsx\"\"" +++ /dev/null @@ -1,109 +0,0 @@ -import { cookies } from 'next/headers' -import { redirect } from 'next/navigation' -import { verifySharedJwt } from '@/lib/jwt' -import Header from '@/components/Header' - -function siteUrl(url: string, domain: string): string { - if (process.env.NODE_ENV === 'production') { - return `https://${domain}` - } - return url -} - -const CORE_SITES = [ - { name: '资产管理', url: 'http://127.0.0.1:6177', desc: 'GPU 服务器、存储服务器等硬件设备信息管理与实时监控', tag: 'CMDB', dot: '#2563eb', domain: 'assets.tlyq.ai' }, - { name: '工单跟踪', url: 'http://127.0.0.1:6176', desc: '故障工单全流程管理,SLA 自动计算,月度/周度报告导出', tag: 'ITS', dot: '#7c3aed', domain: 'issue.tlyq.ai' }, -] - -const OTHER_SITES = [ - { name: '官网', url: 'http://127.0.0.1:6173', desc: 'tlyq.ai 企业官方网站', tag: 'WWW', dot: '#059669', domain: 'www.tlyq.ai' }, - { name: '云平台', url: 'http://127.0.0.1:6174', desc: '云服务登录入口与资源概览', tag: 'CLOUD', dot: '#d97706', domain: 'cloud.tlyq.ai' }, - { name: 'Token 工厂', url: 'http://127.0.0.1:6175', desc: 'Token 管理与发放平台', tag: 'TOKEN', dot: '#e11d48', domain: 'token.tlyq.ai' }, - { name: '代码仓库', url: 'https://git.tlyq.ai', desc: 'Gitea 代码托管与版本管理', tag: 'GIT', dot: '#db2777', domain: 'git.tlyq.ai' }, -] - -const COLORS: Record = { - '#2563eb': { light: 'rgba(37,99,235,0.08)', tag: '#2563eb' }, - '#7c3aed': { light: 'rgba(124,58,237,0.08)', tag: '#7c3aed' }, - '#059669': { light: 'rgba(5,150,105,0.08)', tag: '#059669' }, - '#d97706': { light: 'rgba(217,119,6,0.08)', tag: '#d97706' }, - '#e11d48': { light: 'rgba(225,29,72,0.08)', tag: '#e11d48' }, - '#db2777': { light: 'rgba(219,39,119,0.08)', tag: '#db2777' }, -} - -export default async function HomePage() { - const cookieStore = await cookies() - const sessionCookie = cookieStore.get('session')?.value - let username = '' - if (sessionCookie) { - try { username = JSON.parse(sessionCookie).username || '' } catch { } - } - if (!username) redirect('/login') - - const tlyqToken = cookieStore.get('tlyq_session')?.value - let displayName = username - if (tlyqToken) { - const shared = verifySharedJwt(tlyqToken) - if (shared && shared.displayName && shared.displayName !== shared.username) { - displayName = shared.displayName - } - } - - return ( -
-
- -
-
-
tlyq.ai / OA PORTAL
-

欢迎回来,{displayName}

-
- -
核心系统
-
- {CORE_SITES.map(site => { - const c = COLORS[site.dot] - return ( - -
-
-
{site.name}
- {site.tag} -
-
{site.desc}
-
{site.domain} →
-
- ) - })} -
- -
其他站点
-
- {OTHER_SITES.map(site => { - const c = COLORS[site.dot] - return ( - -
-
-
{site.name}
- {site.tag} -
-
{site.desc}
-
{site.domain} →
-
- ) - })} -
- -
-
- ) -} diff --git a/src/lib/lldap-db.ts b/src/lib/lldap-db.ts new file mode 100644 index 0000000..27defac --- /dev/null +++ b/src/lib/lldap-db.ts @@ -0,0 +1,39 @@ +// lib/lldap-db.ts — LLDAP 操作(读直连 SQLite / 写走 docker exec) +// LLDAP 使用 DELETE journal mode,不可并发写 → 写操作必须通过 docker exec 在 LLDAP 容器内执行 +import { execFileSync } from 'child_process' +import bcrypt from 'bcryptjs' + +const LLDAP_DB = process.env.LLDAP_DB_PATH || '/data/other-sites/lldap/users.db' + +/** 直连 LLDAP SQLite 只读查询 */ +export function queryLldap(sql: string): string { + return execFileSync('sqlite3', [LLDAP_DB, sql], { timeout: 5000, encoding: 'utf8' }).trim() +} + +/** docker exec lldap 执行 LLDAP 容器内操作(密码/写入) */ +function dockerExecLldap(cmd: string, timeout = 5000): string { + return execFileSync('docker', ['exec', 'lldap', 'sh', '-c', cmd], { timeout, encoding: 'utf8' }).trim() +} + +/** docker exec lldap sqlite3 写操作 */ +export function execLldapWrite(sql: string): void { + dockerExecLldap(`sqlite3 /data/users.db "${sql.replace(/"/g, '\\"')}"`, 5000) +} + +/** 安全的 SQL 字符串转义(SQLite 标准:'' → 单引号) */ +export function esc(val: string): string { + return val.replace(/'/g, "''") +} + +/** 修改 LLDAP 用户密码(docker exec lldap + lldap_set_password) */ +export function lldapChangePassword(username: string, newPassword: string): void { + const hash = bcrypt.hashSync(newPassword, 12) + const safeUser = esc(username) + const now = new Date().toISOString().replace('T', ' ').slice(0, 19) + dockerExecLldap(`sqlite3 /data/users.db "UPDATE users SET password_hash = '${hash}', password_modified_date = '${now}', modified_date = '${now}' WHERE user_id = '${safeUser}'"`, 5000) +} + +/** 获取 admin 密码 */ +export function getAdminPassword(): string { + return process.env.LLDAP_ADMIN_PASSWORD || 'admin123' +}