import dotenv from 'dotenv'; import express from 'express'; import cors from 'cors'; import mysql from 'mysql2/promise'; import bcrypt from 'bcryptjs'; import argon2 from 'argon2'; import jwt from 'jsonwebtoken'; dotenv.config(); const app = express(); const port = 3010; const cozeHost = (process.env.COZE_HOST || 'http://localhost:8888').replace(/\/+$/, ''); app.use( cors({ origin(origin, callback) { if (!origin) { callback(null, true); return; } callback(null, true); } }) ); app.use(express.json()); const pool = mysql.createPool({ host: process.env.DB_HOST || 'nw.sgcode.cn', port: Number(process.env.DB_PORT || 21434), user: process.env.DB_USER || 'root', password: process.env.DB_PASSWORD || 'root', database: process.env.DB_NAME || 'opencoze', waitForConnections: true, connectionLimit: 10 }); async function ensureUsersTable() { await pool.query(` CREATE TABLE IF NOT EXISTS \`user\` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(128) NOT NULL DEFAULT '', unique_name VARCHAR(128) NOT NULL UNIQUE, email VARCHAR(128) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL DEFAULT '', description VARCHAR(512) NOT NULL DEFAULT '', icon_uri VARCHAR(512) NOT NULL DEFAULT '', user_verified TINYINT(1) NOT NULL DEFAULT 0, locale VARCHAR(128) NOT NULL DEFAULT '', session_key VARCHAR(256) NOT NULL DEFAULT '', created_at BIGINT UNSIGNED NOT NULL DEFAULT 0, updated_at BIGINT UNSIGNED NOT NULL DEFAULT 0, deleted_at BIGINT UNSIGNED NULL DEFAULT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; `); } app.get('/api/health', async (_req, res) => { try { await pool.query('SELECT 1'); res.json({ ok: true }); } catch (error) { console.error('Health check failed:', error); res.status(500).json({ ok: false, message: 'Database connection failed' }); } }); app.post('/api/register', async (req, res) => { let connection; try { const { email, password, confirmPassword } = req.body ?? {}; if (!email || !password || !confirmPassword) { res.status(400).json({ message: 'Email, password, and confirm password are required' }); return; } if (password !== confirmPassword) { res.status(400).json({ message: 'Passwords do not match' }); return; } const trimmedEmail = String(email).trim().toLowerCase(); const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(trimmedEmail)) { res.status(400).json({ message: 'Please enter a valid email address' }); return; } if (String(password).length < 6) { res.status(400).json({ message: 'Password must be at least 6 characters' }); return; } const [existingRows] = await pool.query('SELECT id FROM `user` WHERE email = ? LIMIT 1', [trimmedEmail]); if (existingRows.length > 0) { res.status(409).json({ message: 'Email is already registered' }); return; } // New users are stored with Argon2id hashes. const passwordHash = await argon2.hash(String(password), { type: argon2.argon2id }); const uniqueName = trimmedEmail; const displayName = trimmedEmail.split('@')[0]; const now = Date.now(); const defaultIconUri = 'default_icon/user_default_icon.png'; const personalSpaceName = 'Personal Space'; const personalSpaceDescription = 'This is your personal space'; connection = await pool.getConnection(); await connection.beginTransaction(); await connection.query( 'INSERT INTO `user` (name, unique_name, email, password, description, icon_uri, user_verified, locale, session_key, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [displayName, uniqueName, trimmedEmail, passwordHash, '', defaultIconUri, 0, 'zh-CN', '', now, now, null] ); const [createdUserRows] = await connection.query('SELECT CAST(id AS CHAR) AS id FROM `user` WHERE email = ? LIMIT 1', [ trimmedEmail ]); const userId = createdUserRows[0]?.id; if (!userId) { throw new Error('Failed to resolve created user ID'); } await connection.query( 'INSERT INTO `space` (owner_id, name, description, icon_uri, creator_id, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [userId, personalSpaceName, personalSpaceDescription, defaultIconUri, userId, now, now, null] ); const [createdSpaceRows] = await connection.query( 'SELECT CAST(id AS CHAR) AS id FROM `space` WHERE owner_id = ? ORDER BY created_at DESC, id DESC LIMIT 1', [userId] ); const spaceId = createdSpaceRows[0]?.id; if (!spaceId) { throw new Error('Failed to resolve created space ID'); } await connection.query( 'INSERT INTO `space_user` (space_id, user_id, role_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', [spaceId, userId, 1, now, now] ); await connection.commit(); res.status(201).json({ message: 'Registered successfully' }); } catch (error) { if (connection) { await connection.rollback(); } console.error('Register failed:', error); res.status(500).json({ message: 'Register failed' }); } finally { if (connection) { connection.release(); } } }); app.post('/api/login', async (req, res) => { try { const { email, password } = req.body ?? {}; if (!email || !password) { res.status(400).json({ message: 'Email and password are required' }); return; } const trimmedEmail = String(email).trim().toLowerCase(); const [rows] = await pool.query('SELECT id, email, password FROM `user` WHERE email = ? LIMIT 1', [trimmedEmail]); const user = rows[0]; if (!user) { res.status(401).json({ message: 'Invalid email or password' }); return; } const rawPassword = String(password); const storedPassword = String(user.password || ''); let isPasswordValid = false; if (storedPassword.startsWith('$argon2')) { isPasswordValid = await argon2.verify(storedPassword, rawPassword); } else if (storedPassword.startsWith('$2')) { isPasswordValid = await bcrypt.compare(rawPassword, storedPassword); } if (!isPasswordValid) { res.status(401).json({ message: 'Invalid email or password' }); return; } res.json({ message: 'Login successful', user: { id: user.id, email: user.email } }); } catch (error) { console.error('Login failed:', error); res.status(500).json({ message: 'Login failed' }); } }); app.get('/api/coze/space-url', async (req, res) => { try { const rawUser = String(req.query.user || req.query.email || '').trim().toLowerCase(); if (!rawUser) { res.status(400).json({ message: 'user is required' }); return; } const [userRows] = await pool.query( 'SELECT CAST(id AS CHAR) AS id, email, unique_name, name FROM `user` WHERE email = ? OR unique_name = ? OR LOWER(name) = ? LIMIT 1', [rawUser, rawUser, rawUser] ); const user = userRows[0]; if (!user) { res.status(404).json({ message: 'User not found' }); return; } const [spaceRows] = await pool.query( 'SELECT CAST(s.id AS CHAR) AS id FROM `space` s INNER JOIN `space_user` su ON su.space_id = s.id WHERE su.user_id = ? AND (s.deleted_at IS NULL OR s.deleted_at = 0) ORDER BY su.role_type ASC, su.id DESC LIMIT 1', [user.id] ); const space = spaceRows[0]; if (!space) { res.status(404).json({ message: 'No space found for user' }); return; } const url = `${cozeHost}/space/${space.id}/develop`; res.json({ url, spaceId: space.id, user: user.email || user.unique_name || user.name }); } catch (error) { console.error('Resolve Coze space URL failed:', error); res.status(500).json({ message: 'Failed to resolve Coze space URL' }); } }); // Coze SSO: issue short-lived JWT and redirect to Coze SSO exchange endpoint app.get('/api/coze/sso-login', (req, res) => { try { const email = String(req.query.email || req.query.user || '').trim().toLowerCase(); if (!email) { res.status(400).json({ message: 'email is required' }); return; } const secret = process.env.SSO_SHARED_SECRET; if (!secret) { res.status(500).json({ message: 'SSO is not configured on server (missing SSO_SHARED_SECRET)' }); return; } const token = jwt.sign( { email }, secret, { algorithm: 'HS256', expiresIn: 300 } // 5 minutes ); const nextPath = encodeURIComponent('/space'); const redirectUrl = `${cozeHost}/api/passport/web/sso/exchange/?token=${encodeURIComponent( token )}&next=${nextPath}`; res.redirect(302, redirectUrl); } catch (error) { console.error('Coze SSO login failed:', error); res.status(500).json({ message: 'Coze SSO login failed' }); } }); ensureUsersTable() .then(() => { console.log('Database initialization completed.'); }) .catch((error) => { console.error('Failed to initialize database (server will still start):', error); }) .finally(() => { app.listen(port, () => { console.log(`Auth API running at http://localhost:${port}`); }); });