├── .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 | 
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 非常欢迎你的加入 或者提交一个 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 | [](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(/