const fs = require("fs"); // ── Patch 1: context.js ────────────────────────────────────── const ctxPath = "/opt/omniai-server/src/routes/context.js"; let ctx = fs.readFileSync(ctxPath, "utf8"); const smsMaxLine = "const SMS_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.SMS_CODE_MAX_ATTEMPTS) || 5);"; const emailConsts = ` const EMAIL_PURPOSES = new Set(["register", "login", "reset"]); const EMAIL_CODE_TTL_MINUTES = Math.max(1, Number(process.env.EMAIL_CODE_TTL_MINUTES) || 10); const EMAIL_CODE_COOLDOWN_SECONDS = Math.max(10, Number(process.env.EMAIL_CODE_COOLDOWN_SECONDS) || 60); const EMAIL_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.EMAIL_CODE_MAX_ATTEMPTS) || 5);`; if (!ctx.includes("EMAIL_PURPOSES")) { ctx = ctx.replace(smsMaxLine, smsMaxLine + emailConsts); console.log("[ctx] added EMAIL_PURPOSES"); } const afterConsume = ' await pool.query("UPDATE sms_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);\n return true;\n}'; const emailFuncs = ` function hashEmailCode(email, code) { const secret = process.env.EMAIL_CODE_SECRET || process.env.JWT_SECRET || "omniai-dev-email-secret"; return crypto.createHash("sha256").update(email + ":" + code + ":" + secret).digest("hex"); } async function sendEmailCode(email, code, purpose) { const provider = String(process.env.EMAIL_PROVIDER || "mock").trim().toLowerCase(); if (provider === "smtp") { const nodemailer = require("nodemailer"); const transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT) || 587, secure: process.env.SMTP_SECURE === "1", auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, }); const purposeText = purpose === "register" ? "\u6ce8\u518c" : purpose === "login" ? "\u767b\u5f55" : "\u91cd\u7f6e\u5bc6\u7801"; await transporter.sendMail({ from: process.env.SMTP_FROM || process.env.SMTP_USER, to: email, subject: "[OmniAI] \u90ae\u7bb1\u9a8c\u8bc1\u7801", text: "\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a" + code + "\n\u7528\u9014\uff1a" + purposeText + "\n\u6709\u6548\u671f\uff1a" + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + " \u5206\u949f\n\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002", html: '

OmniAI \u90ae\u7bb1\u9a8c\u8bc1

\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a

' + code + '

\u7528\u9014\uff1a' + purposeText + '

\u6709\u6548\u671f\uff1a' + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + ' \u5206\u949f


\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002

