├── .env.example ├── .gitattributes ├── public ├── Node.js.png └── 34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png ├── pnpm-workspace.yaml ├── prisma ├── migrations │ ├── 20250607080235_update_comment_1 │ │ └── migration.sql │ ├── 20250710020339_add_comment_relation │ │ └── migration.sql │ ├── 20250621134243_update_ua_long │ │ └── migration.sql │ ├── 20250731080211_thumbnail_long │ │ └── migration.sql │ ├── migration_lock.toml │ ├── 20250621064255_update_users_avatar │ │ └── migration.sql │ ├── 20250728091124_developing_state │ │ └── migration.sql │ ├── 20250621100858_update_add_default_ow_users │ │ └── migration.sql │ ├── 20250729052734_add_scratch_compatible │ │ └── migration.sql │ ├── 20250622072641_update_user_3 │ │ └── migration.sql │ ├── 20250621063514_update_ow_users_contacts │ │ └── migration.sql │ ├── 20250809063846_add_totp │ │ └── migration.sql │ ├── 20250621072946_update │ │ └── migration.sql │ ├── 20250806102209_update_scratch_extension_project_type │ │ └── migration.sql │ ├── 20250731073246_no_teacherid │ │ └── migration.sql │ ├── 20250607120000_update_user_timestamps │ │ └── migration.sql │ ├── 20250731073504_add_thumbnail │ │ └── migration.sql │ ├── 20250601042102_update_only_show_public_project │ │ └── migration.sql │ ├── 20250804004716_add_notification_multichannel_support │ │ └── migration.sql │ ├── 20250621063933_update_ow_users_contacts_2 │ │ └── migration.sql │ ├── 20250601042631_update_index │ │ └── migration.sql │ ├── 20250725040353_fx_commit_error │ │ └── migration.sql │ ├── 20250713072616_add_coderun_devices_2 │ │ └── migration.sql │ ├── 20250607110115_update_relation_1 │ │ └── migration.sql │ ├── 20250629031948_add_user_kv_store │ │ └── migration.sql │ ├── 20250629044145_add_user_kv_store_1 │ │ └── migration.sql │ ├── 20250728075426_add_extensions │ │ └── migration.sql │ ├── 20250608064255_update_condig_1 │ │ └── migration.sql │ ├── 20250615073118_merge_analytics_tables_2 │ │ └── migration.sql │ ├── 20250713080114_add_coderun_devices_3 │ │ └── migration.sql │ ├── 20250729104210_add_account_token │ │ └── migration.sql │ ├── 20250531113639_fix_timezone_and_add_search_view │ │ └── migration.sql │ ├── 20250628032325_no_bigint │ │ └── migration.sql │ ├── 20250713054054_add_coderun_devices │ │ └── migration.sql │ ├── 20250621071548_update_users │ │ └── migration.sql │ ├── 20250628031406_update │ │ └── migration.sql │ ├── 20250601043102_add_author_fields_to_search_view │ │ └── migration.sql │ ├── 20250601043103_add_author_fields_to_search_view copy2 │ │ └── migration.sql │ ├── 20250622042215_update_oauth2 │ │ └── migration.sql │ ├── 20250802081854_update_notifications │ │ └── migration.sql │ ├── 20250615053401_add_analytics_tables │ │ └── migration.sql │ ├── 20250731020425_add_assets_table │ │ └── migration.sql │ ├── 20250601043104_enhance_search_view_with_comments_commits_branches │ │ └── migration.sql │ ├── 20250601043104_enhance_search_view_with_comments_commits_branches copy2 │ │ └── migration.sql │ ├── 20250615083810_rename_analytics_tables │ │ └── migration.sql │ ├── 20250601043104_enhance_search_view_with_comments_commits_branches copy3 │ │ └── migration.sql │ ├── 20250615064305_merge_analytics_tables │ │ └── migration.sql │ └── 20250622015856_create_oauth │ │ └── migration.sql └── views │ └── zerocat_develop │ └── ow_projects_search_view.sql ├── .github ├── ISSUE_TEMPLATE │ ├── custom.md │ ├── feature_request.md │ └── 反馈bug.md ├── dependabot.yml └── workflows │ ├── claude-code-review.yml │ ├── claude.yml │ └── docker-publish.yml ├── .claude └── settings.local.json ├── process.json ├── src ├── index.js ├── paths.js ├── routes │ ├── router_projectlist.js │ ├── router_auth.js │ ├── router_timeline.js │ ├── router_cachekv.js │ ├── router_event.js │ ├── router_search.js │ ├── router_stars.js │ ├── admin │ │ └── coderun.js │ └── router_admin.js ├── default_project.js ├── services │ ├── auth │ │ ├── permissionManager.js │ │ ├── accountTokenService.js │ │ ├── oauth.js │ │ └── sudoAuth.js │ ├── memoryCache.js │ ├── config │ │ └── index.js │ ├── logger.js │ ├── email │ │ ├── emailService.js │ │ └── emailTemplateService.js │ ├── utils │ │ └── ipUtils.js │ ├── global.js │ ├── errorHandler.js │ ├── ip │ │ └── ipLocation.js │ └── coderunManager.js ├── middleware │ ├── ipMiddleware.js │ ├── captcha.js │ ├── rateLimit.js │ ├── geetest.js │ └── sudo.js ├── controllers │ ├── auth │ │ ├── index.js │ │ ├── twoFactorController.js │ │ └── unifiedLoginController.js │ └── users.js ├── views │ └── index.ejs ├── server.js ├── config │ └── notificationTemplates.json ├── app.js └── routes.js ├── docker-compose.yml ├── docker ├── postgres │ └── docker-compose.yml ├── redis │ └── docker-compose.yml └── meilisearch │ ├── docker-compose.yml │ └── config.yml ├── Dockerfile ├── .dockerignore ├── SECURITY.md ├── scripts └── commit_depth.js ├── package.json ├── docs ├── EXTENSIONS_API_DOCS.md ├── admin-notifications-api.md └── sudo-auth-system.md ├── README.md ├── .gitignore ├── test └── sudo-auth-test.js └── server.js /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="mysql://[username]:[password]@[host]:[port]/[database]" -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /public/Node.js.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroCatDev/ZeroCat/HEAD/public/Node.js.png -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - '@prisma/client' 3 | - '@prisma/engines' 4 | - bcrypt 5 | - prisma 6 | -------------------------------------------------------------------------------- /prisma/migrations/20250607080235_update_comment_1/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `ow_comment` MODIFY `page_id` VARCHAR(32) NULL; 3 | -------------------------------------------------------------------------------- /public/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroCatDev/ZeroCat/HEAD/public/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png -------------------------------------------------------------------------------- /prisma/migrations/20250710020339_add_comment_relation/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateIndex 2 | CREATE INDEX `idx_comment_user` ON `ow_comment`(`user_id`); 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250621134243_update_ua_long/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `ow_analytics_device` MODIFY `user_agent` MEDIUMTEXT NULL; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250731080211_thumbnail_long/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `ow_projects` MODIFY `thumbnail` VARCHAR(37) NULL DEFAULT ''; 3 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "mysql" 4 | -------------------------------------------------------------------------------- /prisma/migrations/20250621064255_update_users_avatar/migration.sql: -------------------------------------------------------------------------------- 1 | -- Update avatar field with images field value 2 | UPDATE ow_users SET avatar = images WHERE avatar != images; -------------------------------------------------------------------------------- /prisma/migrations/20250728091124_developing_state/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `ow_scratch_extensions` MODIFY `status` VARCHAR(32) NOT NULL DEFAULT 'developing'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250621100858_update_add_default_ow_users/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `ow_users` MODIFY `createdAt` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0); 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250729052734_add_scratch_compatible/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `ow_scratch_extensions` ADD COLUMN `scratchCompatible` BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /prisma/migrations/20250622072641_update_user_3/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `ow_users` MODIFY `loginTime` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0), 3 | MODIFY `regTime` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0); 4 | -------------------------------------------------------------------------------- /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(npm install:*)", 5 | "Bash(pnpm add:*)", 6 | "Bash(mkdir:*)", 7 | "WebFetch(domain:github.com)" 8 | ], 9 | "deny": [] 10 | } 11 | } -------------------------------------------------------------------------------- /prisma/migrations/20250621063514_update_ow_users_contacts/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `ow_users_contacts` MODIFY `contact_type` ENUM('email', 'phone', 'qq', 'other', 'oauth_google', 'oauth_github', 'oauth_microsoft', 'oauth_40code', 'oauth_linuxdo') NOT NULL; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250809063846_add_totp/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `ow_users_contacts` MODIFY `contact_type` ENUM('email', 'phone', 'qq', 'other', 'oauth_google', 'oauth_github', 'oauth_microsoft', 'oauth_40code', 'oauth_linuxdo', 'totp', 'passkey') NOT NULL; 3 | -------------------------------------------------------------------------------- /process.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "ZeroCat", 5 | "script": "app.js", 6 | "error_file": "./logs/err.log", 7 | "out_file": "./logs/out.log", 8 | "log_date_format": "YYYY-MM-DD HH:mm Z", 9 | 10 | "autostart": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /prisma/migrations/20250621072946_update/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `ow_users` ADD COLUMN `bio` LONGTEXT NULL, 3 | ADD COLUMN `custom_status` JSON NULL, 4 | ADD COLUMN `featured_projects` INTEGER NULL, 5 | ADD COLUMN `location` VARCHAR(100) NULL, 6 | ADD COLUMN `region` VARCHAR(100) NULL; 7 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import paths from './paths.js'; 2 | import defaultProject from './default_project.js'; 3 | import {configureMiddleware} from './middleware.js'; 4 | import serverConfig from './server.js'; 5 | 6 | export { 7 | paths, 8 | defaultProject, 9 | configureMiddleware, 10 | serverConfig 11 | }; -------------------------------------------------------------------------------- /prisma/migrations/20250806102209_update_scratch_extension_project_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- Update project type to 'scratch-extension' for all projects that have scratch extensions 2 | UPDATE `ow_projects` 3 | SET `type` = 'scratch-extension' 4 | WHERE `id` IN ( 5 | SELECT DISTINCT `projectid` 6 | FROM `ow_scratch_extensions` 7 | ); -------------------------------------------------------------------------------- /src/paths.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import {fileURLToPath} from 'url'; 3 | 4 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 5 | const ROOT_DIR = __dirname; 6 | 7 | export default { 8 | ROOT_DIR, 9 | DATA_DIR: path.resolve(ROOT_DIR, 'data'), 10 | VIEWS_DIR: path.resolve(ROOT_DIR, 'views'), 11 | }; -------------------------------------------------------------------------------- /src/routes/router_projectlist.js: -------------------------------------------------------------------------------- 1 | import {Router} from "express"; 2 | import starsRouter from "./router_stars.js"; 3 | import listsRouter from "./router_lists.js"; 4 | 5 | const router = Router(); 6 | 7 | // Mount the star and list routers 8 | router.use("/stars", starsRouter); 9 | router.use("/lists", listsRouter); 10 | 11 | export default router; 12 | -------------------------------------------------------------------------------- /prisma/migrations/20250731073246_no_teacherid/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `teacherid` on the `ow_projects` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- DropIndex 8 | DROP INDEX `idx_project_teacher` ON `ow_projects`; 9 | 10 | -- AlterTable 11 | ALTER TABLE `ow_projects` DROP COLUMN `teacherid`; 12 | -------------------------------------------------------------------------------- /prisma/migrations/20250607120000_update_user_timestamps/migration.sql: -------------------------------------------------------------------------------- 1 | -- Update default values for timestamp columns in ow_users table 2 | ALTER TABLE `ow_users` 3 | MODIFY COLUMN `birthday` timestamp(0) NULL DEFAULT CURRENT_TIMESTAMP(0), 4 | MODIFY COLUMN `createdAt` timestamp(0) NULL DEFAULT CURRENT_TIMESTAMP(0), 5 | MODIFY COLUMN `updatedAt` timestamp(0) NULL DEFAULT CURRENT_TIMESTAMP(0); -------------------------------------------------------------------------------- /prisma/migrations/20250731073504_add_thumbnail/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `ow_projects` ADD COLUMN `thumbnail` VARCHAR(32) NULL DEFAULT '', 3 | MODIFY `title` VARCHAR(1000) NULL DEFAULT 'ZeroCat新项目', 4 | MODIFY `description` VARCHAR(1000) NULL DEFAULT 'ZeroCat上的项目'; 5 | 6 | -- AlterTable 7 | ALTER TABLE `ow_users` MODIFY `display_name` CHAR(20) NOT NULL DEFAULT 'ZeroCat创作者'; 8 | -------------------------------------------------------------------------------- /src/default_project.js: -------------------------------------------------------------------------------- 1 | const project = { 2 | scratch: "4394aef7a0124c8875e7cd2446991708e70167df2e1760eee17676a55c82eff1", 3 | python: "da7548a7a8cffc35d1ed73b39cf8b095b9be7bff30e74896b2288aebca20d10a", 4 | text: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 5 | none: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 6 | }; 7 | export default project; -------------------------------------------------------------------------------- /prisma/migrations/20250601042102_update_only_show_public_project/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX `idx_projects_comments` ON `ow_comment`; 3 | 4 | -- DropIndex 5 | DROP INDEX `idx_projects_state` ON `ow_projects`; 6 | 7 | -- DropIndex 8 | DROP INDEX `idx_projects_commits_project_date` ON `ow_projects_commits`; 9 | 10 | -- DropIndex 11 | DROP INDEX `idx_projects_stars_project` ON `ow_projects_stars`; 12 | -------------------------------------------------------------------------------- /prisma/migrations/20250804004716_add_notification_multichannel_support/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE `ow_notifications` ADD COLUMN `hidden` BOOLEAN NOT NULL DEFAULT false, 3 | ADD COLUMN `push_channels` JSON NULL, 4 | ADD COLUMN `push_error` BOOLEAN NOT NULL DEFAULT false, 5 | ADD COLUMN `push_results` JSON NULL; 6 | 7 | -- CreateIndex 8 | CREATE INDEX `idx_notification_hidden` ON `ow_notifications`(`hidden`); 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' # 明确指定 Compose 文件版本 2 | 3 | services: 4 | zerocat: 5 | container_name: zerocat 6 | image: zerocat:1.0.0 7 | build: 8 | context: . 9 | dockerfile: Dockerfile 10 | ports: 11 | - "3000:3000" 12 | restart: unless-stopped # 推荐值,更安全 13 | environment: 14 | - NODE_ENV=production 15 | - DATABASE_URL=mysql://root:123456@127.0.0.1:3557/zerocat_develop 16 | -------------------------------------------------------------------------------- /prisma/migrations/20250621063933_update_ow_users_contacts_2/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX `unique_primary_contact` ON `ow_users_contacts`; 3 | 4 | -- AlterTable 5 | ALTER TABLE `ow_users_contacts` ADD COLUMN `metadata` JSON NULL; 6 | 7 | -- CreateIndex 8 | CREATE INDEX `idx_user_contacts` ON `ow_users_contacts`(`user_id`); 9 | 10 | -- CreateIndex 11 | CREATE INDEX `idx_user_contact_type` ON `ow_users_contacts`(`user_id`, `contact_type`); 12 | -------------------------------------------------------------------------------- /docker/postgres/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | postgres: 5 | image: postgres:16 6 | container_name: zerocat-postgres 7 | restart: unless-stopped 8 | ports: 9 | - "5431:5432" 10 | environment: 11 | POSTGRES_USER: root # 使用 root 用户名 12 | POSTGRES_PASSWORD: 123456 # 设置 root 密码 13 | POSTGRES_DB: zerocat # 初始化数据库 14 | volumes: 15 | - pgdata:/var/lib/postgresql/data 16 | volumes: 17 | pgdata: 18 | -------------------------------------------------------------------------------- /prisma/migrations/20250601042631_update_index/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateIndex 2 | CREATE INDEX `idx_projects_comments` ON `ow_comment`(`page_type`, `page_id`, `insertedAt`); 3 | 4 | -- CreateIndex 5 | CREATE INDEX `idx_projects_state` ON `ow_projects`(`state`); 6 | 7 | -- CreateIndex 8 | CREATE INDEX `idx_projects_commits_project_date` ON `ow_projects_commits`(`project_id`, `commit_date`); 9 | 10 | -- CreateIndex 11 | CREATE INDEX `idx_projects_stars_project` ON `ow_projects_stars`(`projectid`); 12 | -------------------------------------------------------------------------------- /prisma/migrations/20250725040353_fx_commit_error/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The primary key for the `ow_projects_commits` table will be changed. If it partially fails, the table could be left without primary key constraint. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE `ow_projects_commits` DROP PRIMARY KEY, 9 | ADD COLUMN `depth` INTEGER NULL, 10 | ADD PRIMARY KEY (`id`); 11 | 12 | -- CreateIndex 13 | CREATE INDEX `idx_parent_commit` ON `ow_projects_commits`(`parent_commit_id`); 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 使用轻量且有社区支持的 Node 官方镜像 2 | FROM node:20.19.5-alpine 3 | 4 | # 设置作者信息 5 | LABEL author="wuyuan" 6 | 7 | # 设置工作目录 8 | WORKDIR /app 9 | 10 | # 只复制 package.json 和 lock 文件先安装依赖 11 | COPY package*.json ./ 12 | 13 | # 使用缓存加速构建;锁版本保证一致性 14 | RUN npm install 15 | 16 | # 复制项目文件 17 | COPY . . 18 | 19 | # 预编译 Prisma 20 | RUN npx prisma generate 21 | 22 | # 设置环境变量(可选) 23 | ENV NODE_ENV=production 24 | 25 | # 容器对外暴露的端口 26 | EXPOSE 3000 27 | 28 | # 使用 exec form,避免 shell 问题 29 | CMD ["npm", "run", "start"] 30 | -------------------------------------------------------------------------------- /prisma/migrations/20250713072616_add_coderun_devices_2/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `auth_token` on the `ow_coderun_devices` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- DropIndex 8 | DROP INDEX `ow_coderun_devices_auth_token_idx` ON `ow_coderun_devices`; 9 | 10 | -- DropIndex 11 | DROP INDEX `ow_coderun_devices_auth_token_key` ON `ow_coderun_devices`; 12 | 13 | -- AlterTable 14 | ALTER TABLE `ow_coderun_devices` DROP COLUMN `auth_token`; 15 | -------------------------------------------------------------------------------- /prisma/migrations/20250607110115_update_relation_1/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE `ow_auth_tokens` DROP FOREIGN KEY `fk_tokens_users`; 3 | 4 | -- CreateIndex 5 | CREATE INDEX `idx_project_author` ON `ow_projects`(`authorid`); 6 | 7 | -- CreateIndex 8 | CREATE INDEX `idx_project_teacher` ON `ow_projects`(`teacherid`); 9 | 10 | -- CreateIndex 11 | CREATE INDEX `idx_branch_creator` ON `ow_projects_branch`(`creator`); 12 | 13 | -- CreateIndex 14 | CREATE INDEX `idx_projects_stars_user` ON `ow_projects_stars`(`userid`); 15 | -------------------------------------------------------------------------------- /docker/redis/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | redis: 5 | image: redis:7.4.3 6 | container_name: zerocat_redis 7 | restart: always 8 | ports: 9 | - "6379:6379" 10 | command: ["redis-server", "/usr/local/etc/redis/redis.conf"] 11 | volumes: 12 | # Windows 相对路径兼容写法(推荐) 13 | - ./redis.conf:/usr/local/etc/redis/redis.conf:ro 14 | - ./data:/data 15 | networks: 16 | - internal 17 | environment: 18 | - TZ=Asia/Shanghai 19 | 20 | networks: 21 | internal: 22 | driver: bridge 23 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | npm-debug.log 4 | yarn-debug.log 5 | yarn-error.log 6 | .pnpm-debug.log 7 | 8 | # Git 9 | .git 10 | .gitignore 11 | 12 | # IDE 13 | .idea 14 | .vscode 15 | *.swp 16 | *.swo 17 | 18 | # OS 19 | .DS_Store 20 | Thumbs.db 21 | 22 | # Build 23 | dist 24 | build 25 | .next 26 | out 27 | 28 | # Environment 29 | .env 30 | .env.local 31 | .env.*.local 32 | 33 | # Logs 34 | logs 35 | *.log 36 | 37 | # Testing 38 | coverage 39 | .nyc_output 40 | 41 | # Misc 42 | .dockerignore 43 | Dockerfile 44 | docker-compose.yml 45 | README.md -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | 13 | -------------------------------------------------------------------------------- /prisma/migrations/20250629031948_add_user_kv_store/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `ow_user_kv_store` ( 3 | `user_id` INTEGER UNSIGNED NOT NULL, 4 | `key` VARCHAR(255) NOT NULL, 5 | `value` JSON NOT NULL, 6 | `creator_ip` VARCHAR(100) NULL DEFAULT '', 7 | `created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0), 8 | `updated_at` TIMESTAMP(0) NOT NULL, 9 | 10 | INDEX `ow_user_kv_store_user_id_idx`(`user_id`), 11 | INDEX `ow_user_kv_store_key_idx`(`key`), 12 | PRIMARY KEY (`user_id`, `key`) 13 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 14 | -------------------------------------------------------------------------------- /src/services/auth/permissionManager.js: -------------------------------------------------------------------------------- 1 | import {prisma} from "../global.js"; 2 | 3 | export async function hasProjectPermission(projectId, userId, permission) { 4 | const project = await prisma.ow_projects.findFirst({ 5 | where: {id: Number(projectId)}, 6 | }); 7 | 8 | if (!project) { 9 | return false; 10 | } 11 | 12 | if (permission === "read") { 13 | if (project.state === "public" || project.authorid === userId) { 14 | return true; 15 | } 16 | } else if (permission === "write") { 17 | if (project.authorid === userId) { 18 | return true; 19 | } 20 | } 21 | 22 | return false; 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /docker/meilisearch/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | meilisync-admin: 4 | image: ghcr.io/long2ice/meilisync-admin/meilisync-admin 5 | container_name: meilisync-admin 6 | restart: always 7 | networks: 8 | - meilisync-net 9 | ports: 10 | - "7701:8000" 11 | environment: 12 | - DB_URL=mysql://root:123456@host.docker.internal:3557/meilisync_admin # 可选:如果你后续不使用 MySQL 可移除 13 | - REDIS_URL=redis://redis:6379/0 14 | - SECRET_KEY=secret 15 | - SENTRY_DSN= 16 | 17 | redis: 18 | image: redis:7 19 | container_name: redis 20 | restart: always 21 | networks: 22 | - meilisync-net 23 | 24 | networks: 25 | meilisync-net: 26 | driver: bridge 27 | -------------------------------------------------------------------------------- /src/middleware/ipMiddleware.js: -------------------------------------------------------------------------------- 1 | import ipUtils from '../services/utils/ipUtils.js'; 2 | 3 | /** 4 | * IP地址中间件 5 | * 为请求对象添加IP地址信息 6 | */ 7 | export const ipMiddleware = (req, res, next) => { 8 | // 获取并存储IP信息 9 | const clientIP = ipUtils.getClientIP(req); 10 | const realIP = ipUtils.getRealIP(req); 11 | const isPrivateIP = ipUtils.isPrivateIP(realIP); 12 | 13 | // 在请求对象上添加IP信息 14 | req.ipInfo = { 15 | clientIP, // 优先获取的公网IP 16 | realIP, // 真实连接IP 17 | isPrivateIP, // 是否是私有IP 18 | proxyIPs: req.headers['x-forwarded-for'] ? 19 | req.headers['x-forwarded-for'].split(',').map(ip => ip.trim()) : 20 | [] // 代理IP链 21 | }; 22 | 23 | next(); 24 | }; 25 | 26 | export default ipMiddleware; -------------------------------------------------------------------------------- /src/controllers/auth/index.js: -------------------------------------------------------------------------------- 1 | // 导入各个控制器文件 2 | import * as loginController from './loginController.js'; 3 | import * as registerController from './registerController.js'; 4 | import * as emailController from './emailController.js'; 5 | import * as tokenController from './tokenController.js'; 6 | // Legacy TOTP controller removed; new 2FA/passkey controllers added below 7 | import * as twoFactorController from './twoFactorController.js'; 8 | import * as passkeyController from './passkeyController.js'; 9 | import * as oauthController from './oauthController.js'; 10 | 11 | // 集中导出所有控制器 12 | export { 13 | loginController, 14 | registerController, 15 | emailController, 16 | tokenController, 17 | twoFactorController, 18 | passkeyController, 19 | oauthController 20 | }; -------------------------------------------------------------------------------- /prisma/migrations/20250629044145_add_user_kv_store_1/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `ow_user_kv_store` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropTable 8 | DROP TABLE `ow_user_kv_store`; 9 | 10 | -- CreateTable 11 | CREATE TABLE `ow_cache_kv` ( 12 | `user_id` INTEGER UNSIGNED NOT NULL, 13 | `key` VARCHAR(255) NOT NULL, 14 | `value` JSON NOT NULL, 15 | `creator_ip` VARCHAR(100) NULL DEFAULT '', 16 | `created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0), 17 | `updated_at` TIMESTAMP(0) NOT NULL, 18 | 19 | INDEX `ow_cache_kv_user_id_idx`(`user_id`), 20 | INDEX `ow_cache_kv_key_idx`(`key`), 21 | PRIMARY KEY (`user_id`, `key`) 22 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 23 | -------------------------------------------------------------------------------- /prisma/migrations/20250728075426_add_extensions/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `ow_scratch_extensions` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `projectid` INTEGER NOT NULL, 5 | `branch` VARCHAR(128) NOT NULL DEFAULT '', 6 | `commit` VARCHAR(64) NOT NULL DEFAULT 'latest', 7 | `image` VARCHAR(255) NOT NULL, 8 | `samples` INTEGER NULL, 9 | `docs` VARCHAR(1024) NULL, 10 | `status` VARCHAR(32) NOT NULL DEFAULT 'pending', 11 | `created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0), 12 | `updated_at` TIMESTAMP(0) NOT NULL, 13 | 14 | INDEX `idx_extension_project`(`projectid`), 15 | INDEX `idx_extension_status`(`status`), 16 | INDEX `idx_extension_samples`(`samples`), 17 | PRIMARY KEY (`id`) 18 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 19 | -------------------------------------------------------------------------------- /prisma/migrations/20250608064255_update_condig_1/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `is_public` on the `ow_config` table. All the data in the column will be lost. 5 | - You are about to drop the column `user_id` on the `ow_config` table. All the data in the column will be lost. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE `ow_config` DROP COLUMN `is_public`, 10 | DROP COLUMN `user_id`, 11 | ADD COLUMN `metadata` JSON NULL, 12 | ADD COLUMN `type` ENUM('STRING', 'NUMBER', 'BOOLEAN', 'ARRAY', 'ENUM') NOT NULL DEFAULT 'STRING'; 13 | 14 | -- AlterTable 15 | ALTER TABLE `ow_users` ALTER COLUMN `regTime` DROP DEFAULT, 16 | MODIFY `birthday` TIMESTAMP(0) NULL DEFAULT '2000-03-31 08:00:00', 17 | ALTER COLUMN `createdAt` DROP DEFAULT, 18 | ALTER COLUMN `updatedAt` DROP DEFAULT; 19 | -------------------------------------------------------------------------------- /prisma/migrations/20250615073118_merge_analytics_tables_2/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `visitor_id` on the `AnalyticsDevice` table. All the data in the column will be lost. 5 | - A unique constraint covering the columns `[fingerprint,user_id]` on the table `AnalyticsDevice` will be added. If there are existing duplicate values, this will fail. 6 | 7 | */ 8 | -- DropIndex 9 | DROP INDEX `AnalyticsDevice_fingerprint_key` ON `AnalyticsDevice`; 10 | 11 | -- DropIndex 12 | DROP INDEX `AnalyticsDevice_visitor_id_idx` ON `AnalyticsDevice`; 13 | 14 | -- AlterTable 15 | ALTER TABLE `AnalyticsDevice` DROP COLUMN `visitor_id`, 16 | ADD COLUMN `user_id` INTEGER NULL; 17 | 18 | -- CreateIndex 19 | CREATE INDEX `AnalyticsDevice_user_id_idx` ON `AnalyticsDevice`(`user_id`); 20 | 21 | -- CreateIndex 22 | CREATE UNIQUE INDEX `AnalyticsDevice_fingerprint_user_id_key` ON `AnalyticsDevice`(`fingerprint`, `user_id`); 23 | -------------------------------------------------------------------------------- /prisma/migrations/20250713080114_add_coderun_devices_3/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `coderun_info` on the `ow_coderun_devices` table. All the data in the column will be lost. 5 | - You are about to drop the column `docker_info` on the `ow_coderun_devices` table. All the data in the column will be lost. 6 | - You are about to drop the column `last_config_fetch` on the `ow_coderun_devices` table. All the data in the column will be lost. 7 | - You are about to drop the column `last_report` on the `ow_coderun_devices` table. All the data in the column will be lost. 8 | - You are about to drop the column `system_info` on the `ow_coderun_devices` table. All the data in the column will be lost. 9 | 10 | */ 11 | -- AlterTable 12 | ALTER TABLE `ow_coderun_devices` DROP COLUMN `coderun_info`, 13 | DROP COLUMN `docker_info`, 14 | DROP COLUMN `last_config_fetch`, 15 | DROP COLUMN `last_report`, 16 | DROP COLUMN `system_info`; 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/反馈bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 反馈bug 3 | about: 创建报告以帮助我们处理bug 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /prisma/migrations/20250729104210_add_account_token/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `ow_account_tokens` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `user_id` INTEGER NOT NULL, 5 | `name` VARCHAR(255) NOT NULL, 6 | `token` VARCHAR(255) NOT NULL, 7 | `expires_at` DATETIME(3) NULL, 8 | `is_revoked` BOOLEAN NOT NULL DEFAULT false, 9 | `revoked_at` DATETIME(3) NULL, 10 | `last_used_at` DATETIME(3) NULL, 11 | `last_used_ip` VARCHAR(100) NULL, 12 | `created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0), 13 | `updated_at` TIMESTAMP(0) NOT NULL, 14 | 15 | UNIQUE INDEX `ow_account_tokens_token_key`(`token`), 16 | INDEX `ow_account_tokens_user_id_idx`(`user_id`), 17 | INDEX `ow_account_tokens_token_idx`(`token`), 18 | INDEX `ow_account_tokens_is_revoked_idx`(`is_revoked`), 19 | INDEX `ow_account_tokens_expires_at_idx`(`expires_at`), 20 | PRIMARY KEY (`id`) 21 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 22 | -------------------------------------------------------------------------------- /prisma/migrations/20250531113639_fix_timezone_and_add_search_view/migration.sql: -------------------------------------------------------------------------------- 1 | 2 | -- CreateView 3 | CREATE OR REPLACE VIEW `ow_projects_search` AS 4 | SELECT 5 | p.*, 6 | ( 7 | SELECT pf.source 8 | FROM ow_projects_file pf 9 | INNER JOIN ow_projects_commits pc ON pc.commit_file = pf.sha256 10 | WHERE pc.project_id = p.id 11 | ORDER BY pc.commit_date DESC 12 | LIMIT 1 13 | ) as latest_source, 14 | ( 15 | SELECT GROUP_CONCAT(pt.name SEPARATOR ', ') 16 | FROM ow_projects_tags pt 17 | WHERE pt.projectid = p.id 18 | GROUP BY pt.projectid 19 | ) as tag_list, 20 | ( 21 | SELECT COUNT(*) 22 | FROM ow_comment c 23 | WHERE c.page_type = 'project' 24 | AND c.page_id = p.id 25 | ) as comment_count, 26 | ( 27 | SELECT c.text 28 | FROM ow_comment c 29 | WHERE c.page_type = 'project' 30 | AND c.page_id = p.id 31 | ORDER BY c.insertedAt DESC 32 | LIMIT 1 33 | ) as latest_comment 34 | FROM ow_projects p; -------------------------------------------------------------------------------- /prisma/migrations/20250628032325_no_bigint/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The primary key for the `ow_events` table will be changed. If it partially fails, the table could be left without primary key constraint. 5 | - You are about to alter the column `id` on the `ow_events` table. The data in that column could be lost. The data in that column will be cast from `UnsignedBigInt` to `UnsignedInt`. 6 | - The primary key for the `ow_notifications` table will be changed. If it partially fails, the table could be left without primary key constraint. 7 | - You are about to alter the column `id` on the `ow_notifications` table. The data in that column could be lost. The data in that column will be cast from `UnsignedBigInt` to `UnsignedInt`. 8 | 9 | */ 10 | -- AlterTable 11 | ALTER TABLE `ow_events` DROP PRIMARY KEY, 12 | MODIFY `id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT, 13 | ADD PRIMARY KEY (`id`); 14 | 15 | -- AlterTable 16 | ALTER TABLE `ow_notifications` DROP PRIMARY KEY, 17 | MODIFY `id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT, 18 | ADD PRIMARY KEY (`id`); 19 | -------------------------------------------------------------------------------- /prisma/migrations/20250713054054_add_coderun_devices/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `ow_coderun_devices` ( 3 | `id` VARCHAR(191) NOT NULL, 4 | `device_name` VARCHAR(255) NOT NULL, 5 | `auth_token` VARCHAR(255) NOT NULL, 6 | `runner_token` VARCHAR(255) NOT NULL, 7 | `request_url` VARCHAR(1024) NULL, 8 | `status` VARCHAR(32) NOT NULL DEFAULT 'active', 9 | `device_config` JSON NULL, 10 | `last_report` DATETIME(0) NULL, 11 | `last_config_fetch` DATETIME(0) NULL, 12 | `created_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0), 13 | `updated_at` DATETIME(0) NOT NULL, 14 | `docker_info` JSON NULL, 15 | `system_info` JSON NULL, 16 | `coderun_info` JSON NULL, 17 | 18 | UNIQUE INDEX `ow_coderun_devices_auth_token_key`(`auth_token`), 19 | UNIQUE INDEX `ow_coderun_devices_runner_token_key`(`runner_token`), 20 | INDEX `ow_coderun_devices_status_idx`(`status`), 21 | INDEX `ow_coderun_devices_auth_token_idx`(`auth_token`), 22 | INDEX `ow_coderun_devices_runner_token_idx`(`runner_token`), 23 | PRIMARY KEY (`id`) 24 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 25 | -------------------------------------------------------------------------------- /src/services/memoryCache.js: -------------------------------------------------------------------------------- 1 | class MemoryCache { 2 | constructor() { 3 | this.cache = new Map(); 4 | } 5 | 6 | get(key) { 7 | const item = this.cache.get(key); 8 | if (item) { 9 | if (item.expiry && item.expiry < Date.now()) { 10 | this.cache.delete(key); 11 | return null; 12 | } 13 | return item.value; 14 | } 15 | return null; 16 | } 17 | 18 | set(key, value, ttlSeconds) { 19 | const expiry = ttlSeconds ? Date.now() + (ttlSeconds * 1000) : null; 20 | this.cache.set(key, {value, expiry}); 21 | } 22 | 23 | delete(key) { 24 | this.cache.delete(key); 25 | } 26 | 27 | // 清理过期的缓存项 28 | cleanup() { 29 | const now = Date.now(); 30 | for (const [key, item] of this.cache.entries()) { 31 | if (item.expiry && item.expiry < now) { 32 | this.cache.delete(key); 33 | } 34 | } 35 | } 36 | } 37 | 38 | // 创建单例实例 39 | const memoryCache = new MemoryCache(); 40 | 41 | // 每小时清理一次过期的缓存项 42 | setInterval(() => { 43 | memoryCache.cleanup(); 44 | }, 3600000); 45 | 46 | export default memoryCache; -------------------------------------------------------------------------------- /src/middleware/captcha.js: -------------------------------------------------------------------------------- 1 | import {error as loggerError} from "../logger.js"; 2 | import {get} from "../zcconfig.js"; 3 | import axios from "axios"; 4 | import {URL} from "url"; 5 | 6 | const captchaMiddleware = async (req, res, next) => { 7 | const recaptcha = req.body.recaptcha || req.query.recaptcha; 8 | 9 | if (!recaptcha) { 10 | return res.status(400).send({message: "请完成验证码"}); 11 | } 12 | 13 | try { 14 | const {url, secret} = await get("captcha"); 15 | 16 | const response = await axios.post( 17 | new URL("/siteverify", url), 18 | null, 19 | { 20 | params: { 21 | secret, 22 | response: recaptcha, 23 | }, 24 | } 25 | ); 26 | 27 | if (response.data.success) { 28 | next(); 29 | } else { 30 | res.status(400).send({message: "验证码无效", response: response.data}); 31 | } 32 | } catch (error) { 33 | loggerError("Error verifying recaptcha:", error); 34 | res.status(500).send({message: "验证码验证失败", error: error.message}); 35 | } 36 | }; 37 | 38 | export default captchaMiddleware; 39 | 40 | -------------------------------------------------------------------------------- /prisma/migrations/20250621071548_update_users/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `2fa` on the `ow_users` table. All the data in the column will be lost. 5 | - You are about to drop the column `facebook` on the `ow_users` table. All the data in the column will be lost. 6 | - You are about to drop the column `github` on the `ow_users` table. All the data in the column will be lost. 7 | - You are about to drop the column `google` on the `ow_users` table. All the data in the column will be lost. 8 | - You are about to drop the column `qq` on the `ow_users` table. All the data in the column will be lost. 9 | - You are about to drop the column `twitter` on the `ow_users` table. All the data in the column will be lost. 10 | - You are about to drop the column `weibo` on the `ow_users` table. All the data in the column will be lost. 11 | 12 | */ 13 | -- AlterTable 14 | ALTER TABLE `ow_users` DROP COLUMN `2fa`, 15 | DROP COLUMN `facebook`, 16 | DROP COLUMN `github`, 17 | DROP COLUMN `google`, 18 | DROP COLUMN `qq`, 19 | DROP COLUMN `twitter`, 20 | DROP COLUMN `weibo`, 21 | MODIFY `avatar` VARCHAR(255) NULL DEFAULT 'fcd939e653195bb6d057e8c2519f5cc7'; 22 | -------------------------------------------------------------------------------- /prisma/migrations/20250628031406_update/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to alter the column `actor_id` on the `ow_events` table. The data in that column could be lost. The data in that column will be cast from `UnsignedBigInt` to `UnsignedInt`. 5 | - You are about to alter the column `target_id` on the `ow_events` table. The data in that column could be lost. The data in that column will be cast from `UnsignedBigInt` to `UnsignedInt`. 6 | - The primary key for the `ow_notifications` table will be changed. If it partially fails, the table could be left without primary key constraint. 7 | - You are about to alter the column `id` on the `ow_notifications` table. The data in that column could be lost. The data in that column will be cast from `UnsignedInt` to `UnsignedBigInt`. 8 | 9 | */ 10 | -- AlterTable 11 | ALTER TABLE `ow_events` MODIFY `actor_id` INTEGER UNSIGNED NOT NULL, 12 | MODIFY `target_id` INTEGER UNSIGNED NOT NULL; 13 | 14 | -- AlterTable 15 | ALTER TABLE `ow_notifications` DROP PRIMARY KEY, 16 | MODIFY `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, 17 | ADD PRIMARY KEY (`id`); 18 | 19 | -- CreateIndex 20 | CREATE INDEX `idx_notification_actor` ON `ow_notifications`(`actor_id`); 21 | -------------------------------------------------------------------------------- /prisma/migrations/20250601043102_add_author_fields_to_search_view/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateView 2 | CREATE OR REPLACE VIEW `ow_projects_search` AS 3 | SELECT 4 | p.id, 5 | p.name, 6 | p.title, 7 | p.description, 8 | p.authorid, 9 | p.state, 10 | p.type, 11 | p.license, 12 | p.view_count, 13 | p.like_count, 14 | p.favo_count, 15 | p.star_count, 16 | p.time, 17 | p.tags, 18 | u.display_name as author_display_name, 19 | u.username as author_username, 20 | u.motto as author_motto, 21 | u.images as author_images, 22 | u.type as author_type, 23 | ( 24 | SELECT pf.source 25 | FROM ow_projects_file pf 26 | INNER JOIN ow_projects_commits pc ON pc.commit_file = pf.sha256 27 | WHERE pc.project_id = p.id 28 | AND p.state = 'public' 29 | ORDER BY pc.commit_date DESC 30 | LIMIT 1 31 | ) as latest_source, 32 | ( 33 | SELECT COUNT(*) 34 | FROM ow_comment c 35 | WHERE c.page_type = 'project' 36 | AND c.page_id = p.id 37 | ) as comment_count, 38 | ( 39 | SELECT c.text 40 | FROM ow_comment c 41 | WHERE c.page_type = 'project' 42 | AND c.page_id = p.id 43 | ORDER BY c.insertedAt DESC 44 | LIMIT 1 45 | ) as latest_comment 46 | FROM ow_projects p 47 | LEFT JOIN ow_users u ON p.authorid = u.id 48 | WHERE p.state = 'public'; -------------------------------------------------------------------------------- /prisma/migrations/20250601043103_add_author_fields_to_search_view copy2/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateView 2 | CREATE OR REPLACE VIEW `ow_projects_search` AS 3 | SELECT 4 | p.id, 5 | p.name, 6 | p.title, 7 | p.description, 8 | p.authorid, 9 | p.state, 10 | p.type, 11 | p.license, 12 | p.star_count, 13 | p.time, 14 | ( 15 | SELECT GROUP_CONCAT(pt.name SEPARATOR ', ') 16 | FROM ow_projects_tags pt 17 | WHERE pt.projectid = p.id 18 | GROUP BY pt.projectid 19 | ) as tags, 20 | u.display_name as author_display_name, 21 | u.username as author_username, 22 | u.motto as author_motto, 23 | u.images as author_images, 24 | u.type as author_type, 25 | ( 26 | SELECT pf.source 27 | FROM ow_projects_file pf 28 | INNER JOIN ow_projects_commits pc ON pc.commit_file = pf.sha256 29 | WHERE pc.project_id = p.id 30 | AND p.state = 'public' 31 | ORDER BY pc.commit_date DESC 32 | LIMIT 1 33 | ) as latest_source, 34 | ( 35 | SELECT COUNT(*) 36 | FROM ow_comment c 37 | WHERE c.page_type = 'project' 38 | AND c.page_id = p.id 39 | ) as comment_count, 40 | ( 41 | SELECT c.text 42 | FROM ow_comment c 43 | WHERE c.page_type = 'project' 44 | AND c.page_id = p.id 45 | ORDER BY c.insertedAt DESC 46 | LIMIT 1 47 | ) as latest_comment 48 | FROM ow_projects p 49 | LEFT JOIN ow_users u ON p.authorid = u.id 50 | WHERE p.state = 'public'; -------------------------------------------------------------------------------- /prisma/migrations/20250622042215_update_oauth2/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `ow_oauth_applications` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropTable 8 | DROP TABLE `ow_oauth_applications`; 9 | 10 | -- CreateTable 11 | CREATE TABLE `oauth_applications` ( 12 | `id` INTEGER NOT NULL AUTO_INCREMENT, 13 | `owner_id` INTEGER NOT NULL, 14 | `name` VARCHAR(191) NOT NULL, 15 | `description` VARCHAR(191) NULL, 16 | `homepage_url` VARCHAR(191) NULL, 17 | `client_id` VARCHAR(191) NOT NULL, 18 | `client_secret` VARCHAR(191) NOT NULL, 19 | `redirect_uris` JSON NOT NULL, 20 | `type` VARCHAR(191) NOT NULL DEFAULT 'oauth', 21 | `client_type` VARCHAR(191) NOT NULL DEFAULT 'confidential', 22 | `scopes` JSON NOT NULL, 23 | `webhook_url` VARCHAR(191) NULL, 24 | `logo_url` VARCHAR(191) NULL, 25 | `terms_url` VARCHAR(191) NULL, 26 | `privacy_url` VARCHAR(191) NULL, 27 | `status` VARCHAR(191) NOT NULL DEFAULT 'active', 28 | `is_verified` BOOLEAN NOT NULL DEFAULT false, 29 | `is_public` BOOLEAN NOT NULL DEFAULT false, 30 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 31 | `updated_at` DATETIME(3) NOT NULL, 32 | 33 | UNIQUE INDEX `oauth_applications_client_id_key`(`client_id`), 34 | INDEX `oauth_applications_owner_id_idx`(`owner_id`), 35 | INDEX `oauth_applications_client_id_idx`(`client_id`), 36 | PRIMARY KEY (`id`) 37 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 38 | -------------------------------------------------------------------------------- /prisma/migrations/20250802081854_update_notifications/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `related_id` on the `ow_notifications` table. All the data in the column will be lost. 5 | - You are about to drop the column `related_type` on the `ow_notifications` table. All the data in the column will be lost. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE `ow_notifications` DROP COLUMN `related_id`, 10 | DROP COLUMN `related_type`, 11 | ADD COLUMN `content` TEXT NULL, 12 | ADD COLUMN `link` VARCHAR(255) NULL, 13 | ADD COLUMN `metadata` JSON NULL, 14 | ADD COLUMN `title` VARCHAR(100) NULL; 15 | 16 | -- CreateTable 17 | CREATE TABLE `ow_push_subscriptions` ( 18 | `id` INTEGER NOT NULL AUTO_INCREMENT, 19 | `user_id` INTEGER NOT NULL, 20 | `endpoint` VARCHAR(500) NOT NULL, 21 | `p256dh_key` VARCHAR(255) NOT NULL, 22 | `auth_key` VARCHAR(255) NOT NULL, 23 | `user_agent` TEXT NULL, 24 | `device_info` JSON NULL, 25 | `is_active` BOOLEAN NOT NULL DEFAULT true, 26 | `last_used_at` TIMESTAMP(0) NULL, 27 | `created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0), 28 | `updated_at` TIMESTAMP(0) NOT NULL, 29 | 30 | INDEX `ow_push_subscriptions_user_id_idx`(`user_id`), 31 | INDEX `ow_push_subscriptions_is_active_idx`(`is_active`), 32 | INDEX `ow_push_subscriptions_last_used_at_idx`(`last_used_at`), 33 | UNIQUE INDEX `ow_push_subscriptions_user_id_endpoint_key`(`user_id`, `endpoint`), 34 | PRIMARY KEY (`id`) 35 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 36 | -------------------------------------------------------------------------------- /docker/meilisearch/config.yml: -------------------------------------------------------------------------------- 1 | debug: true 2 | source: 3 | type: mysql 4 | host: host.docker.internal 5 | port: 3557 6 | user: root 7 | password: "123456" 8 | database: zerocat_develop 9 | server_id: 1 10 | 11 | meilisearch: 12 | api_url: http://host.docker.internal:7700 13 | api_key: BXi0YPZzzVanUgZDp9LjdQk59CKaQhviAfiYFdpCTl0 14 | insert_size: 1000 15 | insert_interval: 10 16 | 17 | progress: 18 | type: file 19 | path: progress.json 20 | 21 | sync: 22 | - table: ow_projects_search 23 | index: projects 24 | full: true 25 | fields: 26 | id: 27 | name: 28 | title: 29 | description: 30 | type: 31 | license: 32 | authorid: 33 | state: 34 | view_count: 35 | like_count: 36 | favo_count: 37 | star_count: 38 | time: 39 | tags: 40 | tag_list: 41 | latest_source: 42 | comment_count: 43 | recent_comments_full: 44 | star_users_full: 45 | star_users_names: 46 | author_info: 47 | recent_commits: 48 | commit_count: 49 | fork_details: 50 | included_in_lists: 51 | searchable_attributes: 52 | - name 53 | - title 54 | - description 55 | - tags 56 | - tag_list 57 | - latest_source 58 | - recent_comments_full 59 | - star_users_names 60 | - author_info 61 | filterable_attributes: 62 | - type 63 | - license 64 | - state 65 | - authorid 66 | - view_count 67 | - like_count 68 | - star_count 69 | - comment_count 70 | - commit_count 71 | sortable_attributes: 72 | - star_count 73 | - comment_count 74 | - time 75 | -------------------------------------------------------------------------------- /scripts/commit_depth.js: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | async function calculateDepth(commitId, cache = new Map()) { 6 | // 如果已经计算过这个提交的深度,直接返回缓存的结果 7 | if (cache.has(commitId)) { 8 | return cache.get(commitId); 9 | } 10 | 11 | const commit = await prisma.ow_projects_commits.findUnique({ 12 | where: { id: commitId }, 13 | select: { parent_commit_id: true } 14 | }); 15 | 16 | // 如果是根提交(没有父提交),深度为0 17 | if (!commit || !commit.parent_commit_id) { 18 | cache.set(commitId, 0); 19 | return 0; 20 | } 21 | 22 | // 递归计算父提交的深度,当前提交的深度为父提交深度+1 23 | const parentDepth = await calculateDepth(commit.parent_commit_id, cache); 24 | const depth = parentDepth + 1; 25 | cache.set(commitId, depth); 26 | return depth; 27 | } 28 | 29 | async function main() { 30 | console.log('开始更新提交深度...'); 31 | 32 | // 获取所有提交 33 | const commits = await prisma.ow_projects_commits.findMany({ 34 | select: { id: true } 35 | }); 36 | 37 | console.log(`找到 ${commits.length} 个提交需要更新`); 38 | 39 | // 用于缓存已计算的深度,避免重复计算 40 | const depthCache = new Map(); 41 | 42 | // 批量计算每个提交的深度 43 | for (const commit of commits) { 44 | const depth = await calculateDepth(commit.id, depthCache); 45 | 46 | // 更新提交的深度 47 | await prisma.ow_projects_commits.update({ 48 | where: { id: commit.id }, 49 | data: { depth } 50 | }); 51 | } 52 | 53 | console.log('提交深度更新完成'); 54 | } 55 | 56 | main() 57 | .catch((e) => { 58 | console.error('迁移出错:', e); 59 | process.exit(1); 60 | }) 61 | .finally(async () => { 62 | await prisma.$disconnect(); 63 | }); -------------------------------------------------------------------------------- /src/routes/router_auth.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { parseToken, needLogin } from '../middleware/auth.js'; 3 | import { 4 | sendCode, 5 | auth, 6 | getAuthMethods 7 | } from '../controllers/auth/unifiedAuthController.js'; 8 | 9 | const router = Router(); 10 | 11 | // 应用通用中间件 12 | router.use(parseToken); 13 | 14 | /** 15 | * GET /auth/methods 16 | * 获取可用的认证方法 17 | * 查询参数: purpose (login, sudo, reset_password, change_email, delete_account) 18 | */ 19 | router.get('/methods', getAuthMethods); 20 | 21 | /** 22 | * POST /auth/send-code 23 | * 统一发送验证码接口 24 | * 支持所有认证场景:登录、sudo、重置密码、更改邮箱、删除账户等 25 | * 26 | * 请求体: 27 | * - email: 邮箱地址(可选,某些场景下会自动获取用户邮箱) 28 | * - purpose: 认证目的 (login, sudo, reset_password, change_email, delete_account) 29 | * - userId: 用户ID(某些场景下可选,会从登录状态获取) 30 | */ 31 | router.post('/send-code', (req, res, next) => { 32 | // 根据purpose决定是否需要登录 33 | const { purpose } = req.body; 34 | if (['sudo', 'change_email', 'delete_account'].includes(purpose)) { 35 | return needLogin(req, res, next); 36 | } 37 | next(); 38 | }, sendCode); 39 | 40 | /** 41 | * POST /auth/authenticate 42 | * 统一认证接口 43 | * 支持多种认证方式和认证目的 44 | * 45 | * 请求体: 46 | * - method: 认证方式 (password, email, totp, passkey) 47 | * - purpose: 认证目的 (login, sudo, reset_password, change_email, delete_account) 48 | * - identifier: 用户标识符(用户名或邮箱,login时使用) 49 | * - userId: 用户ID(非login时使用,某些情况下会从登录状态获取) 50 | * - password: 密码(password方式) 51 | * - code_id: 验证码ID(email方式) 52 | * - code: 验证码(email方式) 53 | */ 54 | router.post('/authenticate', (req, res, next) => { 55 | // 根据purpose决定是否需要登录 56 | const { purpose } = req.body; 57 | if (['sudo', 'change_email', 'delete_account'].includes(purpose)) { 58 | return needLogin(req, res, next); 59 | } 60 | next(); 61 | }, auth); 62 | 63 | export default router; -------------------------------------------------------------------------------- /src/views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <%= global.config['site.name'] %> 10 | 11 | 12 | 13 | 18 | 19 | 20 | 21 | 22 | 23 | <% const config = global.publicconfig || {}; %> 24 | <% Object.entries(config).forEach(([key, value])=> { %> 25 | 27 | <% }); %> 28 | 29 | 打开网站 30 | 了解更多 31 | 32 | <% if (global.publicconfig['feedback.qq.group']) { %> 33 | href="<%= global.config['feedback.qq.link'] %>" 35 | <% } %> 36 | active 37 | rounded><%= global.config['feedback.qq.description'] %> 38 | <% } %> 39 | 40 | 41 | 42 | 43 | 44 | 45 | 48 | 49 | -------------------------------------------------------------------------------- /prisma/migrations/20250615053401_add_analytics_tables/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `AnalyticsEvent` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `website_id` INTEGER NOT NULL, 5 | `visitor_id` VARCHAR(191) NOT NULL, 6 | `user_id` INTEGER NULL, 7 | `url_path` VARCHAR(191) NOT NULL, 8 | `url_query` VARCHAR(191) NULL, 9 | `referrer_path` VARCHAR(191) NULL, 10 | `referrer_query` VARCHAR(191) NULL, 11 | `referrer_domain` VARCHAR(191) NULL, 12 | `page_title` VARCHAR(191) NULL, 13 | `target_type` VARCHAR(191) NOT NULL, 14 | `target_id` INTEGER NOT NULL, 15 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 16 | 17 | INDEX `AnalyticsEvent_website_id_idx`(`website_id`), 18 | INDEX `AnalyticsEvent_visitor_id_idx`(`visitor_id`), 19 | INDEX `AnalyticsEvent_user_id_idx`(`user_id`), 20 | INDEX `AnalyticsEvent_created_at_idx`(`created_at`), 21 | PRIMARY KEY (`id`) 22 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 23 | 24 | -- CreateTable 25 | CREATE TABLE `AnalyticsDevice` ( 26 | `id` INTEGER NOT NULL AUTO_INCREMENT, 27 | `visitor_id` VARCHAR(191) NOT NULL, 28 | `website_id` INTEGER NOT NULL, 29 | `hostname` VARCHAR(191) NULL, 30 | `browser` VARCHAR(191) NULL, 31 | `os` VARCHAR(191) NULL, 32 | `device` VARCHAR(191) NULL, 33 | `screen` VARCHAR(191) NULL, 34 | `language` VARCHAR(191) NULL, 35 | `country` VARCHAR(191) NULL, 36 | `subdivision1` VARCHAR(191) NULL, 37 | `subdivision2` VARCHAR(191) NULL, 38 | `city` VARCHAR(191) NULL, 39 | `ip_address` VARCHAR(191) NULL, 40 | `user_agent` VARCHAR(191) NULL, 41 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 42 | 43 | INDEX `AnalyticsDevice_visitor_id_idx`(`visitor_id`), 44 | INDEX `AnalyticsDevice_website_id_idx`(`website_id`), 45 | INDEX `AnalyticsDevice_created_at_idx`(`created_at`), 46 | PRIMARY KEY (`id`) 47 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zerocat", 3 | "version": "1.0.16", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node server.js", 9 | "prisma": "prisma generate", 10 | "prisma:pull": "prisma db pull && prisma generate", 11 | "dev": "NODE_ENV=development nodemon server.js", 12 | "dev:no": "NODE_ENV=development node server.js" 13 | }, 14 | "dependencies": { 15 | "@aws-sdk/client-s3": "^3.826.0", 16 | "@maxmind/geoip2-node": "^6.1.0", 17 | "@prisma/client": "^6.13.0", 18 | "@simplewebauthn/server": "^10.0.1", 19 | "axios": "^1.9.0", 20 | "base32-encode": "^2.0.0", 21 | "base64url": "^3.0.1", 22 | "bcrypt": "^6.0.0", 23 | "body-parser": "^2.2.0", 24 | "compression": "^1.8.0", 25 | "connect-multiparty": "^2.2.0", 26 | "cookie-parser": "^1.4.7", 27 | "cors": "^2.8.5", 28 | "crypto-js": "^4.1.1", 29 | "disposable-domains": "^1.0.12", 30 | "dotenv": "^16.5.0", 31 | "ejs": "^3.1.10", 32 | "email-templates": "^12.0.3", 33 | "express": "^5.1.0", 34 | "express-jwt": "^8.5.1", 35 | "express-rate-limit": "^7.5.1", 36 | "express-session": "^1.18.1", 37 | "express-winston": "^4.2.0", 38 | "file-type": "^21.0.0", 39 | "fluent-ffmpeg": "^2.1.2", 40 | "handlebars": "^4.7.8", 41 | "html-entities": "^2.6.0", 42 | "ioredis": "^5.6.1", 43 | "jsonwebtoken": "^9.0.0", 44 | "morgan": "^1.10.0", 45 | "multer": "^2.0.1", 46 | "mysql2": "^3.6.0", 47 | "nodemailer": "^7.0.3", 48 | "otpauth": "^9.4.0", 49 | "phpass": "^0.1.1", 50 | "rate-limit-redis": "^4.2.1", 51 | "sharp": "^0.34.3", 52 | "sitemap": "^8.0.0", 53 | "tar": "^7.4.3", 54 | "ua-parser-js": "^2.0.3", 55 | "uuid": "^11.1.0", 56 | "validator": "^13.15.15", 57 | "web-push": "^3.6.7", 58 | "winston": "^3.17.0", 59 | "winston-daily-rotate-file": "^5.0.0" 60 | }, 61 | "devDependencies": { 62 | "prisma": "^6.13.0" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/claude-code-review.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code Review 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | # Optional: Only run on specific file changes 7 | # paths: 8 | # - "src/**/*.ts" 9 | # - "src/**/*.tsx" 10 | # - "src/**/*.js" 11 | # - "src/**/*.jsx" 12 | 13 | jobs: 14 | claude-review: 15 | # Optional: Filter by PR author 16 | # if: | 17 | # github.event.pull_request.user.login == 'external-contributor' || 18 | # github.event.pull_request.user.login == 'new-developer' || 19 | # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' 20 | 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | pull-requests: read 25 | issues: read 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 1 33 | 34 | - name: Run Claude Code Review 35 | id: claude-review 36 | uses: anthropics/claude-code-action@v1 37 | env: 38 | ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} 39 | with: 40 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 41 | prompt: | 42 | Please review this pull request and provide feedback on: 43 | - Code quality and best practices 44 | - Potential bugs or issues 45 | - Performance considerations 46 | - Security concerns 47 | - Test coverage 48 | 49 | Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. 50 | 51 | Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. 52 | 53 | # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md 54 | # or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options 55 | claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' 56 | 57 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | actions: read # Required for Claude to read CI results on PRs 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 1 32 | 33 | - name: Run Claude Code 34 | id: claude 35 | uses: anthropics/claude-code-action@v1 36 | env: 37 | ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} 38 | with: 39 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 40 | 41 | # This is an optional setting that allows Claude to read CI results on PRs 42 | additional_permissions: | 43 | actions: read 44 | 45 | # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. 46 | # prompt: 'Update the pull request description to include a summary of changes.' 47 | 48 | # Optional: Add claude_args to customize behavior and configuration 49 | # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md 50 | # or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options 51 | # claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)' 52 | 53 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build and Push 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | tags: ["v*"] 7 | pull_request: 8 | branches: ["main"] 9 | workflow_dispatch: 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: false 14 | jobs: 15 | build-and-push: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | packages: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | - name: Set up Docker Buildx 25 | uses: docker/setup-buildx-action@v3 26 | 27 | - name: Login to Docker Hub 28 | if: github.event_name != 'pull_request' 29 | uses: docker/login-action@v3 30 | with: 31 | username: ${{ vars.DOCKERHUB_USERNAME }} 32 | password: ${{ secrets.DOCKERHUB_TOKEN }} 33 | - name: Log in to Container registry 34 | if: github.event_name != 'pull_request' 35 | uses: docker/login-action@v3 36 | with: 37 | registry: ghcr.io 38 | username: ${{ github.actor }} 39 | password: ${{ secrets.GITHUB_TOKEN }} 40 | - name: Extract Docker metadata 41 | id: meta 42 | uses: docker/metadata-action@v5 43 | with: 44 | images: | 45 | sunwuyuan/zerocat 46 | ghcr.io/zerocatdev/zerocat 47 | tags: | 48 | type=ref,event=branch 49 | type=ref,event=pr 50 | type=semver,pattern={{version}} 51 | type=semver,pattern={{major}}.{{minor}} 52 | type=semver,pattern={{major}} 53 | type=sha,format=long 54 | flavor: | 55 | latest=auto 56 | 57 | - name: Show generated image tags 58 | run: echo "${{ steps.meta.outputs.tags }}" 59 | - name: Build and push Docker image 60 | uses: docker/build-push-action@v5 61 | with: 62 | context: . 63 | push: ${{ github.event_name != 'pull_request' }} 64 | tags: ${{ steps.meta.outputs.tags }} 65 | labels: ${{ steps.meta.outputs.labels }} 66 | cache-from: type=gha 67 | cache-to: type=gha,mode=max 68 | -------------------------------------------------------------------------------- /prisma/migrations/20250731020425_add_assets_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `ow_projects_assets` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `project_id` INTEGER UNSIGNED NOT NULL, 5 | `asset_id` INTEGER NOT NULL, 6 | `usage_context` VARCHAR(255) NULL, 7 | `usage_order` INTEGER NULL DEFAULT 0, 8 | `created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0), 9 | `updated_at` TIMESTAMP(0) NOT NULL, 10 | 11 | INDEX `ow_projects_assets_project_id_idx`(`project_id`), 12 | INDEX `ow_projects_assets_asset_id_idx`(`asset_id`), 13 | INDEX `ow_projects_assets_usage_context_idx`(`usage_context`), 14 | UNIQUE INDEX `ow_projects_assets_project_id_asset_id_key`(`project_id`, `asset_id`), 15 | PRIMARY KEY (`id`) 16 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 17 | 18 | -- CreateTable 19 | CREATE TABLE `ow_assets` ( 20 | `id` INTEGER NOT NULL AUTO_INCREMENT, 21 | `md5` VARCHAR(32) NOT NULL, 22 | `filename` VARCHAR(255) NOT NULL, 23 | `extension` VARCHAR(20) NOT NULL, 24 | `mime_type` VARCHAR(100) NOT NULL, 25 | `file_size` INTEGER NOT NULL, 26 | `uploader_id` INTEGER NOT NULL, 27 | `uploader_ip` VARCHAR(100) NULL, 28 | `uploader_ua` MEDIUMTEXT NULL, 29 | `created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0), 30 | `updated_at` TIMESTAMP(0) NOT NULL, 31 | `is_banned` BOOLEAN NOT NULL DEFAULT false, 32 | `banned_at` TIMESTAMP(0) NULL, 33 | `banned_by` INTEGER NULL, 34 | `ban_reason` VARCHAR(500) NULL, 35 | `usage_count` INTEGER NOT NULL DEFAULT 0, 36 | `last_used_at` TIMESTAMP(0) NULL, 37 | `metadata` JSON NULL, 38 | `tags` VARCHAR(500) NULL, 39 | `category` VARCHAR(50) NULL, 40 | 41 | UNIQUE INDEX `ow_assets_md5_key`(`md5`), 42 | INDEX `ow_assets_md5_idx`(`md5`), 43 | INDEX `ow_assets_uploader_id_idx`(`uploader_id`), 44 | INDEX `ow_assets_created_at_idx`(`created_at`), 45 | INDEX `ow_assets_is_banned_idx`(`is_banned`), 46 | INDEX `ow_assets_extension_idx`(`extension`), 47 | INDEX `ow_assets_category_idx`(`category`), 48 | INDEX `ow_assets_usage_count_idx`(`usage_count`), 49 | PRIMARY KEY (`id`) 50 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 51 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | import logger from './services/logger.js'; 2 | import http from 'http'; 3 | import app from './app.js'; 4 | 5 | /** 6 | * 服务器配置和启动类 7 | */ 8 | class ServerConfig { 9 | constructor() { 10 | this.port = process.env.PORT || 3000; 11 | this.host = process.env.HOST || '0.0.0.0'; 12 | this.server = null; 13 | } 14 | 15 | /** 16 | * 启动HTTP服务器 17 | * @returns {Promise} HTTP服务器实例 18 | */ 19 | async start() { 20 | return new Promise((resolve, reject) => { 21 | try { 22 | // 创建HTTP服务器 23 | this.server = http.createServer(app); 24 | 25 | // 设置错误处理 26 | this.server.on('error', this.handleServerError); 27 | 28 | // 启动服务器 29 | this.server.listen(this.port, this.host, () => { 30 | logger.info(`[server] 服务器已启动,监听 http://${this.host}:${this.port}`); 31 | resolve(this.server); 32 | }); 33 | } catch (error) { 34 | logger.error('[server] 启动服务器失败:', error); 35 | reject(error); 36 | } 37 | }); 38 | } 39 | 40 | /** 41 | * 处理服务器错误 42 | * @param {Error} error 服务器错误 43 | */ 44 | handleServerError(error) { 45 | if (error.code === 'EADDRINUSE') { 46 | logger.error(`[server] 端口 ${this.port} 已被占用,请尝试不同端口`); 47 | } else { 48 | logger.error('[server] 服务器错误:', error); 49 | } 50 | 51 | // 严重错误,退出进程 52 | process.exit(1); 53 | } 54 | 55 | /** 56 | * 关闭服务器 57 | * @returns {Promise} 58 | */ 59 | async stop() { 60 | if (!this.server) { 61 | logger.warn('[server] 尝试关闭未启动的服务器'); 62 | return; 63 | } 64 | 65 | return new Promise((resolve, reject) => { 66 | this.server.close((error) => { 67 | if (error) { 68 | logger.error('[server] 关闭服务器出错:', error); 69 | reject(error); 70 | } else { 71 | logger.info('[server] 服务器已关闭'); 72 | resolve(); 73 | } 74 | }); 75 | }); 76 | } 77 | } 78 | 79 | // 导出配置类 80 | export default new ServerConfig(); -------------------------------------------------------------------------------- /src/routes/router_timeline.js: -------------------------------------------------------------------------------- 1 | import {Router} from "express"; 2 | import logger from "../services/logger.js"; 3 | import {needLogin} from "../middleware/auth.js"; 4 | import {getFollowingTimeline, getMyTimeline, getUserTimeline} from "../services/timeline.js"; 5 | 6 | const router = Router(); 7 | 8 | // 获取用户时间线 9 | router.get("/user/:userid", async (req, res) => { 10 | try { 11 | const {userid} = req.params; 12 | const {page = 1, limit = 20} = req.query; 13 | const isOwner = res.locals.userid === Number(userid); 14 | 15 | logger.debug("Fetching timeline for user", { 16 | userid, 17 | isOwner, 18 | currentUser: res.locals.userid, 19 | }); 20 | 21 | const result = await getUserTimeline(userid, page, limit, isOwner); 22 | 23 | res.status(200).send({ 24 | status: "success", 25 | data: result, 26 | }); 27 | } catch (error) { 28 | logger.error("Error fetching timeline:", error); 29 | res.status(500).send({ 30 | status: "error", 31 | message: "获取时间线失败", 32 | details: error.message, 33 | }); 34 | } 35 | }); 36 | 37 | // 获取关注的用户的时间线(只显示公开事件) 38 | router.get("/following", needLogin, async (req, res) => { 39 | try { 40 | const {page = 1, limit = 20} = req.query; 41 | const result = await getFollowingTimeline(res.locals.userid, page, limit); 42 | 43 | res.status(200).send({ 44 | status: "success", 45 | data: result, 46 | }); 47 | } catch (error) { 48 | logger.error("Error fetching following timeline:", error); 49 | res.status(500).send({ 50 | status: "error", 51 | message: "获取关注时间线失败", 52 | }); 53 | } 54 | }); 55 | 56 | // 获取我的时间线(包含自己和关注的人的事件) 57 | router.get("/me", needLogin, async (req, res) => { 58 | try { 59 | const {page = 1, limit = 20} = req.query; 60 | const result = await getMyTimeline(res.locals.userid, page, limit); 61 | 62 | res.status(200).send({ 63 | status: "success", 64 | data: result, 65 | }); 66 | } catch (error) { 67 | logger.error("Error fetching my timeline:", error); 68 | res.status(500).send({ 69 | status: "error", 70 | message: "获取我的时间线失败", 71 | }); 72 | } 73 | }); 74 | 75 | export default router; 76 | -------------------------------------------------------------------------------- /src/routes/router_cachekv.js: -------------------------------------------------------------------------------- 1 | import {Router} from 'express'; 2 | import {needLogin} from '../middleware/auth.js'; 3 | import * as cachekv from '../services/cachekv.js'; 4 | 5 | const router = Router(); 6 | 7 | router.use(needLogin); 8 | 9 | // Get value by key 10 | router.get('/:key', async (req, res) => { 11 | try { 12 | const {key} = req.params; 13 | const userId = res.locals.userid; 14 | 15 | const value = await cachekv.get(userId, key); 16 | 17 | if (value === undefined) { 18 | return res.status(404).json({error: '键不存在'}); 19 | } 20 | 21 | res.json(value); 22 | } catch (error) { 23 | console.error('获取失败:', error); 24 | res.status(500).json({error: '获取失败'}); 25 | } 26 | }); 27 | 28 | // Set value for key 29 | router.post('/:key', async (req, res) => { 30 | try { 31 | const {key} = req.params; 32 | const userId = res.locals.userid; 33 | 34 | const item = await cachekv.set(userId, key, req.body, req.ip); 35 | 36 | res.json({data: item}); 37 | } catch (error) { 38 | console.error('设置失败:', error); 39 | res.status(500).json({error: '设置失败'}); 40 | } 41 | }); 42 | 43 | // Delete key 44 | router.delete('/:key', async (req, res) => { 45 | try { 46 | const {key} = req.params; 47 | const userId = res.locals.userid; 48 | 49 | const deleted = await cachekv.remove(userId, key); 50 | 51 | if (!deleted) { 52 | return res.status(404).json({error: '键不存在'}); 53 | } 54 | 55 | res.json({message: 'Key deleted successfully'}); 56 | } catch (error) { 57 | console.error('删除失败:', error); 58 | res.status(500).json({error: '删除失败'}); 59 | } 60 | }); 61 | 62 | // List all keys 63 | router.get('/', async (req, res) => { 64 | try { 65 | const userId = res.locals.userid; 66 | const {page = 1, limit = 20, showValue = false} = req.query; 67 | 68 | const result = await cachekv.list(userId, { 69 | page: Number(page), 70 | limit: Number(limit), 71 | showValue: showValue === 'true' 72 | }); 73 | 74 | res.json({ 75 | data: result.items, 76 | pagination: result.pagination 77 | }); 78 | } catch (error) { 79 | console.error('获取失败:', error); 80 | res.status(500).json({error: '获取失败'}); 81 | } 82 | }); 83 | 84 | export default router; -------------------------------------------------------------------------------- /src/middleware/rateLimit.js: -------------------------------------------------------------------------------- 1 | import rateLimit from 'express-rate-limit'; 2 | import RedisStore from 'rate-limit-redis'; 3 | import redis from '../services/redis.js'; 4 | import logger from '../services/logger.js'; 5 | 6 | // 通用限速中间件工厂函数 7 | export const createRateLimit = (options = {}) => { 8 | // 等待Redis连接初始化 9 | if (!redis.client || !redis.isConnected) { 10 | logger.warn('Redis未连接,使用内存存储进行限速'); 11 | return rateLimit({ 12 | windowMs: options.windowMs || 15 * 60 * 1000, 13 | max: options.max || 100, 14 | message: options.message || { 15 | status: 'error', 16 | message: '请求过于频繁,请稍后再试' 17 | }, 18 | skip: (req) => process.env.NODE_ENV === 'development' 19 | }); 20 | } 21 | 22 | const store = new RedisStore({ 23 | // 使用Redis客户端实例 24 | client: redis.client, 25 | prefix: options.prefix || 'rate_limit:', 26 | // 添加sendCommand代理以支持rate-limit-redis 27 | sendCommand: (...args) => redis.client.call(...args) 28 | }); 29 | 30 | return rateLimit({ 31 | store, 32 | windowMs: options.windowMs || 15 * 60 * 1000, // 默认15分钟 33 | max: options.max || 100, // 默认限制100次请求 34 | message: options.message || { 35 | status: 'error', 36 | message: '请求过于频繁,请稍后再试' 37 | }, 38 | // 添加错误处理 39 | handler: (req, res) => { 40 | logger.warn(`Rate limit exceeded for IP ${req.ip}`); 41 | res.status(429).json(options.message || { 42 | status: 'error', 43 | message: '请求过于频繁,请稍后再试' 44 | }); 45 | }, 46 | // 添加跳过函数 47 | skip: (req) => { 48 | // 开发环境跳过限速 49 | return process.env.NODE_ENV === 'development'; 50 | } 51 | }); 52 | }; 53 | 54 | // 敏感操作限速 55 | export const sensitiveActionLimiter = createRateLimit({ 56 | windowMs: 15 * 60 * 1000, // 15分钟 57 | max: 5, // 限制5次请求 58 | prefix: 'rate_limit:sensitive:', 59 | message: { 60 | status: 'error', 61 | message: '敏感操作过于频繁,请稍后再试' 62 | } 63 | }); 64 | 65 | // OAuth相关限速 66 | export const oauthRateLimit = createRateLimit({ 67 | windowMs: 60 * 1000, // 1分钟 68 | max: 10, // 限制10次请求 69 | prefix: 'rate_limit:oauth:', 70 | message: { 71 | status: 'error', 72 | message: 'OAuth请求过于频繁,请稍后再试' 73 | } 74 | }); 75 | 76 | // 导出默认的rateLimit函数 77 | export {rateLimit}; -------------------------------------------------------------------------------- /docs/EXTENSIONS_API_DOCS.md: -------------------------------------------------------------------------------- 1 | ## 账户安全:2FA 与 Passkey 2 | 3 | ### 启用与管理 2FA (TOTP) 4 | 5 | - GET `/account/2fa/status` 6 | - POST `/account/2fa/setup` 7 | - POST `/account/2fa/activate` { token } 8 | - POST `/account/2fa/disable` 9 | 10 | 示例响应(setup): 11 | 12 | ```json 13 | { 14 | "status": "success", 15 | "data": { 16 | "secret": "JBSWY3DPEHPK3PXP...", 17 | "otpauth_url": "otpauth://totp/ZeroCat:username?...", 18 | "algorithm": "SHA256", 19 | "digits": 6, 20 | "period": 30 21 | } 22 | } 23 | ``` 24 | 25 | ### 登录时 2FA 流程 26 | 27 | 当用户启用2FA,密码/邮箱/魔术链接登录返回: 28 | 29 | ```json 30 | { 31 | "status": "need_2fa", 32 | "data": {"challenge_id": "abc123", "expires_in": 600, "available_methods": ["totp", "passkey"]} 33 | } 34 | ``` 35 | 36 | 使用TOTP完成登录: 37 | 38 | - POST `/account/2fa/login/totp` { challenge_id, token } 39 | 40 | 成功后返回标准登录成功响应(携带access_token与refresh_token)。 41 | 42 | ### Passkey (WebAuthn) 支持 43 | 44 | - POST `/account/passkey/begin-registration` 45 | - 响应示例: 46 | ```json 47 | { 48 | "status": "success", 49 | "data": { 50 | "challenge": "...", 51 | "rp": {"id": "example.com", "name": "ZeroCat"}, 52 | "user": {"id": "123", "name": "username"}, 53 | "pubKeyCredParams": [{"type": "public-key", "alg": -7}], 54 | "excludeCredentials": [ {"id": "base64url...", "transports": ["internal"]} ] 55 | } 56 | } 57 | ``` 58 | - POST `/account/passkey/finish-registration` 传入浏览器返回的注册凭据对象 59 | - 响应:`{"status":"success","message":"Passkey 已注册"}` 60 | - POST `/account/passkey/begin-login` { identifier } 61 | - identifier 可省略,省略时走发现式(discoverable)登录,无需用户手动输入账号标识。 62 | - 响应: 63 | ```json 64 | { 65 | "status":"success", 66 | "data":{ /* WebAuthn PublicKeyCredentialRequestOptions */ } 67 | } 68 | ``` 69 | - POST `/account/passkey/finish-login` 传入浏览器返回的断言对象 70 | - 成功返回标准登录成功响应(含access_token与refresh_token) 71 | - POST `/account/passkey/sudo-begin` 72 | - 响应同begin-login 73 | - POST `/account/passkey/sudo-finish` 传入浏览器返回的断言对象 74 | - 响应:`{"status":"success","data":{"sudo_token":"...","expires_in":900}}` 75 | 76 | 管理凭据: 77 | 78 | - GET `/account/passkey/list` 79 | - 响应: 80 | ```json 81 | { 82 | "status":"success", 83 | "data":[ 84 | {"credential_id":"base64url...","transports":["internal"],"counter":12,"registered_at":1710000000} 85 | ] 86 | } 87 | ``` 88 | - POST `/account/passkey/delete` { credential_id } 89 | - 需要 sudo,删除指定凭据;若删除后无剩余凭据,`verified` 自动置为 false。 90 | 91 | 说明:服务器将凭据存储在 `ow_users_contacts` 中,类型为 `passkey`,并在 `metadata` 中保存WebAuthn相关信息以保持向后兼容。 92 | 93 | 94 | -------------------------------------------------------------------------------- /prisma/migrations/20250601043104_enhance_search_view_with_comments_commits_branches/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateView 2 | CREATE OR REPLACE VIEW `ow_projects_search` AS 3 | SELECT 4 | p.id, 5 | p.name, 6 | p.title, 7 | p.description, 8 | p.authorid, 9 | p.state, 10 | p.type, 11 | p.license, 12 | p.star_count, 13 | p.time, 14 | ( 15 | SELECT GROUP_CONCAT(pt.name SEPARATOR ', ') 16 | FROM ow_projects_tags pt 17 | WHERE pt.projectid = p.id 18 | GROUP BY pt.projectid 19 | ) as tags, 20 | u.display_name as author_display_name, 21 | u.username as author_username, 22 | u.motto as author_motto, 23 | u.images as author_images, 24 | u.type as author_type, 25 | ( 26 | SELECT pf.source 27 | FROM ow_projects_file pf 28 | INNER JOIN ow_projects_commits pc ON pc.commit_file = pf.sha256 29 | WHERE pc.project_id = p.id 30 | AND p.state = 'public' 31 | ORDER BY pc.commit_date DESC 32 | LIMIT 1 33 | ) as latest_source, 34 | ( 35 | SELECT COUNT(*) 36 | FROM ow_comment c 37 | WHERE c.page_type = 'project' 38 | AND c.page_id = p.id 39 | ) as comment_count, 40 | ( 41 | SELECT c.text 42 | FROM ow_comment c 43 | WHERE c.page_type = 'project' 44 | AND c.page_id = p.id 45 | ORDER BY c.insertedAt DESC 46 | LIMIT 1 47 | ) as latest_comment, 48 | ( 49 | SELECT GROUP_CONCAT(c.text ORDER BY c.insertedAt DESC SEPARATOR '|||') 50 | FROM ow_comment c 51 | WHERE c.page_type = 'project' 52 | AND c.page_id = p.id 53 | LIMIT 10 54 | ) as recent_comments, 55 | ( 56 | SELECT JSON_ARRAYAGG( 57 | JSON_OBJECT( 58 | 'id', pc.id, 59 | 'message', pc.commit_message, 60 | 'description', pc.commit_description, 61 | 'date', pc.commit_date 62 | ) 63 | ) 64 | FROM ow_projects_commits pc 65 | WHERE pc.project_id = p.id 66 | ORDER BY pc.commit_date DESC 67 | LIMIT 5 68 | ) as recent_commits, 69 | ( 70 | SELECT JSON_ARRAYAGG( 71 | JSON_OBJECT( 72 | 'id', pb.id, 73 | 'name', pb.name, 74 | 'description', pb.description 75 | ) 76 | ) 77 | FROM ow_projects_branch pb 78 | WHERE pb.projectid = p.id 79 | ) as branches 80 | FROM ow_projects p 81 | LEFT JOIN ow_users u ON p.authorid = u.id 82 | WHERE p.state = 'public'; -------------------------------------------------------------------------------- /prisma/migrations/20250601043104_enhance_search_view_with_comments_commits_branches copy2/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateView 2 | CREATE OR REPLACE VIEW `ow_projects_search` AS 3 | SELECT 4 | p.id, 5 | p.name, 6 | p.title, 7 | p.description, 8 | p.authorid, 9 | p.state, 10 | p.type, 11 | p.license, 12 | p.star_count, 13 | p.time, 14 | ( 15 | SELECT GROUP_CONCAT(pt.name SEPARATOR ', ') 16 | FROM ow_projects_tags pt 17 | WHERE pt.projectid = p.id 18 | GROUP BY pt.projectid 19 | ) as tags, 20 | u.display_name as author_display_name, 21 | u.username as author_username, 22 | u.motto as author_motto, 23 | u.images as author_images, 24 | u.type as author_type, 25 | ( 26 | SELECT pf.source 27 | FROM ow_projects_file pf 28 | INNER JOIN ow_projects_commits pc ON pc.commit_file = pf.sha256 29 | WHERE pc.project_id = p.id 30 | AND p.state = 'public' 31 | ORDER BY pc.commit_date DESC 32 | LIMIT 1 33 | ) as latest_source, 34 | ( 35 | SELECT COUNT(*) 36 | FROM ow_comment c 37 | WHERE c.page_type = 'project' 38 | AND c.page_id = p.id 39 | ) as comment_count, 40 | ( 41 | SELECT c.text 42 | FROM ow_comment c 43 | WHERE c.page_type = 'project' 44 | AND c.page_id = p.id 45 | ORDER BY c.insertedAt DESC 46 | LIMIT 1 47 | ) as latest_comment, 48 | ( 49 | SELECT GROUP_CONCAT(c.text ORDER BY c.insertedAt DESC SEPARATOR ',') 50 | FROM ow_comment c 51 | WHERE c.page_type = 'project' 52 | AND c.page_id = p.id 53 | LIMIT 10 54 | ) as recent_comments, 55 | ( 56 | SELECT JSON_ARRAYAGG( 57 | JSON_OBJECT( 58 | 'id', pc.id, 59 | 'message', pc.commit_message, 60 | 'description', pc.commit_description, 61 | 'date', pc.commit_date 62 | ) 63 | ) 64 | FROM ow_projects_commits pc 65 | WHERE pc.project_id = p.id 66 | ORDER BY pc.commit_date DESC 67 | LIMIT 5 68 | ) as recent_commits, 69 | ( 70 | SELECT JSON_ARRAYAGG( 71 | JSON_OBJECT( 72 | 'id', pb.id, 73 | 'name', pb.name, 74 | 'description', pb.description 75 | ) 76 | ) 77 | FROM ow_projects_branch pb 78 | WHERE pb.projectid = p.id 79 | ) as branches 80 | FROM ow_projects p 81 | LEFT JOIN ow_users u ON p.authorid = u.id 82 | WHERE p.state = 'public'; -------------------------------------------------------------------------------- /src/services/config/index.js: -------------------------------------------------------------------------------- 1 | import {prisma} from "../prisma.js"; 2 | import {updateDynamicConfigTypes, validateConfig} from "./configTypes.js"; 3 | import {EventEmitter} from "events"; 4 | 5 | class ConfigService extends EventEmitter { 6 | constructor() { 7 | super(); 8 | this._cache = new Map(); 9 | this._initialized = false; 10 | } 11 | 12 | async init() { 13 | if (this._initialized) return; 14 | 15 | try { 16 | // 从数据库加载所有配置 17 | const dbConfigs = await prisma.ow_config.findMany(); 18 | 19 | // 更新动态配置类型 20 | updateDynamicConfigTypes(dbConfigs); 21 | 22 | // 更新缓存 23 | for (const config of dbConfigs) { 24 | this._cache.set(config.key, validateConfig(config.key, config.value)); 25 | } 26 | 27 | this._initialized = true; 28 | } catch (error) { 29 | console.error("Failed to initialize config service:", error); 30 | throw error; 31 | } 32 | } 33 | 34 | async get(key) { 35 | if (!this._initialized) { 36 | await this.init(); 37 | } 38 | 39 | return this._cache.get(key); 40 | } 41 | 42 | async set(key, value) { 43 | if (!this._initialized) { 44 | await this.init(); 45 | } 46 | 47 | const validatedValue = validateConfig(key, value); 48 | this._cache.set(key, validatedValue); 49 | 50 | // 触发配置更改事件 51 | this.emit("configChanged", {key, value: validatedValue}); 52 | 53 | return validatedValue; 54 | } 55 | 56 | async reload() { 57 | this._initialized = false; 58 | this._cache.clear(); 59 | await this.init(); 60 | this.emit("configReloaded"); 61 | } 62 | 63 | // 获取所有配置值 64 | async getAll() { 65 | if (!this._initialized) { 66 | await this.init(); 67 | } 68 | 69 | const result = {}; 70 | for (const [key, value] of this._cache.entries()) { 71 | result[key] = value; 72 | } 73 | return result; 74 | } 75 | 76 | // 获取公开的配置值 77 | async getPublicConfigs() { 78 | if (!this._initialized) { 79 | await this.init(); 80 | } 81 | 82 | const result = {}; 83 | for (const [key, config] of Object.entries(await this.getAll())) { 84 | if (config.public) { 85 | result[key] = config.value; 86 | } 87 | } 88 | return result; 89 | } 90 | } 91 | 92 | export const configService = new ConfigService(); -------------------------------------------------------------------------------- /src/services/logger.js: -------------------------------------------------------------------------------- 1 | import {createLogger, format, transports} from "winston"; 2 | import DailyRotateFile from "winston-daily-rotate-file"; 3 | import {join} from "path"; 4 | 5 | const {combine, timestamp, printf, errors, colorize} = format; 6 | 7 | // 获取环境变量中的日志级别和日志目录 8 | const logLevel = process.env.LOG_LEVEL || "info"; 9 | const logDirectory = process.env.LOG_DIR || "logs"; 10 | 11 | // 使用单例模式,确保只有一个logger实例 12 | let loggerInstance = null; 13 | 14 | // 自定义日志格式化方式 15 | const logFormat = printf(({level, message, timestamp, stack}) => { 16 | // 确保 message 是一个字符串类型,如果是对象,则使用 JSON.stringify() 17 | let logMessage = `${timestamp} ${level.padEnd(7)}: ${typeof message === 'object' ? JSON.stringify(message) : message}`; 18 | 19 | // 如果存在 stack(通常是错误对象的堆栈),确保它是字符串 20 | if (stack) { 21 | logMessage += `\n${typeof stack === 'object' ? JSON.stringify(stack) : stack}`; 22 | } 23 | 24 | return logMessage; 25 | }); 26 | 27 | // 创建logger单例 28 | const createLoggerInstance = () => { 29 | if (loggerInstance) { 30 | return loggerInstance; 31 | } 32 | 33 | // 确定控制台日志级别 - 开发环境使用debug,生产环境使用配置的级别 34 | const consoleLogLevel = process.env.NODE_ENV === "development" ? "debug" : logLevel; 35 | 36 | loggerInstance = createLogger({ 37 | level: logLevel, 38 | format: combine( 39 | timestamp({format: "YYYY-MM-DD HH:mm:ss"}), // 自定义时间格式 40 | errors({stack: true}), // 捕获错误堆栈信息 41 | logFormat // 自定义日志格式 42 | ), 43 | transports: [ 44 | // 控制台输出 - 根据环境配置级别 45 | new transports.Console({ 46 | level: consoleLogLevel, 47 | format: combine( 48 | colorize(), // 控制台输出颜色 49 | logFormat // 输出格式 50 | ), 51 | }), 52 | 53 | // 错误日志文件:每天生成一个错误日志文件 54 | new DailyRotateFile({ 55 | level: "error", 56 | filename: join(logDirectory, "error-%DATE%.log"), 57 | datePattern: "YYYY-MM-DD", 58 | zippedArchive: true, 59 | maxSize: "20m", 60 | maxFiles: "14d", 61 | }), 62 | 63 | // 综合日志文件:记录所有日志 64 | new DailyRotateFile({ 65 | level: logLevel, 66 | filename: join(logDirectory, "combined-%DATE%.log"), 67 | datePattern: "YYYY-MM-DD", 68 | zippedArchive: true, 69 | maxSize: "20m", 70 | maxFiles: "14d", 71 | }), 72 | ], 73 | }); 74 | 75 | return loggerInstance; 76 | }; 77 | 78 | // 导出logger单例 79 | export default createLoggerInstance(); 80 | -------------------------------------------------------------------------------- /src/controllers/auth/twoFactorController.js: -------------------------------------------------------------------------------- 1 | import twoFactor from '../../services/auth/twoFactor.js'; 2 | import tokenUtils from '../../services/auth/tokenUtils.js'; 3 | import logger from '../../services/logger.js'; 4 | 5 | export async function status(req, res) { 6 | const status = await twoFactor.getTwoFactorStatus(res.locals.userid); 7 | res.json({ status: 'success', data: status }); 8 | } 9 | 10 | export async function setup(req, res) { 11 | const result = await twoFactor.generateTotpSetup(res.locals.userid); 12 | if (!result.success) return res.status(400).json({ status: 'error', message: result.message }); 13 | res.json({ status: 'success', data: result }); 14 | } 15 | 16 | export async function activate(req, res) { 17 | const { token } = req.body; 18 | if (!token) return res.status(400).json({ status: 'error', message: '缺少验证码' }); 19 | const result = await twoFactor.activateTotp(res.locals.userid, token); 20 | if (!result.success) return res.status(400).json({ status: 'error', message: result.message }); 21 | res.json({ status: 'success', message: '二次验证已启用' }); 22 | } 23 | 24 | export async function disable(req, res) { 25 | const result = await twoFactor.disableTotp(res.locals.userid); 26 | if (!result.success) return res.status(400).json({ status: 'error', message: result.message }); 27 | res.json({ status: 'success', message: '二次验证已关闭' }); 28 | } 29 | 30 | // 完成登录:TOTP 提交 31 | export async function finalizeLoginWithTotp(req, res) { 32 | const { challenge_id: challengeId, token } = req.body; 33 | if (!challengeId || !token) return res.status(400).json({ status: 'error', message: '缺少challenge或验证码' }); 34 | const challenge = await twoFactor.getLogin2FAChallenge(challengeId); 35 | if (!challenge) return res.status(400).json({ status: 'error', message: '登录挑战无效' }); 36 | const verify = await twoFactor.verifyTotp(challenge.userId, token); 37 | if (!verify.success) return res.status(400).json({ status: 'error', message: verify.message }); 38 | const data = await twoFactor.consumeLogin2FAChallenge(challengeId); 39 | if (!data) return res.status(400).json({ status: 'error', message: '登录挑战已失效' }); 40 | const tokenResult = await tokenUtils.createUserLoginTokens( 41 | data.userId, 42 | data.userInfo, 43 | data.ipAddress, 44 | data.userAgent, 45 | { recordLoginEvent: true, loginMethod: 'password+totp' } 46 | ); 47 | if (!tokenResult.success) { 48 | logger.error(`创建登录令牌失败: ${tokenResult.message}`); 49 | return res.status(500).json({ status: 'error', message: '创建登录令牌失败' }); 50 | } 51 | const response = tokenUtils.generateLoginResponse({ id: data.userId }, tokenResult, data.userInfo.email || null); 52 | res.json(response); 53 | } 54 | 55 | 56 | -------------------------------------------------------------------------------- /prisma/migrations/20250615083810_rename_analytics_tables/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `AnalyticsDevice` table. If the table is not empty, all the data it contains will be lost. 5 | - You are about to drop the `AnalyticsEvent` table. If the table is not empty, all the data it contains will be lost. 6 | 7 | */ 8 | -- DropTable 9 | DROP TABLE `AnalyticsDevice`; 10 | 11 | -- DropTable 12 | DROP TABLE `AnalyticsEvent`; 13 | 14 | -- CreateTable 15 | CREATE TABLE `ow_analytics_device` ( 16 | `id` INTEGER NOT NULL AUTO_INCREMENT, 17 | `fingerprint` VARCHAR(191) NOT NULL, 18 | `user_id` INTEGER NULL, 19 | `hostname` VARCHAR(191) NULL, 20 | `screen` VARCHAR(191) NULL, 21 | `language` VARCHAR(191) NULL, 22 | `browser` VARCHAR(191) NULL, 23 | `browser_version` VARCHAR(191) NULL, 24 | `os` VARCHAR(191) NULL, 25 | `os_version` VARCHAR(191) NULL, 26 | `device_type` VARCHAR(191) NULL, 27 | `device_vendor` VARCHAR(191) NULL, 28 | `user_agent` VARCHAR(191) NULL, 29 | `first_seen` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 30 | `last_seen` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 31 | 32 | INDEX `ow_analytics_device_user_id_idx`(`user_id`), 33 | INDEX `ow_analytics_device_first_seen_idx`(`first_seen`), 34 | INDEX `ow_analytics_device_last_seen_idx`(`last_seen`), 35 | UNIQUE INDEX `ow_analytics_device_fingerprint_user_id_key`(`fingerprint`, `user_id`), 36 | PRIMARY KEY (`id`) 37 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 38 | 39 | -- CreateTable 40 | CREATE TABLE `ow_analytics_event` ( 41 | `id` INTEGER NOT NULL AUTO_INCREMENT, 42 | `device_id` INTEGER NOT NULL, 43 | `user_id` INTEGER NULL, 44 | `url` VARCHAR(191) NOT NULL, 45 | `url_path` VARCHAR(191) NOT NULL, 46 | `url_query` VARCHAR(191) NULL, 47 | `referrer` VARCHAR(191) NULL, 48 | `referrer_domain` VARCHAR(191) NULL, 49 | `referrer_path` VARCHAR(191) NULL, 50 | `referrer_query` VARCHAR(191) NULL, 51 | `page_title` VARCHAR(191) NULL, 52 | `target_type` VARCHAR(191) NOT NULL, 53 | `target_id` INTEGER NOT NULL, 54 | `ip_address` VARCHAR(191) NULL, 55 | `country` VARCHAR(191) NULL, 56 | `region` VARCHAR(191) NULL, 57 | `city` VARCHAR(191) NULL, 58 | `timezone` VARCHAR(191) NULL, 59 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 60 | 61 | INDEX `ow_analytics_event_device_id_idx`(`device_id`), 62 | INDEX `ow_analytics_event_user_id_idx`(`user_id`), 63 | INDEX `ow_analytics_event_created_at_idx`(`created_at`), 64 | INDEX `ow_analytics_event_referrer_domain_idx`(`referrer_domain`), 65 | INDEX `ow_analytics_event_ip_address_idx`(`ip_address`), 66 | PRIMARY KEY (`id`) 67 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ZeroCat 编程社区 2 | 3 | 高中喵开发,求 Star 支持 4 | 5 | ## 6 | ZeroCat 是一个轻量级的在线编程、分享平台 7 | 8 | 本仓库是 ZeroCat 的后端代码 9 | 10 | ## 内容列表 11 | 12 | - [ZeroCat 编程社区](#zerocat-编程社区) 13 | - [](#) 14 | - [内容列表](#内容列表) 15 | - [前端一键部署](#前端一键部署) 16 | - [背景](#背景) 17 | - [交流](#交流) 18 | - [示例](#示例) 19 | - [安装](#安装) 20 | - [配置数据库](#配置数据库) 21 | - [配置环境变量](#配置环境变量) 22 | - [运行](#运行) 23 | - [使用 Docker](#使用-docker) 24 | - [开发者](#开发者) 25 | - [](#-1) 26 | - [如何贡献](#如何贡献) 27 | - [](#-2) 28 | - [贡献者](#贡献者) 29 | - [许可协议](#许可协议) 30 | - [感谢](#感谢) 31 | 32 | 33 | 34 | ## 背景 35 | 36 | `ZeroCat` 最开始由孙悟元在很早以前提出,我们希望搭建一个全开源的编程社区,这个项目也就从此开始了。但实际上项目在孙悟元初二的时候才有了很大进展。 37 |
维护一个编程社区从某种程度上来说相当不易,但我相信,这个项目会一直开发下去。 38 | 39 | 这个仓库的目标是: 40 |
开发一个完整的支持 Scratch、Python 与其他适合编程初学者的编程社区 41 | 42 | ## 交流 43 | 44 | QQ:964979747 45 | 46 | ## 示例 47 | 48 | 想了解社区效果,请参考 [ZeroCat](https://zerocat.dev)。 49 | 50 | ## 安装 51 | ![使用Nodejs开发](public/Node.js.png) 52 | 53 | 这个项目使用 [node](http://nodejs.org) , [npm](https://npmjs.com), [docker](https://docker.com),请确保你本地已经安装了祂们 54 | 55 | ```sh 56 | $ npm install 57 | # 或者 58 | $ pnpm install 59 | ``` 60 | 61 | ### 配置数据库 62 | 63 | 还没写好 64 | 65 | ### 配置环境变量 66 | 67 | 将`.env.example`修改为`.env`或手动配置环境变量(根据`.env.example`配置) 68 |
请务必不要同时使用环境变量与.env,请注意不要让项目环境与其他项目环境冲突 69 |
目前所有环境变量都必须配置 70 | 71 | ### 运行 72 | 73 | ```sh 74 | $ npm run start 75 | # 或者 76 | $ pnpm run start 77 | ``` 78 | 79 | ### 使用 Docker 80 | 81 | 请确保以安装 Docker 与 DockerCompose 82 | 83 | ```sh 84 | $ docker compose up -d 85 | ``` 86 | 87 | ## 开发者 88 | 89 | [@SunWuyuan](https://github.com/sunwuyuan) 90 | 91 | ## 92 | ## 如何贡献 93 | 94 | ZeroCat 非常欢迎你的加入![提一个 Issue](https://github.com/ZeroCatDev/ZeroCat/issues/new) 或者提交一个 Pull Request。对于小白问题,最好在 qq 群里问,我们会尽量回答。 95 | 96 | ## 97 | ZeroCat 的项目 遵循 [Contributor Covenant](http://contributor-covenant.org/version/1/3/0/) 行为规范 98 |
孙悟元 希望你遵循 [提问的智慧](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md) 99 | 100 | ### 贡献者 101 | 102 | 感谢所有参与项目的人,他们的信息可以在右侧看到,这是实时的且便于查看 103 | 104 | 105 | ## 许可协议 106 | 107 | ZeroCat 社区项目遵循 [AGPL-3.0 许可证](LICENSE)。 108 | 109 | 110 | 版权所有 (C) 2020-2024 孙悟元。 111 | Copyright (C) 2020-2024 Sun Wuyuan. 112 | 113 | 114 | 您可以在开源的前提下免费使用 ZeroCat 社区,但不允许使用 ZeroCat 的名称进行宣传。您需要保留 ZeroCat 的版权声明。 115 | 116 | 如需闭源使用授权,请联系 QQ1847261658。 117 | 118 | 感谢 [scratch-cn/lite](https://gitee.com/scratch-cn/lite) 项目对本项目的启发。 119 | 120 | # 感谢 121 | 122 | 本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助:EdgeOne 提供长期有效的免费套餐,包含不限量的流量和请求,覆盖中国大陆节点,且无任何超额收费,感兴趣的朋友可以点击下面的链接领取 123 | [亚洲最佳CDN、边缘和安全解决方案 - Tencent EdgeOne](https://edgeone.ai/zh?from=github) 124 | 125 | [![EdgeOne](./public/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png)](https://edgeone.ai/zh?from=github) 126 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Serverless directories 108 | .serverless/ 109 | 110 | # FuseBox cache 111 | .fusebox/ 112 | 113 | # DynamoDB Local files 114 | .dynamodb/ 115 | 116 | # TernJS port file 117 | .tern-port 118 | 119 | # Stores VSCode versions used for testing VSCode extensions 120 | .vscode-test 121 | 122 | # yarn v2 123 | .yarn/cache 124 | .yarn/unplugged 125 | .yarn/build-state.yml 126 | .yarn/install-state.gz 127 | .pnp.* 128 | .vercel 129 | 130 | .env* 131 | .flaskenv* 132 | #!.env.project 133 | #!.env.vault 134 | 135 | .idea 136 | .vscode 137 | 138 | docker/redis/data/* 139 | docker/redis/redis.conf 140 | 141 | meili_data 142 | 143 | cache -------------------------------------------------------------------------------- /src/config/notificationTemplates.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_comment": { 3 | "title": "项目评论", 4 | "template": "{{actor_name}} 评论了您的项目 {{target_name}}", 5 | "icon": "comment", 6 | "requiresActor": true, 7 | "requiresData": [ 8 | "target_name" 9 | ] 10 | }, 11 | "project_star": { 12 | "title": "项目星标", 13 | "template": "{{actor_name}} 为您的项目 {{target_name}} 添加了星标", 14 | "icon": "star", 15 | "requiresActor": true, 16 | "requiresData": [ 17 | "target_name" 18 | ] 19 | }, 20 | "project_fork": { 21 | "title": "项目派生", 22 | "template": "{{actor_name}} 派生了您的项目 {{target_name}}", 23 | "icon": "fork", 24 | "requiresActor": true, 25 | "requiresData": [ 26 | "target_name" 27 | ] 28 | }, 29 | "project_update": { 30 | "title": "项目更新", 31 | "template": "您关注的项目 {{target_name}} 有新的更新", 32 | "icon": "update", 33 | "requiresActor": false, 34 | "requiresData": [ 35 | "target_name" 36 | ] 37 | }, 38 | "project_collect": { 39 | "title": "项目收藏", 40 | "template": "{{actor_name}} 收藏了您的项目 {{target_name}}", 41 | "icon": "collect", 42 | "requiresActor": true, 43 | "requiresData": [ 44 | "target_name" 45 | ] 46 | }, 47 | "user_follow": { 48 | "title": "新关注", 49 | "template": "{{actor_name}} 关注了您", 50 | "icon": "follow", 51 | "requiresActor": true, 52 | "requiresData": [] 53 | }, 54 | "user_new_comment": { 55 | "title": "新评论", 56 | "template": "{{target_name}} 有来自 {{actor_name}} 的新评论", 57 | "icon": "comment", 58 | "requiresActor": true, 59 | "requiresData": [] 60 | }, 61 | "system_announcement": { 62 | "title": "系统公告", 63 | "template": "{{custom_title}} - {{custom_content}}", 64 | "icon": "announcement", 65 | "requiresActor": false, 66 | "requiresData": [ 67 | "custom_title", 68 | "custom_content" 69 | ] 70 | }, 71 | "comment_reply": { 72 | "title": "评论回复", 73 | "template": "{{actor_name}} 回复了您的评论: {{comment_text}}", 74 | "icon": "reply", 75 | "requiresActor": true, 76 | "requiresData": [ 77 | "comment_text" 78 | ] 79 | }, 80 | "custom_notification": { 81 | "title": "自定义通知", 82 | "template": "{{custom_title}} - {{custom_content}}", 83 | "icon": "notification", 84 | "requiresActor": false, 85 | "requiresData": [ 86 | "custom_title", 87 | "custom_content" 88 | ] 89 | }, 90 | "comment_create": { 91 | "title": "新评论", 92 | "template": "{{actor_name}} 评论说:{{comment_text}}", 93 | "icon": "comment", 94 | "requiresActor": true, 95 | "requiresData": [ 96 | "comment_text" 97 | ] 98 | }, 99 | "admin_notification": { 100 | "title": "管理员通知", 101 | "template": "{{custom_title}} - {{custom_content}}", 102 | "icon": "admin", 103 | "requiresActor": true, 104 | "requiresData": [ 105 | "custom_title", 106 | "custom_content" 107 | ] 108 | } 109 | } -------------------------------------------------------------------------------- /src/services/email/emailService.js: -------------------------------------------------------------------------------- 1 | import {createTransport} from "nodemailer"; 2 | import zcconfig from "../config/zcconfig.js"; 3 | import logger from "../logger.js"; 4 | 5 | let transporter; 6 | 7 | const getMailConfig = async () => { 8 | const enabled = await zcconfig.get("mail.enabled"); 9 | if (!enabled) { 10 | return null; 11 | } 12 | 13 | const host = await zcconfig.get("mail.host"); 14 | const port = await zcconfig.get("mail.port"); 15 | const secure = await zcconfig.get("mail.secure"); 16 | const user = await zcconfig.get("mail.auth.user"); 17 | const pass = await zcconfig.get("mail.auth.pass"); 18 | const fromName = await zcconfig.get("mail.from_name"); 19 | const fromAddress = await zcconfig.get("mail.from_address"); 20 | 21 | if (!host || !port || !user || !pass) { 22 | logger.error("[email] 缺少必要的邮件配置"); 23 | return null; 24 | } 25 | 26 | const config = { 27 | host, 28 | port, 29 | secure, 30 | auth: { 31 | user, 32 | pass, 33 | } 34 | }; 35 | 36 | return { 37 | config, 38 | from: fromName ? `${fromName} <${fromAddress}>` : fromAddress, 39 | tls: { 40 | maxVersion: 'TLSv1.2', 41 | rejectUnauthorized: false 42 | } 43 | }; 44 | }; 45 | const initializeTransporter = async () => { 46 | try { 47 | const mailConfig = await getMailConfig(); 48 | logger.debug("[email] 邮件配置:", mailConfig); 49 | if (!mailConfig) { 50 | logger.info("[email] 邮件服务已禁用或未正确配置"); 51 | return false; 52 | } 53 | 54 | logger.debug("[email] 初始化邮件传输器:", mailConfig.config); 55 | transporter = createTransport(mailConfig.config); 56 | 57 | // Test the connection 58 | await transporter.verify(); 59 | logger.info("[email] 邮件服务初始化成功"); 60 | return true; 61 | } catch (error) { 62 | logger.error("[email] 邮件服务初始化失败:", error); 63 | return false; 64 | } 65 | }; 66 | 67 | const sendEmail = async (to, subject, html) => { 68 | try { 69 | if (!transporter) { 70 | const initialized = await initializeTransporter(); 71 | if (!initialized) { 72 | throw new Error("Email service is not available or not properly configured"); 73 | } 74 | } 75 | 76 | const mailConfig = await getMailConfig(); 77 | if (!mailConfig) { 78 | throw new Error("Email service is disabled or not properly configured"); 79 | } 80 | 81 | await transporter.sendMail({ 82 | from: mailConfig.from, 83 | to: to, 84 | subject: subject, 85 | html: html, 86 | }); 87 | 88 | return true; 89 | } catch (error) { 90 | logger.error("[email] 发送邮件失败:", error); 91 | throw error; 92 | } 93 | }; 94 | 95 | // Initialize email service when the module is loaded 96 | initializeTransporter().catch(error => { 97 | logger.error("[email] 模块加载时初始化邮件服务失败:", error); 98 | }); 99 | 100 | export {sendEmail}; -------------------------------------------------------------------------------- /src/services/utils/ipUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * IP地址工具函数 3 | * 用于从请求中获取真实IP地址,支持常见的代理服务器配置 4 | */ 5 | 6 | /** 7 | * 获取真实IP地址 8 | * @param {Object} req - Express请求对象 9 | * @returns {string} 真实IP地址 10 | */ 11 | export const getRealIP = (req) => { 12 | // 按优先级检查各种IP头 13 | const ipHeaders = [ 14 | 'x-real-ip', // Nginx代理 15 | 'x-client-ip', // Apache代理 16 | 'cf-connecting-ip', // Cloudflare 17 | 'fastly-client-ip', // Fastly 18 | 'true-client-ip', // Akamai和Cloudflare 19 | 'x-forwarded-for', // 标准代理头 20 | 'x-appengine-user-ip', // Google App Engine 21 | 'x-cluster-client-ip', // Rackspace负载均衡器 22 | 'x-forwarded', // 标准代理头 23 | 'forwarded-for', // 标准代理头 24 | 'forwarded' // 标准代理头 25 | ]; 26 | 27 | // 检查所有可能的IP头 28 | for (const header of ipHeaders) { 29 | const ip = req.headers[header]; 30 | if (ip) { 31 | // 如果是x-forwarded-for,取第一个IP(最原始的客户端IP) 32 | if (header === 'x-forwarded-for') { 33 | const ips = ip.split(','); 34 | return ips[0].trim(); 35 | } 36 | return ip; 37 | } 38 | } 39 | 40 | // 如果没有找到任何IP头,返回直接连接的IP 41 | return req.ip || req.connection.remoteAddress || req.socket.remoteAddress; 42 | }; 43 | 44 | /** 45 | * 检查IP地址是否为私有IP 46 | * @param {string} ip - IP地址 47 | * @returns {boolean} 是否为私有IP 48 | */ 49 | export const isPrivateIP = (ip) => { 50 | // 私有IP地址范围 51 | const privateRanges = [ 52 | /^10\./, // 10.0.0.0/8 53 | /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12 54 | /^192\.168\./, // 192.168.0.0/16 55 | /^127\./, // 127.0.0.0/8 56 | /^169\.254\./, // 169.254.0.0/16 (链路本地) 57 | /^fc00::/, // 唯一本地地址 (ULA) 58 | /^fe80::/ // 链路本地地址 59 | ]; 60 | 61 | return privateRanges.some(range => range.test(ip)); 62 | }; 63 | 64 | /** 65 | * 获取客户端IP地址(优先使用公网IP) 66 | * @param {Object} req - Express请求对象 67 | * @returns {string} 客户端IP地址 68 | */ 69 | export const getClientIP = (req) => { 70 | const realIP = getRealIP(req); 71 | 72 | // 如果是私有IP,尝试获取其他可能的公网IP 73 | if (isPrivateIP(realIP)) { 74 | // 检查其他可能的公网IP头 75 | const publicIPHeaders = [ 76 | 'x-forwarded-for', 77 | 'cf-connecting-ip', 78 | 'true-client-ip' 79 | ]; 80 | 81 | for (const header of publicIPHeaders) { 82 | const ip = req.headers[header]; 83 | if (ip) { 84 | const ips = ip.split(','); 85 | for (const potentialIP of ips) { 86 | const trimmedIP = potentialIP.trim(); 87 | if (!isPrivateIP(trimmedIP)) { 88 | return trimmedIP; 89 | } 90 | } 91 | } 92 | } 93 | } 94 | 95 | return realIP; 96 | }; 97 | 98 | export default { 99 | getRealIP, 100 | isPrivateIP, 101 | getClientIP 102 | }; -------------------------------------------------------------------------------- /prisma/migrations/20250601043104_enhance_search_view_with_comments_commits_branches copy3/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateView 2 | CREATE OR REPLACE VIEW `ow_projects_search` AS 3 | SELECT 4 | p.id, 5 | p.name, 6 | p.title, 7 | p.description, 8 | p.authorid, 9 | p.state, 10 | p.type, 11 | p.license, 12 | p.star_count, 13 | p.time, 14 | COALESCE( 15 | ( 16 | SELECT GROUP_CONCAT(pt.name SEPARATOR ', ') 17 | FROM ow_projects_tags pt 18 | WHERE pt.projectid = p.id 19 | GROUP BY pt.projectid 20 | ), 21 | '' 22 | ) as tags, 23 | u.display_name as author_display_name, 24 | u.username as author_username, 25 | u.motto as author_motto, 26 | u.images as author_images, 27 | u.type as author_type, 28 | COALESCE( 29 | ( 30 | SELECT pf.source 31 | FROM ow_projects_file pf 32 | LEFT JOIN ow_projects_commits pc ON pc.commit_file = pf.sha256 33 | WHERE pc.project_id = p.id 34 | AND p.state = 'public' 35 | ORDER BY pc.commit_date DESC 36 | LIMIT 1 37 | ), 38 | NULL 39 | ) as latest_source, 40 | COALESCE( 41 | ( 42 | SELECT COUNT(*) 43 | FROM ow_comment c 44 | WHERE c.page_type = 'project' 45 | AND c.page_id = p.id 46 | ), 47 | 0 48 | ) as comment_count, 49 | COALESCE( 50 | ( 51 | SELECT c.text 52 | FROM ow_comment c 53 | WHERE c.page_type = 'project' 54 | AND c.page_id = p.id 55 | ORDER BY c.insertedAt DESC 56 | LIMIT 1 57 | ), 58 | NULL 59 | ) as latest_comment, 60 | COALESCE( 61 | ( 62 | SELECT GROUP_CONCAT(c.text ORDER BY c.insertedAt DESC SEPARATOR ',') 63 | FROM ow_comment c 64 | WHERE c.page_type = 'project' 65 | AND c.page_id = p.id 66 | LIMIT 10 67 | ), 68 | NULL 69 | ) as recent_comments, 70 | COALESCE( 71 | ( 72 | SELECT JSON_ARRAYAGG( 73 | JSON_OBJECT( 74 | 'id', pc.id, 75 | 'message', pc.commit_message, 76 | 'description', pc.commit_description, 77 | 'date', pc.commit_date 78 | ) 79 | ) 80 | FROM ow_projects_commits pc 81 | WHERE pc.project_id = p.id 82 | ORDER BY pc.commit_date DESC 83 | LIMIT 5 84 | ), 85 | JSON_ARRAY() 86 | ) as recent_commits, 87 | COALESCE( 88 | ( 89 | SELECT JSON_ARRAYAGG( 90 | JSON_OBJECT( 91 | 'id', pb.id, 92 | 'name', pb.name, 93 | 'description', pb.description 94 | ) 95 | ) 96 | FROM ow_projects_branch pb 97 | WHERE pb.projectid = p.id 98 | ), 99 | JSON_ARRAY() 100 | ) as branches 101 | FROM ow_projects p 102 | LEFT JOIN ow_users u ON p.authorid = u.id 103 | WHERE p.state = 'public'; -------------------------------------------------------------------------------- /src/services/auth/accountTokenService.js: -------------------------------------------------------------------------------- 1 | import { prisma } from "../global.js"; 2 | import logger from "../logger.js"; 3 | 4 | /** 5 | * 验证账户令牌 6 | * 这个函数可以被其他中间件调用 7 | */ 8 | export async function verifyAccountToken(token) { 9 | try { 10 | if (!token || typeof token !== "string") { 11 | return { 12 | valid: false, 13 | message: "无效的令牌格式" 14 | }; 15 | } 16 | 17 | // 查找令牌记录 18 | const tokenRecord = await prisma.ow_account_tokens.findFirst({ 19 | where: { 20 | token: token, 21 | is_revoked: false 22 | }, 23 | include: { 24 | user: { 25 | select: { 26 | id: true, 27 | username: true, 28 | display_name: true, 29 | email: true, 30 | status: true 31 | } 32 | } 33 | } 34 | }); 35 | 36 | if (!tokenRecord) { 37 | return { 38 | valid: false, 39 | message: "令牌不存在或已被吊销" 40 | }; 41 | } 42 | 43 | // 检查用户状态 44 | if (tokenRecord.user.status !== "active") { 45 | return { 46 | valid: false, 47 | message: "用户账户状态异常" 48 | }; 49 | } 50 | 51 | // 检查是否过期 52 | if (tokenRecord.expires_at && tokenRecord.expires_at < new Date()) { 53 | // 自动吊销过期令牌 54 | await prisma.ow_account_tokens.update({ 55 | where: { 56 | id: tokenRecord.id 57 | }, 58 | data: { 59 | is_revoked: true, 60 | revoked_at: new Date() 61 | } 62 | }); 63 | 64 | return { 65 | valid: false, 66 | message: "令牌已过期" 67 | }; 68 | } 69 | 70 | return { 71 | valid: true, 72 | user: { 73 | userid: tokenRecord.user.id, 74 | username: tokenRecord.user.username, 75 | display_name: tokenRecord.user.display_name, 76 | email: tokenRecord.user.email, 77 | token_id: tokenRecord.id, 78 | token_type: "account" 79 | }, 80 | tokenRecord: tokenRecord 81 | }; 82 | } catch (error) { 83 | logger.error("验证账户令牌时出错:", error); 84 | return { 85 | valid: false, 86 | message: "验证令牌时发生错误" 87 | }; 88 | } 89 | } 90 | 91 | /** 92 | * 更新令牌使用记录 93 | */ 94 | export async function updateAccountTokenUsage(tokenId, ipAddress) { 95 | try { 96 | await prisma.ow_account_tokens.update({ 97 | where: { 98 | id: tokenId 99 | }, 100 | data: { 101 | last_used_at: new Date(), 102 | last_used_ip: ipAddress 103 | } 104 | }); 105 | } catch (error) { 106 | logger.error("更新账户令牌使用记录时出错:", error); 107 | } 108 | } -------------------------------------------------------------------------------- /src/controllers/users.js: -------------------------------------------------------------------------------- 1 | import logger from "../services/logger.js"; 2 | import {prisma} from "../services/global.js"; 3 | 4 | /** 5 | * Get users by list of IDs 6 | * @param {Array} userIds - List of user IDs 7 | * @returns {Promise>} 8 | */ 9 | async function getUsersByList(userIds) { 10 | const select = { 11 | id: true, 12 | username: true, 13 | display_name: true, 14 | status: true, 15 | regTime: true, 16 | bio: true, 17 | motto: true, 18 | 19 | avatar: true, 20 | }; 21 | 22 | // Get each user's info 23 | const users = await prisma.ow_users.findMany({ 24 | where: { 25 | id: {in: userIds.map((id) => parseInt(id, 10))}, 26 | }, 27 | select, 28 | }); 29 | 30 | return users; 31 | } 32 | 33 | // 获取用户信息通过用户名 34 | export async function getUserByUsername(username) { 35 | try { 36 | const user = await prisma.ow_users.findFirst({ 37 | where: {username}, 38 | select: { 39 | id: true, 40 | username: true, 41 | display_name: true, 42 | status: true, 43 | regTime: true, 44 | bio: true, 45 | motto: true, 46 | avatar: true, 47 | } 48 | }); 49 | return user; 50 | } catch (err) { 51 | logger.error("Error fetching user by username:", err); 52 | throw err; 53 | } 54 | } 55 | 56 | export {getUsersByList}; 57 | 58 | 59 | /** 60 | * 修改用户名 61 | * @param {import("express").Request} req 62 | * @param {import("express").Response} res 63 | */ 64 | export const changeUsername = async (req, res) => { 65 | try { 66 | const userId = res.locals.userid; 67 | const { newUsername } = req.body; 68 | 69 | if (!newUsername || typeof newUsername !== "string" || newUsername.trim().length === 0) { 70 | return res.status(400).json({ 71 | status: "error", 72 | message: "新用户名不能为空", 73 | code: "INVALID_USERNAME" 74 | }); 75 | } 76 | 77 | const trimmedUsername = newUsername.trim(); 78 | 79 | // 检查用户名是否已被使用 80 | const existingUser = await prisma.ow_users.findFirst({ 81 | where: { username: trimmedUsername } 82 | }); 83 | 84 | if (existingUser) { 85 | return res.status(409).json({ 86 | status: "error", 87 | message: "用户名已被使用", 88 | code: "USERNAME_TAKEN" 89 | }); 90 | } 91 | 92 | // 更新用户名 93 | await prisma.ow_users.update({ 94 | where: { id: userId }, 95 | data: { username: trimmedUsername } 96 | }); 97 | 98 | logger.info(`用户 ${userId} 修改用户名为: ${trimmedUsername}`); 99 | 100 | res.json({ 101 | status: "success", 102 | message: "用户名修改成功" 103 | }); 104 | } catch (error) { 105 | logger.error("修改用户名时出错:", error); 106 | res.status(500).json({ 107 | status: "error", 108 | message: "修改用户名失败", 109 | code: "CHANGE_USERNAME_FAILED" 110 | }); 111 | } 112 | }; 113 | -------------------------------------------------------------------------------- /src/routes/router_event.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview Event routes 3 | */ 4 | import express from "express"; 5 | import {needLogin} from "../middleware/auth.js"; 6 | import { 7 | createEvent, 8 | getActorEvents, 9 | getTargetEvents, 10 | } from "../controllers/events.js"; 11 | import logger from "../services/logger.js"; 12 | 13 | const router = express.Router(); 14 | 15 | /** 16 | * @route GET /events/target/:targetType/:targetId 17 | * @desc Get events for a specific target 18 | * @access Public/Private (depends on event privacy) 19 | */ 20 | router.get("/target/:targetType/:targetId", async (req, res) => { 21 | try { 22 | const {targetType, targetId} = req.params; 23 | const {limit = 10, offset = 0} = req.query; 24 | const includePrivate = !!res.locals.userid; 25 | 26 | const events = await getTargetEvents( 27 | targetType, 28 | parseInt(targetId, 10), 29 | parseInt(limit, 10), 30 | parseInt(offset, 10), 31 | includePrivate 32 | ); 33 | 34 | res.json({ 35 | success: true, 36 | events, 37 | total: events.length 38 | }); 39 | } catch (error) { 40 | logger.error("获取目标事件失败:", error); 41 | res.status(500).json({ 42 | error: "获取事件列表失败" 43 | }); 44 | } 45 | }); 46 | 47 | /** 48 | * @route GET /events/actor/:actorId 49 | * @desc Get events for a specific actor 50 | * @access Public/Private (depends on event privacy) 51 | */ 52 | router.get("/actor/:actorId", async (req, res) => { 53 | try { 54 | const {actorId} = req.params; 55 | const {limit = 10, offset = 0} = req.query; 56 | const currentUserId = res.locals.userid; 57 | const includePrivate = currentUserId && (currentUserId === parseInt(actorId, 10)); 58 | 59 | const events = await getActorEvents( 60 | parseInt(actorId, 10), 61 | parseInt(limit, 10), 62 | parseInt(offset, 10), 63 | includePrivate 64 | ); 65 | 66 | res.json({ 67 | success: true, 68 | events, 69 | total: events.length 70 | }); 71 | } catch (error) { 72 | logger.error("获取用户事件失败:", error); 73 | res.status(500).json({ 74 | error: "获取事件列表失败" 75 | }); 76 | } 77 | }); 78 | 79 | /** 80 | * @route POST /events 81 | * @desc Create a new event 82 | * @access Private 83 | */ 84 | router.post("/", needLogin, async (req, res) => { 85 | try { 86 | const {eventType, targetType, targetId, ...eventData} = req.body; 87 | 88 | if (!eventType || !targetType || !targetId) { 89 | return res.status(400).json({ 90 | error: "缺少必要参数: eventType, targetType, targetId" 91 | }); 92 | } 93 | 94 | // Use current user as actor 95 | const actorId = res.locals.userid; 96 | 97 | const event = await createEvent( 98 | eventType, 99 | actorId, 100 | targetType, 101 | parseInt(targetId, 10), 102 | eventData 103 | ); 104 | 105 | if (!event) { 106 | return res.status(400).json({ 107 | error: "事件创建失败" 108 | }); 109 | } 110 | 111 | res.status(201).json({ 112 | success: true, 113 | event 114 | }); 115 | } catch (error) { 116 | logger.error("创建事件失败:", error); 117 | res.status(500).json({ 118 | error: "创建事件失败" 119 | }); 120 | } 121 | }); 122 | 123 | export default router; 124 | -------------------------------------------------------------------------------- /src/routes/router_search.js: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { prisma } from "../services/global.js"; // 功能函数集 3 | import logger from "../services/logger.js"; 4 | 5 | const router = Router(); 6 | 7 | // 搜索:Scratch项目列表:数据(直接查询表) 8 | router.get("/", async (req, res, next) => { 9 | try { 10 | const { 11 | search_userid: userid, 12 | search_type: type, 13 | search_title: title, 14 | search_source: source, 15 | search_description: description, 16 | search_orderby: orderbyQuery = "time_down", 17 | search_tag: tags = "", 18 | curr = 1, 19 | limit = 10, 20 | search_state: stateQuery = "", 21 | } = req.query; 22 | 23 | // 处理 search_userid,支持列表和单个值 24 | let useridArray = []; 25 | if (userid) { 26 | useridArray = userid.split(",").map(id => Number(id.trim())).filter(id => !isNaN(id)); 27 | } 28 | 29 | const isCurrentUser = 30 | useridArray.length > 0 && 31 | res.locals.userid && 32 | useridArray.includes(Number(res.locals.userid)); 33 | 34 | let state = 35 | stateQuery == "" 36 | ? isCurrentUser 37 | ? ["private", "public"] 38 | : ["public"] 39 | : stateQuery == "private" 40 | ? isCurrentUser 41 | ? ["private"] 42 | : ["public"] 43 | : [stateQuery]; 44 | let tagsArray = tags ? tags.split(",") : []; 45 | logger.debug(tagsArray); 46 | // 处理排序 47 | const [orderbyField, orderDirection] = orderbyQuery.split("_"); 48 | const orderbyMap = { 49 | view: "view_count", 50 | time: "time", 51 | id: "id", 52 | star: "star_count", 53 | }; 54 | const orderDirectionMap = { up: "asc", down: "desc" }; // 修正排序方向 55 | const orderBy = orderbyMap[orderbyField] || "time"; 56 | const order = orderDirectionMap[orderDirection] || "desc"; 57 | 58 | // 构建基本搜索条件 59 | const searchinfo = { 60 | title: title ? { contains: title } : undefined, 61 | source: source ? { contains: source } : undefined, 62 | description: description ? { contains: description } : undefined, 63 | type: type ? { equals: type } : undefined, 64 | state: state ? { in: state } : undefined, 65 | authorid: useridArray.length > 0 66 | ? useridArray.length === 1 67 | ? { equals: useridArray[0] } 68 | : { in: useridArray } 69 | : undefined, 70 | project_tags: 71 | tagsArray.length > 0 72 | ? { some: { name: { in: tagsArray } } } 73 | : undefined, 74 | }; 75 | 76 | // 直接查询表 77 | const totalCount = await prisma.ow_projects.count({ 78 | where: searchinfo, 79 | }); 80 | 81 | const projectresult = await prisma.ow_projects.findMany({ 82 | where: searchinfo, 83 | orderBy: { [orderBy]: order }, 84 | select: { 85 | id: true, 86 | title: true, 87 | description: true, 88 | view_count: true, 89 | thumbnail: true, 90 | star_count: true, 91 | time: true, 92 | tags: true, 93 | state: true, 94 | name: true, 95 | author: { 96 | select: { 97 | display_name: true, 98 | id: true, 99 | avatar: true, 100 | username: true, 101 | }, 102 | }, 103 | }, 104 | skip: (Number(curr) - 1) * Number(limit), 105 | take: Number(limit), 106 | }); 107 | 108 | res.status(200).send({ 109 | projects: projectresult, 110 | totalCount: totalCount, 111 | }); 112 | } catch (error) { 113 | next(error); 114 | } 115 | }); 116 | 117 | export default router; 118 | -------------------------------------------------------------------------------- /src/services/auth/oauth.js: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | 3 | /** 4 | * 验证重定向URI是否在允许列表中 5 | * @param {string} redirectUri - 请求的重定向URI 6 | * @param {string[]} allowedUris - 允许的重定向URI列表 7 | * @returns {boolean} 8 | */ 9 | export function validateRedirectUri(redirectUri, allowedUris) { 10 | try { 11 | const requestedUrl = new URL(redirectUri); 12 | return allowedUris.some((allowed) => { 13 | const allowedUrl = new URL(allowed); 14 | return ( 15 | requestedUrl.protocol === allowedUrl.protocol && 16 | requestedUrl.host === allowedUrl.host && 17 | requestedUrl.pathname === allowedUrl.pathname 18 | ); 19 | }); 20 | } catch (error) { 21 | return false; 22 | } 23 | } 24 | 25 | /** 26 | * 生成授权码 27 | * @returns {Promise} 28 | */ 29 | export async function generateAuthCode() { 30 | const bytes = await crypto.randomBytes(32); 31 | return bytes.toString("base64url"); 32 | } 33 | 34 | /** 35 | * 生成访问令牌和刷新令牌 36 | * @returns {Promise<{accessToken: string, refreshToken: string, expiresIn: number}>} 37 | */ 38 | export async function generateTokens() { 39 | const [accessTokenBytes, refreshTokenBytes] = await Promise.all([ 40 | crypto.randomBytes(32), 41 | crypto.randomBytes(32), 42 | ]); 43 | 44 | return { 45 | accessToken: accessTokenBytes.toString("base64url"), 46 | refreshToken: refreshTokenBytes.toString("base64url"), 47 | expiresIn: 3600, // 1小时过期 48 | }; 49 | } 50 | 51 | /** 52 | * 验证请求的权限范围是否在应用允许的范围内 53 | * @param {string[]} requestedScopes - 请求的权限范围 54 | * @param {string[]} allowedScopes - 应用允许的权限范围 55 | * @returns {boolean} 56 | */ 57 | export function validateScopes(requestedScopes, allowedScopes) { 58 | return requestedScopes.every((scope) => allowedScopes.includes(scope)); 59 | } 60 | 61 | /** 62 | * 验证PKCE挑战 63 | * @param {string} codeVerifier - PKCE验证器 64 | * @param {string} codeChallenge - PKCE挑战 65 | * @param {string} codeChallengeMethod - PKCE方法 66 | * @returns {boolean} 67 | */ 68 | export function validatePKCE(codeVerifier, codeChallenge, codeChallengeMethod) { 69 | if (!codeVerifier || !codeChallenge || !codeChallengeMethod) { 70 | return false; 71 | } 72 | 73 | let challenge; 74 | if (codeChallengeMethod === "S256") { 75 | challenge = crypto 76 | .createHash("sha256") 77 | .update(codeVerifier) 78 | .digest("base64url"); 79 | } else if (codeChallengeMethod === "plain") { 80 | challenge = codeVerifier; 81 | } else { 82 | return false; 83 | } 84 | 85 | return challenge === codeChallenge; 86 | } 87 | 88 | /** 89 | * 验证客户端凭证 90 | * @param {string} clientId - 客户端ID 91 | * @param {string} clientSecret - 客户端密钥 92 | * @returns {Promise} 93 | */ 94 | export async function validateClientCredentials(clientId, clientSecret) { 95 | if (!clientId || !clientSecret) { 96 | return false; 97 | } 98 | 99 | // 使用恒定时间比较以防止时序攻击 100 | return crypto.timingSafeEqual( 101 | Buffer.from(clientSecret), 102 | Buffer.from(clientSecret) 103 | ); 104 | } 105 | 106 | /** 107 | * 生成应用密钥 108 | * @returns {Promise<{clientId: string, clientSecret: string}>} 109 | */ 110 | export async function generateAppCredentials() { 111 | const [clientIdBytes, clientSecretBytes] = await Promise.all([ 112 | crypto.randomBytes(16), 113 | crypto.randomBytes(32), 114 | ]); 115 | 116 | return { 117 | clientId: clientIdBytes.toString("hex"), 118 | clientSecret: clientSecretBytes.toString("hex"), 119 | }; 120 | } 121 | -------------------------------------------------------------------------------- /test/sudo-auth-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * sudo认证系统测试脚本 3 | */ 4 | 5 | import { 6 | generateSudoToken, 7 | verifySudoToken, 8 | revokeSudoToken, 9 | sendEmailVerificationCode, 10 | verifyEmailCode, 11 | verifyUserPassword, 12 | authenticateSudo 13 | } from '../src/services/auth/sudoAuth.js'; 14 | import redisClient from '../src/services/redis.js'; 15 | 16 | const testUserId = 1; // 测试用户ID 17 | const testEmail = 'test@example.com'; 18 | 19 | async function runTests() { 20 | console.log('🧪 开始sudo认证系统测试...\n'); 21 | 22 | try { 23 | // 测试1: 生成sudo令牌 24 | console.log('📝 测试1: 生成sudo令牌'); 25 | const tokenResult = await generateSudoToken(testUserId); 26 | console.log('✅ sudo令牌生成成功:', tokenResult.token.substring(0, 16) + '...'); 27 | console.log('⏰ 过期时间:', tokenResult.expiresAt); 28 | console.log(''); 29 | 30 | const sudoToken = tokenResult.token; 31 | 32 | // 测试2: 验证sudo令牌 33 | console.log('🔍 测试2: 验证sudo令牌'); 34 | const verifyResult = await verifySudoToken(sudoToken); 35 | console.log(verifyResult.valid ? '✅ sudo令牌验证成功' : '❌ sudo令牌验证失败'); 36 | console.log('💬 验证消息:', verifyResult.message); 37 | console.log('👤 用户ID:', verifyResult.userId); 38 | console.log(''); 39 | 40 | // 测试3: 验证无效令牌 41 | console.log('🔍 测试3: 验证无效sudo令牌'); 42 | const invalidVerifyResult = await verifySudoToken('invalid_token'); 43 | console.log(invalidVerifyResult.valid ? '❌ 无效令牌验证通过(异常!)' : '✅ 无效令牌验证失败(正常)'); 44 | console.log('💬 验证消息:', invalidVerifyResult.message); 45 | console.log(''); 46 | 47 | // 测试4: 发送邮件验证码(如果邮件配置正确) 48 | console.log('📧 测试4: 发送邮件验证码'); 49 | try { 50 | const emailResult = await sendEmailVerificationCode(testUserId, testEmail); 51 | if (emailResult.success) { 52 | console.log('✅ 邮件验证码发送成功'); 53 | console.log('💬 消息:', emailResult.message); 54 | } else { 55 | console.log('⚠️ 邮件验证码发送失败:', emailResult.message); 56 | } 57 | } catch (error) { 58 | console.log('⚠️ 邮件验证码发送失败(可能未配置邮件服务):', error.message); 59 | } 60 | console.log(''); 61 | 62 | // 测试5: 统一认证(密码方式 - 需要真实用户) 63 | console.log('🔐 测试5: 统一认证(密码方式)'); 64 | try { 65 | const authResult = await authenticateSudo(testUserId, { 66 | method: 'password', 67 | password: 'test_password' 68 | }); 69 | console.log(authResult.success ? '✅ 密码认证成功' : '⚠️ 密码认证失败(可能用户不存在或密码错误)'); 70 | console.log('💬 认证消息:', authResult.message); 71 | } catch (error) { 72 | console.log('⚠️ 密码认证测试失败:', error.message); 73 | } 74 | console.log(''); 75 | 76 | // 测试6: 撤销sudo令牌 77 | console.log('🗑️ 测试6: 撤销sudo令牌'); 78 | const revokeResult = await revokeSudoToken(sudoToken); 79 | console.log(revokeResult ? '✅ sudo令牌撤销成功' : '❌ sudo令牌撤销失败'); 80 | console.log(''); 81 | 82 | // 测试7: 验证已撤销的令牌 83 | console.log('🔍 测试7: 验证已撤销的sudo令牌'); 84 | const revokedVerifyResult = await verifySudoToken(sudoToken); 85 | console.log(revokedVerifyResult.valid ? '❌ 已撤销令牌验证通过(异常!)' : '✅ 已撤销令牌验证失败(正常)'); 86 | console.log('💬 验证消息:', revokedVerifyResult.message); 87 | console.log(''); 88 | 89 | console.log('🎉 sudo认证系统测试完成!'); 90 | 91 | // Redis连接状态检查 92 | console.log('\n📊 Redis连接状态:'); 93 | console.log('连接状态:', redisClient.isConnected ? '✅ 已连接' : '❌ 未连接'); 94 | 95 | } catch (error) { 96 | console.error('❌ 测试过程中发生错误:', error); 97 | } 98 | } 99 | 100 | // 运行测试 101 | runTests().then(() => { 102 | console.log('\n✨ 测试脚本执行完成'); 103 | }).catch(error => { 104 | console.error('❌ 测试脚本执行失败:', error); 105 | }); -------------------------------------------------------------------------------- /prisma/views/zerocat_develop/ow_projects_search_view.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | `p`.`id` AS `id`, 3 | `p`.`name` AS `name`, 4 | `p`.`title` AS `title`, 5 | `p`.`description` AS `description`, 6 | `p`.`authorid` AS `authorid`, 7 | `p`.`state` AS `state`, 8 | `p`.`type` AS `type`, 9 | `p`.`license` AS `license`, 10 | `p`.`star_count` AS `star_count`, 11 | `p`.`time` AS `time`, 12 | coalesce( 13 | ( 14 | SELECT 15 | GROUP_CONCAT(`pt`.`name` SEPARATOR ', ') 16 | FROM 17 | `zerocat_develop`.`ow_projects_tags` `pt` 18 | WHERE 19 | (`pt`.`projectid` = `p`.`id`) 20 | GROUP BY 21 | `pt`.`projectid` 22 | ), 23 | '' 24 | ) AS `tags`, 25 | `u`.`display_name` AS `author_display_name`, 26 | `u`.`username` AS `author_username`, 27 | `u`.`motto` AS `author_motto`, 28 | `u`.`images` AS `author_images`, 29 | `u`.`type` AS `author_type`, 30 | coalesce( 31 | ( 32 | SELECT 33 | `pf`.`source` 34 | FROM 35 | ( 36 | `zerocat_develop`.`ow_projects_file` `pf` 37 | LEFT JOIN `zerocat_develop`.`ow_projects_commits` `pc` ON((`pc`.`commit_file` = `pf`.`sha256`)) 38 | ) 39 | WHERE 40 | ( 41 | (`pc`.`project_id` = `p`.`id`) 42 | AND (`p`.`state` = 'public') 43 | ) 44 | ORDER BY 45 | `pc`.`commit_date` DESC 46 | LIMIT 47 | 1 48 | ), NULL 49 | ) AS `latest_source`, 50 | coalesce( 51 | ( 52 | SELECT 53 | count(0) 54 | FROM 55 | `zerocat_develop`.`ow_comment` `c` 56 | WHERE 57 | ( 58 | (`c`.`page_type` = 'project') 59 | AND (`c`.`page_id` = `p`.`id`) 60 | ) 61 | ), 62 | 0 63 | ) AS `comment_count`, 64 | coalesce( 65 | ( 66 | SELECT 67 | `c`.`text` 68 | FROM 69 | `zerocat_develop`.`ow_comment` `c` 70 | WHERE 71 | ( 72 | (`c`.`page_type` = 'project') 73 | AND (`c`.`page_id` = `p`.`id`) 74 | ) 75 | ORDER BY 76 | `c`.`insertedAt` DESC 77 | LIMIT 78 | 1 79 | ), NULL 80 | ) AS `latest_comment`, 81 | coalesce( 82 | ( 83 | SELECT 84 | GROUP_CONCAT( 85 | `c`.`text` 86 | ORDER BY 87 | `c`.`insertedAt` DESC SEPARATOR ',' 88 | ) 89 | FROM 90 | `zerocat_develop`.`ow_comment` `c` 91 | WHERE 92 | ( 93 | (`c`.`page_type` = 'project') 94 | AND (`c`.`page_id` = `p`.`id`) 95 | ) 96 | LIMIT 97 | 10 98 | ), NULL 99 | ) AS `recent_comments`, 100 | coalesce( 101 | ( 102 | SELECT 103 | json_arrayagg( 104 | json_object( 105 | 'id', 106 | `pc`.`id`, 107 | 'message', 108 | `pc`.`commit_message`, 109 | 'description', 110 | `pc`.`commit_description`, 111 | 'date', 112 | `pc`.`commit_date` 113 | ) 114 | ) 115 | FROM 116 | `zerocat_develop`.`ow_projects_commits` `pc` 117 | WHERE 118 | (`pc`.`project_id` = `p`.`id`) 119 | LIMIT 120 | 5 121 | ), json_array() 122 | ) AS `recent_commits`, 123 | coalesce( 124 | ( 125 | SELECT 126 | json_arrayagg( 127 | json_object( 128 | 'id', 129 | `pb`.`id`, 130 | 'name', 131 | `pb`.`name`, 132 | 'description', 133 | `pb`.`description` 134 | ) 135 | ) 136 | FROM 137 | `zerocat_develop`.`ow_projects_branch` `pb` 138 | WHERE 139 | (`pb`.`projectid` = `p`.`id`) 140 | ), 141 | json_array() 142 | ) AS `branches` 143 | FROM 144 | ( 145 | `zerocat_develop`.`ow_projects` `p` 146 | LEFT JOIN `zerocat_develop`.`ow_users` `u` ON((`p`.`authorid` = `u`.`id`)) 147 | ) 148 | WHERE 149 | (`p`.`state` = 'public') -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * ZeroCat Backend 服务器入口文件 5 | */ 6 | 7 | import "dotenv/config"; 8 | import logger from './src/services/logger.js'; 9 | import {serverConfig} from './src/index.js'; 10 | import {execSync} from 'child_process'; 11 | import fs from 'fs'; 12 | 13 | // 定义需要检查的目录列表 14 | const REQUIRED_DIRECTORIES = [ 15 | 'cache', 16 | 'cache/ip', 17 | 'cache/usercontent' 18 | ]; 19 | 20 | /** 21 | * 检查并创建必需的目录 22 | */ 23 | async function ensureDirectories() { 24 | logger.info('[server] 检查必需目录...'); 25 | 26 | for (const dir of REQUIRED_DIRECTORIES) { 27 | try { 28 | if (!fs.existsSync(dir)) { 29 | logger.info(`[server] 创建目录: ${dir}`); 30 | fs.mkdirSync(dir, {recursive: true}); 31 | } 32 | } catch (error) { 33 | logger.error(`[server] 创建目录失败 ${dir}:`, error); 34 | throw error; 35 | } 36 | } 37 | 38 | logger.info('[server] 目录检查完成'); 39 | } 40 | 41 | /** 42 | * 运行Prisma迁移和生成 43 | */ 44 | async function runPrismaMigrations() { 45 | // 在调试模式下跳过迁移 46 | if (process.env.NODE_ENV === 'development') { 47 | logger.info('[prisma] 调试模式:跳过Prisma迁移和生成'); 48 | return; 49 | } 50 | 51 | try { 52 | logger.info('[prisma] 开始运行Prisma迁移...'); 53 | execSync('npx prisma migrate deploy', {stdio: 'inherit'}); 54 | logger.info('[prisma] Prisma迁移完成'); 55 | 56 | logger.info('[prisma] 开始生成Prisma客户端...'); 57 | execSync('npx prisma generate', {stdio: 'inherit'}); 58 | logger.info('[prisma] Prisma客户端生成完成'); 59 | } catch (error) { 60 | logger.error('[prisma] Prisma迁移或生成失败:', error); 61 | throw error; 62 | } 63 | } 64 | 65 | /** 66 | * 应用主函数 67 | */ 68 | async function main() { 69 | try { 70 | // 打印启动Banner 71 | printBanner(); 72 | 73 | // 检查必需目录 74 | await ensureDirectories(); 75 | 76 | // 运行Prisma迁移和生成 77 | await runPrismaMigrations(); 78 | 79 | // 启动HTTP服务器 80 | await serverConfig.start(); 81 | 82 | // 设置进程事件处理 83 | setupProcessHandlers(); 84 | } catch (error) { 85 | logger.error('[server] 应用启动失败:', error); 86 | process.exit(1); 87 | } 88 | } 89 | 90 | /** 91 | * 打印启动Banner 92 | */ 93 | function printBanner() { 94 | const banner = ` 95 | ============================================================= 96 | ZeroCat Backend Server 97 | 98 | Version: ${process.env.npm_package_version || '1.0.0'} 99 | Environment: ${process.env.NODE_ENV} 100 | Node.js: ${process.version} 101 | ============================================================= 102 | `; 103 | console.log(banner); 104 | } 105 | 106 | /** 107 | * 设置进程事件处理 108 | */ 109 | function setupProcessHandlers() { 110 | // 处理SIGTERM信号 111 | process.on('SIGTERM', async () => { 112 | logger.info('[server] 接收到SIGTERM信号,开始关闭...'); 113 | await gracefulShutdown(); 114 | }); 115 | 116 | // 处理SIGINT信号 117 | process.on('SIGINT', async () => { 118 | logger.info('[server] 接收到SIGINT信号,开始关闭...'); 119 | await gracefulShutdown(); 120 | }); 121 | } 122 | 123 | /** 124 | * 优雅关闭应用 125 | */ 126 | async function gracefulShutdown() { 127 | try { 128 | logger.info('[server] 开始关闭...'); 129 | 130 | // 等待15秒后强制退出 131 | const forceExitTimeout = setTimeout(() => { 132 | logger.error('[server] 关闭超时,强制退出'); 133 | process.exit(1); 134 | }, 15000); 135 | 136 | // 关闭服务器 137 | await serverConfig.stop(); 138 | 139 | // 取消强制退出定时器 140 | clearTimeout(forceExitTimeout); 141 | 142 | logger.info('[server] 应用已安全关闭'); 143 | process.exit(0); 144 | } catch (error) { 145 | logger.error('[server] 关闭过程中出错:', error); 146 | process.exit(1); 147 | } 148 | } 149 | 150 | // 运行应用 151 | main().catch(error => { 152 | logger.error('[server] 应用运行失败:', error); 153 | process.exit(1); 154 | }); -------------------------------------------------------------------------------- /src/routes/router_stars.js: -------------------------------------------------------------------------------- 1 | import logger from "../services/logger.js"; 2 | import {Router} from "express"; 3 | import {needLogin} from "../middleware/auth.js"; 4 | import {createEvent} from "../controllers/events.js"; 5 | import {getProjectStars, getProjectStarStatus, starProject, unstarProject,} from "../controllers/stars.js"; 6 | 7 | const router = Router(); 8 | 9 | /** 10 | * Star a project 11 | * @route POST /star 12 | * @access Private 13 | */ 14 | router.post("/star", needLogin, async (req, res) => { 15 | try { 16 | const projectId = parseInt(req.body.projectid); 17 | 18 | if (!projectId) { 19 | return res 20 | .status(400) 21 | .send({status: "error", message: "项目ID不能为空"}); 22 | } 23 | 24 | await starProject(res.locals.userid, projectId); 25 | 26 | // Add star event 27 | await createEvent( 28 | "project_star", 29 | res.locals.userid, 30 | "project", 31 | projectId, 32 | { 33 | event_type: "project_star", 34 | actor_id: res.locals.userid, 35 | target_type: "project", 36 | target_id: projectId, 37 | action: "star" 38 | } 39 | ); 40 | 41 | res.status(200).send({status: "success", message: "收藏成功", star: 1}); 42 | } catch (err) { 43 | logger.error("Error starring project:", err); 44 | res.status(500).send({status: "error", message: "收藏项目时出错"}); 45 | } 46 | }); 47 | 48 | /** 49 | * Unstar a project 50 | * @route POST /unstar 51 | * @access Private 52 | */ 53 | router.post("/unstar", needLogin, async (req, res) => { 54 | try { 55 | const projectId = parseInt(req.body.projectid); 56 | 57 | if (!projectId) { 58 | return res 59 | .status(400) 60 | .send({status: "error", message: "项目ID不能为空"}); 61 | } 62 | 63 | await unstarProject(res.locals.userid, projectId); 64 | 65 | res 66 | .status(200) 67 | .send({status: "success", message: "取消收藏成功", star: 0}); 68 | } catch (err) { 69 | logger.error("Error unstarring project:", err); 70 | res.status(500).send({status: "error", message: "取消收藏项目时出错"}); 71 | } 72 | }); 73 | 74 | /** 75 | * Check if a project is starred by the current user 76 | * @route GET /checkstar 77 | * @access Public 78 | */ 79 | router.get("/checkstar", async (req, res) => { 80 | try { 81 | const projectId = parseInt(req.query.projectid); 82 | 83 | if (!projectId) { 84 | return res 85 | .status(400) 86 | .send({status: "error", message: "项目ID不能为空"}); 87 | } 88 | 89 | const status = await getProjectStarStatus(res.locals.userid, projectId); 90 | res.status(200).send({ 91 | status: "success", 92 | message: "获取成功", 93 | star: status, 94 | }); 95 | } catch (err) { 96 | logger.error("Error checking star status:", err); 97 | res.status(500).send({status: "error", message: "检查收藏状态时出错"}); 98 | } 99 | }); 100 | 101 | /** 102 | * Get the number of stars for a project 103 | * @route GET /project/:id/stars 104 | * @access Public 105 | */ 106 | router.get("/project/:id/stars", async (req, res) => { 107 | try { 108 | const projectId = parseInt(req.params.id); 109 | 110 | if (!projectId) { 111 | return res.status(400).send({status: "error", message: "项目ID不能为空"}); 112 | } 113 | 114 | const stars = await getProjectStars(projectId); 115 | res.status(200).send({ 116 | status: "success", 117 | message: "获取成功", 118 | data: stars, 119 | }); 120 | } catch (err) { 121 | logger.error("Error getting project stars:", err); 122 | res.status(500).send({status: "error", message: "获取项目收藏数时出错"}); 123 | } 124 | }); 125 | 126 | export default router; 127 | -------------------------------------------------------------------------------- /prisma/migrations/20250615064305_merge_analytics_tables/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `city` on the `AnalyticsDevice` table. All the data in the column will be lost. 5 | - You are about to drop the column `country` on the `AnalyticsDevice` table. All the data in the column will be lost. 6 | - You are about to drop the column `created_at` on the `AnalyticsDevice` table. All the data in the column will be lost. 7 | - You are about to drop the column `device` on the `AnalyticsDevice` table. All the data in the column will be lost. 8 | - You are about to drop the column `ip_address` on the `AnalyticsDevice` table. All the data in the column will be lost. 9 | - You are about to drop the column `subdivision1` on the `AnalyticsDevice` table. All the data in the column will be lost. 10 | - You are about to drop the column `subdivision2` on the `AnalyticsDevice` table. All the data in the column will be lost. 11 | - You are about to drop the column `website_id` on the `AnalyticsDevice` table. All the data in the column will be lost. 12 | - You are about to drop the column `visitor_id` on the `AnalyticsEvent` table. All the data in the column will be lost. 13 | - You are about to drop the column `website_id` on the `AnalyticsEvent` table. All the data in the column will be lost. 14 | - A unique constraint covering the columns `[fingerprint]` on the table `AnalyticsDevice` will be added. If there are existing duplicate values, this will fail. 15 | - Added the required column `fingerprint` to the `AnalyticsDevice` table without a default value. This is not possible if the table is not empty. 16 | - Added the required column `device_id` to the `AnalyticsEvent` table without a default value. This is not possible if the table is not empty. 17 | - Added the required column `url` to the `AnalyticsEvent` table without a default value. This is not possible if the table is not empty. 18 | 19 | */ 20 | -- DropIndex 21 | DROP INDEX `AnalyticsDevice_created_at_idx` ON `AnalyticsDevice`; 22 | 23 | -- DropIndex 24 | DROP INDEX `AnalyticsDevice_website_id_idx` ON `AnalyticsDevice`; 25 | 26 | -- DropIndex 27 | DROP INDEX `AnalyticsEvent_visitor_id_idx` ON `AnalyticsEvent`; 28 | 29 | -- DropIndex 30 | DROP INDEX `AnalyticsEvent_website_id_idx` ON `AnalyticsEvent`; 31 | 32 | -- AlterTable 33 | ALTER TABLE `AnalyticsDevice` DROP COLUMN `city`, 34 | DROP COLUMN `country`, 35 | DROP COLUMN `created_at`, 36 | DROP COLUMN `device`, 37 | DROP COLUMN `ip_address`, 38 | DROP COLUMN `subdivision1`, 39 | DROP COLUMN `subdivision2`, 40 | DROP COLUMN `website_id`, 41 | ADD COLUMN `browser_version` VARCHAR(191) NULL, 42 | ADD COLUMN `device_type` VARCHAR(191) NULL, 43 | ADD COLUMN `device_vendor` VARCHAR(191) NULL, 44 | ADD COLUMN `fingerprint` VARCHAR(191) NOT NULL, 45 | ADD COLUMN `first_seen` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 46 | ADD COLUMN `last_seen` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 47 | ADD COLUMN `os_version` VARCHAR(191) NULL; 48 | 49 | -- AlterTable 50 | ALTER TABLE `AnalyticsEvent` DROP COLUMN `visitor_id`, 51 | DROP COLUMN `website_id`, 52 | ADD COLUMN `city` VARCHAR(191) NULL, 53 | ADD COLUMN `country` VARCHAR(191) NULL, 54 | ADD COLUMN `device_id` INTEGER NOT NULL, 55 | ADD COLUMN `ip_address` VARCHAR(191) NULL, 56 | ADD COLUMN `referrer` VARCHAR(191) NULL, 57 | ADD COLUMN `region` VARCHAR(191) NULL, 58 | ADD COLUMN `timezone` VARCHAR(191) NULL, 59 | ADD COLUMN `url` VARCHAR(191) NOT NULL; 60 | 61 | -- CreateIndex 62 | CREATE INDEX `AnalyticsDevice_first_seen_idx` ON `AnalyticsDevice`(`first_seen`); 63 | 64 | -- CreateIndex 65 | CREATE INDEX `AnalyticsDevice_last_seen_idx` ON `AnalyticsDevice`(`last_seen`); 66 | 67 | -- CreateIndex 68 | CREATE UNIQUE INDEX `AnalyticsDevice_fingerprint_key` ON `AnalyticsDevice`(`fingerprint`); 69 | 70 | -- CreateIndex 71 | CREATE INDEX `AnalyticsEvent_device_id_idx` ON `AnalyticsEvent`(`device_id`); 72 | 73 | -- CreateIndex 74 | CREATE INDEX `AnalyticsEvent_referrer_domain_idx` ON `AnalyticsEvent`(`referrer_domain`); 75 | 76 | -- CreateIndex 77 | CREATE INDEX `AnalyticsEvent_ip_address_idx` ON `AnalyticsEvent`(`ip_address`); 78 | -------------------------------------------------------------------------------- /src/middleware/geetest.js: -------------------------------------------------------------------------------- 1 | import logger from "../services/logger.js"; 2 | import zcconfig from "../services/config/zcconfig.js"; 3 | import axios from "axios"; 4 | import {createHmac} from "crypto"; 5 | 6 | // Get configuration values 7 | let GEE_CAPTCHA_ID = ''; 8 | let GEE_CAPTCHA_KEY = ''; 9 | let GEE_API_SERVER = "http://gcaptcha4.geetest.com/validate"; 10 | 11 | // Initialize configuration async 12 | async function initConfig() { 13 | try { 14 | GEE_CAPTCHA_ID = await zcconfig.get("captcha.GEE_CAPTCHA_ID", ""); 15 | GEE_CAPTCHA_KEY = await zcconfig.get("captcha.GEE_CAPTCHA_KEY", ""); 16 | //logger.debug("Geetest config loaded"); 17 | } catch (err) { 18 | logger.error("[geetest] 加载Geetest配置失败:", err); 19 | } 20 | } 21 | 22 | // Initialize config 23 | initConfig(); 24 | 25 | /** 26 | * 生成签名的函数,使用 HMAC-SHA256 27 | * @param {String} value - 待签名的字符串 28 | * @param {String} key - 签名密钥 29 | * @returns {String} 签名结果 30 | */ 31 | function hmacSha256Encode(value, key) { 32 | return createHmac("sha256", key).update(value, "utf8").digest("hex"); 33 | } 34 | 35 | /** 36 | * 验证码中间件 37 | * @param {Object} req - express的request对象 38 | * @param {Object} res - express的response对象 39 | * @param {Function} next - express的next函数 40 | */ 41 | async function geetestMiddleware(req, res, next) { 42 | // 开发环境下跳过验证码检查 43 | if (process.env.NODE_ENV === "development") { 44 | logger.debug("[geetest] 开发模式: 跳过验证码检查"); 45 | return next(); 46 | } 47 | 48 | // 如果未正确配置验证码,也跳过检查 49 | if (!GEE_CAPTCHA_ID || !GEE_CAPTCHA_KEY) { 50 | logger.warn("[geetest] Geetest未正确配置,跳过验证码检查"); 51 | return next(); 52 | } 53 | 54 | // 验证码信息 55 | let geetest = {}; 56 | 57 | // 处理验证码信息 58 | try { 59 | logger.debug(req.body.captcha); 60 | if (req.body.captcha) { 61 | // 如果是字符串则转为json 62 | if (typeof req.body.captcha === "string") { 63 | geetest = JSON.parse(req.body.captcha); 64 | } else { 65 | geetest = req.body.captcha; 66 | } 67 | } else { 68 | geetest = req.query || req.body; 69 | } 70 | } catch (error) { 71 | logger.error("[geetest] 验证码解析错误:", error); 72 | return res.status(400).json({ 73 | status: "error", 74 | code: 400, 75 | message: "验证码数据无效" 76 | }); 77 | } 78 | 79 | if (!geetest.lot_number || !geetest.captcha_output || !geetest.captcha_id || !geetest.pass_token || !geetest.gen_time) { 80 | logger.error("[geetest] 验证码数据缺失"); 81 | return res.status(400).json({ 82 | status: "error", 83 | code: 400, 84 | message: "验证码数据不完整" 85 | }); 86 | } 87 | 88 | logger.debug(geetest); 89 | 90 | // 生成签名 91 | const sign_token = hmacSha256Encode(geetest.lot_number, GEE_CAPTCHA_KEY); 92 | 93 | // 准备请求参数 94 | const datas = { 95 | lot_number: geetest.lot_number, 96 | captcha_output: geetest.captcha_output, 97 | captcha_id: geetest.captcha_id, 98 | pass_token: geetest.pass_token, 99 | gen_time: geetest.gen_time, 100 | sign_token, 101 | }; 102 | logger.debug(datas); 103 | 104 | try { 105 | // 发送请求到极验服务 106 | logger.debug("[geetest] 发送请求到极验服务..."); 107 | const result = await axios.post(GEE_API_SERVER, datas, { 108 | headers: {"Content-Type": "application/x-www-form-urlencoded"}, 109 | }); 110 | logger.debug(result.data); 111 | 112 | if (result.data.result === "success") { 113 | next(); // 验证成功,继续处理请求 114 | } else { 115 | logger.debug(`[geetest] 验证失败: ${result.data.reason}`); 116 | return res.status(400).json({ 117 | status: "error", 118 | code: 400, 119 | message: `请完成验证码/${result.data.reason}`, 120 | }); 121 | } 122 | } catch (error) { 123 | logger.error("[geetest] 极验服务器错误:", error); 124 | // 极验服务器出错时放行,避免阻塞业务逻辑 125 | next(); 126 | } 127 | } 128 | 129 | export default geetestMiddleware; 130 | 131 | -------------------------------------------------------------------------------- /src/services/auth/sudoAuth.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import {prisma} from '../global.js'; 3 | import redisClient from '../redis.js'; 4 | import logger from '../logger.js'; 5 | import { authenticate } from './unifiedAuth.js'; 6 | 7 | const SUDO_TOKEN_PREFIX = 'sudo_token:'; 8 | const SUDO_TOKEN_EXPIRY = 15 * 60; // 15分钟 9 | 10 | /** 11 | * 生成sudo令牌 12 | * @param {number} userId 用户ID 13 | * @returns {Promise<{token: string, expiresAt: Date}>} 14 | */ 15 | export async function generateSudoToken(userId) { 16 | try { 17 | const token = crypto.randomBytes(32).toString('hex'); 18 | const expiresAt = new Date(Date.now() + SUDO_TOKEN_EXPIRY * 1000); 19 | 20 | const sudoData = { 21 | userId, 22 | createdAt: Date.now(), 23 | expiresAt: expiresAt.getTime() 24 | }; 25 | 26 | await redisClient.set(`${SUDO_TOKEN_PREFIX}${token}`, sudoData, SUDO_TOKEN_EXPIRY); 27 | 28 | logger.info(`[sudo] 为用户 ${userId} 生成sudo令牌,有效期15分钟`); 29 | return { token, expiresAt }; 30 | } catch (error) { 31 | logger.error('[sudo] 生成sudo令牌失败:', error); 32 | throw new Error('生成sudo令牌失败'); 33 | } 34 | } 35 | 36 | /** 37 | * 验证sudo令牌 38 | * @param {string} token sudo令牌 39 | * @returns {Promise<{valid: boolean, userId?: number, message: string}>} 40 | */ 41 | export async function verifySudoToken(token) { 42 | try { 43 | logger.debug(`[sudo] 正在验证sudo令牌: ${token.substring(0, 8)}...`); 44 | if (!token) { 45 | return { valid: false, message: '未提供sudo令牌' }; 46 | } 47 | 48 | const sudoData = await redisClient.get(`${SUDO_TOKEN_PREFIX}${token}`); 49 | 50 | if (!sudoData) { 51 | return { valid: false, message: 'sudo令牌无效或已过期' }; 52 | } 53 | 54 | if (sudoData.expiresAt < Date.now()) { 55 | await redisClient.delete(`${SUDO_TOKEN_PREFIX}${token}`); 56 | return { valid: false, message: 'sudo令牌已过期' }; 57 | } 58 | 59 | return { 60 | valid: true, 61 | userId: sudoData.userId, 62 | message: 'sudo令牌有效' 63 | }; 64 | } catch (error) { 65 | logger.error('[sudo] 验证sudo令牌失败:', error); 66 | return { valid: false, message: '验证sudo令牌时发生错误' }; 67 | } 68 | } 69 | 70 | /** 71 | * 撤销sudo令牌 72 | * @param {string} token sudo令牌 73 | * @returns {Promise} 74 | */ 75 | export async function revokeSudoToken(token) { 76 | try { 77 | await redisClient.delete(`${SUDO_TOKEN_PREFIX}${token}`); 78 | logger.info(`[sudo] sudo令牌已撤销: ${token.substring(0, 8)}...`); 79 | return true; 80 | } catch (error) { 81 | logger.error('[sudo] 撤销sudo令牌失败:', error); 82 | return false; 83 | } 84 | } 85 | 86 | /** 87 | * 使用统一认证系统进行sudo认证 88 | * @param {number} userId 用户ID 89 | * @param {Object} authData 认证数据 90 | * @param {string} authData.method 认证方式:'password' | 'email' | 'totp' | 'passkey' 91 | * @param {string} authData.password 密码(password方式) 92 | * @param {string} authData.codeId 验证码ID(email方式) 93 | * @param {string} authData.code 验证码(email方式) 94 | * @returns {Promise<{success: boolean, token?: string, message: string}>} 95 | */ 96 | export async function authenticateSudo(userId, authData) { 97 | try { 98 | // 使用统一认证系统(允许totp/passkey) 99 | const authResult = await authenticate({ 100 | ...authData, 101 | purpose: 'sudo', 102 | userId 103 | }); 104 | 105 | if (!authResult.success) { 106 | logger.warn(`[sudo] 用户 ${userId} sudo认证失败: ${authResult.message}`); 107 | return { success: false, message: authResult.message }; 108 | } 109 | 110 | // 认证成功,生成sudo令牌 111 | const { token } = await generateSudoToken(userId); 112 | 113 | logger.info(`[sudo] 用户 ${userId} sudo认证成功,方式: ${authData.method}`); 114 | return { 115 | success: true, 116 | token, 117 | message: 'sudo认证成功' 118 | }; 119 | } catch (error) { 120 | logger.error('[sudo] sudo认证失败:', error); 121 | return { success: false, message: 'sudo认证失败' }; 122 | } 123 | } -------------------------------------------------------------------------------- /src/services/email/emailTemplateService.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | import { dirname } from 'path'; 4 | import logger from '../logger.js'; 5 | import zcconfig from '../config/zcconfig.js'; 6 | import ejs from 'ejs'; 7 | import fs from 'fs'; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = dirname(__filename); 11 | 12 | class EmailTemplateService { 13 | constructor() { 14 | this.templateCache = new Map(); 15 | this.templatesPath = path.join(__dirname, '../../templates/emails'); 16 | } 17 | 18 | /** 19 | * 获取站点配置信息 20 | */ 21 | async getSiteConfig() { 22 | const siteName = await zcconfig.get("site.name") || 'ZeroCat'; 23 | const siteDomain = await zcconfig.get("site.domain") || 'zerocat.top'; 24 | const siteEmail = await zcconfig.get("site.email") || 'support@zerocat.top'; 25 | const frontendUrl = await zcconfig.get("urls.frontend") || `https://${siteDomain}`; 26 | 27 | return { 28 | siteName, 29 | siteDomain, 30 | siteEmail, 31 | frontendUrl, 32 | year: new Date().getFullYear() 33 | }; 34 | } 35 | 36 | /** 37 | * 处理链接URL,自动补充协议或前端URL 38 | */ 39 | async processLink(link, isRawLink = false) { 40 | if (!link) return null; 41 | 42 | // 如果是原始链接模式且不包含协议 43 | if (isRawLink && !link.startsWith('http://') && !link.startsWith('https://')) { 44 | const siteConfig = await this.getSiteConfig(); 45 | return `${siteConfig.frontendUrl}${link.startsWith('/') ? '' : '/'}${link}`; 46 | } 47 | 48 | // 如果没有协议,默认添加https:// 49 | if (!link.startsWith('http://') && !link.startsWith('https://')) { 50 | return `https://${link}`; 51 | } 52 | 53 | return link; 54 | } 55 | 56 | /** 57 | * 渲染邮件模板 58 | */ 59 | async renderTemplate(templateName, data) { 60 | try { 61 | const siteConfig = await this.getSiteConfig(); 62 | const templateData = { 63 | ...siteConfig, 64 | ...data, 65 | loginUrl: `${siteConfig.frontendUrl}/login`, 66 | }; 67 | 68 | // 直接使用EJS渲染 69 | const templatePath = path.join(this.templatesPath, `${templateName}.ejs`); 70 | 71 | if (!fs.existsSync(templatePath)) { 72 | throw new Error(`模板文件不存在: ${templatePath}`); 73 | } 74 | 75 | const html = await ejs.renderFile(templatePath, templateData); 76 | // 改进的文本提取,更好地处理HTML标签和空白字符 77 | const text = html 78 | .replace(/]*>[\s\S]*?<\/style>/gi, '') // 移除style标签及其内容 79 | .replace(/]*>[\s\S]*?<\/script>/gi, '') // 移除script标签及其内容 80 | .replace(/<[^>]*>/g, '') // 移除所有HTML标签 81 | .replace(/\s+/g, ' ') // 合并多个空白字符为单个空格 82 | .replace(/\n\s*\n/g, '\n') // 移除多余的空行 83 | .trim(); 84 | 85 | return { 86 | subject: data.subject || templateData.title || '通知', 87 | html, 88 | text 89 | }; 90 | } catch (error) { 91 | logger.error(`渲染邮件模板失败 ${templateName}:`, error); 92 | throw error; 93 | } 94 | } 95 | 96 | /** 97 | * 从HTML中提取纯文本内容(用于通知内容) 98 | */ 99 | extractPlainText(html) { 100 | return html 101 | .replace(/]*>[\s\S]*?<\/style>/gi, '') // 移除style标签及其内容 102 | .replace(/]*>[\s\S]*?<\/script>/gi, '') // 移除script标签及其内容 103 | .replace(/<[^>]*>/g, '') // 移除所有HTML标签 104 | .replace(/\s+/g, ' ') // 合并多个空白字符为单个空格 105 | .replace(/\n\s*\n/g, '\n') // 移除多余的空行 106 | .trim() 107 | .substring(0, 200) + '...'; // 截取前200字符 108 | } 109 | } 110 | 111 | // 创建单例实例 112 | const emailTemplateService = new EmailTemplateService(); 113 | 114 | export default emailTemplateService; 115 | 116 | // 导出核心方法 117 | export const { 118 | renderTemplate, 119 | getSiteConfig, 120 | processLink, 121 | extractPlainText 122 | } = emailTemplateService; -------------------------------------------------------------------------------- /src/services/global.js: -------------------------------------------------------------------------------- 1 | import zcconfig from "./config/zcconfig.js"; 2 | import logger from "./logger.js"; 3 | import crypto from "crypto"; 4 | import jwt from "jsonwebtoken"; 5 | import { PasswordHash } from "phpass"; 6 | import fs from "fs"; 7 | import { createRequire } from 'module'; 8 | 9 | // Some Node.js environments or older bundlers may not support `import ... assert { type: 'json' }`. 10 | // Use createRequire to synchronously load JSON in a compatible way. 11 | const require = createRequire(import.meta.url); 12 | const DisposableDomains = require('disposable-domains/index.json'); 13 | const DisposableWildcards = require('disposable-domains/wildcard.json'); 14 | //prisma client 15 | import { PrismaClient } from "@prisma/client"; 16 | import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; 17 | 18 | const prisma = new PrismaClient() 19 | 20 | 21 | const pwdHash = new PasswordHash(); 22 | const s3config = { 23 | endpoint: await zcconfig.get("s3.endpoint"), 24 | region: await zcconfig.get("s3.region"), 25 | credentials: { 26 | accessKeyId: await zcconfig.get("s3.AWS_ACCESS_KEY_ID"), 27 | secretAccessKey: await zcconfig.get("s3.AWS_SECRET_ACCESS_KEY"), 28 | }, 29 | }; 30 | //logger.debug(s3config); 31 | 32 | const s3 = new S3Client(s3config); 33 | 34 | async function S3update(name, fileContent) { 35 | try { 36 | const command = new PutObjectCommand({ 37 | Bucket: await zcconfig.get("s3.bucket"), 38 | Key: name, 39 | Body: fileContent, 40 | }); 41 | 42 | const data = await s3.send(command); 43 | logger.debug(data); 44 | logger.debug( 45 | `成功上传了文件 ${await zcconfig.get("s3.bucket")}/${name}` 46 | ); 47 | } catch (err) { 48 | logger.error("S3 update Error:", err); 49 | } 50 | } 51 | 52 | async function S3updateFromPath(name, path) { 53 | try { 54 | const fileContent = fs.readFileSync(path); 55 | await S3update(name, fileContent); 56 | } catch (err) { 57 | logger.error("S3 update Error:", err); 58 | } 59 | } 60 | 61 | function md5(data) { 62 | return crypto.createHash("md5").update(data).digest("base64"); 63 | } 64 | 65 | function hash(data) { 66 | return pwdHash.hashPassword(data); 67 | } 68 | 69 | function checkhash(pwd, storeHash) { 70 | return pwdHash.checkPassword(pwd, storeHash); 71 | } 72 | 73 | function userpwTest(pw) { 74 | return /^(?:\d+|[a-zA-Z]+|[!@#$%^&*]+){6,16}$/.test(pw); 75 | } 76 | 77 | function emailTest(email) { 78 | return /^([a-zA-Z]|[0-9])(\w|\-)+@[a-zA-Z0-9]+\.[a-zA-Z]{2,4}$/.test(email); 79 | } 80 | 81 | 82 | function randomPassword(len = 12) { 83 | const chars = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"; 84 | const maxPos = chars.length; 85 | const password = Array.from({ length: len - 4 }, () => 86 | chars.charAt(Math.floor(Math.random() * maxPos)) 87 | ).join(""); 88 | return `${password}@Aa1`; 89 | } 90 | 91 | async function generateJwt(json) { 92 | try { 93 | const secret = await zcconfig.get("security.jwttoken"); 94 | logger.debug(secret); 95 | if (!secret) { 96 | throw new Error("JWT secret is not defined in the configuration"); 97 | } 98 | return jwt.sign(json, secret); 99 | } catch (error) { 100 | logger.error("Error generating JWT:", error); 101 | throw error; 102 | } 103 | } 104 | 105 | function isJSON(str) { 106 | if (typeof str !== "string") return false; 107 | try { 108 | const obj = JSON.parse(str); 109 | return obj && typeof obj === "object"; 110 | } catch (e) { 111 | logger.error("error:", str, e); 112 | return false; 113 | } 114 | } 115 | function isDisposableEmail(email) { 116 | 117 | //在DisposableDomains与DisposableWildcards两个array中查找域名 118 | if (email) { 119 | email = email.toLowerCase().trim(); 120 | } 121 | if (!email || !email.includes("@")) { 122 | return false; 123 | } 124 | 125 | const domain = email.split("@")[1]; 126 | return DisposableDomains.includes(domain) || 127 | DisposableWildcards.some(wildcard => domain.endsWith(wildcard)); 128 | } 129 | export { 130 | prisma, 131 | S3updateFromPath, 132 | S3update, 133 | md5, 134 | hash, 135 | checkhash, 136 | userpwTest, 137 | emailTest, 138 | randomPassword, 139 | generateJwt, 140 | isJSON, 141 | isDisposableEmail, 142 | }; 143 | 144 | -------------------------------------------------------------------------------- /docs/admin-notifications-api.md: -------------------------------------------------------------------------------- 1 | 注:涉及登录的接口可能在启用2FA时返回need_2fa状态,随后需调用`/account/2fa/login/totp`或passkey登录完成流程。 2 | 参考一下文档和已有管理员页面,创建新的与代码集成的通知管理员页面 3 | 发送通知`POST /admin/notifications/send` 4 | | 参数 | 类型 | 必需 | 默认值 | 说明 | 5 | |------|------|------|--------|------| 6 | | `recipient` | string | ✓ | - | 接收者用户名或ID | 7 | | `title` | string | ✓ | - | 通知标题 | 8 | | `content` | string | ✓ | - | 通知内容 | 9 | | `link` | string | ✗ | - | 可点击的链接 | 10 | | `high_priority` | boolean | ✗ | false | 是否为高优先级通知 | 11 | | `notification_type` | string | ✗ | 'system_announcement' | 通知类型 | 12 | | `metadata` | object | ✗ | {} | 附加元数据 | 13 | 14 | ```json 15 | { 16 | "success": true, 17 | "message": "通知发送成功", 18 | "result": { 19 | "notification_id": 12345, 20 | "recipient_id": 789, 21 | "sent_at": "2024-01-01T12:00:00Z" 22 | } 23 | } 24 | ``` 25 | 26 | 27 | ## 2. 群发通知 28 | 29 | ### `POST /admin/notifications/broadcast` 30 | 31 | 向多个用户群发通知。 32 | 33 | #### 请求参数 34 | 35 | | 参数 | 类型 | 必需 | 默认值 | 说明 | 36 | |------|------|------|--------|------| 37 | | `recipients` | array | ✓ | - | 接收者用户名或ID数组 | 38 | | `title` | string | ✓ | - | 通知标题 | 39 | | `content` | string | ✓ | - | 通知内容 | 40 | | `link` | string | ✗ | - | 可点击的链接 | 41 | | `high_priority` | boolean | ✗ | false | 是否为高优先级通知 | 42 | | `notification_type` | string | ✗ | 'system_announcement' | 通知类型 | 43 | | `metadata` | object | ✗ | {} | 附加元数据 | 44 | 45 | 46 | #### 响应示例 47 | 48 | ```json 49 | { 50 | "success": true, 51 | "message": "群发通知完成: 成功 3/3", 52 | "result": { 53 | "total": 3, 54 | "successful": [ 55 | {"user_id": 1, "notification_id": 101}, 56 | {"user_id": 2, "notification_id": 102}, 57 | {"user_id": 3, "notification_id": 103} 58 | ], 59 | "failed": [] 60 | } 61 | } 62 | ``` 63 | 64 | --- 65 | 66 | ## 3. 获取通知列表 67 | 68 | ### `GET /admin/notifications/list` 69 | 70 | 获取所有通知的管理视图,支持过滤和分页。 71 | 72 | #### 查询参数 73 | 74 | | 参数 | 类型 | 必需 | 默认值 | 说明 | 75 | |------|------|------|--------|------| 76 | | `limit` | integer | ✗ | 20 | 每页数量 | 77 | | `offset` | integer | ✗ | 0 | 偏移量 | 78 | | `user_id` | string | ✗ | - | 按用户ID过滤 | 79 | | `notification_type` | string | ✗ | - | 按通知类型过滤 | 80 | | `unread_only` | boolean | ✗ | false | 只显示未读通知 | 81 | | `date_from` | string | ✗ | - | 开始日期 (ISO格式) | 82 | | `date_to` | string | ✗ | - | 结束日期 (ISO格式) | 83 | 84 | 85 | #### 响应示例 86 | 87 | ```json 88 | { 89 | "success": true, 90 | "notifications": [ 91 | { 92 | "id": 12345, 93 | "title": "系统维护通知", 94 | "content": "系统将于今晚进行维护", 95 | "user_id": 789, 96 | "username": "user123", 97 | "notification_type": "system_announcement", 98 | "read": false, 99 | "created_at": "2024-01-01T12:00:00Z" 100 | } 101 | ], 102 | "total": 156, 103 | "has_more": true 104 | } 105 | ``` 106 | 107 | --- 108 | 109 | ## 4. 获取统计信息 110 | 111 | ### `GET /admin/notifications/stats` 112 | 113 | 获取通知系统的统计信息。 114 | 115 | #### 响应示例 116 | 117 | ```json 118 | { 119 | "success": true, 120 | "stats": { 121 | "total_notifications": 1234, 122 | "unread_notifications": 56, 123 | "notifications_today": 23, 124 | "notifications_this_week": 145, 125 | "top_notification_types": [ 126 | {"type": "system_announcement", "count": 456}, 127 | {"type": "user_interaction", "count": 321}, 128 | {"type": "feature_announcement", "count": 234} 129 | ], 130 | "active_users_with_notifications": 89 131 | } 132 | } 133 | ``` 134 | 135 | --- 136 | 137 | ## 5. 搜索用户 138 | 139 | ### `GET /admin/notifications/users/search` 140 | 141 | 搜索用户,用于发送通知时的用户选择。 142 | 143 | #### 查询参数 144 | 145 | | 参数 | 类型 | 必需 | 默认值 | 说明 | 146 | |------|------|------|--------|------| 147 | | `q` | string | ✓ | - | 搜索查询字符串 (最少2个字符) | 148 | | `limit` | integer | ✗ | 20 | 返回结果数量限制 | 149 | 150 | #### 示例请求 151 | 152 | ```javascript 153 | GET /admin/notifications/users/search?q=john&limit=10 154 | ``` 155 | 156 | #### 响应示例 157 | 158 | ```json 159 | { 160 | "success": true, 161 | "users": [ 162 | { 163 | "id": 123, 164 | "username": "john_doe", 165 | "display_name": "John Doe", 166 | "avatar": "/avatars/john.jpg", 167 | "email": "john@example.com", 168 | "type": "user" 169 | }, 170 | { 171 | "id": 456, 172 | "username": "johnny", 173 | "display_name": "Johnny Smith", 174 | "avatar": "/avatars/johnny.jpg", 175 | "email": "johnny@example.com", 176 | "type": "user" 177 | } 178 | ], 179 | "total": 2 180 | } 181 | ``` 182 | 183 | --- 184 | -------------------------------------------------------------------------------- /src/routes/admin/coderun.js: -------------------------------------------------------------------------------- 1 | import {Router} from "express"; 2 | import {PrismaClient} from "@prisma/client"; 3 | import {needAdmin} from "../../middleware/auth.js"; 4 | import codeRunManager from "../../services/coderunManager.js"; 5 | 6 | const router = Router(); 7 | 8 | const prisma = new PrismaClient(); 9 | 10 | // List all CodeRun devices 11 | router.get("/devices", needAdmin, async (req, res) => { 12 | try { 13 | const devices = await prisma.ow_coderun_devices.findMany({ 14 | orderBy: {created_at: "desc"}, 15 | }); 16 | res.json({success: true, devices}); 17 | } catch (error) { 18 | console.error("Error fetching devices:", error); 19 | res.status(500).json({success: false, error: "Internal server error"}); 20 | } 21 | }); 22 | 23 | // Get runner status from manager 24 | router.get("/status", needAdmin, async (req, res) => { 25 | try { 26 | const runnerStatus = codeRunManager.getAllRunners(); 27 | res.json({success: true, status: runnerStatus}); 28 | } catch (error) { 29 | console.error("Error fetching runner status:", error); 30 | res.status(500).json({success: false, error: "Internal server error"}); 31 | } 32 | }); 33 | 34 | // Get a single device status 35 | router.get("/devices/:id/status", needAdmin, async (req, res) => { 36 | try { 37 | const status = codeRunManager.getRunnerStatus(req.params.id); 38 | if (!status) { 39 | return res 40 | .status(404) 41 | .json({success: false, error: "Runner status not found"}); 42 | } 43 | res.json({success: true, status}); 44 | } catch (error) { 45 | console.error("Error fetching device status:", error); 46 | res.status(500).json({success: false, error: "Internal server error"}); 47 | } 48 | }); 49 | 50 | // Get a single device 51 | router.get("/devices/:id", needAdmin, async (req, res) => { 52 | try { 53 | const device = await prisma.ow_coderun_devices.findUnique({ 54 | where: {id: req.params.id}, 55 | }); 56 | if (!device) { 57 | return res 58 | .status(404) 59 | .json({success: false, error: "Device not found"}); 60 | } 61 | res.json({success: true, device}); 62 | } catch (error) { 63 | console.error("Error fetching device:", error); 64 | res.status(500).json({success: false, error: "Internal server error"}); 65 | } 66 | }); 67 | 68 | // Update device configuration 69 | router.put("/devices/:id", needAdmin, async (req, res) => { 70 | try { 71 | const {device_name, request_url, device_config, status} = req.body; 72 | const device = await prisma.ow_coderun_devices.update({ 73 | where: {id: req.params.id}, 74 | data: { 75 | device_name, 76 | request_url, 77 | device_config, 78 | status, 79 | updated_at: new Date(), 80 | }, 81 | }); 82 | 83 | // Update status in manager if changed 84 | if (status) { 85 | await codeRunManager.updateRunnerStatus(req.params.id, status); 86 | } 87 | 88 | res.json({success: true, device}); 89 | } catch (error) { 90 | console.error("Error updating device:", error); 91 | res.status(500).json({success: false, error: "Internal server error"}); 92 | } 93 | }); 94 | 95 | // Delete a device 96 | router.delete("/devices/:id", needAdmin, async (req, res) => { 97 | try { 98 | await prisma.ow_coderun_devices.delete({ 99 | where: {id: req.params.id}, 100 | }); 101 | res.json({success: true}); 102 | } catch (error) { 103 | console.error("Error deleting device:", error); 104 | res.status(500).json({success: false, error: "Internal server error"}); 105 | } 106 | }); 107 | 108 | // Delete all inactive devices 109 | router.delete("/devices/inactive/all", needAdmin, async (req, res) => { 110 | try { 111 | const result = await prisma.ow_coderun_devices.deleteMany({ 112 | where: { 113 | status: { 114 | not: "active", 115 | }, 116 | }, 117 | }); 118 | res.json({success: true, deletedCount: result.count}); 119 | } catch (error) { 120 | console.error("Error deleting inactive devices:", error); 121 | res.status(500).json({success: false, error: "Internal server error"}); 122 | } 123 | }); 124 | 125 | export default router; 126 | -------------------------------------------------------------------------------- /src/services/errorHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview 全局错误处理服务 3 | * 提供统一的错误处理机制,包括未捕获异常、Express错误等 4 | */ 5 | import logger from './logger.js'; 6 | 7 | /** 8 | * 错误处理服务类 9 | */ 10 | class ErrorHandlerService { 11 | /** 12 | * 创建Express错误处理中间件 13 | * @returns {Function} Express错误处理中间件 14 | */ 15 | createExpressErrorHandler() { 16 | return (err, req, res, next) => { 17 | // 记录错误 18 | this.logError(err, req); 19 | 20 | // 获取错误状态码,默认500 21 | const statusCode = err.status || err.statusCode || 500; 22 | 23 | // 判断是否为生产环境 24 | const isProd = process.env.NODE_ENV === 'production'; 25 | 26 | // 构造错误响应 27 | const errorResponse = { 28 | status: 'error', 29 | code: err.code || 'server_error', 30 | message: err.message || '服务器内部错误' 31 | }; 32 | 33 | // 在非生产环境下,添加详细错误信息 34 | if (!isProd) { 35 | errorResponse.stack = err.stack; 36 | errorResponse.details = err.details || null; 37 | } 38 | 39 | // 发送错误响应 40 | res.status(statusCode).json(errorResponse); 41 | }; 42 | } 43 | 44 | /** 45 | * 注册全局未捕获异常处理器 46 | */ 47 | registerGlobalHandlers() { 48 | // 处理未捕获的Promise异常 49 | process.on('unhandledRejection', (reason, promise) => { 50 | logger.error('[errorHandler] 未捕获的Promise异常:', reason); 51 | }); 52 | 53 | // 处理未捕获的同步异常 54 | process.on('uncaughtException', (error) => { 55 | logger.error('[errorHandler] 未捕获的异常:', error); 56 | 57 | // 如果是严重错误,可能需要优雅退出 58 | if (this.isFatalError(error)) { 59 | logger.error('[errorHandler] 检测到严重错误,应用将在1秒后退出'); 60 | 61 | // 延迟退出,给日志写入时间 62 | setTimeout(() => { 63 | process.exit(1); 64 | }, 1000); 65 | } 66 | }); 67 | 68 | logger.info('[errorHandler] 全局错误处理器已注册'); 69 | } 70 | 71 | /** 72 | * 判断是否为致命错误 73 | * @param {Error} error - 错误对象 74 | * @returns {boolean} 是否为致命错误 75 | */ 76 | isFatalError(error) { 77 | // 这些类型的错误通常表明程序状态已不可靠 78 | const fatalErrorTypes = [ 79 | 'EvalError', 80 | 'RangeError', 81 | 'ReferenceError', 82 | 'SyntaxError', 83 | 'URIError' 84 | ]; 85 | 86 | // 一些系统错误也可能是致命的 87 | const fatalSystemErrors = [ 88 | 'EADDRINUSE', // 端口被占用 89 | 'ECONNREFUSED', // 连接被拒绝 90 | 'EACCES', // 权限拒绝 91 | 'ENOENT', // 找不到文件 92 | 'ESOCKETTIMEDOUT' // 套接字超时 93 | ]; 94 | 95 | return ( 96 | fatalErrorTypes.includes(error.name) || 97 | (error.code && fatalSystemErrors.includes(error.code)) 98 | ); 99 | } 100 | 101 | /** 102 | * 记录错误信息 103 | * @param {Error} error - 错误对象 104 | * @param {Object} req - Express请求对象 105 | */ 106 | logError(error, req = null) { 107 | // 构建基本错误信息 108 | const errorInfo = { 109 | message: error.message, 110 | stack: error.stack, 111 | name: error.name, 112 | code: error.code 113 | }; 114 | 115 | // 如果有请求对象,添加请求信息 116 | if (req) { 117 | errorInfo.request = { 118 | method: req.method, 119 | url: req.originalUrl || req.url, 120 | headers: this.sanitizeHeaders(req.headers), 121 | ip: req.ipInfo?.clientIP || req.ip || req.connection.remoteAddress 122 | }; 123 | } 124 | 125 | // 记录详细错误日志 126 | logger.error('[errorHandler] 应用错误:', errorInfo); 127 | } 128 | 129 | /** 130 | * 清理请求头中的敏感信息 131 | * @param {Object} headers - 请求头对象 132 | * @returns {Object} 清理后的请求头 133 | */ 134 | sanitizeHeaders(headers) { 135 | const sanitized = {...headers}; 136 | 137 | // 移除敏感信息 138 | const sensitiveHeaders = [ 139 | 'authorization', 140 | 'cookie', 141 | 'set-cookie', 142 | 'x-api-key' 143 | ]; 144 | 145 | sensitiveHeaders.forEach(header => { 146 | if (sanitized[header]) { 147 | sanitized[header] = '[REDACTED]'; 148 | } 149 | }); 150 | 151 | return sanitized; 152 | } 153 | } 154 | 155 | // 创建单例 156 | const errorHandlerService = new ErrorHandlerService(); 157 | 158 | export default errorHandlerService; -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import express from "express"; 3 | import logger from "./services/logger.js"; 4 | 5 | // 导入配置模块 6 | import {configureMiddleware} from "./index.js"; 7 | import {configureRoutes} from "./routes.js"; 8 | import zcconfigInstance from "./services/config/zcconfig.js"; 9 | 10 | // 导入服务 11 | import geoIpService from "./services/ip/ipLocation.js"; 12 | import schedulerService from "./services/scheduler.js"; 13 | import errorHandlerService from "./services/errorHandler.js"; 14 | import sitemapService from './services/sitemap.js'; 15 | import codeRunManager from './services/coderunManager.js'; 16 | 17 | // 全局初始化标志,防止重复初始化 18 | global.appInitialized = global.appInitialized || false; 19 | 20 | /** 21 | * 应用程序主类 22 | */ 23 | class Application { 24 | constructor() { 25 | this.app = express(); 26 | this._initPromise = this.configureApp(); 27 | } 28 | 29 | /** 30 | * 获取初始化完成的Promise 31 | * @returns {Promise} 初始化Promise 32 | */ 33 | get initialized() { 34 | return this._initPromise; 35 | } 36 | 37 | /** 38 | * 配置应用程序 39 | */ 40 | async configureApp() { 41 | try { 42 | logger.debug('[app] 开始配置应用程序...'); 43 | 44 | // 初始化配置并设置为全局变量 45 | await zcconfigInstance.initialize(); 46 | global.config = {}; 47 | 48 | // 设置全局配置访问器 49 | Object.defineProperty(global, 'config', { 50 | get: () => { 51 | const configs = {}; 52 | for (const [key, value] of zcconfigInstance.cache.entries()) { 53 | configs[key] = value; 54 | } 55 | return configs; 56 | }, 57 | configurable: false, 58 | enumerable: true 59 | }); 60 | 61 | // 设置全局公共配置访问器 62 | Object.defineProperty(global, 'publicconfig', { 63 | get: () => { 64 | return zcconfigInstance.getPublicConfigs(); 65 | }, 66 | configurable: false, 67 | enumerable: true 68 | }); 69 | 70 | // 配置中间件 71 | await configureMiddleware(this.app); 72 | logger.debug('[app] 中间件配置完成'); 73 | // 配置路由 74 | await configureRoutes(this.app); 75 | logger.debug('[app] 路由配置完成'); 76 | // 添加全局错误处理中间件 77 | this.app.use(errorHandlerService.createExpressErrorHandler()); 78 | logger.debug('[app] 全局错误处理中间件配置完成'); 79 | // 设置未捕获异常处理 80 | this.setupExceptionHandling(); 81 | logger.debug('[app] 未捕获异常处理配置完成'); 82 | // 初始化sitemap服务 83 | await sitemapService.initialize(); 84 | logger.debug('[app] sitemap服务初始化完成'); 85 | logger.info('[app] 应用程序配置完成'); 86 | } catch (error) { 87 | logger.error('[app] 应用配置失败:', error); 88 | process.exit(1); 89 | } 90 | } 91 | 92 | /** 93 | * 设置全局异常处理 94 | */ 95 | setupExceptionHandling() { 96 | // 使用错误处理服务注册全局处理器 97 | errorHandlerService.registerGlobalHandlers(); 98 | } 99 | 100 | /** 101 | * 初始化服务 102 | */ 103 | async initializeServices() { 104 | try { 105 | // 防止重复初始化服务 106 | if (global.appInitialized) { 107 | logger.debug('[app] 服务已经初始化过,跳过重复初始化'); 108 | return; 109 | } 110 | 111 | logger.info('[app] 开始初始化服务...'); 112 | //TODO 初始化MaxMind GeoIP服务 113 | // 初始化GeoIP服务 114 | await geoIpService.loadConfigFromDB().catch(error => { 115 | logger.error('[app] 初始化MaxMind GeoIP失败:', error); 116 | }); 117 | 118 | // 初始化调度服务 119 | schedulerService.initialize(); 120 | 121 | // Initialize CodeRunManager 122 | await codeRunManager.initialize(); 123 | 124 | logger.info('[app] 所有服务初始化完成'); 125 | 126 | // 标记应用已初始化 127 | global.appInitialized = true; 128 | } catch (error) { 129 | logger.error('[app] 服务初始化失败:', error); 130 | } 131 | } 132 | 133 | /** 134 | * 启动应用 135 | * @returns {express.Application} Express应用实例 136 | */ 137 | getApp() { 138 | return this.app; 139 | } 140 | } 141 | 142 | // 创建应用实例 143 | const application = new Application(); 144 | 145 | // 初始化服务 146 | Promise.all([ 147 | application.initialized, 148 | application.initializeServices() 149 | ]).catch(error => { 150 | logger.error('[app] 初始化失败:', error); 151 | }); 152 | 153 | // 导出Express应用实例 154 | export default application.getApp(); 155 | -------------------------------------------------------------------------------- /docs/sudo-auth-system.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 你需要参考文档,创建一个sudotoken的管理组件,可以调用获取sudotoken,如果没有则阻塞执行并弹出弹框,创建统一的账户认证组件,允许使用各种认证方式。任何关键的请求都可以调用sudo组件获取sudotoken,sudotoken管理组件需要存储记录 4 | ,并判断过期等等,保留一定缓冲时间。 5 | ## 认证方式与目的 6 | 7 | **认证方式**:password, email, totp, passkey 8 | 9 | **获取认证方法** 10 | ``` 11 | GET /auth/methods?purpose=login 12 | ``` 13 | 14 | **响应:** 15 | ```json 16 | { 17 | "status": "success", 18 | "data": { 19 | "purpose": "login", 20 | "available_methods": ["password", "email", "passkey"] 21 | } 22 | } 23 | ``` 24 | ## 二次验证(2FA) 25 | 26 | 当用户启用了2FA后,密码/邮箱/魔术链接登录将返回need_2fa响应: 27 | 28 | ``` 29 | POST /account/login 30 | ``` 31 | 32 | 成功但需要2FA示例: 33 | 34 | ```json 35 | { 36 | "status": "need_2fa", 37 | "message": "需要二次验证", 38 | "data": { 39 | "challenge_id": "abc123...", 40 | "expires_in": 600, 41 | "available_methods": ["totp", "passkey"] 42 | } 43 | } 44 | ``` 45 | 46 | 使用TOTP完成登录: 47 | 48 | ``` 49 | POST /account/2fa/login/totp 50 | { 51 | "challenge_id": "abc123...", 52 | "token": "123456" 53 | } 54 | ``` 55 | 56 | 响应(登录成功): 57 | 58 | ```json 59 | { 60 | "status": "success", 61 | "data": { 62 | "user": {"id": 123, "username": "testuser"}, 63 | "access_token": "...", 64 | "refresh_token": "...", 65 | "expires_in": 3600 66 | } 67 | } 68 | ``` 69 | 70 | 管理2FA: 71 | 72 | ``` 73 | GET /account/2fa/status 74 | POST /account/2fa/setup 75 | POST /account/2fa/activate { token } 76 | POST /account/2fa/disable 77 | ``` 78 | 79 | ## Passkey(WebAuthn) 80 | 81 | 注册: 82 | 83 | ``` 84 | POST /account/passkey/begin-registration 85 | POST /account/passkey/finish-registration 86 | ``` 87 | 88 | 登录: 89 | 90 | ``` 91 | POST /account/passkey/begin-login { identifier } 92 | POST /account/passkey/finish-login { user_id, challenge, assertion } 93 | ``` 94 | 95 | sudo 提升: 96 | 97 | ``` 98 | POST /account/passkey/sudo-begin 99 | POST /account/passkey/sudo-finish { challenge, assertion } 100 | ``` 101 | 102 | 103 | **发送验证码** 104 | ``` 105 | POST /auth/send-code 106 | { 107 | "email": "user@example.com", 108 | "purpose": "login", 109 | "userId": 123 110 | } 111 | ``` 112 | **响应:** 113 | ```json 114 | { 115 | "status": "success", 116 | "data": { 117 | "code_id": "uuid" 118 | } 119 | } 120 | ``` 121 | **统一认证** 122 | ``` 123 | POST /auth/authenticate 124 | ``` 125 | 126 | 密码认证示例: 127 | ```json 128 | { 129 | "method": "password", 130 | "purpose": "login", 131 | "identifier": "username_or_email", 132 | "password": "your_password" 133 | } 134 | ``` 135 | 136 | 注意:启用2FA的用户不会直接在此接口获得令牌,需要按照2FA流程完成。 137 | 138 | **成功响应(登录):** 139 | ```json 140 | { 141 | "status": "success", 142 | "message": "login认证成功", 143 | "data": { 144 | "user": { 145 | "id": 123, 146 | "username": "testuser", 147 | "display_name": "Test User", 148 | "email": "user@example.com" 149 | }, 150 | "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", 151 | "refresh_token": "def502001a2b3c...", 152 | "expires_in": 3600 153 | } 154 | } 155 | ``` 156 | 157 | 邮件认证示例: 158 | ```json 159 | { 160 | "method": "email", 161 | "purpose": "sudo", 162 | "code_id": "uuid-from-send-code", 163 | "code": "123456" 164 | } 165 | ``` 166 | 167 | **成功响应(sudo):** 168 | ```json 169 | { 170 | "status": "success", 171 | "message": "sudo认证成功", 172 | "data": { 173 | "sudo_token": "a1b2c3d4e5f6...", 174 | "expires_in": 900 175 | } 176 | } 177 | ``` 178 | 179 | 也可以使用passkey完成sudo: 180 | 181 | ``` 182 | POST /account/passkey/sudo-begin 183 | POST /account/passkey/sudo-finish { challenge, assertion } 184 | ``` 185 | 186 | **重置密码认证示例:** 187 | ```json 188 | { 189 | "method": "email", 190 | "purpose": "reset_password", 191 | "code_id": "uuid-from-send-code", 192 | "code": "654321" 193 | } 194 | ``` 195 | 196 | **成功响应(重置密码):** 197 | ```json 198 | { 199 | "status": "success", 200 | "message": "reset_password认证成功", 201 | "data": { 202 | "reset_token": "reset_abc123...", 203 | "expires_in": 1800 204 | } 205 | } 206 | ``` 207 | 208 | ## 中间件 209 | 210 | 导入: 211 | ```javascript 212 | import { requireSudo, optionalSudo } from '../middleware/sudo.js'; 213 | ``` 214 | 215 | 使用: 216 | ```javascript 217 | // 强制sudo 218 | router.delete('/admin/users/:id', requireSudo, deleteUser); 219 | // 可选sudo 220 | router.get('/admin/users', optionalSudo, getUsers); 221 | ``` 222 | 223 | sudo令牌传递方式: 224 | - Authorization头: `Authorization: Sudo ` 225 | - 自定义头: `X-Sudo-Token: ` 226 | - Query参数: `?sudo_token=` 227 | - 请求体: `{"sudo_token": ""}` 228 | 229 | ## 错误码 230 | 231 | - EMAIL_REQUIRED: 需要邮箱 232 | - NEED_LOGIN: 需要登录 233 | - EMAIL_NOT_FOUND: 邮箱未注册 234 | - SEND_CODE_FAILED: 发送验证码失败 235 | - AUTH_FAILED: 认证失败 236 | - SUDO_TOKEN_REQUIRED: 需要sudo令牌 237 | - SUDO_TOKEN_INVALID: sudo令牌无效 -------------------------------------------------------------------------------- /prisma/migrations/20250622015856_create_oauth/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `ow_oauth_applications` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `owner_id` INTEGER NOT NULL, 5 | `name` VARCHAR(255) NOT NULL, 6 | `description` TEXT NULL, 7 | `homepage_url` VARCHAR(255) NULL, 8 | `client_id` VARCHAR(255) NOT NULL, 9 | `client_secret` VARCHAR(255) NOT NULL, 10 | `redirect_uris` JSON NOT NULL, 11 | `type` VARCHAR(50) NOT NULL DEFAULT 'oauth', 12 | `scopes` JSON NOT NULL, 13 | `webhook_url` VARCHAR(255) NULL, 14 | `webhook_secret` VARCHAR(255) NULL, 15 | `logo_url` VARCHAR(255) NULL, 16 | `terms_url` VARCHAR(255) NULL, 17 | `privacy_url` VARCHAR(255) NULL, 18 | `setup_url` VARCHAR(255) NULL, 19 | `setup_on_update` BOOLEAN NOT NULL DEFAULT false, 20 | `is_public` BOOLEAN NOT NULL DEFAULT false, 21 | `is_verified` BOOLEAN NOT NULL DEFAULT false, 22 | `rate_limit` INTEGER NOT NULL DEFAULT 5000, 23 | `status` VARCHAR(50) NOT NULL DEFAULT 'active', 24 | `metadata` JSON NULL, 25 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 26 | `updated_at` DATETIME(3) NOT NULL, 27 | 28 | UNIQUE INDEX `ow_oauth_applications_client_id_key`(`client_id`), 29 | INDEX `ow_oauth_applications_owner_id_idx`(`owner_id`), 30 | INDEX `ow_oauth_applications_client_id_idx`(`client_id`), 31 | INDEX `ow_oauth_applications_type_idx`(`type`), 32 | PRIMARY KEY (`id`) 33 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 34 | 35 | -- CreateTable 36 | CREATE TABLE `ow_oauth_authorizations` ( 37 | `id` INTEGER NOT NULL AUTO_INCREMENT, 38 | `application_id` INTEGER NOT NULL, 39 | `user_id` INTEGER NOT NULL, 40 | `authorized_email` VARCHAR(255) NOT NULL, 41 | `scopes` JSON NOT NULL, 42 | `code` VARCHAR(255) NULL, 43 | `code_challenge` VARCHAR(255) NULL, 44 | `code_challenge_method` VARCHAR(20) NULL, 45 | `code_expires_at` DATETIME(3) NULL, 46 | `status` VARCHAR(50) NOT NULL DEFAULT 'active', 47 | `last_used_at` DATETIME(3) NULL, 48 | `metadata` JSON NULL, 49 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 50 | `updated_at` DATETIME(3) NOT NULL, 51 | 52 | UNIQUE INDEX `ow_oauth_authorizations_code_key`(`code`), 53 | INDEX `ow_oauth_authorizations_code_idx`(`code`), 54 | INDEX `ow_oauth_authorizations_user_id_idx`(`user_id`), 55 | UNIQUE INDEX `ow_oauth_authorizations_application_id_user_id_key`(`application_id`, `user_id`), 56 | PRIMARY KEY (`id`) 57 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 58 | 59 | -- CreateTable 60 | CREATE TABLE `ow_oauth_access_tokens` ( 61 | `id` INTEGER NOT NULL AUTO_INCREMENT, 62 | `application_id` INTEGER NOT NULL, 63 | `authorization_id` INTEGER NOT NULL, 64 | `user_id` INTEGER NOT NULL, 65 | `access_token` VARCHAR(255) NOT NULL, 66 | `refresh_token` VARCHAR(255) NULL, 67 | `scopes` JSON NOT NULL, 68 | `expires_at` DATETIME(3) NOT NULL, 69 | `refresh_token_expires_at` DATETIME(3) NULL, 70 | `ip_address` VARCHAR(100) NULL, 71 | `user_agent` TEXT NULL, 72 | `last_used_at` DATETIME(3) NULL, 73 | `last_used_ip` VARCHAR(100) NULL, 74 | `is_revoked` BOOLEAN NOT NULL DEFAULT false, 75 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 76 | `updated_at` DATETIME(3) NOT NULL, 77 | 78 | UNIQUE INDEX `ow_oauth_access_tokens_access_token_key`(`access_token`), 79 | UNIQUE INDEX `ow_oauth_access_tokens_refresh_token_key`(`refresh_token`), 80 | INDEX `ow_oauth_access_tokens_access_token_idx`(`access_token`), 81 | INDEX `ow_oauth_access_tokens_refresh_token_idx`(`refresh_token`), 82 | INDEX `ow_oauth_access_tokens_user_id_idx`(`user_id`), 83 | INDEX `ow_oauth_access_tokens_application_id_idx`(`application_id`), 84 | INDEX `ow_oauth_access_tokens_authorization_id_idx`(`authorization_id`), 85 | PRIMARY KEY (`id`) 86 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 87 | 88 | -- CreateTable 89 | CREATE TABLE `ow_oauth_scopes` ( 90 | `id` INTEGER NOT NULL AUTO_INCREMENT, 91 | `name` VARCHAR(100) NOT NULL, 92 | `description` TEXT NOT NULL, 93 | `is_default` BOOLEAN NOT NULL DEFAULT false, 94 | `requires_verification` BOOLEAN NOT NULL DEFAULT false, 95 | `category` VARCHAR(50) NOT NULL, 96 | `risk_level` VARCHAR(20) NOT NULL DEFAULT 'low', 97 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 98 | `updated_at` DATETIME(3) NOT NULL, 99 | 100 | UNIQUE INDEX `ow_oauth_scopes_name_key`(`name`), 101 | INDEX `ow_oauth_scopes_category_idx`(`category`), 102 | INDEX `ow_oauth_scopes_risk_level_idx`(`risk_level`), 103 | PRIMARY KEY (`id`) 104 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 105 | -------------------------------------------------------------------------------- /src/services/ip/ipLocation.js: -------------------------------------------------------------------------------- 1 | import logger from "../logger.js"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | import {fileURLToPath} from "url"; 5 | import {Reader} from "@maxmind/geoip2-node"; 6 | import zcconfig from "../config/zcconfig.js"; 7 | import downloadMaxmindDb from "./downloadMaxmindDb.js"; 8 | // 固定的数据库文件路径 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 10 | const DB_FILE = path.resolve(__dirname, "../../../cache/ip/GeoLite2-City.mmdb"); 11 | 12 | // 配置参数 13 | var CONFIG = { 14 | enabled: false, // 是否启用MaxMind 15 | }; 16 | 17 | // 存储Reader实例 18 | let geoipReader = null; 19 | const defaultResponse = { 20 | address: "未知", 21 | most_specific_country_or_region: "未知", 22 | location: { 23 | accuracyRadius: -1, 24 | latitude: 0, 25 | longitude: 0, 26 | metroCode: -1, 27 | timeZone: "未知", 28 | }, 29 | }; 30 | // 从数据库加载配置 31 | const loadConfigFromDB = async () => { 32 | 33 | try { 34 | const enabled = await zcconfig.get("maxmind.enabled"); 35 | logger.debug("[ip] 从数据库加载MaxMind配置:", enabled); 36 | if (enabled !== null) { 37 | CONFIG.enabled = enabled === true; 38 | } 39 | logger.debug("[ip] 已从数据库加载MaxMind配置", CONFIG); 40 | await initMaxMind(); 41 | return CONFIG; 42 | } catch (error) { 43 | logger.error("[ip] 从数据库加载MaxMind配置失败:", error); 44 | } 45 | }; 46 | 47 | // 初始化MaxMind数据库 48 | const initMaxMind = async () => { 49 | if (geoipReader) { 50 | geoipReader = null; 51 | } 52 | 53 | if (!CONFIG.enabled) { 54 | logger.debug("[ip] MaxMind GeoIP未启用,跳过初始化"); 55 | return; 56 | } 57 | 58 | try { 59 | await downloadMaxmindDb.loadMaxmind(); 60 | 61 | // 加载数据库 62 | const dbBuffer = fs.readFileSync(DB_FILE); 63 | geoipReader = Reader.openBuffer(dbBuffer); 64 | logger.info("[ip] MaxMind GeoIP数据库加载成功"); 65 | } catch (error) { 66 | logger.error("[ip] 初始化MaxMind GeoIP数据库失败:", error); 67 | geoipReader = null; 68 | } 69 | }; 70 | 71 | /** 72 | * 获取IP地址的地理位置信息 73 | * @param {string} ipAddress - 需要定位的IP地址 74 | * @returns {Object} 地理位置信息 75 | { 76 | * address: "未知", 77 | * most_specific_country_or_region: "未知", 78 | * location: { 79 | * accuracyRadius: -1, 80 | * latitude: 0, 81 | * longitude: 0, 82 | * metroCode: -1, 83 | * timeZone: "未知", 84 | * }, 85 | * } 86 | */ 87 | const getIPLocation = async (ipAddress) => { 88 | if (!ipAddress) { 89 | logger.warn("[ip] IP地址为空"); 90 | return defaultResponse; 91 | } 92 | 93 | if (CONFIG.enabled && geoipReader) { 94 | try { 95 | const response = geoipReader.city(ipAddress); 96 | if (!response) { 97 | logger.debug(`[ip] MaxMind查询IP(${ipAddress})位置失败: 返回空响应`); 98 | return defaultResponse; 99 | } 100 | 101 | return { 102 | address: `${ 103 | response.city?.names?.["zh-CN"] || response.city?.names?.en || "" 104 | } ${ 105 | response.subdivisions?.[0]?.names?.["zh-CN"] || 106 | response.subdivisions?.[0]?.names?.en || 107 | "" 108 | } ${ 109 | response.country?.names?.["zh-CN"] || 110 | response.country?.names?.en || 111 | "未知" 112 | } (${response.country?.isoCode || ""}) ${ 113 | response.continent?.names?.["zh-CN"] || 114 | response.continent?.names?.en || 115 | "" 116 | }`, 117 | most_specific_country_or_region: 118 | response.city?.names?.["zh-CN"] || 119 | // response.city?.names?.en || 120 | response.subdivisions?.[0]?.names?.["zh-CN"] || 121 | // response.subdivisions?.[0]?.names?.en || 122 | response.country?.names?.["zh-CN"] || 123 | // response.country?.names?.en || 124 | response.continent?.names?.["zh-CN"] || 125 | // response.continent?.names?.en || 126 | response.registeredCountry?.names?.["zh-CN"] || 127 | response.registeredCountry?.names?.en || 128 | "未知", 129 | 130 | location: response.location, 131 | //response: response, 132 | }; 133 | } catch (error) { 134 | logger.debug(`[ip] MaxMind查询IP(${ipAddress})位置失败: ${error.message}`); 135 | } 136 | } 137 | 138 | return defaultResponse; 139 | }; 140 | 141 | // 导出模块 142 | export default { 143 | getIPLocation, 144 | loadConfigFromDB, 145 | }; 146 | -------------------------------------------------------------------------------- /src/middleware/sudo.js: -------------------------------------------------------------------------------- 1 | import logger from '../services/logger.js'; 2 | import { verifySudoToken } from '../services/auth/sudoAuth.js'; 3 | 4 | /** 5 | * sudo验证中间件 6 | * 验证请求是否包含有效的sudo令牌(或已通过passkey二次验证) 7 | * @param {import("express").Request} req 8 | * @param {import("express").Response} res 9 | * @param {import("express").NextFunction} next 10 | */ 11 | export const requireSudo = async (req, res, next) => { 12 | try { 13 | // 确保用户已登录 14 | if (!res.locals.userid) { 15 | return res.status(401).json({ 16 | status: 'error', 17 | message: '需要登录后才能进行sudo操作', 18 | code: 'SUDO_NEED_LOGIN' 19 | }); 20 | } 21 | 22 | // 获取sudo令牌 - 从多个来源尝试获取 23 | let sudoToken = null; 24 | 25 | // 1. 从Authorization header获取 (格式: Sudo ) 26 | const authHeader = req.headers['authorization']; 27 | if (authHeader) { 28 | const parts = authHeader.split(' '); 29 | if (parts.length === 2 && parts[0].toLowerCase() === 'sudo') { 30 | sudoToken = parts[1]; 31 | } 32 | } 33 | 34 | // 2. 从X-Sudo-Token header获取 35 | if (!sudoToken && req.headers['x-sudo-token']) { 36 | sudoToken = req.headers['x-sudo-token']; 37 | } 38 | 39 | // 3. 从query参数获取 40 | if (!sudoToken && req.query.sudo_token) { 41 | sudoToken = req.query.sudo_token; 42 | } 43 | 44 | // 4. 从请求体获取 45 | if (!sudoToken && req.body && req.body.sudo_token) { 46 | sudoToken = req.body.sudo_token; 47 | } 48 | 49 | if (!sudoToken) { 50 | return res.status(403).json({ 51 | status: 'error', 52 | message: '此操作需要sudo权限,请先进入sudo模式', 53 | code: 'SUDO_TOKEN_REQUIRED' 54 | }); 55 | } 56 | 57 | // 验证sudo令牌 58 | const verification = await verifySudoToken(sudoToken); 59 | 60 | if (!verification.valid) { 61 | return res.status(403).json({ 62 | status: 'error', 63 | message: verification.message, 64 | code: 'SUDO_TOKEN_INVALID' 65 | }); 66 | } 67 | 68 | // 验证令牌是否属于当前用户 69 | if (verification.userId !== res.locals.userid) { 70 | logger.warn(`[sudo] 用户 ${res.locals.userid} 尝试使用其他用户的sudo令牌`); 71 | return res.status(403).json({ 72 | status: 'error', 73 | message: 'sudo令牌不属于当前用户', 74 | code: 'SUDO_TOKEN_MISMATCH' 75 | }); 76 | } 77 | 78 | // 在响应对象中标记已通过sudo验证 79 | res.locals.sudoVerified = true; 80 | res.locals.sudoToken = sudoToken; 81 | 82 | next(); 83 | } catch (error) { 84 | logger.error('[sudo] sudo验证中间件错误:', error); 85 | return res.status(500).json({ 86 | status: 'error', 87 | message: '验证sudo权限时发生错误', 88 | code: 'SUDO_VERIFICATION_ERROR' 89 | }); 90 | } 91 | }; 92 | 93 | /** 94 | * 可选的sudo验证中间件 95 | * 如果提供了sudo令牌则验证,但不强制要求 96 | * @param {import("express").Request} req 97 | * @param {import("express").Response} res 98 | * @param {import("express").NextFunction} next 99 | */ 100 | export const optionalSudo = async (req, res, next) => { 101 | try { 102 | // 获取sudo令牌 103 | let sudoToken = null; 104 | 105 | const authHeader = req.headers['authorization']; 106 | if (authHeader) { 107 | const parts = authHeader.split(' '); 108 | if (parts.length === 2 && parts[0].toLowerCase() === 'sudo') { 109 | sudoToken = parts[1]; 110 | } 111 | } 112 | 113 | if (!sudoToken && req.headers['x-sudo-token']) { 114 | sudoToken = req.headers['x-sudo-token']; 115 | } 116 | 117 | if (!sudoToken && req.query.sudo_token) { 118 | sudoToken = req.query.sudo_token; 119 | } 120 | 121 | if (!sudoToken && req.body && req.body.sudo_token) { 122 | sudoToken = req.body.sudo_token; 123 | } 124 | 125 | if (!sudoToken) { 126 | // 没有提供sudo令牌,继续处理但不标记sudo验证 127 | res.locals.sudoVerified = false; 128 | return next(); 129 | } 130 | 131 | // 验证提供的sudo令牌 132 | const verification = await verifySudoToken(sudoToken); 133 | 134 | if (verification.valid && verification.userId === res.locals.userid) { 135 | res.locals.sudoVerified = true; 136 | res.locals.sudoToken = sudoToken; 137 | } else { 138 | res.locals.sudoVerified = false; 139 | } 140 | 141 | next(); 142 | } catch (error) { 143 | logger.error('[sudo] 可选sudo验证中间件错误:', error); 144 | res.locals.sudoVerified = false; 145 | next(); 146 | } 147 | }; -------------------------------------------------------------------------------- /src/services/coderunManager.js: -------------------------------------------------------------------------------- 1 | import {PrismaClient} from "@prisma/client"; 2 | import zcconfig from "./config/zcconfig.js"; 3 | import logger from "./logger.js"; 4 | import schedulerService from "./scheduler.js"; 5 | import {set} from "./cachekv.js"; 6 | 7 | const prisma = new PrismaClient(); 8 | 9 | class CodeRunManager { 10 | constructor() { 11 | this.runners = new Map(); // Store runner status 12 | this.CACHE_KEY = "system:coderun:runners"; 13 | } 14 | 15 | async initialize() { 16 | // Load existing active devices from database 17 | try { 18 | const activeDevices = await prisma.ow_coderun_devices.findMany({ 19 | where: { 20 | status: 'active' 21 | } 22 | }); 23 | 24 | // Initialize state for each active device 25 | const now = new Date(); 26 | for (const device of activeDevices) { 27 | this.runners.set(device.id, { 28 | lastReport: now, 29 | status: 'active', 30 | lastReportData: { 31 | docker: {}, 32 | system: {}, 33 | coderun: {} 34 | } 35 | }); 36 | } 37 | 38 | logger.info(`[CodeRunManager] Loaded ${activeDevices.length} active devices from database`); 39 | } catch (error) { 40 | logger.error('[CodeRunManager] Error loading active devices:', error); 41 | } 42 | 43 | // Get report interval from config 44 | const reportInterval = await zcconfig.get( 45 | "coderun.report_interval", 46 | ); // Default 5 min 47 | const checkInterval = reportInterval * 1.5; 48 | 49 | // Register scheduler task 50 | schedulerService.registerTask("coderun-status-check", { 51 | interval: checkInterval, 52 | handler: async () => this.checkInactiveRunners(), 53 | runImmediately: true, 54 | }); 55 | 56 | // Update cache after initialization 57 | await this.updateCache(); 58 | 59 | logger.info( 60 | "[CodeRunManager] Initialized with check interval:", 61 | checkInterval 62 | ); 63 | } 64 | 65 | async updateRunnerStatus(runnerId, status) { 66 | const now = new Date(); 67 | this.runners.set(runnerId, { 68 | lastReport: now, 69 | status: status, 70 | }); 71 | 72 | // Update cache 73 | await this.updateCache(); 74 | } 75 | 76 | async checkInactiveRunners() { 77 | const reportInterval = await zcconfig.get( 78 | "coderun.report_interval", 79 | 300000 80 | ); 81 | const now = new Date(); 82 | const inactiveThreshold = new Date(now.getTime() - reportInterval * 2); 83 | 84 | // Check each runner 85 | for (const [runnerId, runnerData] of this.runners.entries()) { 86 | if ( 87 | runnerData.lastReport < inactiveThreshold && 88 | runnerData.status === "active" 89 | ) { 90 | // Mark as inactive in database 91 | await prisma.ow_coderun_devices.update({ 92 | where: {id: runnerId}, 93 | data: {status: "inactive"}, 94 | }); 95 | 96 | // Update local status 97 | runnerData.status = "inactive"; 98 | logger.info(`[CodeRunManager] Runner ${runnerId} marked as inactive`); 99 | } 100 | } 101 | 102 | // Update cache after changes 103 | await this.updateCache(); 104 | } 105 | 106 | async handleRunnerReport(runnerId, reportData) { 107 | const existingRunner = this.runners.get(runnerId); 108 | const wasInactive = existingRunner?.status === "inactive"; 109 | 110 | // Update runner status 111 | await this.updateRunnerStatus(runnerId, "active"); 112 | 113 | // If runner was inactive, reactivate in database 114 | if (wasInactive) { 115 | await prisma.ow_coderun_devices.update({ 116 | where: {id: runnerId}, 117 | data: {status: "active"}, 118 | }); 119 | logger.info(`[CodeRunManager] Runner ${runnerId} reactivated`); 120 | } 121 | 122 | // Store report data 123 | this.runners.get(runnerId).lastReportData = reportData; 124 | } 125 | 126 | async updateCache() { 127 | // Convert Map to object for storage 128 | const runnersData = Object.fromEntries(this.runners); 129 | await set(1, this.CACHE_KEY, runnersData); 130 | } 131 | 132 | getRunnerStatus(runnerId) { 133 | return this.runners.get(runnerId); 134 | } 135 | 136 | getAllRunners() { 137 | return Object.fromEntries(this.runners); 138 | } 139 | } 140 | 141 | const codeRunManager = new CodeRunManager(); 142 | export default codeRunManager; 143 | -------------------------------------------------------------------------------- /src/routes/router_admin.js: -------------------------------------------------------------------------------- 1 | import {Router} from "express"; 2 | import logger from "../services/logger.js"; 3 | import configRouter from "./admin/config.js"; 4 | import usersRouter from "./admin/users.js"; 5 | import projectsRouter from "./admin/projects.js"; 6 | import coderunRouter from "./admin/coderun.js"; 7 | import extensionsRouter from "./admin/extensions.js"; 8 | import notificationsRouter from "./admin/notifications.js"; 9 | import {needAdmin} from '../middleware/auth.js'; 10 | 11 | import sitemapService from '../services/sitemap.js'; 12 | import zcconfig from '../services/config/zcconfig.js'; 13 | 14 | const router = Router(); 15 | router.use(needAdmin); 16 | /** 17 | * Admin Router 18 | * 管理后台路由模块,包含: 19 | * 1. 配置管理 20 | * 2. 用户管理 21 | * 3. 内容管理(待实现) 22 | * 4. 系统监控(待实现) 23 | * 5. 日志查看(待实现) 24 | * 6. 通知管理 25 | */ 26 | 27 | // 使用统一的配置管理路由 28 | router.use("/config", configRouter); 29 | router.use("/users", usersRouter); 30 | router.use("/projects", projectsRouter); 31 | router.use("/coderun", coderunRouter); 32 | router.use("/extensions", extensionsRouter); 33 | router.use("/notifications", notificationsRouter); 34 | 35 | // ==================== 通知管理页面 ==================== 36 | 37 | /** 38 | * @api {get} /admin/notifications 通知管理页面 39 | * @apiName AdminNotificationsPage 40 | * @apiGroup AdminNotifications 41 | * @apiPermission admin 42 | * @apiDescription 展示管理员通知管理界面 43 | */ 44 | router.get("/notifications", async (req, res) => { 45 | try { 46 | res.render("admin_notifications", { 47 | global: { 48 | config: global.config || {} 49 | } 50 | }); 51 | } catch (error) { 52 | logger.error("渲染通知管理页面失败:", error); 53 | res.status(500).json({ 54 | status: "error", 55 | message: "页面加载失败", 56 | error: error.message 57 | }); 58 | } 59 | }); 60 | // ==================== 系统信息路由 ==================== 61 | 62 | /** 63 | * @api {get} /admin/system/info 获取系统信息 64 | * @apiName GetSystemInfo 65 | * @apiGroup AdminSystem 66 | * @apiPermission admin 67 | * 68 | * @apiSuccess {String} status 请求状态 69 | * @apiSuccess {Object} data 系统信息 70 | */ 71 | router.get("/system/info", async (req, res) => { 72 | try { 73 | const systemInfo = { 74 | node_version: process.version, 75 | platform: process.platform, 76 | arch: process.arch, 77 | uptime: process.uptime(), 78 | memory_usage: process.memoryUsage(), 79 | cpu_usage: process.cpuUsage(), 80 | }; 81 | 82 | res.json({ 83 | status: "success", 84 | data: systemInfo 85 | }); 86 | } catch (error) { 87 | logger.error("获取系统信息失败:", error); 88 | res.status(500).json({ 89 | status: "error", 90 | message: "获取系统信息失败", 91 | error: error.message 92 | }); 93 | } 94 | }); 95 | 96 | // Sitemap management routes 97 | router.get('/sitemap/status', async (req, res) => { 98 | try { 99 | const status = await sitemapService.getSitemapStatus(); 100 | res.json({ 101 | status: 'success', 102 | data: status 103 | }); 104 | } catch (error) { 105 | logger.error('[admin] Error getting sitemap status:', error); 106 | res.status(500).json({ 107 | status: 'error', 108 | message: '获取站点地图状态失败' 109 | }); 110 | } 111 | }); 112 | 113 | router.post('/sitemap/settings', async (req, res) => { 114 | try { 115 | const {enabled, autoUpdate} = req.body; 116 | 117 | // 验证并更新设置 118 | if (typeof enabled === 'boolean') { 119 | await zcconfig.set('sitemap.enabled', enabled); 120 | } 121 | 122 | if (typeof autoUpdate === 'boolean') { 123 | await zcconfig.set('sitemap.auto_update', autoUpdate); 124 | } 125 | 126 | // 重新初始化服务 127 | await sitemapService.initialize(); 128 | 129 | res.json({ 130 | status: 'success', 131 | message: '设置已更新' 132 | }); 133 | } catch (error) { 134 | logger.error('[admin] Error updating sitemap settings:', error); 135 | res.status(500).json({ 136 | status: 'error', 137 | message: '更新站点地图设置失败' 138 | }); 139 | } 140 | }); 141 | 142 | router.post('/sitemap/generate', async (req, res) => { 143 | try { 144 | const {type = 'full'} = req.body; 145 | 146 | if (type !== 'full' && type !== 'incremental') { 147 | return res.status(400).json({ 148 | status: 'error', 149 | message: '无效的生成类型' 150 | }); 151 | } 152 | 153 | const hash = type === 'full' 154 | ? await sitemapService.generateFullSitemap() 155 | : await sitemapService.generateIncrementalSitemap(); 156 | 157 | res.json({ 158 | status: 'success', 159 | message: '站点地图生成成功', 160 | data: {hash} 161 | }); 162 | } catch (error) { 163 | logger.error('[admin] Error generating sitemap:', error); 164 | res.status(500).json({ 165 | status: 'error', 166 | message: '生成站点地图失败' 167 | }); 168 | } 169 | }); 170 | 171 | export default router; -------------------------------------------------------------------------------- /src/controllers/auth/unifiedLoginController.js: -------------------------------------------------------------------------------- 1 | import logger from '../../services/logger.js'; 2 | import { 3 | sendVerificationCode, 4 | authenticate 5 | } from '../../services/auth/unifiedAuth.js'; 6 | 7 | /** 8 | * 发送登录验证码(统一接口) 9 | * 注意:建议使用统一认证接口 POST /auth/send-code 10 | */ 11 | export const sendLoginCode = async (req, res) => { 12 | try { 13 | const { email } = req.body; 14 | 15 | if (!email) { 16 | return res.status(400).json({ 17 | status: 'error', 18 | message: '邮箱是必需的', 19 | code: 'EMAIL_REQUIRED' 20 | }); 21 | } 22 | 23 | const result = await sendVerificationCode(null, email, 'login'); 24 | 25 | if (result.success) { 26 | res.json({ 27 | status: 'success', 28 | message: result.message, 29 | data: { 30 | code_id: result.codeId, 31 | email: email, 32 | expires_in: 300 // 5分钟 33 | } 34 | }); 35 | } else { 36 | res.status(400).json({ 37 | status: 'error', 38 | message: result.message, 39 | code: 'SEND_CODE_FAILED' 40 | }); 41 | } 42 | } catch (error) { 43 | logger.error('[unified-login] 发送登录验证码失败:', error); 44 | res.status(500).json({ 45 | status: 'error', 46 | message: '发送验证码时发生错误', 47 | code: 'INTERNAL_ERROR' 48 | }); 49 | } 50 | }; 51 | 52 | /** 53 | * 统一登录接口 54 | * 注意:建议使用统一认证接口 POST /auth/authenticate 55 | */ 56 | export const unifiedLogin = async (req, res) => { 57 | try { 58 | const { 59 | method, 60 | identifier, 61 | password, 62 | code_id: codeId, 63 | code 64 | } = req.body; 65 | 66 | // 执行统一认证 67 | const authResult = await authenticate({ 68 | method, 69 | purpose: 'login', 70 | identifier, 71 | password, 72 | codeId, 73 | code 74 | }); 75 | 76 | if (authResult.success) { 77 | res.json({ 78 | status: 'success', 79 | message: '登录成功', 80 | data: { 81 | user: authResult.user, 82 | access_token: authResult.data?.access_token, 83 | refresh_token: authResult.data?.refresh_token, 84 | expires_in: authResult.data?.expires_in 85 | } 86 | }); 87 | } else { 88 | res.status(400).json({ 89 | status: 'error', 90 | message: authResult.message, 91 | code: 'LOGIN_FAILED' 92 | }); 93 | } 94 | } catch (error) { 95 | logger.error('[unified-login] 统一登录失败:', error); 96 | res.status(500).json({ 97 | status: 'error', 98 | message: '登录时发生错误', 99 | code: 'INTERNAL_ERROR' 100 | }); 101 | } 102 | }; 103 | 104 | /** 105 | * 密码登录(兼容接口) 106 | */ 107 | export const loginWithPassword = async (req, res) => { 108 | try { 109 | const { un: identifier, pw: password } = req.body; 110 | 111 | if (!identifier || !password) { 112 | return res.status(400).json({ 113 | status: 'error', 114 | message: '用户名和密码都是必需的' 115 | }); 116 | } 117 | 118 | // 使用统一认证 119 | const authResult = await authenticate({ 120 | method: 'password', 121 | purpose: 'login', 122 | identifier, 123 | password 124 | }); 125 | 126 | if (authResult.success) { 127 | // 统一认证层不直接签发令牌 128 | res.json({ 129 | status: 'success', 130 | message: '认证成功', 131 | data: { user: authResult.user } 132 | }); 133 | } else { 134 | res.status(400).json({ 135 | status: 'error', 136 | message: authResult.message 137 | }); 138 | } 139 | } catch (error) { 140 | logger.error('[unified-login] 密码登录失败:', error); 141 | res.status(500).json({ 142 | status: 'error', 143 | message: '登录失败' 144 | }); 145 | } 146 | }; 147 | 148 | /** 149 | * 验证码登录(兼容接口) 150 | */ 151 | export const loginWithCode = async (req, res) => { 152 | try { 153 | const { email, code } = req.body; 154 | 155 | if (!email || !code) { 156 | return res.status(400).json({ 157 | status: 'error', 158 | message: '邮箱和验证码都是必需的' 159 | }); 160 | } 161 | 162 | // 注意:这里需要先通过邮箱获取code_id 163 | // 在实际应用中,前端应该保存发送验证码时返回的code_id 164 | // 这里为了兼容性,我们使用email作为标识符查找验证码 165 | 166 | res.status(501).json({ 167 | status: 'error', 168 | message: '此接口需要升级,请使用 /auth/authenticate 接口', 169 | code: 'DEPRECATED' 170 | }); 171 | } catch (error) { 172 | logger.error('[unified-login] 验证码登录失败:', error); 173 | res.status(500).json({ 174 | status: 'error', 175 | message: '登录失败' 176 | }); 177 | } 178 | }; -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import logger from './services/logger.js'; 2 | import paths from './paths.js'; 3 | import zcconfig from './services/config/zcconfig.js'; 4 | 5 | 6 | /** 7 | * 配置应用路由 8 | * @param {express.Application} app Express应用实例 9 | */ 10 | export async function configureRoutes(app) { 11 | // 加载配置信息到全局 12 | await zcconfig.loadConfigsFromDB(); 13 | logger.info('[routes] 配置信息已加载到全局'); 14 | 15 | // 设置视图目录和引擎 16 | app.set("env", process.cwd()); 17 | app.set("data", paths.DATA_DIR); 18 | //logger.debug(paths.VIEWS_DIR) 19 | app.set("views", paths.VIEWS_DIR); 20 | app.set("view engine", "ejs"); 21 | 22 | logger.debug('[routes] 视图目录:', paths.VIEWS_DIR); 23 | // 首页路由 24 | app.get("/", (req, res) => { 25 | res.render("index"); 26 | }); 27 | 28 | // 健康检查路由 29 | app.get("/check", (req, res) => { 30 | res.status(200).json({ 31 | message: "success", 32 | code: 200, 33 | }); 34 | }); 35 | 36 | // Scratch工具路由 37 | app.get("/scratchtool", (req, res) => { 38 | res.set("Content-Type", "application/javascript"); 39 | res.render("scratchtool"); 40 | }); 41 | 42 | // 注册业务路由 43 | await registerBusinessRoutes(app); 44 | 45 | // 404路由处理 46 | app.all("/{*path}", (req, res) => { 47 | res.status(404).json({ 48 | status: "error", 49 | code: "404", 50 | message: "找不到页面", 51 | }); 52 | }); 53 | } 54 | 55 | /** 56 | * 注册业务相关路由 57 | * @param {express.Application} app Express应用实例 58 | */ 59 | async function registerBusinessRoutes(app) { 60 | try { 61 | // 新的标准化路由注册 62 | const accountModule = await import('./routes/router_account.js'); 63 | app.use("/account", accountModule.default); 64 | 65 | const eventModule = await import('./routes/router_event.js'); 66 | app.use("/events", eventModule.default); 67 | 68 | // 统计分析路由 69 | const analyticsModule = await import('./routes/router_analytics.js'); 70 | app.use("/analytics", analyticsModule.default); 71 | 72 | // 使用新的通知路由 (获取绝对路径版本) 73 | const notificationModule = await import('./routes/router_notifications.js'); 74 | app.use("/notifications", notificationModule.default); 75 | 76 | // 个人中心路由 77 | const myModule = await import('./routes/router_my.js'); 78 | app.use("/my", myModule.default); 79 | 80 | // 搜索API路由 81 | const searchModule = await import('./routes/router_search.js'); 82 | app.use("/searchapi", searchModule.default); 83 | 84 | // Scratch路由 85 | const scratchModule = await import('./routes/router_scratch.js'); 86 | app.use("/scratch", scratchModule.default); 87 | 88 | // API路由 89 | const apiModule = await import('./routes/router_api.js'); 90 | app.use("/api", apiModule.default); 91 | 92 | // 管理后台路由 93 | const adminModule = await import('./routes/router_admin.js'); 94 | app.use("/admin", adminModule.default); 95 | 96 | // 项目列表路由 97 | const projectlistModule = await import('./routes/router_projectlist.js'); 98 | app.use("/projectlist", projectlistModule.default); 99 | 100 | // 项目路由 101 | const projectModule = await import('./routes/router_project.js'); 102 | app.use("/project", projectModule.default); 103 | 104 | // 评论路由 105 | const commentModule = await import('./routes/router_comment.js'); 106 | app.use("/comment", commentModule.default); 107 | 108 | // 用户路由 109 | const userModule = await import('./routes/router_user.js'); 110 | app.use("/user", userModule.default); 111 | 112 | // 时间线路由 113 | const timelineModule = await import('./routes/router_timeline.js'); 114 | app.use("/timeline", timelineModule.default); 115 | 116 | // 关注路由 117 | const followsModule = await import('./routes/router_follows.js'); 118 | app.use("/follows", followsModule.default); 119 | 120 | // OAuth路由 121 | const oauthModule = await import('./routes/router_oauth.js'); 122 | app.use("/oauth", oauthModule.default); 123 | 124 | // CacheKV路由 125 | const cachekvModule = await import('./routes/router_cachekv.js'); 126 | app.use("/cachekv", cachekvModule.default); 127 | 128 | // 账户令牌路由 129 | const accountTokenModule = await import('./routes/router_accounttoken.js'); 130 | app.use("/accounttoken", accountTokenModule.default); 131 | 132 | // CodeRun Runner路由 133 | const coderunRunnerModule = await import('./routes/admin/coderun_runner.js'); 134 | app.use("/coderun", coderunRunnerModule.default); 135 | 136 | // Extensions路由 137 | const extensionsModule = await import('./routes/router_extensions.js'); 138 | app.use("/extensions", extensionsModule.default); 139 | 140 | // 素材管理路由 141 | const assetsModule = await import('./routes/router_assets.js'); 142 | app.use("/assets", assetsModule.default); 143 | 144 | // 统一认证路由 145 | const authModule = await import('./routes/router_auth.js'); 146 | app.use("/auth", authModule.default); 147 | 148 | logger.info('[routes] 所有业务路由注册成功'); 149 | } catch (error) { 150 | logger.error('[routes] 注册业务路由失败:', error); 151 | throw error; 152 | } 153 | } --------------------------------------------------------------------------------