152 lines
6.9 KiB
TypeScript
152 lines
6.9 KiB
TypeScript
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'
|
||
import { sendSetupLinkEmail } from '@/lib/email'
|
||
import { signSetupToken } from '@/lib/setup-token'
|
||
|
||
const execAsync = promisify(exec)
|
||
|
||
const INTERNAL_KEY = 'oa-internal-key-tlyq-2026'
|
||
|
||
function generatePassword(): string {
|
||
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
|
||
const lower = 'abcdefghjkmnpqrstuvwxyz'
|
||
const digits = '23456789'
|
||
const special = '!@#$%&*'
|
||
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('')
|
||
}
|
||
|
||
async function fetchRoles(siteUrl: string): Promise<string[]> {
|
||
try {
|
||
const res = await fetch(`${siteUrl}/api/internal/roles`, {
|
||
headers: { 'x-internal-key': INTERNAL_KEY },
|
||
signal: AbortSignal.timeout(5000),
|
||
})
|
||
const data = await res.json()
|
||
return (data.roles || []).map((r: { name: string }) => r.name)
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
async function syncToSite(siteUrl: string, username: string, password: string): Promise<boolean> {
|
||
try {
|
||
const res = await fetch(`${siteUrl}/api/auth/login`, {
|
||
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}';"`
|
||
}
|
||
|
||
export async function POST(request: Request) {
|
||
try {
|
||
const cookieStore = await cookies()
|
||
const token = cookieStore.get('tlyq_session')?.value
|
||
if (!token) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||
const session = verifySharedJwt(token)
|
||
if (!session || !(await isLldapAdmin(session.username))) {
|
||
return NextResponse.json({ error: '仅管理员可创建用户' }, { status: 403 })
|
||
}
|
||
|
||
const { username, displayName, assetsRole, issueRole, email } = await request.json()
|
||
if (!username) return NextResponse.json({ error: '用户名不能为空' }, { status: 400 })
|
||
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'),
|
||
])
|
||
|
||
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 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 })
|
||
|
||
// 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 })
|
||
|
||
// 3. 自动登录各站点触发用户同步
|
||
const [assetsOk, issueOk] = await Promise.all([
|
||
syncToSite('http://localhost:6177', username, password),
|
||
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'
|
||
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 {}
|
||
}
|
||
|
||
// 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)
|
||
emailSent = true
|
||
} catch (e) {
|
||
console.error('发送邮件失败:', e)
|
||
}
|
||
}
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
password: emailSent ? undefined : password,
|
||
synced: { assets: assetsOk, issue: issueOk },
|
||
roles: { assets: ar, issue: ir, applied: roleResults },
|
||
emailSent,
|
||
message: emailSent
|
||
? `用户已创建,密码设置链接已发送至 ${email}`
|
||
: '用户已创建并同步至所有站点',
|
||
})
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : '创建失败'
|
||
return NextResponse.json({ error: msg }, { status: 500 })
|
||
}
|
||
}
|