', }); return { provider: "smtp" }; } console.log("[email:" + purpose + "] " + email + " verification code: " + code + " (mock provider)"); return { provider: "mock", devCode: process.env.EMAIL_DEV_RETURN_CODE === "1" ? code : undefined }; } async function consumeEmailCode(email, code, purpose) { const { rows } = await pool.query( "SELECT id, code_hash, attempts FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1", [email, purpose] ); const row = rows[0]; if (!row) return false; if (Number(row.attempts || 0) >= EMAIL_CODE_MAX_ATTEMPTS) return false; const expectedHash = hashEmailCode(email, String(code || "").trim()); if (row.code_hash !== expectedHash) { await pool.query("UPDATE email_verification_codes SET attempts = attempts + 1 WHERE id = $1", [row.id]); return false; } await pool.query("UPDATE email_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]); return true; }`; if (!ctx.includes("hashEmailCode")) { ctx = ctx.replace(afterConsume, afterConsume + emailFuncs); console.log("[ctx] added email functions"); } // Update exports if (!ctx.includes("EMAIL_PURPOSES,")) { ctx = ctx.replace(" EMAIL_PATTERN,\n SMS_PURPOSES,", " EMAIL_PATTERN,\n EMAIL_PURPOSES,\n EMAIL_CODE_TTL_MINUTES,\n EMAIL_CODE_COOLDOWN_SECONDS,\n EMAIL_CODE_MAX_ATTEMPTS,\n SMS_PURPOSES,"); } if (!ctx.includes("hashEmailCode,")) { ctx = ctx.replace(" sendSmsCode,\n createLoginResultForUserId,", " sendSmsCode,\n hashEmailCode,\n sendEmailCode,\n consumeEmailCode,\n createLoginResultForUserId,"); } fs.writeFileSync(ctxPath, ctx, "utf8"); console.log("[ctx] written"); // ── Patch 2: auth.js ───────────────────────────────────────── const authPath = "/opt/omniai-server/src/routes/auth.js"; let auth = fs.readFileSync(authPath, "utf8"); // 2a. Add imports inside context.js destructuring if (!auth.includes("hashEmailCode,")) { auth = auth.replace( '} = require("./context");', ' EMAIL_PURPOSES,\n EMAIL_CODE_TTL_MINUTES,\n EMAIL_CODE_COOLDOWN_SECONDS,\n EMAIL_CODE_MAX_ATTEMPTS,\n hashEmailCode,\n sendEmailCode,\n consumeEmailCode,\n} = require("./context");' ); console.log("[auth] added imports"); } // 2b. Insert new routes before module.exports const newRoutes = ` // ============================================================ // Email verification routes // ============================================================ router.post("/auth/email/send-code", async (req, res) => { const email = normalizeEmail(req.body?.email); const purpose = String(req.body?.purpose || "register"); const emailError = validateEmail(email); if (emailError) return res.status(400).json({ error: emailError }); if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u7528\u9014\u65e0\u6548" }); if (purpose === "register") { const inviteOk = await ensureBetaInviteCode(req, res); if (!inviteOk) return; } try { const { rows: recentCodes } = await pool.query( "SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND created_at > NOW() - ($3::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1", [email, purpose, EMAIL_CODE_COOLDOWN_SECONDS] ); if (recentCodes.length > 0) { return res.status(429).json({ error: "\u9a8c\u8bc1\u7801\u53d1\u9001\u592a\u9891\u7e41\uff0c\u8bf7 " + EMAIL_CODE_COOLDOWN_SECONDS + " \u79d2\u540e\u518d\u8bd5" }); } if (purpose === "register") { const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1", [email]); if (existing.length > 0) return res.status(409).json({ error: "\u8be5\u90ae\u7bb1\u5df2\u6ce8\u518c" }); } if (purpose === "login" || purpose === "reset") { const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]); if (existing.length === 0) return res.status(404).json({ error: "\u8be5\u90ae\u7bb1\u5c1a\u672a\u6ce8\u518c" }); } const code = generateSmsCode(); const codeHash = hashEmailCode(email, code); await pool.query( "INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, $2, $3, NOW() + ($4::text || ' minutes')::interval)", [email, purpose, codeHash, EMAIL_CODE_TTL_MINUTES] ); const sendResult = await sendEmailCode(email, code, purpose); res.json({ success: true, provider: sendResult.provider, ttlSeconds: EMAIL_CODE_TTL_MINUTES * 60, cooldownSeconds: EMAIL_CODE_COOLDOWN_SECONDS, ...(sendResult.devCode ? { devCode: sendResult.devCode } : {}), }); } catch (error) { console.error("[auth/email/send-code] failed", error); res.status(500).json({ error: "\u9a8c\u8bc1\u7801\u53d1\u9001\u5931\u8d25" }); } }); router.post("/auth/email/verify", async (req, res) => { const email = normalizeEmail(req.body?.email); const code = String(req.body?.code || "").trim(); const purpose = String(req.body?.purpose || "register"); const emailError = validateEmail(email); if (emailError) return res.status(400).json({ error: emailError }); if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" }); if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u7528\u9014\u65e0\u6548" }); try { const verified = await consumeEmailCode(email, code, purpose); if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" }); if (purpose === "register" || purpose === "login") { await pool.query("UPDATE users SET email_verified = 1 WHERE LOWER(email) = LOWER($1)", [email]); } res.json({ success: true }); } catch (error) { console.error("[auth/email/verify] failed", error); res.status(500).json({ error: "\u9a8c\u8bc1\u5931\u8d25" }); } }); router.post("/auth/forgot-password", async (req, res) => { const email = normalizeEmail(req.body?.email); const emailError = validateEmail(email); if (emailError) return res.status(400).json({ error: emailError }); try { const { rows } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]); if (rows.length === 0) { return res.json({ success: true, message: "\u5982\u679c\u8be5\u90ae\u7bb1\u5df2\u6ce8\u518c\uff0c\u91cd\u7f6e\u94fe\u63a5\u5df2\u53d1\u9001" }); } const { rows: recentCodes } = await pool.query( "SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = 'reset' AND created_at > NOW() - ($2::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1", [email, EMAIL_CODE_COOLDOWN_SECONDS] ); if (recentCodes.length > 0) { return res.status(429).json({ error: "\u53d1\u9001\u592a\u9891\u7e41\uff0c\u8bf7 " + EMAIL_CODE_COOLDOWN_SECONDS + " \u79d2\u540e\u518d\u8bd5" }); } const code = generateSmsCode(); const codeHash = hashEmailCode(email, code); await pool.query( "INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, 'reset', $2, NOW() + ($3::text || ' minutes')::interval)", [email, codeHash, EMAIL_CODE_TTL_MINUTES] ); await sendEmailCode(email, code, "reset"); res.json({ success: true, message: "\u91cd\u7f6e\u9a8c\u8bc1\u7801\u5df2\u53d1\u9001\u5230\u60a8\u7684\u90ae\u7bb1" }); } catch (error) { console.error("[auth/forgot-password] failed", error); res.status(500).json({ error: "\u53d1\u9001\u5931\u8d25" }); } }); router.post("/auth/reset-password", async (req, res) => { const email = normalizeEmail(req.body?.email); const code = String(req.body?.code || "").trim(); const newPassword = String(req.body?.newPassword || ""); const emailError = validateEmail(email); if (emailError) return res.status(400).json({ error: emailError }); if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" }); const passwordError = validatePassword(newPassword); if (passwordError) return res.status(400).json({ error: passwordError }); try { const verified = await consumeEmailCode(email, code, "reset"); if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" }); const hash = await bcrypt.hash(newPassword, 10); await pool.query("UPDATE users SET password_hash = $1 WHERE LOWER(email) = LOWER($2)", [hash, email]); res.json({ success: true, message: "\u5bc6\u7801\u91cd\u7f6e\u6210\u529f\uff0c\u8bf7\u91cd\u65b0\u767b\u5f55" }); } catch (error) { console.error("[auth/reset-password] failed", error); res.status(500).json({ error: "\u5bc6\u7801\u91cd\u7f6e\u5931\u8d25" }); } }); `; if (!auth.includes("/auth/email/send-code")) { const endMarker = "\n}\n\nmodule.exports = {"; auth = auth.replace(endMarker, "\n" + newRoutes + "}\n\nmodule.exports = {"); console.log("[auth] added new routes"); } // 2c. Update register-email to require verification code // Replace: router.post("/auth/register-email" ... without code check // With: router.post("/auth/register-email" ... with code verification const oldRegisterEmail = ` router.post("/auth/register-email", async (req, res) => { const email = normalizeEmail(req.body?.email); const usernameInput = String(req.body?.username || "").trim(); const password = String(req.body?.password || ""); const emailError = validateEmail(email); if (emailError) return res.status(400).json({ error: emailError }); const passwordError = validatePassword(password); if (passwordError) return res.status(400).json({ error: passwordError }); const registrationInvite = await ensureRegistrationInvite(req, res); if (!registrationInvite) return; try { const { rows: existingEmail }`; const newRegisterEmail = ` router.post("/auth/register-email", async (req, res) => { const email = normalizeEmail(req.body?.email); const usernameInput = String(req.body?.username || "").trim(); const password = String(req.body?.password || ""); const code = String(req.body?.code || "").trim(); const emailError = validateEmail(email); if (emailError) return res.status(400).json({ error: emailError }); if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" }); const passwordError = validatePassword(password); if (passwordError) return res.status(400).json({ error: passwordError }); const registrationInvite = await ensureRegistrationInvite(req, res); if (!registrationInvite) return; try { const verified = await consumeEmailCode(email, code, "register"); if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" }); const { rows: existingEmail }`; if (auth.includes(oldRegisterEmail)) { auth = auth.replace(oldRegisterEmail, newRegisterEmail); console.log("[auth] updated register-email with verification"); } else { console.log("[auth] WARNING: register-email pattern not found, skipping"); } fs.writeFileSync(authPath, auth, "utf8"); console.log("[auth] written"); console.log("\nDone.");