diff --git a/scripts/local-auth-server.cjs b/scripts/local-auth-server.cjs new file mode 100644 index 0000000..5f4f315 --- /dev/null +++ b/scripts/local-auth-server.cjs @@ -0,0 +1 @@ +require("../server/local-auth/index.cjs"); diff --git a/server/local-auth/admin-api.cjs b/server/local-auth/admin-api.cjs new file mode 100644 index 0000000..357f6b9 --- /dev/null +++ b/server/local-auth/admin-api.cjs @@ -0,0 +1,354 @@ +const { publicUser, requireAdmin } = require("./auth.cjs"); +const { loadDb, saveDb, getSubscription, addUserLog } = require("./db.cjs"); +const { sendJson } = require("./http-utils.cjs"); +const { nowIso, addHours, passwordHash } = require("./security.cjs"); +const { systemConfigDescriptions } = require("./config.cjs"); + +function isValidSubscription(subscription) { + return subscription && new Date(subscription.expires_at).getTime() > Date.now(); +} + +function userRow(db, user) { + const subscription = getSubscription(db, user.id); + const owner = db.users.find(item => Number(item.id) === Number(user.owner_admin_id)); + const deviceCount = db.devices.filter(item => Number(item.user_id) === Number(user.id)).length || (user.device_fingerprint ? 1 : 0); + + return { + ...publicUser(user), + avatar: user.avatar || null, + device_fingerprint: user.device_fingerprint || "", + current_token: null, + subscription_type: subscription.subscription_type, + expires_at: subscription.expires_at, + owner_admin_name: owner?.username || null, + device_count: deviceCount, + }; +} + +function configRows(db) { + return Object.entries(db.systemConfigs).map(([config_key, config_value], index) => ({ + id: index + 1, + config_key, + config_value: String(config_value ?? ""), + description: systemConfigDescriptions[config_key] || "", + updated_at: nowIso(), + updated_by: "local-admin", + })); +} + +function logRows(db, logs) { + return logs + .slice() + .sort((left, right) => new Date(right.created_at) - new Date(left.created_at)) + .map(log => ({ + ...log, + username: db.users.find(user => Number(user.id) === Number(log.user_id))?.username || null, + })); +} + +function generationStats(db, userId) { + const logs = userId + ? db.generationLogs.filter(log => Number(log.user_id) === Number(userId)) + : db.generationLogs; + const stats = { script: 0, voice: 0, video: 0, subtitle: 0, total: logs.length }; + for (const log of logs) { + if (Object.prototype.hasOwnProperty.call(stats, log.type)) stats[log.type] += 1; + } + return stats; +} + +function updateUserRole(user, role, inviteCode) { + user.role = role === "admin" ? "admin" : "user"; + user.is_super_admin = user.is_super_admin || 0; + if (inviteCode !== undefined) user.invite_code = inviteCode || null; + user.updated_at = nowIso(); +} + +async function handleAdminApi(req, res, pathname, body, requestUrl) { + if (!pathname.startsWith("/api/admin")) return false; + + const db = loadDb(); + const admin = requireAdmin(db, req); + if (!admin) { + sendJson(res, 401, { success: false, message: "需要管理员登录" }); + return true; + } + + if (req.method === "GET" && pathname === "/api/admin/stats") { + const activeUsers = db.users.filter(user => user.status === "active").length; + const adminCount = db.users.filter(user => user.role === "admin" || Number(user.is_super_admin) === 1).length; + const validSubscriptions = db.subscriptions.filter(isValidSubscription).length; + sendJson(res, 200, { + success: true, + stats: { + total_users: db.users.length, + active_users: activeUsers, + admin_count: adminCount, + valid_subscriptions: validSubscriptions, + expired_subscriptions: db.subscriptions.length - validSubscriptions, + total_devices: db.devices.length, + }, + }); + return true; + } + + if (req.method === "GET" && pathname === "/api/admin/users") { + const search = String(requestUrl.searchParams.get("search") || "").trim().toLowerCase(); + const status = String(requestUrl.searchParams.get("status") || "").trim(); + const subscription = String(requestUrl.searchParams.get("subscription") || "").trim(); + + let users = db.users; + if (search) { + users = users.filter(user => + String(user.username || "").toLowerCase().includes(search) || + String(user.email || "").toLowerCase().includes(search), + ); + } + if (status) users = users.filter(user => user.status === status); + if (subscription === "valid") users = users.filter(user => isValidSubscription(getSubscription(db, user.id))); + if (subscription === "expired") users = users.filter(user => !isValidSubscription(getSubscription(db, user.id))); + + saveDb(db); + sendJson(res, 200, { success: true, users: users.map(user => userRow(db, user)) }); + return true; + } + + if (req.method === "GET" && pathname === "/api/admin/admins") { + const admins = db.users + .filter(user => user.role === "admin" || Number(user.is_super_admin) === 1) + .map(user => ({ + id: user.id, + username: user.username, + email: user.email, + invite_code: user.invite_code || null, + is_super_admin: Number(user.is_super_admin || 0), + status: user.status, + owned_user_count: db.users.filter(item => Number(item.owner_admin_id) === Number(user.id)).length, + })); + sendJson(res, 200, { success: true, admins }); + return true; + } + + if (req.method === "POST" && pathname === "/api/admin/add-time") { + const user = db.users.find(item => Number(item.id) === Number(body.user_id)); + if (!user) { + sendJson(res, 200, { success: false, message: "用户不存在" }); + return true; + } + const subscription = getSubscription(db, user.id); + const base = new Date(subscription.expires_at).getTime() > Date.now() ? new Date(subscription.expires_at) : new Date(); + subscription.expires_at = addHours(base, Number(body.hours || 0)).toISOString(); + subscription.updated_at = nowIso(); + addUserLog(db, user.id, "add_time", body.reason || `管理员增加 ${body.hours || 0} 小时`); + saveDb(db); + sendJson(res, 200, { success: true, subscription }); + return true; + } + + if (req.method === "POST" && pathname === "/api/admin/user/status") { + const user = db.users.find(item => Number(item.id) === Number(body.user_id)); + if (!user) { + sendJson(res, 200, { success: false, message: "用户不存在" }); + return true; + } + user.status = body.status === "disabled" ? "disabled" : "active"; + user.updated_at = nowIso(); + addUserLog(db, user.id, "status", `管理员修改状态为 ${user.status}`); + saveDb(db); + sendJson(res, 200, { success: true, user: userRow(db, user) }); + return true; + } + + if (req.method === "POST" && pathname === "/api/admin/device/unbind") { + const user = db.users.find(item => Number(item.id) === Number(body.user_id)); + if (!user) { + sendJson(res, 200, { success: false, message: "用户不存在" }); + return true; + } + user.device_fingerprint = ""; + user.last_login_device = null; + db.devices = db.devices.filter(item => Number(item.user_id) !== Number(user.id)); + addUserLog(db, user.id, "device_unbind", "管理员解绑设备"); + saveDb(db); + sendJson(res, 200, { success: true }); + return true; + } + + if (req.method === "POST" && pathname === "/api/admin/user/reset-password") { + const user = db.users.find(item => Number(item.id) === Number(body.user_id)); + if (!user || !body.new_password) { + sendJson(res, 200, { success: false, message: "用户或新密码无效" }); + return true; + } + user.password = passwordHash(body.new_password); + user.updated_at = nowIso(); + addUserLog(db, user.id, "reset_password", "管理员重置密码"); + saveDb(db); + sendJson(res, 200, { success: true }); + return true; + } + + if (req.method === "POST" && pathname === "/api/admin/create-admin") { + const email = String(body.email || "").trim(); + const username = String(body.username || "").trim(); + const password = String(body.password || ""); + if (!email || !username || !password) { + sendJson(res, 200, { success: false, message: "管理员账号信息不完整" }); + return true; + } + if (db.users.some(user => user.email === email || user.username === username)) { + sendJson(res, 200, { success: false, message: "账号已存在" }); + return true; + } + const user = { + id: db.nextUserId++, + username, + email, + password: passwordHash(password), + role: "admin", + status: "active", + points: 999999, + is_super_admin: 0, + owner_admin_id: admin.id, + invite_code: body.invite_code || null, + max_devices: 99, + device_fingerprint: "", + created_at: nowIso(), + updated_at: nowIso(), + last_login_at: null, + last_login_device: null, + current_token: "", + cloud_digital_human_disabled: 0, + }; + db.users.push(user); + getSubscription(db, user.id); + addUserLog(db, user.id, "create_admin", `由 ${admin.username} 创建管理员`); + saveDb(db); + sendJson(res, 200, { success: true, admin: userRow(db, user) }); + return true; + } + + if (req.method === "POST" && pathname === "/api/admin/points/modify") { + const user = db.users.find(item => Number(item.id) === Number(body.user_id)); + if (!user) { + sendJson(res, 200, { success: false, message: "用户不存在" }); + return true; + } + user.points = Number(user.points || 0) + Number(body.points_change || 0); + user.updated_at = nowIso(); + addUserLog(db, user.id, "points_modify", body.reason || `积分变更 ${body.points_change || 0}`); + saveDb(db); + sendJson(res, 200, { success: true, points: user.points }); + return true; + } + + const userLogMatch = pathname.match(/^\/api\/admin\/logs\/user\/(\d+)$/); + if (req.method === "GET" && userLogMatch) { + const logs = db.userLogs.filter(log => Number(log.user_id) === Number(userLogMatch[1])); + sendJson(res, 200, { success: true, logs: logRows(db, logs) }); + return true; + } + + if (req.method === "GET" && pathname === "/api/admin/logs/all") { + const limit = Number(requestUrl.searchParams.get("limit") || 100); + sendJson(res, 200, { success: true, logs: logRows(db, db.userLogs).slice(0, limit), page: 1, limit }); + return true; + } + + if (req.method === "POST" && pathname === "/api/admin/user/assign-owner") { + const user = db.users.find(item => Number(item.id) === Number(body.user_id)); + if (!user) { + sendJson(res, 200, { success: false, message: "用户不存在" }); + return true; + } + user.owner_admin_id = body.owner_admin_id ? Number(body.owner_admin_id) : null; + user.updated_at = nowIso(); + addUserLog(db, user.id, "assign_owner", `管理员归属变更为 ${user.owner_admin_id || "无"}`); + saveDb(db); + sendJson(res, 200, { success: true, user: userRow(db, user) }); + return true; + } + + if (req.method === "PUT" && pathname === "/api/admin/admin/invite-code") { + const user = db.users.find(item => Number(item.id) === Number(body.admin_id)); + if (!user) { + sendJson(res, 200, { success: false, message: "管理员不存在" }); + return true; + } + user.invite_code = body.invite_code || null; + user.updated_at = nowIso(); + addUserLog(db, user.id, "invite_code", "管理员邀请码变更"); + saveDb(db); + sendJson(res, 200, { success: true, admin: userRow(db, user) }); + return true; + } + + if (req.method === "POST" && pathname === "/api/admin/user/role") { + const user = db.users.find(item => Number(item.id) === Number(body.user_id)); + if (!user) { + sendJson(res, 200, { success: false, message: "用户不存在" }); + return true; + } + updateUserRole(user, body.role, body.invite_code); + addUserLog(db, user.id, "role", `角色变更为 ${user.role}`); + saveDb(db); + sendJson(res, 200, { success: true, user: userRow(db, user) }); + return true; + } + + if (req.method === "GET" && pathname === "/api/admin/generation-stats") { + sendJson(res, 200, { + success: true, + stats: generationStats(db, requestUrl.searchParams.get("userId")), + }); + return true; + } + + if (req.method === "GET" && pathname === "/api/admin/generation-logs") { + const userId = requestUrl.searchParams.get("userId"); + const limit = Number(requestUrl.searchParams.get("limit") || 100); + const logs = userId ? db.generationLogs.filter(log => Number(log.user_id) === Number(userId)) : db.generationLogs; + const rows = logRows(db, logs).slice(0, limit); + sendJson(res, 200, { success: true, logs: rows, total: logs.length }); + return true; + } + + if (req.method === "GET" && pathname === "/api/admin/system/config") { + sendJson(res, 200, { success: true, configs: configRows(db) }); + return true; + } + + if (req.method === "PUT" && pathname === "/api/admin/system/config") { + const updates = Array.isArray(body.configs) + ? body.configs.reduce((acc, item) => ({ ...acc, [item.config_key]: item.config_value }), {}) + : body.configs || body; + for (const [key, value] of Object.entries(updates || {})) { + if (key in db.systemConfigs) db.systemConfigs[key] = String(value ?? ""); + } + addUserLog(db, admin.id, "system_config", "管理员更新系统配置"); + saveDb(db); + sendJson(res, 200, { success: true, configs: configRows(db) }); + return true; + } + + if (req.method === "PUT" && pathname === "/api/admin/user/cloud-digital-human") { + const user = db.users.find(item => Number(item.id) === Number(body.user_id)); + if (!user) { + sendJson(res, 200, { success: false, message: "用户不存在" }); + return true; + } + user.cloud_digital_human_disabled = body.disabled ? 1 : 0; + user.updated_at = nowIso(); + addUserLog(db, user.id, "cloud_digital_human", `云数字人状态 ${user.cloud_digital_human_disabled ? "禁用" : "启用"}`); + saveDb(db); + sendJson(res, 200, { success: true, user: userRow(db, user) }); + return true; + } + + sendJson(res, 404, { success: false, message: `Admin endpoint not found: ${req.method} ${pathname}` }); + return true; +} + +module.exports = { + handleAdminApi, +}; diff --git a/server/local-auth/app-api.cjs b/server/local-auth/app-api.cjs new file mode 100644 index 0000000..ecc7956 --- /dev/null +++ b/server/local-auth/app-api.cjs @@ -0,0 +1,177 @@ +const { publicUser, findUserByToken } = require("./auth.cjs"); +const { loadDb, saveDb, getSubscription, addUserLog, addGenerationLog } = require("./db.cjs"); +const { sendJson } = require("./http-utils.cjs"); +const { nowIso, createToken, checkPassword, passwordHash, daysRemaining } = require("./security.cjs"); + +function findLoginUser(db, account) { + const value = String(account || "").trim(); + return db.users.find(user => user.email === value || user.username === value); +} + +async function handleAppApi(req, res, pathname, body) { + const db = loadDb(); + + if (req.method === "GET" && pathname === "/api/system/config/public") { + sendJson(res, 200, { success: true, config: db.systemConfigs }); + return true; + } + + if (req.method === "POST" && pathname === "/api/register") { + const username = String(body.username || "").trim(); + const email = String(body.email || "").trim(); + const password = String(body.password || ""); + + if (!username || !email || !password) { + sendJson(res, 200, { success: false, message: "用户名、手机号和密码不能为空" }); + return true; + } + + if (db.users.some(user => user.email === email || user.username === username)) { + sendJson(res, 200, { success: false, message: "用户名或手机号已存在" }); + return true; + } + + const isFirstUser = db.users.length === 0; + const user = { + id: db.nextUserId++, + username, + email, + password: passwordHash(password), + role: isFirstUser ? "admin" : "user", + status: "active", + points: isFirstUser ? 999999 : 1000, + is_super_admin: isFirstUser ? 1 : 0, + owner_admin_id: null, + invite_code: body.invite_code || null, + max_devices: 99, + device_fingerprint: body.device_fingerprint || "", + created_at: nowIso(), + updated_at: nowIso(), + last_login_at: nowIso(), + last_login_device: body.device_fingerprint || null, + current_token: "", + cloud_digital_human_disabled: 0, + }; + const token = createToken(user.id); + user.current_token = token; + db.users.push(user); + const subscription = getSubscription(db, user.id); + addUserLog(db, user.id, "register", "本地注册成功", { device_fingerprint: body.device_fingerprint }); + saveDb(db); + + sendJson(res, 200, { + success: true, + token, + userId: user.id, + user: publicUser(user), + subscription, + message: "本地注册成功", + }); + return true; + } + + if (req.method === "POST" && pathname === "/api/login") { + const account = body.email || body.username || body.phone; + const password = String(body.password || ""); + const user = findLoginUser(db, account); + + if (!user || !checkPassword(password, user.password)) { + sendJson(res, 200, { success: false, message: "账号或密码错误" }); + return true; + } + + if (user.status !== "active") { + sendJson(res, 200, { success: false, message: "账户已被禁用" }); + return true; + } + + const token = createToken(user.id); + user.current_token = token; + user.last_login_at = nowIso(); + user.last_login_device = body.device_fingerprint || null; + const subscription = getSubscription(db, user.id); + addUserLog(db, user.id, "login", "本地登录成功", { device_fingerprint: body.device_fingerprint }); + saveDb(db); + + sendJson(res, 200, { + success: true, + token, + user: publicUser(user), + subscription, + message: "本地登录成功", + }); + return true; + } + + if (req.method === "POST" && pathname === "/api/verify") { + const user = findUserByToken(db, body.token); + if (!user || user.status !== "active") { + sendJson(res, 200, { valid: false, success: false, message: "Token 无效" }); + return true; + } + + const subscription = getSubscription(db, user.id); + saveDb(db); + sendJson(res, 200, { + valid: true, + success: true, + payload: { userId: user.id, role: user.role }, + user: publicUser(user), + subscription, + }); + return true; + } + + if (req.method === "POST" && pathname === "/api/subscription/check") { + const userId = Number(body.user_id || body.userId); + const subscription = getSubscription(db, userId); + const remainingDays = daysRemaining(subscription.expires_at); + saveDb(db); + sendJson(res, 200, { + success: true, + hasAccess: remainingDays > 0, + expiresAt: subscription.expires_at, + remainingDays, + isExpired: remainingDays <= 0, + subscription, + }); + return true; + } + + if (req.method === "POST" && pathname === "/api/device/checkin") { + const userId = Number(body.user_id || body.userId || 0); + if (userId) { + const user = db.users.find(item => Number(item.id) === userId); + if (user) { + user.device_fingerprint = body.device_fingerprint || user.device_fingerprint || ""; + user.last_login_device = body.device_fingerprint || user.last_login_device || null; + user.updated_at = nowIso(); + } + if (body.device_fingerprint && !db.devices.some(item => item.user_id === userId && item.device_fingerprint === body.device_fingerprint)) { + db.devices.push({ user_id: userId, device_fingerprint: body.device_fingerprint, created_at: nowIso() }); + } + saveDb(db); + } + sendJson(res, 200, { success: true, message: "本地设备校验通过" }); + return true; + } + + if (req.method === "POST" && pathname === "/api/user/generation-log") { + addGenerationLog( + db, + body.user_id || body.userId, + body.type, + body.detail || body.message || body, + { device_fingerprint: body.device_fingerprint }, + ); + saveDb(db); + sendJson(res, 200, { success: true }); + return true; + } + + return false; +} + +module.exports = { + handleAppApi, +}; diff --git a/server/local-auth/auth.cjs b/server/local-auth/auth.cjs new file mode 100644 index 0000000..5184831 --- /dev/null +++ b/server/local-auth/auth.cjs @@ -0,0 +1,33 @@ +const { verifyToken } = require("./security.cjs"); + +function publicUser(user) { + if (!user) return null; + const { password, current_token, ...safeUser } = user; + return safeUser; +} + +function findUserByToken(db, token) { + const payload = verifyToken(token); + if (!payload?.userId) return null; + return db.users.find(user => Number(user.id) === Number(payload.userId)) || null; +} + +function getBearerToken(req) { + const header = req.headers.authorization || ""; + const match = String(header).match(/^Bearer\s+(.+)$/i); + return match ? match[1] : ""; +} + +function requireAdmin(db, req) { + const user = findUserByToken(db, getBearerToken(req)); + if (!user || user.status !== "active") return null; + if (user.role !== "admin" && Number(user.is_super_admin || 0) !== 1) return null; + return user; +} + +module.exports = { + publicUser, + findUserByToken, + getBearerToken, + requireAdmin, +}; diff --git a/server/local-auth/config.cjs b/server/local-auth/config.cjs new file mode 100644 index 0000000..64f0a25 --- /dev/null +++ b/server/local-auth/config.cjs @@ -0,0 +1,75 @@ +const path = require("node:path"); +const os = require("node:os"); + +const root = path.resolve(__dirname, "../.."); + +const defaultSystemConfig = { + aliyun_bailian_api_key: "", + aliyun_bailian_base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1", + aliyun_oss_access_key_id: "", + aliyun_oss_access_key_secret: "", + aliyun_oss_bucket: "", + aliyun_oss_region: "oss-cn-beijing", + volcengine_api_key: "", + volcengine_model: "doubao-seed-2-0-mini-260215", + runninghub_api_key: "", + runninghub_workflow_id_1: "2013514129943826433", + runninghub_workflow_id_2: "2013514129943826433", + runninghub_tts_webapp_id_v2: "1965614643077070850", + runninghub_tts_webapp_id_v3: "2028779949334728706", + runninghub_tts_v2_audio_node: "13", + runninghub_tts_v2_text_node: "14", + runninghub_tts_v2_emotion_node: "15", + cloud_beauty_base_url: "", + cloud_beauty_api_key: "", + cloud_beauty_default_mode: "256m", + cloud_beauty_enabled: "false", + trial_duration_hours: "168", + update_url: "", +}; + +const systemConfigDescriptions = { + aliyun_bailian_api_key: "阿里云百炼 / DashScope API Key", + aliyun_bailian_base_url: "阿里云百炼兼容 OpenAI Base URL", + aliyun_oss_access_key_id: "阿里云 OSS AccessKey ID", + aliyun_oss_access_key_secret: "阿里云 OSS AccessKey Secret", + aliyun_oss_bucket: "阿里云 OSS Bucket", + aliyun_oss_region: "阿里云 OSS Region", + volcengine_api_key: "火山引擎 API Key", + volcengine_model: "火山引擎默认模型", + runninghub_api_key: "RunningHub API Key", + runninghub_workflow_id_1: "RunningHub 工作流 ID 1", + runninghub_workflow_id_2: "RunningHub 工作流 ID 2", + runninghub_tts_webapp_id_v2: "RunningHub TTS WebApp ID V2", + runninghub_tts_webapp_id_v3: "RunningHub TTS WebApp ID V3", + runninghub_tts_v2_audio_node: "RunningHub TTS V2 音频节点", + runninghub_tts_v2_text_node: "RunningHub TTS V2 文本节点", + runninghub_tts_v2_emotion_node: "RunningHub TTS V2 情绪节点", + cloud_beauty_base_url: "云端美颜 Base URL", + cloud_beauty_api_key: "云端美颜 API Key", + cloud_beauty_default_mode: "云端美颜默认模式", + cloud_beauty_enabled: "是否启用云端美颜", + trial_duration_hours: "试用时长(小时)", + update_url: "软件更新地址", +}; + +function resolveDataDir() { + if (process.env.LOCAL_AUTH_DATA_DIR) return path.resolve(process.env.LOCAL_AUTH_DATA_DIR); + return path.join(process.env.APPDATA || os.homedir() || root, "aIzhinengti-clean-local-auth"); +} + +module.exports = { + root, + host: process.env.LOCAL_AUTH_HOST || "127.0.0.1", + port: Number(process.env.LOCAL_AUTH_PORT || process.env.PORT || 3302), + dataDir: resolveDataDir(), + tokenSecret: process.env.LOCAL_AUTH_SECRET || "local-auth-dev-secret", + adminPanelDir: process.env.ADMIN_PANEL_DIR || path.join(root, "admin-panel"), + defaultUser: { + username: process.env.LOCAL_AUTH_DEFAULT_USERNAME || "本地管理员", + email: process.env.LOCAL_AUTH_DEFAULT_EMAIL || "13800138000", + password: process.env.LOCAL_AUTH_DEFAULT_PASSWORD || "123456", + }, + defaultSystemConfig, + systemConfigDescriptions, +}; diff --git a/server/local-auth/db.cjs b/server/local-auth/db.cjs new file mode 100644 index 0000000..810b69f --- /dev/null +++ b/server/local-auth/db.cjs @@ -0,0 +1,186 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { dataDir, defaultUser, defaultSystemConfig } = require("./config.cjs"); +const { addYears, nowIso, passwordHash } = require("./security.cjs"); + +const dbPath = path.join(dataDir, "users.json"); +let memoryDb = null; + +function createInitialDb() { + const expiresAt = addYears(new Date(), 100).toISOString(); + const user = { + id: 1, + username: defaultUser.username, + email: defaultUser.email, + password: passwordHash(defaultUser.password), + role: "admin", + status: "active", + points: 999999, + is_super_admin: 1, + owner_admin_id: null, + invite_code: "LOCALADMIN", + max_devices: 99, + device_fingerprint: "", + created_at: nowIso(), + updated_at: nowIso(), + last_login_at: null, + last_login_device: null, + current_token: "", + cloud_digital_human_disabled: 0, + }; + + return { + nextUserId: 2, + nextSubscriptionId: 2, + nextUserLogId: 1, + nextGenerationLogId: 1, + users: [user], + subscriptions: [ + { + id: 1, + user_id: 1, + subscription_type: "local", + expires_at: expiresAt, + trial_started_at: nowIso(), + created_at: nowIso(), + updated_at: nowIso(), + }, + ], + generationLogs: [], + userLogs: [], + devices: [], + systemConfigs: { ...defaultSystemConfig }, + }; +} + +function normalizeSystemConfigs(value) { + if (Array.isArray(value)) { + return value.reduce((configs, item) => { + if (item?.config_key) configs[item.config_key] = String(item.config_value ?? ""); + return configs; + }, { ...defaultSystemConfig }); + } + return { ...defaultSystemConfig, ...(value || {}) }; +} + +function normalizeDb(db) { + const initial = createInitialDb(); + const normalized = { ...initial, ...(db || {}) }; + normalized.users = Array.isArray(normalized.users) ? normalized.users : initial.users; + normalized.subscriptions = Array.isArray(normalized.subscriptions) ? normalized.subscriptions : initial.subscriptions; + normalized.generationLogs = Array.isArray(normalized.generationLogs) ? normalized.generationLogs : []; + normalized.userLogs = Array.isArray(normalized.userLogs) ? normalized.userLogs : []; + normalized.devices = Array.isArray(normalized.devices) ? normalized.devices : []; + normalized.systemConfigs = normalizeSystemConfigs(normalized.systemConfigs); + + for (const user of normalized.users) { + user.role = user.role || "user"; + user.status = user.status || "active"; + user.points = Number(user.points || 0); + user.max_devices = Number(user.max_devices || 1); + user.is_super_admin = Number(user.is_super_admin || 0); + user.cloud_digital_human_disabled = Number(user.cloud_digital_human_disabled || 0); + user.created_at = user.created_at || nowIso(); + user.updated_at = user.updated_at || nowIso(); + } + + normalized.nextUserId = Math.max( + Number(normalized.nextUserId || 1), + ...normalized.users.map(user => Number(user.id || 0) + 1), + ); + normalized.nextSubscriptionId = Math.max( + Number(normalized.nextSubscriptionId || 1), + ...normalized.subscriptions.map(item => Number(item.id || 0) + 1), + ); + normalized.nextUserLogId = Math.max( + Number(normalized.nextUserLogId || 1), + ...normalized.userLogs.map(item => Number(item.id || 0) + 1), + ); + normalized.nextGenerationLogId = Math.max( + Number(normalized.nextGenerationLogId || 1), + ...normalized.generationLogs.map(item => Number(item.id || 0) + 1), + ); + + return normalized; +} + +function ensureDb() { + if (memoryDb) return; + + try { + fs.mkdirSync(dataDir, { recursive: true }); + + if (!fs.existsSync(dbPath)) { + fs.writeFileSync(dbPath, JSON.stringify(createInitialDb(), null, 2), "utf8"); + } + } catch (error) { + memoryDb = createInitialDb(); + console.warn(`[local-auth] cannot write ${dbPath}; using in-memory database: ${error.message}`); + } +} + +function loadDb() { + ensureDb(); + if (memoryDb) return memoryDb; + return normalizeDb(JSON.parse(fs.readFileSync(dbPath, "utf8"))); +} + +function saveDb(db) { + const normalized = normalizeDb(db); + if (memoryDb) { + memoryDb = normalized; + return; + } + fs.writeFileSync(dbPath, JSON.stringify(normalized, null, 2), "utf8"); +} + +function getSubscription(db, userId) { + const existing = db.subscriptions.find(item => Number(item.user_id) === Number(userId)); + if (existing) return existing; + + const subscription = { + id: db.nextSubscriptionId++, + user_id: Number(userId), + subscription_type: "local", + expires_at: addYears(new Date(), 100).toISOString(), + trial_started_at: nowIso(), + created_at: nowIso(), + updated_at: nowIso(), + }; + db.subscriptions.push(subscription); + return subscription; +} + +function addUserLog(db, userId, action, message, extra = {}) { + db.userLogs.push({ + id: db.nextUserLogId++, + user_id: Number(userId || 0) || null, + action, + message, + device_fingerprint: extra.device_fingerprint || null, + ip_address: extra.ip_address || "127.0.0.1", + created_at: nowIso(), + }); +} + +function addGenerationLog(db, userId, type, detail, extra = {}) { + db.generationLogs.push({ + id: db.nextGenerationLogId++, + user_id: Number(userId || 0) || null, + type: type || "unknown", + detail: typeof detail === "string" ? detail : JSON.stringify(detail || {}), + device_fingerprint: extra.device_fingerprint || null, + ip_address: extra.ip_address || "127.0.0.1", + created_at: nowIso(), + }); +} + +module.exports = { + dbPath, + ensureDb, + loadDb, + saveDb, + getSubscription, + addUserLog, + addGenerationLog, +}; diff --git a/server/local-auth/http-utils.cjs b/server/local-auth/http-utils.cjs new file mode 100644 index 0000000..fbc5a29 --- /dev/null +++ b/server/local-auth/http-utils.cjs @@ -0,0 +1,68 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +function sendJson(res, statusCode, body) { + res.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Cache-Control": "no-store", + }); + res.end(JSON.stringify(body)); +} + +function sendText(res, statusCode, text, contentType = "text/plain; charset=utf-8") { + res.writeHead(statusCode, { + "Content-Type": contentType, + "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-store", + }); + res.end(text); +} + +function redirect(res, location) { + res.writeHead(302, { Location: location }); + res.end(); +} + +async function readBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const text = Buffer.concat(chunks).toString("utf8"); + if (!text) return {}; + + try { + return JSON.parse(text); + } catch { + return {}; + } +} + +function serveFile(res, filePath, contentType = "text/html; charset=utf-8") { + if (!fs.existsSync(filePath)) { + sendText(res, 404, "Not found"); + return; + } + + res.writeHead(200, { + "Content-Type": contentType, + "Cache-Control": "no-store", + }); + fs.createReadStream(filePath).pipe(res); +} + +function safeJoin(baseDir, requestPath) { + const resolved = path.resolve(baseDir, requestPath.replace(/^\/+/, "")); + if (!resolved.startsWith(path.resolve(baseDir))) return null; + return resolved; +} + +module.exports = { + sendJson, + sendText, + redirect, + readBody, + serveFile, + safeJoin, +}; diff --git a/server/local-auth/index.cjs b/server/local-auth/index.cjs new file mode 100644 index 0000000..6d0464a --- /dev/null +++ b/server/local-auth/index.cjs @@ -0,0 +1,60 @@ +const http = require("node:http"); +const { host, port, defaultUser } = require("./config.cjs"); +const { dbPath, ensureDb } = require("./db.cjs"); +const { handleAppApi } = require("./app-api.cjs"); +const { handleAdminApi } = require("./admin-api.cjs"); +const { handleAdminStatic } = require("./static-admin.cjs"); +const { sendJson } = require("./http-utils.cjs"); +const { nowIso } = require("./security.cjs"); +const { readBody } = require("./http-utils.cjs"); + +function createLocalAuthServer() { + ensureDb(); + + return http.createServer(async (req, res) => { + const requestUrl = new URL(req.url, `http://${req.headers.host || `${host}:${port}`}`); + const pathname = requestUrl.pathname.replace(/\/+$/, "") || "/"; + + try { + if (req.method === "OPTIONS") { + sendJson(res, 200, { success: true }); + return; + } + + if (req.method === "GET" && (pathname === "/" || pathname === "/health" || pathname === "/api/health")) { + sendJson(res, 200, { success: true, service: "local-auth", admin: "/admin/", time: nowIso() }); + return; + } + + if (handleAdminStatic(req, res, requestUrl.pathname)) return; + + const body = ["POST", "PUT", "PATCH"].includes(req.method) ? await readBody(req) : {}; + if (await handleAdminApi(req, res, pathname, body, requestUrl)) return; + if (await handleAppApi(req, res, pathname, body, requestUrl)) return; + + sendJson(res, 404, { success: false, message: `Local auth endpoint not found: ${req.method} ${pathname}` }); + } catch (error) { + sendJson(res, 500, { success: false, message: error.message || "Local auth server error" }); + } + }); +} + +function start() { + const server = createLocalAuthServer(); + server.listen(port, host, () => { + console.log(`[local-auth] running at http://${host}:${port}`); + console.log(`[local-auth] admin panel: http://${host}:${port}/admin/`); + console.log(`[local-auth] default account: ${defaultUser.email} / ${defaultUser.password}`); + console.log(`[local-auth] data file: ${dbPath}`); + }); + return server; +} + +if (require.main === module || module.parent?.filename?.endsWith("local-auth-server.cjs")) { + start(); +} + +module.exports = { + createLocalAuthServer, + start, +}; diff --git a/server/local-auth/security.cjs b/server/local-auth/security.cjs new file mode 100644 index 0000000..f6a5d63 --- /dev/null +++ b/server/local-auth/security.cjs @@ -0,0 +1,63 @@ +const crypto = require("node:crypto"); +const { tokenSecret } = require("./config.cjs"); + +function nowIso() { + return new Date().toISOString(); +} + +function addYears(date, years) { + const next = new Date(date); + next.setFullYear(next.getFullYear() + years); + return next; +} + +function addHours(date, hours) { + return new Date(date.getTime() + Number(hours || 0) * 3600000); +} + +function sign(value) { + return crypto.createHmac("sha256", tokenSecret).update(value).digest("base64url"); +} + +function createToken(userId) { + const payload = Buffer.from(JSON.stringify({ userId, issuedAt: Date.now() })).toString("base64url"); + return `${payload}.${sign(payload)}`; +} + +function verifyToken(token) { + if (!token || typeof token !== "string" || !token.includes(".")) return null; + const [payload, signature] = token.split("."); + if (!payload || !signature || sign(payload) !== signature) return null; + + try { + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + return null; + } +} + +function passwordHash(password, salt = crypto.randomBytes(16).toString("hex")) { + const hash = crypto.pbkdf2Sync(String(password), salt, 120000, 32, "sha256").toString("hex"); + return `${salt}:${hash}`; +} + +function checkPassword(password, stored) { + const [salt] = String(stored || "").split(":"); + if (!salt) return false; + return passwordHash(password, salt) === stored; +} + +function daysRemaining(expiresAt) { + return Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 86400000)); +} + +module.exports = { + nowIso, + addYears, + addHours, + createToken, + verifyToken, + passwordHash, + checkPassword, + daysRemaining, +}; diff --git a/server/local-auth/static-admin.cjs b/server/local-auth/static-admin.cjs new file mode 100644 index 0000000..d2bc157 --- /dev/null +++ b/server/local-auth/static-admin.cjs @@ -0,0 +1,40 @@ +const path = require("node:path"); +const { adminPanelDir } = require("./config.cjs"); +const { redirect, serveFile, safeJoin } = require("./http-utils.cjs"); + +function handleAdminStatic(req, res, pathname) { + if (req.method !== "GET") return false; + + if (pathname === "/admin") { + redirect(res, "/admin/"); + return true; + } + + if (pathname === "/admin/") { + serveFile(res, path.join(adminPanelDir, "index.html")); + return true; + } + + if (pathname.startsWith("/admin/")) { + const filePath = safeJoin(adminPanelDir, pathname.replace(/^\/admin\//, "")); + if (!filePath) return false; + const ext = path.extname(filePath).toLowerCase(); + const contentType = { + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".svg": "image/svg+xml", + }[ext] || "application/octet-stream"; + serveFile(res, filePath, contentType); + return true; + } + + return false; +} + +module.exports = { + handleAdminStatic, +};