cli-claw-kit 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +245 -0
- package/config/default-groups.json +1 -0
- package/config/global-agents-md.template.md +37 -0
- package/config/mount-allowlist.json +11 -0
- package/container/Dockerfile +160 -0
- package/container/agent-runner/dist/.tsbuildinfo +1 -0
- package/container/agent-runner/dist/agent-definitions.js +22 -0
- package/container/agent-runner/dist/channel-prefixes.js +16 -0
- package/container/agent-runner/dist/codex-config.js +29 -0
- package/container/agent-runner/dist/image-detector.js +96 -0
- package/container/agent-runner/dist/index.js +2587 -0
- package/container/agent-runner/dist/mcp-tools.js +1076 -0
- package/container/agent-runner/dist/stream-event.types.js +5 -0
- package/container/agent-runner/dist/stream-processor.js +867 -0
- package/container/agent-runner/dist/types.js +6 -0
- package/container/agent-runner/dist/utils.js +115 -0
- package/container/agent-runner/package.json +36 -0
- package/container/agent-runner/prompts/security-rules.md +31 -0
- package/container/agent-runner/src/agent-definitions.ts +27 -0
- package/container/agent-runner/src/channel-prefixes.ts +16 -0
- package/container/agent-runner/src/codex-config.ts +40 -0
- package/container/agent-runner/src/image-detector.ts +116 -0
- package/container/agent-runner/src/index.ts +3107 -0
- package/container/agent-runner/src/mcp-tools.ts +1295 -0
- package/container/agent-runner/src/stream-event.types.ts +10 -0
- package/container/agent-runner/src/stream-processor.ts +932 -0
- package/container/agent-runner/src/types.ts +75 -0
- package/container/agent-runner/src/utils.ts +114 -0
- package/container/agent-runner/tsconfig.json +17 -0
- package/container/build.sh +28 -0
- package/container/entrypoint.sh +64 -0
- package/container/skills/agent-browser/SKILL.md +159 -0
- package/container/skills/install-skill/SKILL.md +64 -0
- package/container/skills/post-test-cleanup/SKILL.md +121 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/agent-output-parser.js +459 -0
- package/dist/app-root.js +52 -0
- package/dist/assistant-meta-footer.js +1 -0
- package/dist/auth.js +91 -0
- package/dist/billing.js +694 -0
- package/dist/channel-prefixes.js +16 -0
- package/dist/cli.js +86 -0
- package/dist/commands.js +79 -0
- package/dist/config.js +120 -0
- package/dist/container-runner.js +981 -0
- package/dist/daily-summary.js +210 -0
- package/dist/db.js +3683 -0
- package/dist/dingtalk.js +1347 -0
- package/dist/feishu-markdown-style.js +97 -0
- package/dist/feishu-streaming-card.js +1875 -0
- package/dist/feishu.js +1628 -0
- package/dist/file-manager.js +270 -0
- package/dist/group-queue.js +1070 -0
- package/dist/group-runtime.js +35 -0
- package/dist/host-workspace-cwd.js +85 -0
- package/dist/im-channel.js +384 -0
- package/dist/im-command-utils.js +142 -0
- package/dist/im-downloader.js +45 -0
- package/dist/im-manager.js +527 -0
- package/dist/im-utils.js +53 -0
- package/dist/image-detector.js +96 -0
- package/dist/index.js +5828 -0
- package/dist/logger.js +22 -0
- package/dist/mcp-utils.js +66 -0
- package/dist/message-attachments.js +69 -0
- package/dist/message-notifier.js +36 -0
- package/dist/middleware/auth.js +85 -0
- package/dist/mount-security.js +315 -0
- package/dist/permissions.js +67 -0
- package/dist/project-memory.js +6 -0
- package/dist/provider-pool.js +189 -0
- package/dist/qq.js +826 -0
- package/dist/reset-admin.js +42 -0
- package/dist/routes/admin.js +543 -0
- package/dist/routes/agent-definitions.js +241 -0
- package/dist/routes/agents.js +533 -0
- package/dist/routes/auth.js +675 -0
- package/dist/routes/billing.js +490 -0
- package/dist/routes/browse.js +210 -0
- package/dist/routes/bug-report.js +387 -0
- package/dist/routes/config.js +1868 -0
- package/dist/routes/files.js +671 -0
- package/dist/routes/groups.js +1367 -0
- package/dist/routes/mcp-servers.js +320 -0
- package/dist/routes/memory.js +523 -0
- package/dist/routes/monitor.js +307 -0
- package/dist/routes/skills.js +777 -0
- package/dist/routes/tasks.js +509 -0
- package/dist/routes/usage.js +64 -0
- package/dist/routes/workspace-config.js +458 -0
- package/dist/runtime-build.js +112 -0
- package/dist/runtime-command-handler.js +189 -0
- package/dist/runtime-command-registry.js +1 -0
- package/dist/runtime-config.js +1777 -0
- package/dist/runtime-identity.js +52 -0
- package/dist/schemas.js +590 -0
- package/dist/script-runner.js +64 -0
- package/dist/sdk-query.js +82 -0
- package/dist/skill-utils.js +145 -0
- package/dist/sqlite-compat.js +19 -0
- package/dist/stream-event.types.js +5 -0
- package/dist/streaming-runtime-meta.js +29 -0
- package/dist/task-scheduler.js +695 -0
- package/dist/task-utils.js +13 -0
- package/dist/telegram-pairing.js +59 -0
- package/dist/telegram.js +897 -0
- package/dist/terminal-manager.js +307 -0
- package/dist/tool-step-display.js +1 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +85 -0
- package/dist/web-context.js +161 -0
- package/dist/web.js +1377 -0
- package/dist/wechat-crypto.js +182 -0
- package/dist/wechat.js +589 -0
- package/dist/workspace-runtime-reset.js +35 -0
- package/package.json +107 -0
- package/shared/assistant-meta-footer.ts +127 -0
- package/shared/channel-prefixes.ts +16 -0
- package/shared/dist/assistant-meta-footer.d.ts +29 -0
- package/shared/dist/assistant-meta-footer.js +85 -0
- package/shared/dist/channel-prefixes.d.ts +4 -0
- package/shared/dist/channel-prefixes.js +16 -0
- package/shared/dist/image-detector.d.ts +20 -0
- package/shared/dist/image-detector.js +96 -0
- package/shared/dist/runtime-command-registry.d.ts +38 -0
- package/shared/dist/runtime-command-registry.js +185 -0
- package/shared/dist/stream-event.d.ts +65 -0
- package/shared/dist/stream-event.js +8 -0
- package/shared/dist/tool-step-display.d.ts +4 -0
- package/shared/dist/tool-step-display.js +11 -0
- package/shared/image-detector.ts +116 -0
- package/shared/runtime-command-registry.ts +252 -0
- package/shared/stream-event.ts +67 -0
- package/shared/tool-step-display.ts +21 -0
- package/shared/tsconfig.json +24 -0
- package/web/dist/assets/BillingPage-B1wBR_o-.js +52 -0
- package/web/dist/assets/ChatPage-6GBZ9nXN.css +32 -0
- package/web/dist/assets/ChatPage-BOJcXtaj.js +161 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/dist/assets/SettingsPage-DoY7FoZ_.js +153 -0
- package/web/dist/assets/ShareImageDialog-C1ga8b7l.js +22 -0
- package/web/dist/assets/TasksPage-CRivnNsx.js +14 -0
- package/web/dist/assets/_basePickBy-Bf-bSoS9.js +1 -0
- package/web/dist/assets/_baseUniq-zAOaCuKw.js +1 -0
- package/web/dist/assets/arc-Dm9mVQ9U.js +1 -0
- package/web/dist/assets/architectureDiagram-2XIMDMQ5-BLmzX1wr.js +36 -0
- package/web/dist/assets/band-CquvqAHh.js +1 -0
- package/web/dist/assets/blockDiagram-WCTKOSBZ-B9pcqm3j.js +132 -0
- package/web/dist/assets/c4Diagram-IC4MRINW-Cytx1q3b.js +10 -0
- package/web/dist/assets/channel-BOVj73LR.js +1 -0
- package/web/dist/assets/channel-meta-CQD0Pei-.js +41 -0
- package/web/dist/assets/chunk-4BX2VUAB-0ToDr6RE.js +1 -0
- package/web/dist/assets/chunk-55IACEB6-DQDjnXfS.js +1 -0
- package/web/dist/assets/chunk-FMBD7UC4-Di8ABm6c.js +15 -0
- package/web/dist/assets/chunk-JSJVCQXG-BZQN6rnX.js +1 -0
- package/web/dist/assets/chunk-KX2RTZJC-zBbcpaN_.js +1 -0
- package/web/dist/assets/chunk-NQ4KR5QH-BCrLoU88.js +220 -0
- package/web/dist/assets/chunk-QZHKN3VN-Bqk8juan.js +1 -0
- package/web/dist/assets/chunk-WL4C6EOR-D2YX-MHY.js +189 -0
- package/web/dist/assets/classDiagram-VBA2DB6C-DUUoMyaK.js +1 -0
- package/web/dist/assets/classDiagram-v2-RAHNMMFH-DUUoMyaK.js +1 -0
- package/web/dist/assets/clone-BmaCesfa.js +1 -0
- package/web/dist/assets/cose-bilkent-S5V4N54A-CTsv6qQA.js +1 -0
- package/web/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/web/dist/assets/dagre-KLK3FWXG-Ci4Jh9nu.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-E7M64L7V-BFRnfTI2.js +24 -0
- package/web/dist/assets/diagram-IFDJBPK2-B7Zhnp0b.js +43 -0
- package/web/dist/assets/diagram-P4PSJMXO-BVyP7nwq.js +24 -0
- package/web/dist/assets/erDiagram-INFDFZHY-NorKdTOF.js +70 -0
- package/web/dist/assets/error-CGD5mp5f.js +1 -0
- package/web/dist/assets/flowDiagram-PKNHOUZH-Ch97nABF.js +162 -0
- package/web/dist/assets/ganttDiagram-A5KZAMGK-BQ2pLWsy.js +292 -0
- package/web/dist/assets/gitGraphDiagram-K3NZZRJ6-bcvnBsD2.js +65 -0
- package/web/dist/assets/graph-CeAEckur.js +1 -0
- package/web/dist/assets/index-CPnL1_qC.js +768 -0
- package/web/dist/assets/index-DVevCbcO.css +10 -0
- package/web/dist/assets/infoDiagram-LFFYTUFH-CcsrFdj-.js +2 -0
- package/web/dist/assets/init-Dmth1JHB.js +1 -0
- package/web/dist/assets/ishikawaDiagram-PHBUUO56-1upyMfHN.js +70 -0
- package/web/dist/assets/journeyDiagram-4ABVD52K-CKUi-V0c.js +139 -0
- package/web/dist/assets/kanban-definition-K7BYSVSG-DOnQwXfL.js +89 -0
- package/web/dist/assets/layout-BmMMqTnJ.js +1 -0
- package/web/dist/assets/linear-DiaJloY5.js +1 -0
- package/web/dist/assets/mermaid.core-BWLV1B2v.js +254 -0
- package/web/dist/assets/mindmap-definition-YRQLILUH-BeAKHVWP.js +68 -0
- package/web/dist/assets/ordinal-DILIJJjt.js +1 -0
- package/web/dist/assets/pieDiagram-SKSYHLDU-DfiMSfWo.js +30 -0
- package/web/dist/assets/quadrantDiagram-337W2JSQ-wZxZOJxd.js +7 -0
- package/web/dist/assets/requirementDiagram-Z7DCOOCP-BK4HHm17.js +73 -0
- package/web/dist/assets/sankeyDiagram-WA2Y5GQK-BX6t2avX.js +10 -0
- package/web/dist/assets/sequenceDiagram-2WXFIKYE-BPQlkbAa.js +145 -0
- package/web/dist/assets/sheet-rI0FfB1g.js +6 -0
- package/web/dist/assets/sliders-horizontal-CuijWFNK.js +6 -0
- package/web/dist/assets/sparkles-BsMYXJoT.js +11 -0
- package/web/dist/assets/square-0CqMX1Q3.js +11 -0
- package/web/dist/assets/stateDiagram-RAJIS63D-DxkV0Vwd.js +1 -0
- package/web/dist/assets/stateDiagram-v2-FVOUBMTO-qLYoiOPe.js +1 -0
- package/web/dist/assets/step-D51IIHGA.js +1 -0
- package/web/dist/assets/tasks-D8JjBTwx.js +1 -0
- package/web/dist/assets/time-O8zIGux3.js +1 -0
- package/web/dist/assets/timeline-definition-YZTLITO2-kNp1DyFc.js +61 -0
- package/web/dist/assets/treemap-KZPCXAKY-CkrClVhk.js +162 -0
- package/web/dist/assets/utils-KGAn0XTg.js +11 -0
- package/web/dist/assets/vennDiagram-LZ73GAT5-CgdzEZz4.js +34 -0
- package/web/dist/assets/xychartDiagram-JWTSCODW-DfYGPfNB.js +7 -0
- package/web/dist/assets/zap-_hKJYy7J.js +6 -0
- package/web/dist/favicon.svg +332 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-55-Regular.woff2 +0 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-65-Medium.woff2 +0 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2 +0 -0
- package/web/dist/fonts/DMSans-latin-ext.woff2 +0 -0
- package/web/dist/fonts/DMSans-latin.woff2 +0 -0
- package/web/dist/icons/README.md +20 -0
- package/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/web/dist/icons/icon-128.png +0 -0
- package/web/dist/icons/icon-144.png +0 -0
- package/web/dist/icons/icon-152.png +0 -0
- package/web/dist/icons/icon-192.png +0 -0
- package/web/dist/icons/icon-192.svg +332 -0
- package/web/dist/icons/icon-384.png +0 -0
- package/web/dist/icons/icon-48.png +0 -0
- package/web/dist/icons/icon-512-maskable.png +0 -0
- package/web/dist/icons/icon-512.png +0 -0
- package/web/dist/icons/icon-512.svg +332 -0
- package/web/dist/icons/icon-72.png +0 -0
- package/web/dist/icons/icon-96.png +0 -0
- package/web/dist/icons/loading-logo.svg +332 -0
- package/web/dist/icons/logo-1024.png +0 -0
- package/web/dist/icons/logo-icon.svg +332 -0
- package/web/dist/icons/logo-text.svg +332 -0
- package/web/dist/index.html +30 -0
- package/web/dist/manifest.webmanifest +1 -0
- package/web/dist/registerSW.js +1 -0
- package/web/dist/sw.js +1 -0
- package/web/dist/workbox-08d6266a.js +1 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
// Authentication routes
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { readFile } from 'fs/promises';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
import { Hono } from 'hono';
|
|
7
|
+
import { authMiddleware } from '../middleware/auth.js';
|
|
8
|
+
import { getClientIp } from '../utils.js';
|
|
9
|
+
import { DATA_DIR } from '../config.js';
|
|
10
|
+
import { LoginSchema, RegisterSchema, ProfileUpdateSchema, ChangePasswordSchema, } from '../schemas.js';
|
|
11
|
+
import { getUserByUsername, getUserById, createInitialAdminUser, createUserSession, deleteUserSession, deleteUserSessionsByUserId, updateUserFields, getUserSessions, getUserCount, registerUserWithInvite, registerUserWithoutInvite, logAuthEvent, ensureUserHomeGroup, } from '../db.js';
|
|
12
|
+
import { getRegistrationConfig, getEnabledProviders, getFeishuProviderConfigWithSource, getAppearanceConfig, } from '../runtime-config.js';
|
|
13
|
+
import { verifyPassword, hashPassword, generateSessionToken, sessionExpiresAt, checkLoginRateLimit, recordLoginAttempt, clearLoginAttempts, validateUsername, validatePassword, generateUserId, } from '../auth.js';
|
|
14
|
+
import { logger } from '../logger.js';
|
|
15
|
+
import { invalidateSessionCache, invalidateUserSessions, } from '../web-context.js';
|
|
16
|
+
import { SESSION_COOKIE_NAME_SECURE, SESSION_COOKIE_NAME_PLAIN, TRUST_PROXY, } from '../config.js';
|
|
17
|
+
import { getSystemSettings } from '../runtime-config.js';
|
|
18
|
+
const authRoutes = new Hono();
|
|
19
|
+
// --- Helper Functions ---
|
|
20
|
+
/** Detect if the current request arrived over HTTPS (direct or behind proxy) */
|
|
21
|
+
function isSecureRequest(c) {
|
|
22
|
+
if (TRUST_PROXY) {
|
|
23
|
+
const proto = c.req.header('x-forwarded-proto');
|
|
24
|
+
if (proto === 'https')
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
// Hono / node-server: URL scheme
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(c.req.url, 'http://localhost');
|
|
30
|
+
if (url.protocol === 'https:')
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* ignore */
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
function getSessionCookieName(secure) {
|
|
39
|
+
return secure ? SESSION_COOKIE_NAME_SECURE : SESSION_COOKIE_NAME_PLAIN;
|
|
40
|
+
}
|
|
41
|
+
export function setSessionCookie(c, token) {
|
|
42
|
+
const secure = isSecureRequest(c);
|
|
43
|
+
const name = getSessionCookieName(secure);
|
|
44
|
+
const secureSuffix = secure ? '; Secure' : '';
|
|
45
|
+
return `${name}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${30 * 24 * 60 * 60}${secureSuffix}`;
|
|
46
|
+
}
|
|
47
|
+
export function clearSessionCookie(c) {
|
|
48
|
+
const secure = isSecureRequest(c);
|
|
49
|
+
const name = getSessionCookieName(secure);
|
|
50
|
+
const secureSuffix = secure ? '; Secure' : '';
|
|
51
|
+
return `${name}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0${secureSuffix}`;
|
|
52
|
+
}
|
|
53
|
+
export function isUsernameConflictError(err) {
|
|
54
|
+
return (err instanceof Error &&
|
|
55
|
+
err.message.includes('UNIQUE constraint failed: users.username'));
|
|
56
|
+
}
|
|
57
|
+
export function toUserPublic(u) {
|
|
58
|
+
return {
|
|
59
|
+
id: u.id,
|
|
60
|
+
username: u.username,
|
|
61
|
+
display_name: u.display_name,
|
|
62
|
+
role: u.role,
|
|
63
|
+
status: u.status,
|
|
64
|
+
permissions: u.permissions,
|
|
65
|
+
must_change_password: u.must_change_password,
|
|
66
|
+
disable_reason: u.disable_reason,
|
|
67
|
+
notes: u.notes,
|
|
68
|
+
avatar_emoji: u.avatar_emoji ?? null,
|
|
69
|
+
avatar_color: u.avatar_color ?? null,
|
|
70
|
+
avatar_url: u.avatar_url ?? null,
|
|
71
|
+
ai_name: u.ai_name ?? null,
|
|
72
|
+
ai_avatar_emoji: u.ai_avatar_emoji ?? null,
|
|
73
|
+
ai_avatar_color: u.ai_avatar_color ?? null,
|
|
74
|
+
ai_avatar_url: u.ai_avatar_url ?? null,
|
|
75
|
+
created_at: u.created_at,
|
|
76
|
+
last_login_at: u.last_login_at,
|
|
77
|
+
last_active_at: null,
|
|
78
|
+
deleted_at: u.deleted_at,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function buildSetupStatus() {
|
|
82
|
+
// Check ALL enabled providers, not just the first one.
|
|
83
|
+
// V3→V4 migration can produce empty providers that sort before real ones,
|
|
84
|
+
// causing getClaudeProviderConfig() (first-match) to return an unconfigured provider.
|
|
85
|
+
const providers = getEnabledProviders();
|
|
86
|
+
const claudeConfigured = providers.some((p) => {
|
|
87
|
+
const hasOfficial = !!p.claudeCodeOauthToken?.trim() ||
|
|
88
|
+
!!p.claudeOAuthCredentials ||
|
|
89
|
+
!!p.anthropicApiKey?.trim();
|
|
90
|
+
const hasThirdParty = !!(p.anthropicBaseUrl?.trim() && p.anthropicAuthToken?.trim());
|
|
91
|
+
return hasOfficial || hasThirdParty;
|
|
92
|
+
});
|
|
93
|
+
const { source: feishuSource } = getFeishuProviderConfigWithSource();
|
|
94
|
+
const feishuConfigured = feishuSource !== 'none';
|
|
95
|
+
return {
|
|
96
|
+
needsSetup: false,
|
|
97
|
+
claudeConfigured,
|
|
98
|
+
feishuConfigured,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// --- Routes ---
|
|
102
|
+
// Public: check if system is initialized (any user exists)
|
|
103
|
+
authRoutes.get('/status', (c) => {
|
|
104
|
+
const initialized = getUserCount(true) > 0;
|
|
105
|
+
return c.json({ initialized });
|
|
106
|
+
});
|
|
107
|
+
// Public: initial admin setup (only when no users exist)
|
|
108
|
+
authRoutes.post('/setup', async (c) => {
|
|
109
|
+
const body = await c.req.json().catch(() => ({}));
|
|
110
|
+
const { username, password } = body;
|
|
111
|
+
if (!username || !password) {
|
|
112
|
+
return c.json({ error: 'Username and password are required' }, 400);
|
|
113
|
+
}
|
|
114
|
+
const usernameError = validateUsername(username);
|
|
115
|
+
if (usernameError)
|
|
116
|
+
return c.json({ error: usernameError }, 400);
|
|
117
|
+
const passwordError = validatePassword(password);
|
|
118
|
+
if (passwordError)
|
|
119
|
+
return c.json({ error: passwordError }, 400);
|
|
120
|
+
const now = new Date().toISOString();
|
|
121
|
+
const userId = generateUserId();
|
|
122
|
+
const passwordHash = await hashPassword(password);
|
|
123
|
+
const ip = getClientIp(c);
|
|
124
|
+
const ua = c.req.header('user-agent') || null;
|
|
125
|
+
const createResult = createInitialAdminUser({
|
|
126
|
+
id: userId,
|
|
127
|
+
username,
|
|
128
|
+
password_hash: passwordHash,
|
|
129
|
+
display_name: username,
|
|
130
|
+
role: 'admin',
|
|
131
|
+
status: 'active',
|
|
132
|
+
must_change_password: false,
|
|
133
|
+
notes: 'Initial admin (setup wizard)',
|
|
134
|
+
created_at: now,
|
|
135
|
+
updated_at: now,
|
|
136
|
+
});
|
|
137
|
+
if (!createResult.ok) {
|
|
138
|
+
if (createResult.reason === 'already_initialized') {
|
|
139
|
+
return c.json({ error: 'System already initialized' }, 403);
|
|
140
|
+
}
|
|
141
|
+
return c.json({ error: 'Username already taken' }, 400);
|
|
142
|
+
}
|
|
143
|
+
logAuthEvent({
|
|
144
|
+
event_type: 'user_created',
|
|
145
|
+
username,
|
|
146
|
+
actor_username: 'system',
|
|
147
|
+
ip_address: ip,
|
|
148
|
+
user_agent: ua,
|
|
149
|
+
details: { source: 'setup_wizard', role: 'admin' },
|
|
150
|
+
});
|
|
151
|
+
// Create admin home group (web:main, folder=main, host mode)
|
|
152
|
+
try {
|
|
153
|
+
ensureUserHomeGroup(userId, 'admin', username);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
logger.warn({ err, userId }, 'Failed to create admin home group during setup');
|
|
157
|
+
}
|
|
158
|
+
// Auto-login
|
|
159
|
+
const token = generateSessionToken();
|
|
160
|
+
createUserSession({
|
|
161
|
+
id: token,
|
|
162
|
+
user_id: userId,
|
|
163
|
+
ip_address: ip,
|
|
164
|
+
user_agent: ua,
|
|
165
|
+
created_at: now,
|
|
166
|
+
expires_at: sessionExpiresAt(),
|
|
167
|
+
last_active_at: now,
|
|
168
|
+
});
|
|
169
|
+
const newUser = getUserById(userId);
|
|
170
|
+
return new Response(JSON.stringify({
|
|
171
|
+
success: true,
|
|
172
|
+
user: toUserPublic(newUser),
|
|
173
|
+
setupStatus: buildSetupStatus(),
|
|
174
|
+
}), {
|
|
175
|
+
status: 201,
|
|
176
|
+
headers: {
|
|
177
|
+
'Content-Type': 'application/json',
|
|
178
|
+
'Set-Cookie': setSessionCookie(c, token),
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
authRoutes.post('/login', async (c) => {
|
|
183
|
+
const body = await c.req.json().catch(() => ({}));
|
|
184
|
+
const validation = LoginSchema.safeParse(body);
|
|
185
|
+
if (!validation.success) {
|
|
186
|
+
return c.json({ error: 'Invalid credentials' }, 401);
|
|
187
|
+
}
|
|
188
|
+
const { username, password } = validation.data;
|
|
189
|
+
const ip = getClientIp(c);
|
|
190
|
+
const ua = c.req.header('user-agent') || null;
|
|
191
|
+
// Rate limiting
|
|
192
|
+
const { maxLoginAttempts, loginLockoutMinutes } = getSystemSettings();
|
|
193
|
+
const rateCheck = checkLoginRateLimit(username, ip, maxLoginAttempts, loginLockoutMinutes);
|
|
194
|
+
if (!rateCheck.allowed) {
|
|
195
|
+
logAuthEvent({
|
|
196
|
+
event_type: 'login_failed',
|
|
197
|
+
username,
|
|
198
|
+
ip_address: ip,
|
|
199
|
+
user_agent: ua,
|
|
200
|
+
details: { reason: 'rate_limited' },
|
|
201
|
+
});
|
|
202
|
+
return c.json({
|
|
203
|
+
error: `Too many login attempts. Try again in ${rateCheck.retryAfterSeconds}s`,
|
|
204
|
+
}, 429);
|
|
205
|
+
}
|
|
206
|
+
const user = getUserByUsername(username);
|
|
207
|
+
// Constant-time: always run bcrypt compare even if user doesn't exist (prevents timing attacks)
|
|
208
|
+
// 使用运行时生成的合法 bcrypt hash,确保 bcrypt.compare 不会抛异常
|
|
209
|
+
const DUMMY_HASH = '$2b$12$GBXvNon/zJbUI4jtleGnP.YX03zXP5eSXjppo7a3vyWEUK/2YwdP.';
|
|
210
|
+
let passwordMatch;
|
|
211
|
+
try {
|
|
212
|
+
passwordMatch = await verifyPassword(password, user ? user.password_hash : DUMMY_HASH);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// 如果 hash 格式异常,视为不匹配,不泄漏内部错误
|
|
216
|
+
passwordMatch = false;
|
|
217
|
+
}
|
|
218
|
+
if (!user || user.status !== 'active' || !passwordMatch) {
|
|
219
|
+
recordLoginAttempt(username, ip);
|
|
220
|
+
logAuthEvent({
|
|
221
|
+
event_type: 'login_failed',
|
|
222
|
+
username,
|
|
223
|
+
ip_address: ip,
|
|
224
|
+
user_agent: ua,
|
|
225
|
+
details: {
|
|
226
|
+
reason: !user
|
|
227
|
+
? 'user_not_found'
|
|
228
|
+
: user.status !== 'active'
|
|
229
|
+
? 'account_inactive'
|
|
230
|
+
: 'wrong_password',
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
return c.json({ error: 'Invalid credentials' }, 401);
|
|
234
|
+
}
|
|
235
|
+
// Success — create session
|
|
236
|
+
const token = generateSessionToken();
|
|
237
|
+
const now = new Date().toISOString();
|
|
238
|
+
createUserSession({
|
|
239
|
+
id: token,
|
|
240
|
+
user_id: user.id,
|
|
241
|
+
ip_address: ip,
|
|
242
|
+
user_agent: ua,
|
|
243
|
+
created_at: now,
|
|
244
|
+
expires_at: sessionExpiresAt(),
|
|
245
|
+
last_active_at: now,
|
|
246
|
+
});
|
|
247
|
+
clearLoginAttempts(username, ip);
|
|
248
|
+
updateUserFields(user.id, { last_login_at: now });
|
|
249
|
+
// Ensure user has a home group (backfill for existing users)
|
|
250
|
+
try {
|
|
251
|
+
ensureUserHomeGroup(user.id, user.role, user.username);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
// Don't block login if home group creation fails
|
|
255
|
+
logger.warn({ err, userId: user.id }, 'Failed to ensure home group during login');
|
|
256
|
+
}
|
|
257
|
+
logAuthEvent({
|
|
258
|
+
event_type: 'login_success',
|
|
259
|
+
username,
|
|
260
|
+
ip_address: ip,
|
|
261
|
+
user_agent: ua,
|
|
262
|
+
});
|
|
263
|
+
const updatedUser = getUserById(user.id) ?? user;
|
|
264
|
+
const setupStatus = updatedUser.role === 'admin' ? buildSetupStatus() : undefined;
|
|
265
|
+
return new Response(JSON.stringify({
|
|
266
|
+
success: true,
|
|
267
|
+
user: toUserPublic(updatedUser),
|
|
268
|
+
setupStatus,
|
|
269
|
+
}), {
|
|
270
|
+
status: 200,
|
|
271
|
+
headers: {
|
|
272
|
+
'Content-Type': 'application/json',
|
|
273
|
+
'Set-Cookie': setSessionCookie(c, token),
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
authRoutes.get('/register/status', (c) => {
|
|
278
|
+
// Before initial admin setup, force users through /setup first.
|
|
279
|
+
if (getUserCount(true) === 0) {
|
|
280
|
+
return c.json({
|
|
281
|
+
allowRegistration: false,
|
|
282
|
+
requireInviteCode: true,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
const config = getRegistrationConfig();
|
|
286
|
+
return c.json({
|
|
287
|
+
allowRegistration: config.allowRegistration,
|
|
288
|
+
requireInviteCode: config.requireInviteCode,
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
authRoutes.post('/register', async (c) => {
|
|
292
|
+
if (getUserCount(true) === 0) {
|
|
293
|
+
return c.json({ error: '系统尚未初始化,请先完成管理员设置。' }, 403);
|
|
294
|
+
}
|
|
295
|
+
// Check registration switch
|
|
296
|
+
const regConfig = getRegistrationConfig();
|
|
297
|
+
if (!regConfig.allowRegistration) {
|
|
298
|
+
return c.json({ error: '注册功能已关闭' }, 403);
|
|
299
|
+
}
|
|
300
|
+
const body = await c.req.json().catch(() => ({}));
|
|
301
|
+
const validation = RegisterSchema.safeParse(body);
|
|
302
|
+
if (!validation.success) {
|
|
303
|
+
return c.json({ error: 'Invalid request', details: validation.error.format() }, 400);
|
|
304
|
+
}
|
|
305
|
+
const { username, password, display_name, invite_code } = validation.data;
|
|
306
|
+
// If invite code is required but not provided, reject
|
|
307
|
+
if (regConfig.requireInviteCode && !invite_code) {
|
|
308
|
+
return c.json({ error: '需要提供邀请码' }, 400);
|
|
309
|
+
}
|
|
310
|
+
const ip = getClientIp(c);
|
|
311
|
+
const ua = c.req.header('user-agent') || null;
|
|
312
|
+
// IP-based rate limiting for register endpoint
|
|
313
|
+
const { maxLoginAttempts: regMaxAttempts, loginLockoutMinutes: regLockoutMin, } = getSystemSettings();
|
|
314
|
+
const rateCheck = checkLoginRateLimit(`register:${ip}`, ip, regMaxAttempts, regLockoutMin);
|
|
315
|
+
if (!rateCheck.allowed) {
|
|
316
|
+
return c.json({
|
|
317
|
+
error: `Too many registration attempts. Try again in ${rateCheck.retryAfterSeconds}s`,
|
|
318
|
+
}, 429);
|
|
319
|
+
}
|
|
320
|
+
// Validate username format
|
|
321
|
+
const usernameError = validateUsername(username);
|
|
322
|
+
if (usernameError)
|
|
323
|
+
return c.json({ error: usernameError }, 400);
|
|
324
|
+
const passwordError = validatePassword(password);
|
|
325
|
+
if (passwordError)
|
|
326
|
+
return c.json({ error: passwordError }, 400);
|
|
327
|
+
const now = new Date().toISOString();
|
|
328
|
+
const userId = generateUserId();
|
|
329
|
+
const passwordHash = await hashPassword(password);
|
|
330
|
+
// Branch: with invite code or without
|
|
331
|
+
const withInvite = !!invite_code;
|
|
332
|
+
const result = withInvite
|
|
333
|
+
? registerUserWithInvite({
|
|
334
|
+
id: userId,
|
|
335
|
+
username,
|
|
336
|
+
password_hash: passwordHash,
|
|
337
|
+
display_name: display_name || username,
|
|
338
|
+
invite_code: invite_code,
|
|
339
|
+
created_at: now,
|
|
340
|
+
updated_at: now,
|
|
341
|
+
})
|
|
342
|
+
: registerUserWithoutInvite({
|
|
343
|
+
id: userId,
|
|
344
|
+
username,
|
|
345
|
+
password_hash: passwordHash,
|
|
346
|
+
display_name: display_name || username,
|
|
347
|
+
created_at: now,
|
|
348
|
+
updated_at: now,
|
|
349
|
+
});
|
|
350
|
+
if (!result.ok) {
|
|
351
|
+
recordLoginAttempt(`register:${ip}`, ip);
|
|
352
|
+
if (result.reason === 'username_taken') {
|
|
353
|
+
return c.json({ error: 'Registration failed. Username may already be taken.' }, 400);
|
|
354
|
+
}
|
|
355
|
+
return c.json({ error: 'Invalid or expired invite code' }, 400);
|
|
356
|
+
}
|
|
357
|
+
if (withInvite) {
|
|
358
|
+
logAuthEvent({
|
|
359
|
+
event_type: 'invite_used',
|
|
360
|
+
username,
|
|
361
|
+
ip_address: ip,
|
|
362
|
+
user_agent: ua,
|
|
363
|
+
details: { invite_code: invite_code.slice(0, 8) + '...' },
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
logAuthEvent({
|
|
367
|
+
event_type: 'register_success',
|
|
368
|
+
username,
|
|
369
|
+
ip_address: ip,
|
|
370
|
+
user_agent: ua,
|
|
371
|
+
details: { role: result.role, with_invite: withInvite },
|
|
372
|
+
});
|
|
373
|
+
// Create home group for new user
|
|
374
|
+
try {
|
|
375
|
+
ensureUserHomeGroup(userId, result.role, username);
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
logger.warn({ err, userId }, 'Failed to create home group during registration');
|
|
379
|
+
}
|
|
380
|
+
// Auto-login
|
|
381
|
+
const token = generateSessionToken();
|
|
382
|
+
createUserSession({
|
|
383
|
+
id: token,
|
|
384
|
+
user_id: userId,
|
|
385
|
+
ip_address: ip,
|
|
386
|
+
user_agent: ua,
|
|
387
|
+
created_at: now,
|
|
388
|
+
expires_at: sessionExpiresAt(),
|
|
389
|
+
last_active_at: now,
|
|
390
|
+
});
|
|
391
|
+
const newUser = getUserById(userId);
|
|
392
|
+
return new Response(JSON.stringify({ success: true, user: toUserPublic(newUser) }), {
|
|
393
|
+
status: 201,
|
|
394
|
+
headers: {
|
|
395
|
+
'Content-Type': 'application/json',
|
|
396
|
+
'Set-Cookie': setSessionCookie(c, token),
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
authRoutes.post('/logout', authMiddleware, (c) => {
|
|
401
|
+
const sessionId = c.get('sessionId');
|
|
402
|
+
deleteUserSession(sessionId);
|
|
403
|
+
invalidateSessionCache(sessionId);
|
|
404
|
+
const user = c.get('user');
|
|
405
|
+
logAuthEvent({
|
|
406
|
+
event_type: 'logout',
|
|
407
|
+
username: user.username,
|
|
408
|
+
ip_address: getClientIp(c),
|
|
409
|
+
});
|
|
410
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
411
|
+
status: 200,
|
|
412
|
+
headers: {
|
|
413
|
+
'Content-Type': 'application/json',
|
|
414
|
+
'Set-Cookie': clearSessionCookie(c),
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
authRoutes.get('/me', authMiddleware, (c) => {
|
|
419
|
+
const authUser = c.get('user');
|
|
420
|
+
const fullUser = getUserById(authUser.id);
|
|
421
|
+
if (!fullUser)
|
|
422
|
+
return c.json({ error: 'User not found' }, 404);
|
|
423
|
+
const userPublic = toUserPublic(fullUser);
|
|
424
|
+
const appearance = getAppearanceConfig();
|
|
425
|
+
// Admin users get setup status for the onboarding wizard
|
|
426
|
+
if (fullUser.role === 'admin') {
|
|
427
|
+
return c.json({
|
|
428
|
+
user: userPublic,
|
|
429
|
+
appearance,
|
|
430
|
+
setupStatus: buildSetupStatus(),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return c.json({ user: userPublic, appearance });
|
|
434
|
+
});
|
|
435
|
+
authRoutes.put('/profile', authMiddleware, async (c) => {
|
|
436
|
+
const body = await c.req.json().catch(() => ({}));
|
|
437
|
+
const validation = ProfileUpdateSchema.safeParse(body);
|
|
438
|
+
if (!validation.success) {
|
|
439
|
+
return c.json({ error: 'Invalid request', details: validation.error.format() }, 400);
|
|
440
|
+
}
|
|
441
|
+
const user = c.get('user');
|
|
442
|
+
const fullUser = getUserById(user.id);
|
|
443
|
+
if (!fullUser)
|
|
444
|
+
return c.json({ error: 'User not found' }, 404);
|
|
445
|
+
const updates = {};
|
|
446
|
+
if (validation.data.username !== undefined) {
|
|
447
|
+
const usernameError = validateUsername(validation.data.username);
|
|
448
|
+
if (usernameError)
|
|
449
|
+
return c.json({ error: usernameError }, 400);
|
|
450
|
+
if (validation.data.username !== fullUser.username) {
|
|
451
|
+
const existed = getUserByUsername(validation.data.username);
|
|
452
|
+
if (existed && existed.id !== fullUser.id) {
|
|
453
|
+
return c.json({ error: 'Username already taken' }, 409);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
updates.username = validation.data.username;
|
|
457
|
+
}
|
|
458
|
+
if (validation.data.display_name !== undefined) {
|
|
459
|
+
updates.display_name = validation.data.display_name;
|
|
460
|
+
}
|
|
461
|
+
if (validation.data.avatar_emoji !== undefined) {
|
|
462
|
+
updates.avatar_emoji = validation.data.avatar_emoji;
|
|
463
|
+
}
|
|
464
|
+
if (validation.data.avatar_color !== undefined) {
|
|
465
|
+
updates.avatar_color = validation.data.avatar_color;
|
|
466
|
+
}
|
|
467
|
+
if (validation.data.avatar_url !== undefined) {
|
|
468
|
+
updates.avatar_url = validation.data.avatar_url;
|
|
469
|
+
}
|
|
470
|
+
if (validation.data.ai_name !== undefined) {
|
|
471
|
+
updates.ai_name = validation.data.ai_name;
|
|
472
|
+
}
|
|
473
|
+
if (validation.data.ai_avatar_emoji !== undefined) {
|
|
474
|
+
updates.ai_avatar_emoji = validation.data.ai_avatar_emoji;
|
|
475
|
+
}
|
|
476
|
+
if (validation.data.ai_avatar_color !== undefined) {
|
|
477
|
+
updates.ai_avatar_color = validation.data.ai_avatar_color;
|
|
478
|
+
}
|
|
479
|
+
if (validation.data.ai_avatar_url !== undefined) {
|
|
480
|
+
updates.ai_avatar_url = validation.data.ai_avatar_url;
|
|
481
|
+
}
|
|
482
|
+
if (Object.keys(updates).length === 0) {
|
|
483
|
+
return c.json({ error: 'No fields to update' }, 400);
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
updateUserFields(user.id, updates);
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
if (isUsernameConflictError(err)) {
|
|
490
|
+
return c.json({ error: 'Username already taken' }, 409);
|
|
491
|
+
}
|
|
492
|
+
throw err;
|
|
493
|
+
}
|
|
494
|
+
const updated = getUserById(user.id);
|
|
495
|
+
logAuthEvent({
|
|
496
|
+
event_type: 'profile_updated',
|
|
497
|
+
username: updated.username,
|
|
498
|
+
actor_username: fullUser.username,
|
|
499
|
+
ip_address: getClientIp(c),
|
|
500
|
+
details: { fields: Object.keys(updates) },
|
|
501
|
+
});
|
|
502
|
+
return c.json({ success: true, user: toUserPublic(updated) });
|
|
503
|
+
});
|
|
504
|
+
authRoutes.put('/password', authMiddleware, async (c) => {
|
|
505
|
+
const body = await c.req.json().catch(() => ({}));
|
|
506
|
+
const validation = ChangePasswordSchema.safeParse(body);
|
|
507
|
+
if (!validation.success) {
|
|
508
|
+
return c.json({ error: 'Invalid request', details: validation.error.format() }, 400);
|
|
509
|
+
}
|
|
510
|
+
const user = c.get('user');
|
|
511
|
+
const fullUser = getUserById(user.id);
|
|
512
|
+
if (!fullUser)
|
|
513
|
+
return c.json({ error: 'User not found' }, 404);
|
|
514
|
+
const match = await verifyPassword(validation.data.current_password, fullUser.password_hash);
|
|
515
|
+
if (!match)
|
|
516
|
+
return c.json({ error: 'Current password is incorrect' }, 401);
|
|
517
|
+
if (validation.data.current_password === validation.data.new_password) {
|
|
518
|
+
return c.json({ error: 'New password must be different from current password' }, 400);
|
|
519
|
+
}
|
|
520
|
+
const passwordError = validatePassword(validation.data.new_password);
|
|
521
|
+
if (passwordError)
|
|
522
|
+
return c.json({ error: passwordError }, 400);
|
|
523
|
+
const newHash = await hashPassword(validation.data.new_password);
|
|
524
|
+
updateUserFields(user.id, {
|
|
525
|
+
password_hash: newHash,
|
|
526
|
+
must_change_password: false,
|
|
527
|
+
});
|
|
528
|
+
// Revoke all existing sessions for this user
|
|
529
|
+
invalidateUserSessions(user.id);
|
|
530
|
+
deleteUserSessionsByUserId(user.id);
|
|
531
|
+
// Create a fresh session for the current request
|
|
532
|
+
const now = new Date().toISOString();
|
|
533
|
+
const ip = getClientIp(c);
|
|
534
|
+
const ua = c.req.header('user-agent') || null;
|
|
535
|
+
const newToken = generateSessionToken();
|
|
536
|
+
createUserSession({
|
|
537
|
+
id: newToken,
|
|
538
|
+
user_id: user.id,
|
|
539
|
+
ip_address: ip,
|
|
540
|
+
user_agent: ua,
|
|
541
|
+
created_at: now,
|
|
542
|
+
expires_at: sessionExpiresAt(),
|
|
543
|
+
last_active_at: now,
|
|
544
|
+
});
|
|
545
|
+
logAuthEvent({
|
|
546
|
+
event_type: 'password_changed',
|
|
547
|
+
username: user.username,
|
|
548
|
+
ip_address: ip,
|
|
549
|
+
details: { cleared_force_change: true, sessions_revoked: true },
|
|
550
|
+
});
|
|
551
|
+
const updated = getUserById(user.id);
|
|
552
|
+
return new Response(JSON.stringify({ success: true, user: toUserPublic(updated) }), {
|
|
553
|
+
status: 200,
|
|
554
|
+
headers: {
|
|
555
|
+
'Content-Type': 'application/json',
|
|
556
|
+
'Set-Cookie': setSessionCookie(c, newToken),
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
authRoutes.get('/sessions', authMiddleware, (c) => {
|
|
561
|
+
const user = c.get('user');
|
|
562
|
+
const currentSessionId = c.get('sessionId');
|
|
563
|
+
const sessions = getUserSessions(user.id);
|
|
564
|
+
return c.json({
|
|
565
|
+
sessions: sessions.map((s) => ({
|
|
566
|
+
shortId: s.id.slice(0, 8),
|
|
567
|
+
ip_address: s.ip_address,
|
|
568
|
+
user_agent: s.user_agent,
|
|
569
|
+
created_at: s.created_at,
|
|
570
|
+
last_active_at: s.last_active_at,
|
|
571
|
+
is_current: s.id === currentSessionId,
|
|
572
|
+
})),
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
authRoutes.delete('/sessions/:id', authMiddleware, (c) => {
|
|
576
|
+
const user = c.get('user');
|
|
577
|
+
const targetId = c.req.param('id');
|
|
578
|
+
const sessions = getUserSessions(user.id);
|
|
579
|
+
// Support both full token and shortId (first 8 chars) for lookup
|
|
580
|
+
const target = sessions.find((s) => s.id === targetId || s.id.slice(0, 8) === targetId);
|
|
581
|
+
if (!target)
|
|
582
|
+
return c.json({ error: 'Session not found' }, 404);
|
|
583
|
+
deleteUserSession(target.id);
|
|
584
|
+
invalidateSessionCache(target.id);
|
|
585
|
+
logAuthEvent({
|
|
586
|
+
event_type: 'session_revoked',
|
|
587
|
+
username: user.username,
|
|
588
|
+
ip_address: getClientIp(c),
|
|
589
|
+
});
|
|
590
|
+
return c.json({ success: true });
|
|
591
|
+
});
|
|
592
|
+
// --- Avatar Upload ---
|
|
593
|
+
const AVATARS_DIR = path.join(DATA_DIR, 'avatars');
|
|
594
|
+
const ALLOWED_AVATAR_TYPES = {
|
|
595
|
+
'image/jpeg': '.jpg',
|
|
596
|
+
'image/png': '.png',
|
|
597
|
+
'image/gif': '.gif',
|
|
598
|
+
'image/webp': '.webp',
|
|
599
|
+
};
|
|
600
|
+
const MAX_AVATAR_SIZE = 3 * 1024 * 1024; // 3MB
|
|
601
|
+
authRoutes.post('/avatar', authMiddleware, async (c) => {
|
|
602
|
+
const user = c.get('user');
|
|
603
|
+
const contentType = c.req.header('content-type') || '';
|
|
604
|
+
if (!contentType.includes('multipart/form-data')) {
|
|
605
|
+
return c.json({ error: 'Expected multipart/form-data' }, 400);
|
|
606
|
+
}
|
|
607
|
+
const formData = await c.req.formData();
|
|
608
|
+
const file = formData.get('avatar');
|
|
609
|
+
if (!file || !(file instanceof File)) {
|
|
610
|
+
return c.json({ error: 'No avatar file provided' }, 400);
|
|
611
|
+
}
|
|
612
|
+
if (file.size > MAX_AVATAR_SIZE) {
|
|
613
|
+
return c.json({ error: 'File too large (max 3MB)' }, 400);
|
|
614
|
+
}
|
|
615
|
+
const ext = ALLOWED_AVATAR_TYPES[file.type];
|
|
616
|
+
if (!ext) {
|
|
617
|
+
return c.json({ error: 'Unsupported image type. Use jpg, png, gif or webp' }, 400);
|
|
618
|
+
}
|
|
619
|
+
fs.mkdirSync(AVATARS_DIR, { recursive: true });
|
|
620
|
+
// Delete old avatar files for this user
|
|
621
|
+
try {
|
|
622
|
+
const existing = fs
|
|
623
|
+
.readdirSync(AVATARS_DIR)
|
|
624
|
+
.filter((f) => f.startsWith(`${user.id}-`));
|
|
625
|
+
for (const f of existing) {
|
|
626
|
+
fs.unlinkSync(path.join(AVATARS_DIR, f));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
/* ignore */
|
|
631
|
+
}
|
|
632
|
+
const filename = `${user.id}-${crypto.randomBytes(4).toString('hex')}${ext}`;
|
|
633
|
+
const filePath = path.join(AVATARS_DIR, filename);
|
|
634
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
635
|
+
const tmpPath = filePath + '.tmp';
|
|
636
|
+
fs.writeFileSync(tmpPath, buffer);
|
|
637
|
+
fs.renameSync(tmpPath, filePath);
|
|
638
|
+
const avatarUrl = `/api/auth/avatars/${filename}`;
|
|
639
|
+
// Update user profile — target=user stores as avatar_url, otherwise ai_avatar_url
|
|
640
|
+
const target = c.req.query('target');
|
|
641
|
+
const field = target === 'user' ? 'avatar_url' : 'ai_avatar_url';
|
|
642
|
+
updateUserFields(user.id, { [field]: avatarUrl });
|
|
643
|
+
const updated = getUserById(user.id);
|
|
644
|
+
return c.json({ success: true, avatarUrl, user: toUserPublic(updated) });
|
|
645
|
+
});
|
|
646
|
+
// Serve avatar files (public, no auth required)
|
|
647
|
+
authRoutes.get('/avatars/:filename', async (c) => {
|
|
648
|
+
const filename = c.req.param('filename');
|
|
649
|
+
// Security: only allow simple filenames (no path traversal)
|
|
650
|
+
if (!filename || /[/\\]/.test(filename) || filename.includes('..')) {
|
|
651
|
+
return c.json({ error: 'Invalid filename' }, 400);
|
|
652
|
+
}
|
|
653
|
+
const filePath = path.join(AVATARS_DIR, filename);
|
|
654
|
+
if (!fs.existsSync(filePath)) {
|
|
655
|
+
return c.json({ error: 'Avatar not found' }, 404);
|
|
656
|
+
}
|
|
657
|
+
const ext = path.extname(filename).toLowerCase();
|
|
658
|
+
const mimeTypes = {
|
|
659
|
+
'.jpg': 'image/jpeg',
|
|
660
|
+
'.jpeg': 'image/jpeg',
|
|
661
|
+
'.png': 'image/png',
|
|
662
|
+
'.gif': 'image/gif',
|
|
663
|
+
'.webp': 'image/webp',
|
|
664
|
+
};
|
|
665
|
+
const contentType = mimeTypes[ext] || 'application/octet-stream';
|
|
666
|
+
const data = await readFile(filePath);
|
|
667
|
+
return new Response(data, {
|
|
668
|
+
status: 200,
|
|
669
|
+
headers: {
|
|
670
|
+
'Content-Type': contentType,
|
|
671
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
672
|
+
},
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
export default authRoutes;
|