├── .dockerignore ├── .env.example ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── custom.md │ ├── feature_request.md │ └── 反馈bug.md ├── dependabot.yml └── workflows │ └── docker-publish.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── README_ZH.md ├── SECURITY.md ├── config ├── eventConfig.js └── eventConfig.json ├── controllers ├── auth │ ├── emailController.js │ ├── index.js │ ├── loginController.js │ ├── oauthController.js │ ├── registerController.js │ ├── tokenController.js │ └── totpController.js ├── email.js ├── events.js ├── follows.js ├── lists.js ├── notifications.js ├── oauth.js ├── projects.js ├── stars.js └── users.js ├── data └── README.md ├── docker-compose.yml ├── docs ├── EVENT_SYSTEM.md ├── auth_api.md ├── event-formats.json ├── event-system-migration-guide.md ├── event-system-updates.md ├── event-types.json ├── events-direct-schema.md ├── events-system.md ├── notificationTypes.json ├── notifications.md ├── oauth_temp_token.md ├── user-relationships.md └── user_status_migration.md ├── meilisearch ├── config.yml └── docker-compose.yml ├── middleware ├── auth.js ├── captcha.js ├── geetest.js └── rateLimit.js ├── package.json ├── pnpm-lock.yaml ├── prisma ├── migrations │ ├── 20250531113638_init │ │ └── migration.sql │ ├── 20250531113639_fix_timezone_and_add_search_view │ │ └── migration.sql │ ├── 20250531113640_add_enhanced_projects_search_view │ │ └── migration.sql │ ├── 20250531113641_optimize_projects_search_view │ │ └── migration.sql │ ├── 20250601042102_update_only_show_public_project │ │ └── migration.sql │ ├── 20250601042631_update_index │ │ └── migration.sql │ ├── 20250601043102_add_author_fields_to_search_view │ │ └── migration.sql │ ├── 20250601043103_add_author_fields_to_search_view copy2 │ │ └── migration.sql │ ├── 20250601043104_enhance_search_view_with_comments_commits_branches copy2 │ │ └── migration.sql │ ├── 20250601043104_enhance_search_view_with_comments_commits_branches copy3 │ │ └── migration.sql │ ├── 20250601043104_enhance_search_view_with_comments_commits_branches │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── process.json ├── public └── Node.js.png ├── redis └── docker-compose.yml ├── routes ├── router_account.js ├── router_admin.js ├── router_api.js ├── router_comment.js ├── router_event.js ├── router_follows.js ├── router_lists.js ├── router_my.js ├── router_notifications.js ├── router_project.js ├── router_projectlist.js ├── router_scratch.js ├── router_search.js ├── router_stars.js ├── router_timeline.js └── router_user.js ├── server.js ├── services ├── auth │ ├── auth.js │ ├── magiclink.js │ ├── permissionManager.js │ ├── tokenManager.js │ ├── tokenUtils.js │ ├── totp.js │ └── verification.js ├── config │ ├── configTypes.js │ └── zcconfig.js ├── email │ ├── emailService.js │ └── emailTemplates.js ├── errorHandler.js ├── global.js ├── ip │ ├── downloadMaxmindDb.js │ └── ipLocation.js ├── logger.js ├── memoryCache.js ├── redis.js └── scheduler.js ├── src ├── app.js ├── default_project.js ├── index.js ├── middleware.js ├── paths.js ├── routes.js └── server.js ├── usercontent └── .gitkeep └── views ├── index.ejs └── scratchtool.ejs /.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 -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="mysql://[username]:[password]@[host]:[port]/[database]" 2 | # MaxMind GeoIP License Key 3 | maxmind.LICENSE_KEY="YOUR_LICENSE_KEY" -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | usercontent/* 139 | !usercontent/.gitkeep 140 | 141 | # MaxMind GeoIP database files 142 | data/*.mmdb 143 | 144 | redis/data/* 145 | redis/redis.conf 146 | 147 | meili_data -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 使用轻量且有社区支持的 Node 官方镜像 2 | FROM node:20.19.0-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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ZeroCat Programming Community 2 | [中文](./README_ZH.md) | [English](./README.md) 3 | 4 | If you like this project, please give me a star. 5 | ## 6 | ZeroCat is a lightweight online programming and sharing platform. 7 | 8 | This repository contains the backend code for ZeroCat. 9 | 10 | ## Contents 11 | 12 | - [ZeroCat Programming Community](#zerocat-programming-community) 13 | - [](#) 14 | - [Contents](#contents) 15 | - [Background](#background) 16 | - [Communication](#communication) 17 | - [Example](#example) 18 | - [Installation](#installation) 19 | - [Configure Database](#configure-database) 20 | - [Configure Environment Variables](#configure-environment-variables) 21 | - [Run](#run) 22 | - [Use Docker](#use-docker) 23 | - [Developer](#developer) 24 | - [How to Contribute](#how-to-contribute) 25 | - [Contributor Covenant Code of Conduct](#contributor-covenant-code-of-conduct) 26 | - [Contributors](#contributors) 27 | - [License](#license) 28 | 29 | 30 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FZeroCatDev%2Fzerocat.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2FZeroCatDev%2Fzerocat?ref=badge_large) 31 | 32 | ## Background 33 | 34 | `ZeroCat` was originally proposed by [@sunwuyuan](https://github.com/sunwuyuan) a long time ago. Our goal is to create a fully open-source programming community, and this project started from that vision. However, significant progress was made only when Sun Wuyuan was in the second year of middle school. 35 | 36 | Maintaining a programming community is, to some extent, quite challenging, but I believe this project will continue to develop. 37 | 38 | The goal of this repository is to: 39 |
Develop a comprehensive programming community that supports Scratch, Python, and other languages suitable for beginner programmers. 40 | 41 | ## Communication 42 | 43 | QQ: 964979747 44 | 45 | ## Example 46 | 47 | To see the community in action, please refer to [ZeroCat](https://zerocat.houlangs.com). 48 | 49 | ## Installation 50 | ![Developed with Node.js](public/Node.js.png) 51 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2FZeroCatDev%2Fzerocat.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2FZeroCatDev%2Fzerocat?ref=badge_shield) 52 | 53 | This project uses [Node.js](http://nodejs.org), [npm](https://npmjs.com), and [Docker](https://docker.com). Please ensure that these are installed on your local machine. 54 | 55 | ```sh 56 | $ npm install 57 | # Or use cnpm 58 | $ cnpm install 59 | # Or use any other npm tool you prefer (haha) 60 | $ XXX install 61 | ``` 62 | 63 | ### Configure Database 64 | 65 | Not written yet. 66 | 67 | ### Configure Environment Variables 68 | 69 | Rename `.env.example` to `.env` or configure the environment variables manually (refer to `.env.example` for guidance). 70 |
Please do not use both environment variables and `.env` at the same time, and ensure the project's environment does not conflict with other projects. 71 |
Currently, all environment variables must be configured. 72 | 73 | ### Run 74 | 75 | ```sh 76 | $ npm run start 77 | ``` 78 | 79 | ### Use Docker 80 | 81 | Make sure Docker and Docker Compose are installed. 82 | 83 | ```sh 84 | $ docker compose up -d 85 | ``` 86 | 87 | ## Developer 88 | 89 | [@SunWuyuan](https://github.com/sunwuyuan) 90 | 91 | ## How to Contribute 92 | 93 | - [ZeroCat](https://zerocat.houlangs.com) 94 | We warmly welcome your contributions! Please [submit an Issue](https://github.com/ZeroCatDev/ZeroCat/issues/new) or submit a Pull Request. For beginner-level questions, it's best to ask in the QQ group, and we will try to help. 95 | 96 | ## Contributor Covenant Code of Conduct 97 | 98 | The ZeroCat project follows the [Contributor Covenant](http://contributor-covenant.org/version/1/3/0/) Code of Conduct. 99 |
Sun Wuyuan encourages you to follow [The Smart Way to Ask Questions](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md). 100 | 101 | ### Contributors 102 | 103 | Thanks to everyone who has contributed to this project. Their information can be found on the right side of the repository page. It is updated in real time and easy to view. 104 | 105 | ## License 106 | 107 | The ZeroCat community project is licensed under the [AGPL-3.0 License](LICENSE). 108 | 109 | Copyright (C) 2020-2024 Sun Wuyuan. 110 | 111 | You are free to use the ZeroCat community under an open-source license, but you may not use the name "ZeroCat" for promotional purposes. You must retain the copyright notice for ZeroCat. 112 | 113 | For closed-source usage licenses, please contact QQ 1847261658. 114 | 115 | Thanks to the [scratch-cn/lite](https://gitee.com/scratch-cn/lite) project for inspiring this project. 116 | -------------------------------------------------------------------------------- /README_ZH.md: -------------------------------------------------------------------------------- 1 | # ZeroCat 编程社区 2 | [中文](./README_ZH.md) | [English](./README.md) 3 | 4 | 高中喵开发,求 Star 支持 5 | 6 | ## 7 | ZeroCat 是一个轻量级的在线编程、分享平台 8 | 9 | 本仓库是 ZeroCat 的后端代码 10 | 11 | ## 内容列表 12 | 13 | - [ZeroCat 编程社区](#zerocat-编程社区) 14 | - [](#) 15 | - [内容列表](#内容列表) 16 | - [背景](#背景) 17 | - [交流](#交流) 18 | - [示例](#示例) 19 | - [安装](#安装) 20 | - [配置数据库](#配置数据库) 21 | - [配置环境变量](#配置环境变量) 22 | - [运行](#运行) 23 | - [使用 Docker](#使用-docker) 24 | - [开发者](#开发者) 25 | - [](#-1) 26 | - [如何贡献](#如何贡献) 27 | - [](#-2) 28 | - [贡献者](#贡献者) 29 | - [许可协议](#许可协议) 30 | 31 | ## 背景 32 | 33 | `ZeroCat` 最开始由 [@sunwuyuan](https://github.com/sunwuyuan) 在很早以前提出,我们希望搭建一个全开源的编程社区,这个项目也就从此开始了。但实际上项目在孙悟元初二的时候才有了很大进展。 34 |
维护一个编程社区从某种程度上来说相当不易,但我相信,这个项目会一直开发下去。 35 | 36 | 这个仓库的目标是: 37 |
开发一个完整的支持 Scratch、Python 与其他适合编程初学者的编成社区 38 | 39 | ## 交流 40 | 41 | QQ:964979747 42 | 43 | ## 示例 44 | 45 | 想了解社区效果,请参考 [ZeroCat](https://zerocat.houlangs.com)。 46 | 47 | ## 安装 48 | ![使用Nodejs开发](public/Node.js.png) 49 | 50 | 这个项目使用 [node](http://nodejs.org) , [npm](https://npmjs.com), [docker](https://docker.com),请确保你本地已经安装了祂们 51 | 52 | ```sh 53 | $ npm install 54 | # 或者使用cnpm 55 | $ cnpm install 56 | # 或者使用任意奇奇怪怪的npm工具(笑 57 | $ XXX install 58 | ``` 59 | 60 | ### 配置数据库 61 | 62 | 还没写好 63 | 64 | ### 配置环境变量 65 | 66 | 将`.env.example`修改为`.env`或手动配置环境变量(根据`.env.example`配置) 67 |
请务必不要同时使用环境变量与.env,请注意不要让项目环境与其他项目环境冲突 68 |
目前所有环境变量都必须配置 69 | 70 | ### 运行 71 | 72 | ```sh 73 | $ npm run start 74 | ``` 75 | 76 | ### 使用 Docker 77 | 78 | 请确保以安装 Docker 与 DockerCompose 79 | 80 | ```sh 81 | $ docker compose up -d 82 | ``` 83 | 84 | ## 开发者 85 | 86 | [@SunWuyuan](https://github.com/sunwuyuan) 87 | 88 | ## 89 | ## 如何贡献 90 | 91 | - [ZeroCat](https://zerocat.houlangs.com) 92 | 非常欢迎你的加入![提一个 Issue](https://github.com/ZeroCatDev/ZeroCat/issues/new) 或者提交一个 Pull Request。对于小白问题,最好在 qq 群里问,我们会尽量回答。 93 | 94 | ## 95 | ZeroCat 的项目 遵循 [Contributor Covenant](http://contributor-covenant.org/version/1/3/0/) 行为规范 96 |
孙悟元 希望你遵循 [提问的智慧](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md) 97 | 98 | ### 贡献者 99 | 100 | 感谢所有参与项目的人,他们的信息可以在右侧看到,这是实时的且便于查看 101 | ## 许可协议 102 | 103 | ZeroCat 社区项目遵循 [AGPL-3.0 许可证](LICENSE)。 104 | 105 | 106 | 版权所有 (C) 2020-2024 孙悟元。 107 | Copyright (C) 2020-2024 Sun Wuyuan. 108 | 109 | 110 | 您可以在开源的前提下免费使用 ZeroCat 社区,但不允许使用 ZeroCat 的名称进行宣传。您需要保留 ZeroCat 的版权声明。 111 | 112 | 如需闭源使用授权,请联系 QQ1847261658。 113 | 114 | 感谢 [scratch-cn/lite](https://gitee.com/scratch-cn/lite) 项目对本项目的启发。 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config/eventConfig.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 事件配置系统 3 | * 定义事件类型、目标类型、受众类型及其关系 4 | */ 5 | 6 | /** 7 | * 受众获取所需的数据依赖 8 | * 指定每种受众类型需要获取的相关数据 9 | * @type {Object.} 10 | */ 11 | export const AudienceDataDependencies = { 12 | ["project_owner"]: { 13 | target: 'project', // 从事件目标获取数据 14 | fields: ['authorid'] 15 | }, 16 | ["project_collaborators"]: { 17 | query: 'project_collaborators', // 需要额外查询 18 | relationField: 'project_id', // 关联字段 19 | userField: 'user_id' // 用户ID字段 20 | }, 21 | ["project_followers"]: { 22 | query: 'user_relationships', 23 | relationField: 'target_user_id', 24 | userField: 'source_user_id', 25 | additionalFilters: { 26 | relationship_type: 'follow' 27 | } 28 | }, 29 | ["project_stargazers"]: { 30 | query: 'project_stars', 31 | relationField: 'project_id', 32 | userField: 'user_id' 33 | }, 34 | ["project_owner_followers"]: { 35 | query: 'user_relationships', 36 | relationField: 'target_user_id', 37 | userField: 'source_user_id', 38 | additionalFilters: { 39 | relationship_type: 'follow' 40 | }, 41 | dependsOn: { 42 | audienceType: "project_owner", 43 | field: 'id' 44 | } 45 | }, 46 | ["user_followers"]: { 47 | query: 'user_relationships', 48 | relationField: 'target_user_id', 49 | userField: 'source_user_id', 50 | additionalFilters: { 51 | relationship_type: 'follow' 52 | }, 53 | sourceField: 'actor_id' // 使用行为者ID而不是目标ID 54 | }, 55 | ["user_following"]: { 56 | query: 'user_relationships', 57 | relationField: 'source_user_id', 58 | userField: 'target_user_id', 59 | additionalFilters: { 60 | relationship_type: 'follow' 61 | }, 62 | sourceField: 'actor_id' 63 | }, 64 | ["comment_author"]: { 65 | target: 'comment', 66 | fields: ['user_id'] 67 | }, 68 | ["thread_participants"]: { 69 | query: 'ow_comment', 70 | specialFilter: 'thread', // 特殊处理,需要从事件数据中获取thread信息 71 | userField: 'user_id' 72 | }, 73 | ["mentioned_users"]: { 74 | eventData: 'mentioned_users' // 直接从事件数据中获取 75 | }, 76 | ["system_admins"]: { 77 | query: 'ow_users', 78 | additionalFilters: { 79 | type: 'admin' 80 | }, 81 | userField: 'id' 82 | }, 83 | ["custom_users"]: { 84 | eventData: 'custom_users' // 直接从事件数据中获取 85 | } 86 | }; 87 | 88 | /** 89 | * 事件配置 90 | * @type {Object.} 91 | */ 92 | export const EventConfig = { 93 | project_commit: { 94 | public: true, 95 | notifyTargets: ["project_owner", "project_followers", "project_owner_followers"], 96 | notificationData: ['project_name'] 97 | }, 98 | project_update: { 99 | public: true, 100 | notifyTargets: ["project_owner", "project_followers", "project_owner_followers"], 101 | notificationData: ['project_name'] 102 | }, 103 | project_fork: { 104 | public: true, 105 | notifyTargets: ["project_owner", "project_owner_followers"], 106 | notificationData: ['project_name'] 107 | }, 108 | project_create: { 109 | public: true, 110 | notifyTargets: ["user_followers"], 111 | notificationData: ['project_name'] 112 | }, 113 | project_publish: { 114 | public: true, 115 | notifyTargets: ["user_followers", "project_owner_followers"], 116 | notificationData: ['project_name'] 117 | }, 118 | comment_create: { 119 | public: false, 120 | notifyTargets: ["project_owner", "thread_participants", "mentioned_users"], 121 | notificationData: ['project_name', 'comment_text'] 122 | }, 123 | user_profile_update: { 124 | public: true, 125 | notifyTargets: ["user_followers"] 126 | }, 127 | user_login: { 128 | public: false, 129 | notifyTargets: [] 130 | }, 131 | user_register: { 132 | public: true, 133 | notifyTargets: [] 134 | }, 135 | project_rename: { 136 | public: true, 137 | notifyTargets: ["project_followers", "project_owner_followers"], 138 | notificationData: ['project_name'] 139 | }, 140 | project_info_update: { 141 | public: true, 142 | notifyTargets: ["project_followers", "project_owner_followers"], 143 | notificationData: ['project_name'] 144 | }, 145 | project_star: { 146 | public: true, 147 | notifyTargets: ["project_owner", "project_owner_followers"], 148 | notificationData: ['project_name'] 149 | }, 150 | project_like: { 151 | public: true, 152 | notifyTargets: ["project_owner", "project_owner_followers"], 153 | notificationData: ['project_name'] 154 | }, 155 | project_collect: { 156 | public: true, 157 | notifyTargets: ["project_owner", "project_owner_followers"], 158 | notificationData: ['project_name'] 159 | }, 160 | user_follow: { 161 | public: true, 162 | notifyTargets: ["user_followers"] 163 | }, 164 | comment_reply: { 165 | public: true, 166 | notifyTargets: ["comment_author", "mentioned_users"], 167 | notificationData: ['comment_text'] 168 | }, 169 | comment_like: { 170 | public: true, 171 | notifyTargets: ["comment_author"], 172 | notificationData: ['comment_text'] 173 | } 174 | }; 175 | 176 | export default { 177 | EventConfig, 178 | AudienceDataDependencies 179 | }; -------------------------------------------------------------------------------- /config/eventConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "targetTypes": { 3 | "PROJECT": "project", 4 | "USER": "user", 5 | "COMMENT": "comment" 6 | }, 7 | "eventTypes": { 8 | "PROJECT_CREATE": "project_create", 9 | "PROJECT_UPDATE": "project_update", 10 | "PROJECT_FORK": "project_fork", 11 | "PROJECT_PUBLISH": "project_publish", 12 | "PROJECT_DELETE": "project_delete", 13 | "PROJECT_RENAME": "project_rename", 14 | "PROJECT_INFO_UPDATE": "project_info_update", 15 | "PROJECT_STAR": "project_star", 16 | "PROJECT_COMMIT": "project_commit", 17 | "COMMENT_CREATE": "comment_create", 18 | "USER_PROFILE_UPDATE": "user_profile_update", 19 | "USER_LOGIN": "user_login", 20 | "USER_REGISTER": "user_register" 21 | }, 22 | "eventConfig": { 23 | "project_commit": { 24 | "public": true, 25 | "notifyTargets": ["project_owner", "project_followers"] 26 | }, 27 | "project_update": { 28 | "public": true, 29 | "notifyTargets": ["project_owner"] 30 | }, 31 | "project_fork": { 32 | "public": true, 33 | "notifyTargets": ["project_owner"] 34 | }, 35 | "project_create": { 36 | "public": true, 37 | "notifyTargets": ["user_followers"] 38 | }, 39 | "project_publish": { 40 | "public": true, 41 | "notifyTargets": ["user_followers"] 42 | }, 43 | "comment_create": { 44 | "public": false, 45 | "notifyTargets": ["page_owner", "thread_participants"] 46 | }, 47 | "user_profile_update": { 48 | "public": true, 49 | "notifyTargets": ["user_followers"] 50 | }, 51 | "user_login": { 52 | "public": false, 53 | "notifyTargets": [] 54 | }, 55 | "user_register": { 56 | "public": true, 57 | "notifyTargets": [] 58 | }, 59 | "project_rename": { 60 | "public": true, 61 | "notifyTargets": ["project_followers"] 62 | }, 63 | "project_info_update": { 64 | "public": true, 65 | "notifyTargets": ["project_followers"] 66 | }, 67 | "project_star": { 68 | "public": true, 69 | "notifyTargets": ["project_owner"] 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /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 | import * as totpController from './totpController.js'; 7 | import * as oauthController from './oauthController.js'; 8 | 9 | // 集中导出所有控制器 10 | export { 11 | loginController, 12 | registerController, 13 | emailController, 14 | tokenController, 15 | totpController, 16 | oauthController 17 | }; -------------------------------------------------------------------------------- /controllers/auth/totpController.js: -------------------------------------------------------------------------------- 1 | import logger from "../../services/logger.js"; 2 | import { prisma } from "../../services/global.js"; 3 | import totpUtils from "../../services/auth/totp.js"; 4 | 5 | const { 6 | isTotpTokenValid, 7 | createTotpTokenForUser, 8 | enableTotpToken, 9 | removeTotpToken, 10 | validateTotpToken, 11 | } = totpUtils; 12 | 13 | /** 14 | * 获取验证器列表 15 | */ 16 | export const getTotpList = async (req, res) => { 17 | try { 18 | let totpData = await prisma.ow_users_totp.findMany({ 19 | where: { user_id: Number(res.locals.userid) }, 20 | select: { 21 | id: true, 22 | user_id: true, 23 | name: true, 24 | type: true, 25 | status: true, 26 | }, 27 | }); 28 | // 获取列表中status为unverified的数量并从列表中删除这些数据 29 | const unverifiedTotpCount = totpData.filter( 30 | (totp) => totp.status === "unverified" 31 | ).length; 32 | totpData = totpData.filter((item) => item.status !== "unverified"); 33 | 34 | return res.json({ 35 | status: "success", 36 | message: "获取成功", 37 | data: { 38 | list: totpData, 39 | unverified: unverifiedTotpCount, 40 | }, 41 | }); 42 | } catch (error) { 43 | logger.error("获取验证器列表时出错:", error); 44 | return res.status(500).json({ 45 | status: "error", 46 | message: "获取验证器列表失败", 47 | error: error.message, 48 | }); 49 | } 50 | }; 51 | 52 | /** 53 | * 重命名验证器 54 | */ 55 | export const renameTotpToken = async (req, res) => { 56 | const { totp_id, name } = req.body; 57 | if (!totp_id || !name) { 58 | return res.status(400).json({ 59 | status: "error", 60 | message: "TOTP ID 和名称是必需的", 61 | }); 62 | } 63 | try { 64 | let renamedTotp; 65 | renamedTotp = await prisma.ow_users_totp.update({ 66 | where: { id: Number(totp_id) }, 67 | data: { name: name }, 68 | select: { 69 | id: true, 70 | user_id: true, 71 | name: true, 72 | type: true, 73 | status: true, 74 | }, 75 | }); 76 | return res.json({ 77 | status: "success", 78 | message: "验证器已重命名", 79 | data: renamedTotp, 80 | }); 81 | } catch (error) { 82 | logger.error("重命名验证器时出错:", error); 83 | return res.status(500).json({ 84 | status: "error", 85 | message: "重命名验证器失败", 86 | error: error.message, 87 | }); 88 | } 89 | }; 90 | 91 | /** 92 | * 验证TOTP令牌 93 | */ 94 | export const checkTotpToken = async (req, res) => { 95 | const { totp_token, userId } = req.body; 96 | if (!totp_token || !userId) { 97 | return res.status(400).json({ 98 | status: "error", 99 | message: "验证器令牌和用户 ID 是必需的", 100 | }); 101 | } 102 | 103 | try { 104 | const isValid = await isTotpTokenValid(userId, totp_token); 105 | return res.json({ 106 | status: "success", 107 | message: "令牌验证结果", 108 | data: { validated: isValid }, 109 | }); 110 | } catch (error) { 111 | logger.debug("验证令牌时出错:", error); 112 | return res.status(500).json({ 113 | status: "error", 114 | message: "验证令牌失败", 115 | error: error.message, 116 | }); 117 | } 118 | }; 119 | 120 | /** 121 | * 删除验证器 122 | */ 123 | export const deleteTotpToken = async (req, res) => { 124 | const { totp_id } = req.body; 125 | if (!totp_id) { 126 | return res.status(400).json({ 127 | status: "error", 128 | message: "验证器 ID 是必需的", 129 | }); 130 | } 131 | try { 132 | const deletedTotp = await removeTotpToken(res.locals.userid, totp_id); 133 | return res.json({ 134 | status: "success", 135 | message: "验证器已删除", 136 | data: deletedTotp, 137 | }); 138 | } catch (error) { 139 | logger.error("删除验证器时出错:", error); 140 | return res.status(500).json({ 141 | status: "error", 142 | message: "删除验证器失败", 143 | error: error.message, 144 | }); 145 | } 146 | }; 147 | 148 | /** 149 | * 生成验证器 150 | */ 151 | export const generateTotpToken = async (req, res) => { 152 | try { 153 | const info = await createTotpTokenForUser(res.locals.userid); 154 | return res.json({ 155 | status: "success", 156 | message: "验证器创建成功", 157 | data: info, 158 | }); 159 | } catch (error) { 160 | logger.error("创建验证器时出错:", error); 161 | return res.status(500).json({ 162 | status: "error", 163 | message: "创建验证器失败", 164 | error: error.message, 165 | }); 166 | } 167 | }; 168 | 169 | /** 170 | * 激活验证器 171 | */ 172 | export const activateTotpToken = async (req, res) => { 173 | const { totp_id, totp_token } = req.body; 174 | 175 | if (!totp_id || !totp_token) { 176 | return res.status(400).json({ 177 | status: "error", 178 | message: "验证器ID和令牌是必需的", 179 | }); 180 | } 181 | 182 | try { 183 | const activatedTotp = await enableTotpToken( 184 | res.locals.userid, 185 | totp_id, 186 | totp_token 187 | ); 188 | return res.json({ 189 | status: "success", 190 | message: "验证器已激活", 191 | data: activatedTotp, 192 | }); 193 | } catch (error) { 194 | logger.error("激活验证器时出错:", error); 195 | return res.status(500).json({ 196 | status: "error", 197 | message: "激活验证器失败", 198 | error: error.message, 199 | }); 200 | } 201 | }; -------------------------------------------------------------------------------- /controllers/email.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import base32Encode from 'base32-encode'; 3 | import { prisma } from "../services/global.js"; 4 | import { sendEmail } from "../services/email/emailService.js"; 5 | import { TOTP } from "otpauth"; 6 | import logger from '../services/logger.js'; 7 | import memoryCache from '../services/memoryCache.js'; 8 | import zcconfig from "../services/config/zcconfig.js"; 9 | 10 | // 创建TOTP实例的通用函数 11 | function createTotpInstance(secret) { 12 | return new TOTP({ 13 | secret: secret, 14 | algorithm: "SHA256", 15 | digits: 6, 16 | period: 300, // 5分钟有效期 17 | issuer: "ZeroCat", 18 | label: "邮箱验证" 19 | }); 20 | } 21 | 22 | // 生成验证码 23 | function generateEmailToken(secret) { 24 | const totp = createTotpInstance(secret); 25 | return totp.generate(); 26 | } 27 | 28 | // 验证验证码 29 | function validateEmailToken(secret, token) { 30 | try { 31 | const totp = createTotpInstance(secret); 32 | // window: 1 表示允许前后一个时间窗口的验证码 33 | return totp.validate({ token, window: 1 }) !== null; 34 | } catch (error) { 35 | logger.error('验证码验证失败:', error); 36 | return false; 37 | } 38 | } 39 | 40 | // Generate a Base32 hash for TOTP 41 | const generateContactHash = () => { 42 | // 生成16字节的随机数据 43 | const buffer = crypto.randomBytes(16); 44 | // 使用 base32-encode 库将随机字节转换为 Base32 格式 45 | return base32Encode(buffer, 'RFC4648', { padding: false }); 46 | }; 47 | 48 | // Add a contact for a user 49 | const addUserContact = async (userId, contactValue, contactType, isPrimary = false) => { 50 | const contactHash = generateContactHash(); 51 | 52 | try { 53 | const contact = await prisma.ow_users_contacts.create({ 54 | data: { 55 | user_id: userId, 56 | contact_value: contactValue, 57 | contact_info: contactHash, 58 | contact_type: contactType, 59 | is_primary: isPrimary, 60 | verified: false 61 | } 62 | }); 63 | 64 | return contact; 65 | } catch (error) { 66 | if (error.code === 'P2002') { 67 | throw new Error('Contact already exists'); 68 | } 69 | throw error; 70 | } 71 | }; 72 | 73 | // 添加速率限制 74 | const rateLimitEmailVerification = async (email) => { 75 | const key = `email_verification:${email}`; 76 | const count = memoryCache.get(key) || 0; 77 | 78 | if (count >= 3) { 79 | throw new Error('发送验证码过于频繁,请稍后再试'); 80 | } 81 | 82 | memoryCache.set(key, count + 1, 3600); // 1小时过期 83 | }; 84 | 85 | // 定义不同场景的邮件模板 86 | const EMAIL_TEMPLATES = { 87 | // 验证邮箱模板 88 | VERIFY: (code, verifyUrl) => ` 89 | 验证您的邮箱 90 | 91 | 您的验证码是: ${code} 92 | 此验证码将在5分钟内有效。 93 | 94 | 您也可以点击以下链接完成验证: 95 | ${verifyUrl} 96 | 97 | 如果这不是您的操作,请忽略此邮件。 98 | `, 99 | 100 | // 重置密码模板 101 | RESET_PASSWORD: (code, verifyUrl) => ` 102 | 重置密码验证 103 | 104 | 您正在重置密码,验证码是: ${code} 105 | 此验证码将在5分钟内有效。 106 | 107 | 如果这不是您的操作,请忽略此邮件并考虑修改您的密码。 108 | `, 109 | 110 | // 添加邮箱模板 111 | ADD_EMAIL: (code, verifyUrl) => ` 112 | 验证新邮箱 113 | 114 | 您正在添加新的邮箱地址,验证码是: ${code} 115 | 此验证码将在5分钟内有效。 116 | 117 | 您也可以点击以下链接完成验证: 118 | ${verifyUrl} 119 | 120 | 如果这不是您的操作,请忽略此邮件。 121 | `, 122 | 123 | // 默认模板 124 | DEFAULT: (code, verifyUrl) => ` 125 | 验证码 126 | 127 | 您的验证码是: ${code} 128 | 此验证码将在5分钟内有效。 129 | 130 | 如果这不是您的操作,请忽略此邮件。 131 | `, 132 | 133 | // 登录验证模板 134 | LOGIN: (code) => ` 135 | 登录验证 136 | 137 | 您正在使用邮箱验证码登录,验证码是: ${code} 138 | 此验证码将在5分钟内有效。 139 | 140 | 如果这不是您的操作,请忽略此邮件并考虑修改您的密码。 141 | `, 142 | 143 | // 解绑 OAuth 验证模板 144 | UNLINK_OAUTH: (code) => ` 145 | 解绑 OAuth 验证 146 | 147 | 您正在请求解绑 OAuth 账号,验证码是: ${code} 148 | 此验证码将在5分钟内有效。 149 | 150 | 如果这不是您的操作,请忽略此邮件并考虑修改您的密码。 151 | ` 152 | }; 153 | 154 | // 邮件主题映射 155 | const EMAIL_SUBJECTS = { 156 | VERIFY: '验证您的邮箱', 157 | RESET_PASSWORD: '重置密码验证', 158 | ADD_EMAIL: '验证新邮箱', 159 | DEFAULT: '验证码', 160 | LOGIN: '登录验证码' 161 | }; 162 | 163 | // Send verification email 164 | const sendVerificationEmail = async (contactValue, contactHash, template = 'DEFAULT') => { 165 | await rateLimitEmailVerification(contactValue); 166 | 167 | const token = generateEmailToken(contactHash); 168 | const verifyUrl = `${await zcconfig.get("urls.frontend")}/app/account/email/verify?email=${encodeURIComponent(contactValue)}&token=${encodeURIComponent(token)}`; 169 | 170 | // 获取对应的邮件模板和主题 171 | const emailTemplate = EMAIL_TEMPLATES[template] || EMAIL_TEMPLATES.DEFAULT; 172 | const emailSubject = EMAIL_SUBJECTS[template] || EMAIL_SUBJECTS.DEFAULT; 173 | 174 | // 生成邮件内容 175 | const emailContent = emailTemplate(token, verifyUrl); 176 | 177 | await sendEmail(contactValue, emailSubject, emailContent); 178 | }; 179 | 180 | // Verify contact 181 | const verifyContact = async (contactValue, token) => { 182 | const contact = await prisma.ow_users_contacts.findFirst({ 183 | where: { 184 | contact_value: contactValue 185 | } 186 | }); 187 | 188 | if (!contact) { 189 | logger.debug('未找到邮箱联系方式'); 190 | return false; 191 | } 192 | 193 | const isValid = validateEmailToken(contact.contact_info, token); 194 | logger.debug('验证结果:', isValid); 195 | 196 | if (!isValid) { 197 | logger.debug('验证码错误'); 198 | return false; 199 | } 200 | 201 | await prisma.ow_users_contacts.update({ 202 | where: { 203 | contact_id: contact.contact_id 204 | }, 205 | data: { 206 | verified: true 207 | } 208 | }); 209 | 210 | return true; 211 | }; 212 | 213 | export { 214 | addUserContact, 215 | sendVerificationEmail, 216 | verifyContact 217 | }; 218 | -------------------------------------------------------------------------------- /controllers/projects.js: -------------------------------------------------------------------------------- 1 | import logger from "../services/logger.js"; 2 | import { prisma } from "../services/global.js"; 3 | 4 | import { createHash } from "crypto"; 5 | import { getUsersByList } from "./users.js"; 6 | import { name } from "ejs"; 7 | 8 | async function getProjectsByList(list, userid = null) { 9 | const select = projectSelectionFields(); 10 | const projectIds = list.map(Number); 11 | const projects = await prisma.ow_projects.findMany({ 12 | where: { id: { in: projectIds } }, 13 | select, 14 | }); 15 | if (userid) { 16 | return projects.filter( 17 | (project) => !(project.state === "private" && project.authorid !== userid) 18 | ); 19 | } 20 | return projects; 21 | } 22 | 23 | async function getProjectById(projectId, userid = null) { 24 | const select = projectSelectionFields(); 25 | const project = await prisma.ow_projects.findFirst({ 26 | where: { id: Number(projectId) }, 27 | select, 28 | }); 29 | if (!project) { 30 | return null; 31 | } 32 | if (userid && project.state === "private" && project.authorid !== userid) { 33 | return null; 34 | } 35 | return project; 36 | } 37 | 38 | async function getProjectsAndUsersByProjectsList(list, userid = null) { 39 | const projects = await getProjectsByList(list, userid); 40 | const userslist = [...new Set(projects.map((project) => project.authorid))]; 41 | const users = await getUsersByList(userslist); 42 | return { projects, users }; 43 | } 44 | 45 | function extractProjectData(body) { 46 | const fields = [ 47 | "type", 48 | "license", 49 | "state", 50 | "title", 51 | "description", 52 | "history", 53 | ]; 54 | return fields.reduce( 55 | (acc, field) => (body[field] ? { ...acc, [field]: body[field] } : acc), 56 | {} 57 | ); 58 | } 59 | 60 | const extractProjectTags = (tags) => 61 | // 如果某项为空,则删除 62 | Array.isArray(tags) 63 | ? tags.map(String).filter((tag) => tag) 64 | : tags 65 | .split(",") 66 | .map((tag) => tag.trim()) 67 | .filter((tag) => tag); 68 | 69 | async function handleTagsChange(projectId, tags) { 70 | const existingTags = await prisma.ow_projects_tags.findMany({ 71 | where: { projectid: projectId }, 72 | select: { id: true, name: true }, 73 | }); 74 | tags = extractProjectTags(tags); 75 | 76 | const tagNames = new Set(tags); 77 | await Promise.all( 78 | existingTags.map(async (tag) => { 79 | if (!tagNames.has(tag.name)) { 80 | await prisma.ow_projects_tags.delete({ where: { id: tag.id } }); 81 | } else { 82 | tagNames.delete(tag.name); 83 | } 84 | }) 85 | ); 86 | 87 | await Promise.all( 88 | [...tagNames].map(async (name) => { 89 | await prisma.ow_projects_tags.create({ 90 | data: { projectid: projectId, name }, 91 | }); 92 | }) 93 | ); 94 | } 95 | 96 | /** 97 | * Set project file 98 | * @param {string | Object} source - Project source 99 | * @returns {Promise} - SHA256 of the source 100 | */ 101 | function setProjectFile(source) { 102 | const sourcedata = isJson(source) ? JSON.stringify(source) : source; 103 | const sha256 = createHash("sha256").update(sourcedata).digest("hex"); 104 | prisma.ow_projects_file 105 | .create({ data: { sha256, source: sourcedata }}) 106 | .catch(logger.error); 107 | return sha256; 108 | } 109 | 110 | /** 111 | * Get project file 112 | * @param {string} sha256 - SHA256 of the source 113 | * @returns {Promise<{ source: string }>} - Project source 114 | */ 115 | async function getProjectFile(sha256) { 116 | return prisma.ow_projects_file.findFirst({ 117 | where: { sha256 }, 118 | select: { source: true }, 119 | }); 120 | } 121 | 122 | /** 123 | * Handle error response 124 | * @param {Response} res - Response object 125 | * @param {Error} err - Error object 126 | * @param {string} msg - Error message 127 | * @returns {void} 128 | */ 129 | function handleError(res, err, msg) { 130 | logger.error(err); 131 | res.status(500).send({ status: "error", msg, error: err }); 132 | } 133 | 134 | /** 135 | * Select project information fields 136 | * @returns {Object} - Selected fields 137 | */ 138 | function projectSelectionFields() { 139 | return { 140 | id: true, 141 | name: true, 142 | default_branch: true, 143 | type: true, 144 | license: true, 145 | authorid: true, 146 | state: true, 147 | view_count: true, 148 | time: true, 149 | title: true, 150 | description: true, 151 | tags: true, 152 | star_count: true, 153 | }; 154 | } 155 | 156 | /** 157 | * Select author information fields 158 | * @returns {Object} - Selected fields 159 | */ 160 | function authorSelectionFields() { 161 | return { 162 | id: true, 163 | username: true, 164 | display_name: true, 165 | status: true, 166 | regTime: true, 167 | motto: true, 168 | images: true, 169 | }; 170 | } 171 | 172 | // 工具函数:判断是否为有效 JSON 173 | function isJson(str) { 174 | try { 175 | JSON.stringify(str); 176 | return true; 177 | } catch (error) { 178 | logger.debug(error); 179 | return false; 180 | } 181 | } 182 | 183 | export { 184 | getProjectsByList, 185 | getUsersByList, 186 | getProjectsAndUsersByProjectsList, 187 | extractProjectData, 188 | isJson, 189 | setProjectFile, 190 | getProjectFile, 191 | projectSelectionFields, 192 | authorSelectionFields, 193 | extractProjectTags, 194 | handleTagsChange, 195 | getProjectById, 196 | }; 197 | -------------------------------------------------------------------------------- /controllers/stars.js: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import logger from "../services/logger.js"; 3 | import { prisma } from "../services/global.js"; 4 | import { createEvent } from "./events.js"; 5 | 6 | const prismaClient = new PrismaClient(); 7 | 8 | /** 9 | * Star a project for a user 10 | * @param {number} userId - The user ID 11 | * @param {number} projectId - The project ID 12 | * @returns {Promise} - The created star record 13 | */ 14 | export async function starProject(userId, projectId) { 15 | try { 16 | const parsedUserId = parseInt(userId); 17 | const parsedProjectId = parseInt(projectId); 18 | 19 | // 检查项目是否存在 20 | const project = await prismaClient.ow_projects.findUnique({ 21 | where: { id: parsedProjectId } 22 | }); 23 | 24 | if (!project) { 25 | throw new Error("项目不存在"); 26 | } 27 | 28 | // 检查是否已经收藏 29 | const existingStar = await prismaClient.ow_projects_stars.findFirst({ 30 | where: { 31 | userid: parsedUserId, 32 | projectid: parsedProjectId, 33 | } 34 | }); 35 | 36 | if (existingStar) { 37 | return existingStar; 38 | } 39 | 40 | // 创建收藏 41 | const star = await prismaClient.ow_projects_stars.create({ 42 | data: { 43 | userid: parsedUserId, 44 | projectid: parsedProjectId, 45 | createTime: new Date() 46 | } 47 | }); 48 | 49 | // 更新项目收藏数 50 | await prismaClient.ow_projects.update({ 51 | where: { id: parsedProjectId }, 52 | data: { 53 | star_count: { 54 | increment: 1 55 | } 56 | } 57 | }); 58 | 59 | // 创建收藏事件 60 | await createEvent({ 61 | eventType: "project_star", 62 | actorId: parsedUserId, 63 | 64 | targetType: "project", 65 | targetId: parsedProjectId, 66 | eventData: { 67 | NotificationTo: [project.authorid] 68 | } 69 | }); 70 | 71 | 72 | return star; 73 | } catch (error) { 74 | logger.error("Error in starProject:", error); 75 | throw error; 76 | } 77 | } 78 | 79 | /** 80 | * Unstar a project for a user 81 | * @param {number} userId - The user ID 82 | * @param {number} projectId - The project ID 83 | * @returns {Promise} - The deleted star record 84 | */ 85 | export async function unstarProject(userId, projectId) { 86 | try { 87 | const parsedUserId = parseInt(userId); 88 | const parsedProjectId = parseInt(projectId); 89 | 90 | // 检查项目是否存在 91 | const project = await prismaClient.ow_projects.findUnique({ 92 | where: { id: parsedProjectId } 93 | }); 94 | 95 | if (!project) { 96 | throw new Error("项目不存在"); 97 | } 98 | 99 | // 检查用户是否已收藏该项目 100 | const existingStar = await prismaClient.ow_projects_stars.findFirst({ 101 | where: { 102 | userid: parsedUserId, 103 | projectid: parsedProjectId, 104 | } 105 | }); 106 | 107 | if (!existingStar) { 108 | return { count: 0 }; 109 | } 110 | 111 | // 删除收藏记录 112 | const star = await prismaClient.ow_projects_stars.deleteMany({ 113 | where: { 114 | userid: parsedUserId, 115 | projectid: parsedProjectId, 116 | } 117 | }); 118 | 119 | // 更新项目收藏数 120 | await prismaClient.ow_projects.update({ 121 | where: { id: parsedProjectId }, 122 | data: { 123 | star_count: { 124 | decrement: 1 125 | } 126 | } 127 | }); 128 | 129 | return star; 130 | } catch (error) { 131 | logger.error("Error in unstarProject:", error); 132 | throw error; 133 | } 134 | } 135 | 136 | /** 137 | * Check if a user has starred a project 138 | * @param {number} userId - The user ID 139 | * @param {number} projectId - The project ID 140 | * @returns {Promise} - True if the user has starred the project 141 | */ 142 | export async function getProjectStarStatus(userId, projectId) { 143 | try { 144 | if (!userId) { 145 | return false; 146 | } 147 | 148 | const parsedUserId = parseInt(userId); 149 | const parsedProjectId = parseInt(projectId); 150 | 151 | const star = await prismaClient.ow_projects_stars.findFirst({ 152 | where: { 153 | userid: parsedUserId, 154 | projectid: parsedProjectId, 155 | } 156 | }); 157 | 158 | return !!star; 159 | } catch (error) { 160 | logger.error("Error in getProjectStarStatus:", error); 161 | throw error; 162 | } 163 | } 164 | 165 | /** 166 | * Get the number of stars for a project 167 | * @param {number} projectId - The project ID 168 | * @returns {Promise} - The number of stars 169 | */ 170 | export async function getProjectStars(projectId) { 171 | try { 172 | const parsedProjectId = parseInt(projectId); 173 | 174 | const count = await prismaClient.ow_projects_stars.count({ 175 | where: { 176 | projectid: parsedProjectId, 177 | } 178 | }); 179 | 180 | return count; 181 | } catch (error) { 182 | logger.error("Error in getProjectStars:", error); 183 | throw error; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /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 | motto: true, 17 | images: true, 18 | }; 19 | 20 | // Get each user's info 21 | const users = await prisma.ow_users.findMany({ 22 | where: { 23 | id: { in: userIds.map((id) => parseInt(id, 10)) }, 24 | }, 25 | select, 26 | }); 27 | 28 | return users; 29 | } 30 | 31 | // 获取用户信息通过用户名 32 | export async function getUserByUsername(username) { 33 | try { 34 | const user = await prisma.ow_users.findFirst({ 35 | where: { username }, 36 | select: { 37 | id: true, 38 | username: true, 39 | display_name: true, 40 | status: true, 41 | regTime: true, 42 | motto: true, 43 | images: true, 44 | } 45 | }); 46 | return user; 47 | } catch (err) { 48 | logger.error("Error fetching user by username:", err); 49 | throw err; 50 | } 51 | } 52 | 53 | export { getUsersByList }; 54 | 55 | -------------------------------------------------------------------------------- /data/README.md: -------------------------------------------------------------------------------- 1 | # MaxMind GeoIP 数据库 2 | 3 | ## 使用方法 4 | 5 | 1. 前往 [MaxMind官网](https://www.maxmind.com/) 注册账号,获取免费的 GeoLite2 许可证密钥和账户ID 6 | 2. 在数据库中配置以下参数: 7 | ```sql 8 | INSERT INTO ow_config (key, value, is_public) VALUES ('maxmind.license_key', '你的许可证密钥', 0); 9 | INSERT INTO ow_config (key, value, is_public) VALUES ('maxmind.account_id', '你的账户ID', 0); 10 | INSERT INTO ow_config (key, value, is_public) VALUES ('maxmind.enabled', 'true', 0); 11 | INSERT INTO ow_config (key, value, is_public) VALUES ('maxmind.update_interval', '30', 0); -- 可选,更新间隔天数 12 | ``` 13 | 14 | ## 自动下载功能 15 | 16 | 系统具备以下自动功能: 17 | 18 | 1. **启动时自动检查**:应用启动时会自动检查数据库文件是否存在,如果不存在且功能已启用,则自动下载 19 | 2. **动态加载机制**:数据库下载完成后,系统会自动动态加载新数据库,无需重启应用 20 | 3. **热插拔支持**:系统可以在运行时动态更新GeoIP数据库,不会中断任何正在处理的请求 21 | 4. **自动加载配置**:从数据库自动加载配置,无需手动配置文件 22 | 5. **自动错误处理**:如果数据库文件不存在或下载失败,系统会自动回退到使用模拟数据 23 | 6. **进度显示**:下载和解压过程会在控制台显示进度,方便监控 24 | 25 | ## 数据库配置选项 26 | 27 | 系统从数据库的 `ow_config` 表中读取配置: 28 | 29 | | 键名 | 说明 | 是否必须 | 30 | |------|------|---------| 31 | | maxmind.enabled | 是否启用MaxMind功能 | 是 | 32 | | maxmind.license_key | MaxMind许可证密钥 | 是 | 33 | | maxmind.account_id | MaxMind账户ID | 是 | 34 | | maxmind.update_interval | 数据库更新间隔(天) | 否,默认30天 | 35 | 36 | ## 数据库文件 37 | 38 | 数据文件将固定保存在 `data/GeoLite2-City.mmdb` 位置。所有代码已经硬编码使用此位置。 39 | 40 | ## 数据库管理工具 41 | 42 | 系统提供了两个管理工具: 43 | 44 | ### 1. 手动下载数据库 45 | 46 | ```bash 47 | # 从数据库读取配置并下载最新数据库 48 | node tools/downloadMaxmindDb.js 49 | ``` 50 | 51 | 该工具会: 52 | 1. 从数据库读取账户ID和许可证密钥 53 | 2. 使用官方API下载最新的数据库文件(显示下载进度) 54 | 3. 自动解压并安装到正确位置(显示解压进度) 55 | 56 | ### 2. 定时更新脚本 57 | 58 | ```bash 59 | # 检查数据库是否需要更新,如需要则自动下载 60 | node tools/updateGeoIPDatabase.js 61 | 62 | # 带自动重启参数 63 | node tools/updateGeoIPDatabase.js --restart 64 | ``` 65 | 66 | 该脚本会: 67 | 1. 检查MaxMind功能是否启用 68 | 2. 检查数据库文件是否存在,或文件是否过期需要更新 69 | 3. 如果需要更新,则自动调用下载脚本并显示进度 70 | 4. 使用`--restart`参数时,下载完成后会自动重启应用 71 | 72 | ## 设置定时更新 73 | 74 | 虽然系统启动时会自动检查数据库,但推荐设置定时任务定期更新数据库: 75 | 76 | ### Linux/Unix (Cron) 77 | 78 | ```bash 79 | # 编辑crontab 80 | crontab -e 81 | 82 | # 添加以下内容,每周一凌晨3点更新,并自动动态加载新数据库 83 | 0 3 * * 1 cd /path/to/project && node tools/updateGeoIPDatabase.js --reload >> /path/to/logs/geoip-update.log 2>&1 84 | ``` 85 | 86 | ### Windows (计划任务) 87 | 88 | 1. 创建批处理文件 `update-geoip.bat`: 89 | ``` 90 | cd D:\path\to\project 91 | node tools/updateGeoIPDatabase.js --reload 92 | ``` 93 | 2. 使用任务计划程序创建计划任务,指向该批处理文件 94 | 95 | ## 在代码中使用 96 | 97 | ```javascript 98 | import ipLocation from '../utils/ipLocation.js'; 99 | 100 | // 更新配置 (将保存到数据库) 101 | await ipLocation.updateConfig({ 102 | enabled: true, 103 | licenseKey: '你的许可证密钥', 104 | accountId: '你的账户ID' 105 | }); 106 | 107 | // 使用IP定位 108 | const location = await ipLocation.getIPLocation('8.8.8.8'); 109 | console.log(location); 110 | ``` 111 | 112 | ## 注意事项 113 | 114 | 1. 数据库配置仅保存在数据库中,不使用任何本地配置文件或环境变量 115 | 2. 应用启动时会自动检查数据库文件,如果未找到且功能已启用则自动下载 116 | 3. 下载完成后系统会自动动态加载新数据库,无需重启应用 117 | 4. 系统支持运行时热更新,可以在不中断服务的情况下更新GeoIP数据 118 | 5. 数据库路径固定为 `data/GeoLite2-City.mmdb`,不可更改 119 | 6. 账户ID和许可证密钥必须正确配置,否则无法下载数据库 120 | 7. 如果启用了MaxMind但数据库文件不存在,系统会回退到使用模拟数据 121 | 8. GeoLite2数据库每周更新一次,建议设置定时任务定期更新 122 | 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 | -------------------------------------------------------------------------------- /docs/EVENT_SYSTEM.md: -------------------------------------------------------------------------------- 1 | # Event System Documentation 2 | 3 | ## Overview 4 | 5 | The ZeroCat event system has been refactored to use a centralized JSON configuration for all event-related data. This makes the code more maintainable and easier to understand. 6 | 7 | ## Key Components 8 | 9 | ### 1. Configuration (`config/eventConfig.json`) 10 | 11 | This JSON file contains all event-related configuration in one place: 12 | 13 | - `targetTypes`: Defines the types of entities that can be targets of events (PROJECT, USER, COMMENT) 14 | - `eventTypes`: Constants for all supported event types 15 | - `eventConfig`: Configuration for each event type, including: 16 | - `public`: Whether the event is visible to all users 17 | - `notifyTargets`: Which users should be notified when this event occurs 18 | 19 | ### 2. Controller (`controllers/events.js`) 20 | 21 | The event controller imports the configuration and provides: 22 | 23 | - Event creation functionality 24 | - Event retrieval 25 | - Notification processing 26 | - Helper functions 27 | 28 | ### 3. Routes (`src/routes/event.routes.js`) 29 | 30 | The routes file defines the API endpoints for: 31 | 32 | - Getting events for a specific target 33 | - Getting events for a specific actor 34 | - Creating new events 35 | - Retrieving follower information 36 | 37 | ## How to Add a New Event Type 38 | 39 | 1. Add the new event type to `eventTypes` in `config/eventConfig.json` 40 | 2. Add configuration for the event in the `eventConfig` section, specifying: 41 | - `public`: Boolean indicating visibility 42 | - `notifyTargets`: Array of user roles to notify 43 | 44 | ## Notification Targets 45 | 46 | The following notification targets are supported: 47 | 48 | - `project_owner`: The owner of the project 49 | - `project_followers`: Users following the project 50 | - `user_followers`: Users following the actor 51 | - `page_owner`: The owner of a page where an action occurred 52 | - `thread_participants`: Users who have commented in a thread -------------------------------------------------------------------------------- /docs/event-formats.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseEventFields": { 3 | "event_type": "字符串,事件类型,如 project_create, user_login 等", 4 | "actor_id": "数字,执行动作的用户ID", 5 | "target_type": "字符串,目标类型,如 project, user, comment 等", 6 | "target_id": "数字,目标对象的ID", 7 | "metadata": "可选,额外信息,键值对对象" 8 | }, 9 | "eventTypes": { 10 | "project_commit": { 11 | "description": "项目提交代码事件", 12 | "fields": { 13 | "commit_id": "字符串,提交的唯一标识符", 14 | "commit_message": "字符串,提交信息", 15 | "branch": "字符串,分支名称", 16 | "commit_file": "字符串,提交的文件路径", 17 | "project_name": "字符串,项目名称", 18 | "project_title": "字符串,项目标题", 19 | "project_type": "字符串,项目类型", 20 | "project_description": "字符串,可选,项目描述", 21 | "project_state": "字符串,项目状态" 22 | }, 23 | "notification": ["项目所有者", "项目关注者"] 24 | }, 25 | "project_update": { 26 | "description": "项目更新事件", 27 | "fields": { 28 | "update_type": "字符串,更新类型", 29 | "old_value": "字符串,可选,旧值", 30 | "new_value": "字符串,可选,新值" 31 | }, 32 | "notification": ["项目所有者"] 33 | }, 34 | "project_fork": { 35 | "description": "项目分支(复刻)事件", 36 | "fields": { 37 | "fork_id": "数字,分支项目ID", 38 | "project_name": "字符串,项目名称", 39 | "project_title": "字符串,项目标题" 40 | }, 41 | "notification": ["项目所有者"] 42 | }, 43 | "project_create": { 44 | "description": "创建新项目事件", 45 | "fields": { 46 | "project_type": "字符串,项目类型", 47 | "project_name": "字符串,项目名称", 48 | "project_title": "字符串,项目标题", 49 | "project_description": "字符串,可选,项目描述", 50 | "project_state": "字符串,项目状态" 51 | }, 52 | "notification": ["用户关注者"] 53 | }, 54 | "project_publish": { 55 | "description": "发布项目事件", 56 | "fields": { 57 | "old_state": "字符串,旧状态", 58 | "new_state": "字符串,新状态", 59 | "project_title": "字符串,项目标题" 60 | }, 61 | "notification": ["用户关注者"] 62 | }, 63 | "comment_create": { 64 | "description": "创建评论事件", 65 | "fields": { 66 | "page_type": "字符串,页面类型", 67 | "page_id": "数字,页面ID", 68 | "pid": "数字,可选,父评论ID", 69 | "rid": "数字,可选,回复ID", 70 | "text": "字符串,评论内容,限制为100个字符" 71 | }, 72 | "notification": ["页面所有者", "对话参与者"] 73 | }, 74 | "user_profile_update": { 75 | "description": "用户资料更新事件", 76 | "fields": { 77 | "update_type": "字符串,更新类型", 78 | "old_value": "字符串,可选,旧值", 79 | "new_value": "字符串,可选,新值" 80 | }, 81 | "notification": ["用户关注者"] 82 | }, 83 | "user_login": { 84 | "description": "用户登录事件", 85 | "fields": {}, 86 | "notification": [] 87 | }, 88 | "user_register": { 89 | "description": "用户注册事件", 90 | "fields": { 91 | "username": "字符串,用户名" 92 | }, 93 | "notification": [] 94 | }, 95 | "project_rename": { 96 | "description": "项目重命名事件", 97 | "fields": { 98 | "old_name": "字符串,旧项目名", 99 | "new_name": "字符串,新项目名", 100 | "project_title": "字符串,项目标题", 101 | "project_type": "字符串,项目类型", 102 | "project_state": "字符串,项目状态" 103 | }, 104 | "notification": ["项目关注者"] 105 | }, 106 | "project_info_update": { 107 | "description": "项目信息更新事件", 108 | "fields": { 109 | "updated_fields": "字符串数组,更新的字段名", 110 | "old_values": "对象,包含更新字段的旧值", 111 | "new_values": "对象,包含更新字段的新值", 112 | "project_name": "字符串,项目名称", 113 | "project_title": "字符串,项目标题", 114 | "project_type": "字符串,项目类型", 115 | "project_description": "字符串,可选,项目描述", 116 | "project_state": "字符串,项目状态" 117 | }, 118 | "notification": ["项目关注者"] 119 | } 120 | }, 121 | "targetTypes": { 122 | "project": "项目", 123 | "user": "用户", 124 | "comment": "评论" 125 | }, 126 | "usage": { 127 | "description": "此文档仅供人类阅读,用于了解事件系统中的各种事件格式。在代码中请使用实际的事件模型和验证器。", 128 | "example": { 129 | "创建项目事件": { 130 | "event_type": "project_create", 131 | "actor_id": 123, 132 | "target_type": "project", 133 | "target_id": 456, 134 | "project_type": "scratch", 135 | "project_name": "my_project", 136 | "project_title": "My Awesome Project", 137 | "project_description": "This is a cool project", 138 | "project_state": "draft" 139 | }, 140 | "用户登录事件": { 141 | "event_type": "user_login", 142 | "actor_id": 123, 143 | "target_type": "user", 144 | "target_id": 123 145 | } 146 | } 147 | } 148 | } -------------------------------------------------------------------------------- /docs/event-system-migration-guide.md: -------------------------------------------------------------------------------- 1 | # Event System Migration Guide 2 | 3 | ## Overview 4 | 5 | This document provides guidance for migrating from the old event system to the new schema-based event system. The new system uses Zod for schema validation and provides a more structured approach to event handling. 6 | 7 | ## Migration Steps 8 | 9 | ### 1. Install Dependencies 10 | 11 | The new event system requires the Zod library. Make sure it's installed: 12 | 13 | ```bash 14 | npm install zod 15 | # or 16 | pnpm add zod 17 | ``` 18 | 19 | ### 2. Run Database Migrations 20 | 21 | Apply the database migration to add necessary indices: 22 | 23 | ```bash 24 | npx prisma migrate dev --name enhance_event_model 25 | ``` 26 | 27 | This will create the following indices: 28 | - `idx_events_actor_id`: For faster queries by actor 29 | - `idx_events_type_target`: For faster queries by event type and target 30 | - `idx_events_public`: For faster filtering of public/private events 31 | 32 | ### 3. Update Event Creation Code 33 | 34 | If you're currently using the old event API: 35 | 36 | ```javascript 37 | // Old approach 38 | import { createEvent } from '../controllers/events.js'; 39 | 40 | await createEvent( 41 | 'project_create', 42 | userId, 43 | 'project', 44 | projectId, 45 | { 46 | project_type: 'scratch', 47 | // other fields... 48 | } 49 | ); 50 | ``` 51 | 52 | You can continue using this API as it's backwards compatible. The controller will transform your event data internally to match the new schema requirements. 53 | 54 | ### 4. Update Event Retrieval Code 55 | 56 | For retrieving events, use the new methods: 57 | 58 | ```javascript 59 | // Get events for a target 60 | import { getTargetEvents, TargetTypes } from '../controllers/events.js'; 61 | 62 | const events = await getTargetEvents( 63 | "project", // target type 64 | projectId, // target ID 65 | 10, // limit 66 | 0, // offset 67 | false // include private events? 68 | ); 69 | 70 | // Get events for an actor 71 | import { getActorEvents } from '../controllers/events.js'; 72 | 73 | const events = await getActorEvents( 74 | userId, // actor ID 75 | 10, // limit 76 | 0, // offset 77 | false // include private events? 78 | ); 79 | ``` 80 | 81 | ### 5. Add New Event Types 82 | 83 | If you need to add a new event type: 84 | 85 | 1. Define the schema in `models/events.js`: 86 | 87 | ```javascript 88 | export const MyNewEventSchema = BaseEventSchema.extend({ 89 | // Add your event-specific fields here 90 | field1: z.string(), 91 | field2: z.number(), 92 | // etc. 93 | }); 94 | ``` 95 | 96 | 2. Add the event configuration to `EventConfig` in the same file: 97 | 98 | ```javascript 99 | export const EventConfig = { 100 | // ... existing event configs ... 101 | 102 | 'my_new_event': { 103 | schema: MyNewEventSchema, 104 | logToDatabase: true, 105 | public: true, 106 | notifyTargets: ['appropriate_targets'], 107 | }, 108 | }; 109 | ``` 110 | 111 | ## Troubleshooting 112 | 113 | ### Schema Validation Errors 114 | 115 | If you get validation errors, check the event data against the schema defined in `models/events.js`. The logs will contain detailed error information. 116 | 117 | ### Migration Issues 118 | 119 | If you encounter issues during migration, try: 120 | 121 | 1. Make sure there are no conflicting migration files 122 | 2. Check that the database user has sufficient privileges to create indices 123 | 3. Verify the database connection configuration 124 | 125 | ## Additional Resources 126 | 127 | For more detailed information, refer to: 128 | - [Event System Documentation](./events-system.md) 129 | - [Zod Documentation](https://github.com/colinhacks/zod) -------------------------------------------------------------------------------- /docs/event-system-updates.md: -------------------------------------------------------------------------------- 1 | # 事件系统更新文档 2 | 3 | ## 事件创建格式变更 4 | 5 | 事件系统已更新,现在所有事件创建时需要在事件数据中包含基本的事件元数据字段。这确保了事件数据的一致性并满足了 schema 验证的要求。 6 | 7 | ### 旧格式示例 8 | 9 | ```javascript 10 | await createEvent("project_create", userId, "project", projectId, { 11 | project_name: "my-project", 12 | project_title: "My Project", 13 | // 其他事件特定字段... 14 | }); 15 | ``` 16 | 17 | ### 新格式示例 18 | 19 | ```javascript 20 | await createEvent("project_create", userId, "project", projectId, { 21 | // 必须包含这些基本字段 22 | event_type: "project_create", 23 | actor_id: userId, 24 | target_type: "project", 25 | target_id: projectId, 26 | 27 | // 事件特定字段 28 | project_name: "my-project", 29 | project_title: "My Project", 30 | // 其他事件特定字段... 31 | }); 32 | ``` 33 | 34 | ## 更新说明 35 | 36 | 1. 每个事件数据对象现在必须包含以下基本字段: 37 | - `event_type`: 事件类型,与第一个参数保持一致 38 | - `actor_id`: 执行操作的用户ID,与第二个参数保持一致 39 | - `target_type`: 目标类型,与第三个参数保持一致 40 | - `target_id`: 目标ID,与第四个参数保持一致 41 | 42 | 2. 所有事件类型字符串必须使用小写格式(如 `user_login` 而非 `USER_LOGIN`) 43 | 44 | 3. 请参考 `docs/event-formats.json` 文件了解每种事件类型所需的特定字段 45 | 46 | ## 迁移指南 47 | 48 | 当更新现有代码时,请确保: 49 | 50 | 1. 将所有 `EventTypes.常量` 形式替换为对应的小写字符串格式 51 | - 例如:`EventTypes.USER_LOGIN` 更新为 `"user_login"` 52 | 53 | 2. 在事件数据对象中添加基本元数据字段: 54 | ```javascript 55 | { 56 | event_type: eventType, 57 | actor_id: actorId, 58 | target_type: targetType, 59 | target_id: targetId, 60 | // 事件特定字段... 61 | } 62 | ``` 63 | 64 | 3. 继续使用第五个参数 `forcePrivate` 来控制事件的可见性(如果需要) 65 | 66 | ## 注意事项 67 | 68 | - 事件数据在保存前会使用 `models/events.js` 中定义的 schema 进行验证 69 | - 不符合对应事件类型 schema 的数据将导致事件创建失败 70 | - 请查看 `services/eventService.js` 中的 `createEvent` 函数了解完整的事件创建流程 -------------------------------------------------------------------------------- /docs/event-types.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_commit": { 3 | "event_type": "project_commit", 4 | "actor_id": 123, 5 | "target_type": "project", 6 | "target_id": 456, 7 | "public": 1, 8 | "event_data": { 9 | "commit_id": "a1b2c3d4", 10 | "commit_message": "更新了项目代码", 11 | "branch": "main" 12 | } 13 | }, 14 | "project_update": { 15 | "event_type": "project_update", 16 | "actor_id": 123, 17 | "target_type": "project", 18 | "target_id": 456, 19 | "public": 1, 20 | "event_data": { 21 | "update_type": "code", 22 | "old_value": "", 23 | "new_value": "" 24 | } 25 | }, 26 | "project_rename": { 27 | "event_type": "project_rename", 28 | "actor_id": 123, 29 | "target_type": "project", 30 | "target_id": 456, 31 | "public": 1, 32 | "event_data": { 33 | "old_name": "old-project-name", 34 | "new_name": "new-project-name" 35 | } 36 | }, 37 | "project_info_update": { 38 | "event_type": "project_info_update", 39 | "actor_id": 123, 40 | "target_type": "project", 41 | "target_id": 456, 42 | "public": 1, 43 | "event_data": { 44 | "updated_fields": ["title", "description"], 45 | "old_values": { 46 | "title": "旧标题", 47 | "description": "旧描述" 48 | }, 49 | "new_values": { 50 | "title": "新标题", 51 | "description": "新描述" 52 | } 53 | } 54 | }, 55 | "user_profile_update": { 56 | "event_type": "user_profile_update", 57 | "actor_id": 123, 58 | "target_type": "user", 59 | "target_id": 123, 60 | "public": 1, 61 | "event_data": { 62 | "update_type": "display_name", 63 | "old_value": "旧用户名", 64 | "new_value": "新用户名" 65 | }, 66 | 67 | "project_create": { 68 | "event_type": "project_create", 69 | "actor_id": 123, 70 | "target_type": "project", 71 | "target_id": 456, 72 | "public": 1, 73 | "event_data": {} 74 | }, 75 | "project_fork": { 76 | "event_type": "project_fork", 77 | "actor_id": 123, 78 | "target_type": "project", 79 | "target_id": 456, 80 | "public": 1, 81 | "event_data": { 82 | "fork_id": 789 83 | } 84 | }, 85 | "user_register": { 86 | "event_type": "user_register", 87 | "actor_id": 123, 88 | "target_type": "user", 89 | "target_id": 123, 90 | "public": 1, 91 | "event_data": {} 92 | } 93 | , 94 | "comment_create": { 95 | "event_type": "comment_create", 96 | "actor_id": 123, 97 | "target_type": "comment", 98 | "target_id": 789, 99 | "public": 0, 100 | "event_data": { 101 | "page_type": "project", 102 | "page_id": 456 103 | } 104 | }, 105 | "project_star": { 106 | "event_type": "project_star", 107 | "actor_id": 123, 108 | "target_type": "project", 109 | "target_id": 456, 110 | "public": 1, 111 | "event_data": {} 112 | }, 113 | "project_publish": { 114 | "event_type": "project_publish", 115 | "actor_id": 123, 116 | "target_type": "project", 117 | "target_id": 456, 118 | "public": 1, 119 | "event_data": {} 120 | }, 121 | "user_login": { 122 | "event_type": "user_login", 123 | "actor_id": 123, 124 | "target_type": "user", 125 | "target_id": 123, 126 | "public": 0, 127 | "event_data": {} 128 | } 129 | } 130 | } -------------------------------------------------------------------------------- /docs/events-direct-schema.md: -------------------------------------------------------------------------------- 1 | # 直接存储事件数据的新架构 2 | 3 | ## 概述 4 | 5 | 我们对事件系统进行了重构,移除了旧的 `dbFields` 字段过滤方法,改为使用 Zod 验证模式和直接存储整个事件数据结构的方法。这种新方法简化了代码,提高了可维护性,并且增强了类型安全性。 6 | 7 | ## 重大变更 8 | 9 | 1. 移除了 `EventTypes` 中的 `dbFields` 数组 10 | 2. 使用 Zod 验证模式直接验证完整事件数据 11 | 3. 将整个验证通过的事件数据存储在数据库中 12 | 4. 为支持旧版代码提供了向后兼容层 13 | 14 | ## 新事件系统的工作方式 15 | 16 | ### 1. 定义事件模式 17 | 18 | 每个事件类型都使用 Zod 模式定义: 19 | 20 | ```javascript 21 | // models/events.js 22 | export const ProjectCommitEventSchema = BaseEventSchema.extend({ 23 | commit_id: z.string(), 24 | commit_message: z.string(), 25 | branch: z.string(), 26 | // 其他字段... 27 | }); 28 | ``` 29 | 30 | ### 2. 事件配置 31 | 32 | 每个事件类型的配置包含验证模式和其他元数据: 33 | 34 | ```javascript 35 | export const EventConfig = { 36 | 'project_commit': { 37 | schema: ProjectCommitEventSchema, 38 | logToDatabase: true, 39 | public: true, 40 | notifyTargets: ['project_owner', 'project_followers'], 41 | }, 42 | // 其他事件类型... 43 | }; 44 | ``` 45 | 46 | ### 3. 创建事件 47 | 48 | 创建事件时,所有数据都会通过验证模式进行验证: 49 | 50 | ```javascript 51 | // 创建事件 52 | await createEvent( 53 | 'project_create', // 事件类型 54 | userId, // 操作者ID 55 | 'project', // 目标类型 56 | projectId, // 目标ID 57 | { 58 | project_type: 'scratch', 59 | project_name: 'my-project', 60 | project_title: '我的项目', 61 | project_description: '项目描述', 62 | project_state: 'private' 63 | } 64 | ); 65 | ``` 66 | 67 | ### 4. 数据验证和存储 68 | 69 | 1. 事件数据通过 Zod 模式验证 70 | 2. 验证通过的完整数据直接存储到数据库 71 | 3. 不再需要提取特定字段 72 | 73 | ```javascript 74 | // 验证数据 75 | const validationResult = eventConfig.schema.safeParse(eventData); 76 | 77 | // 如果验证通过,存储完整数据 78 | const event = await prisma.ow_events.create({ 79 | data: { 80 | event_type: normalizedEventType, 81 | actor_id: BigInt(validatedData.actor_id), 82 | target_type: validatedData.target_type, 83 | target_id: BigInt(validatedData.target_id), 84 | event_data: validatedData, // 存储完整的验证后数据 85 | public: isPublic ? 1 : 0 86 | }, 87 | }); 88 | ``` 89 | 90 | ## 兼容旧版代码 91 | 92 | 为确保与使用旧版 `EventTypes` 对象的代码兼容,我们提供了向后兼容层: 93 | 94 | ```javascript 95 | // 旧版 EventTypes 常量兼容层 96 | export const EventTypes = { 97 | // 映射旧版结构到新版 98 | 'project_commit': 'project_commit', 99 | 'project_update': 'project_update', 100 | // ...其他映射 101 | 102 | // 常用的事件类型常量(大写格式) 103 | PROJECT_CREATE: 'project_create', 104 | PROJECT_DELETE: 'project_delete', 105 | // ...其他常量 106 | 107 | // 获取事件配置的辅助方法 108 | get(eventType) { 109 | const type = typeof eventType === 'string' ? eventType : String(eventType); 110 | return EventConfig[type.toLowerCase()]; 111 | } 112 | }; 113 | ``` 114 | 115 | ## 升级指南 116 | 117 | ### 1. 直接使用事件类型字符串 118 | 119 | ```javascript 120 | // 旧代码 121 | await createEvent( 122 | EventTypes.PROJECT_CREATE, 123 | userId, 124 | "project", 125 | projectId, 126 | // ... 127 | ); 128 | 129 | // 新代码 - 使用字符串 130 | await createEvent( 131 | 'project_create', 132 | userId, 133 | "project", 134 | projectId, 135 | // ... 136 | ); 137 | ``` 138 | 139 | ### 2. 不再需要考虑 dbFields 140 | 141 | 旧代码: 142 | ```javascript 143 | // 旧系统 - 需要提供 dbFields 中定义的所有字段 144 | const eventData = { 145 | project_type: project.type, // 在 dbFields 中 146 | project_name: project.name, // 在 dbFields 中 147 | // ...其他必需字段 148 | }; 149 | ``` 150 | 151 | 新代码: 152 | ```javascript 153 | // 新系统 - 提供所有相关数据,由 Zod 验证确保正确性 154 | const eventData = { 155 | // 根据事件类型提供所有相关数据 156 | project_type: project.type, 157 | project_name: project.name, 158 | project_title: project.title, 159 | // ...其他数据 160 | }; 161 | ``` 162 | 163 | ### 3. 验证错误处理 164 | 165 | 如果事件数据不符合定义的模式,系统会拒绝创建事件并记录错误: 166 | 167 | ```javascript 168 | // 错误数据将被验证拒绝 169 | const invalidData = { /* 缺少必需字段 */ }; 170 | const result = await createEvent('project_create', userId, 'project', projectId, invalidData); 171 | // result 将为 null,错误会被记录 172 | ``` 173 | 174 | ## 迁移注意事项 175 | 176 | 1. 检查所有使用 `EventTypes.XXX` 形式常量的代码 177 | 2. 考虑直接使用字符串形式的事件类型 178 | 3. 确保提供事件所需的所有数据字段 179 | 4. 使用 Zod 验证模式中定义的类型作为指导 180 | 181 | ## 总结 182 | 183 | 新的事件系统移除了不必要的 `dbFields` 过滤步骤,使用 Zod 验证模式直接验证和存储完整事件数据。这种方式更加简洁、类型安全,并且保持了向后兼容性。 -------------------------------------------------------------------------------- /docs/events-system.md: -------------------------------------------------------------------------------- 1 | # Event System Documentation 2 | 3 | ## Overview 4 | 5 | The event system allows the application to track and respond to various activities within the platform. It was restructured to use schema validation and standardize event data formats. 6 | 7 | ## Key Features 8 | 9 | - **Schema Validation**: All events are validated against predefined schemas 10 | - **Standardized Event Format**: Consistent event data structure across the application 11 | - **Notification Support**: Automatic notification of relevant users based on event type 12 | - **Privacy Controls**: Events can be marked as public or private 13 | 14 | ## Using the Event System 15 | 16 | ### Event Types 17 | 18 | Each event type is defined in `models/events.js` with its own schema. The available event types are: 19 | 20 | - `project_commit`: When a user commits changes to a project 21 | - `project_update`: When a project is updated 22 | - `project_fork`: When a project is forked 23 | - `project_create`: When a new project is created 24 | - `project_publish`: When a project is published 25 | - `comment_create`: When a user creates a comment 26 | - `user_profile_update`: When a user updates their profile 27 | - `user_login`: When a user logs in 28 | - `user_register`: When a user registers 29 | - `project_rename`: When a project is renamed 30 | - `project_info_update`: When project information is updated 31 | 32 | ### Creating Events 33 | 34 | To create an event, use the `createEvent` function from the events controller: 35 | 36 | ```javascript 37 | import { createEvent, TargetTypes } from '../controllers/events.js'; 38 | 39 | // Example: Create a project_create event 40 | await createEvent( 41 | 'project_create', // event type 42 | userId, // actor ID 43 | "project", // target type 44 | projectId, // target ID 45 | { 46 | project_type: 'scratch', 47 | project_name: 'my-project', 48 | project_title: 'My Project', 49 | project_description: 'Description of my project', 50 | project_state: 'private' 51 | } 52 | ); 53 | ``` 54 | 55 | The event data will be validated against the schema defined for the event type. 56 | 57 | ### Retrieving Events 58 | 59 | To get events for a specific target: 60 | 61 | ```javascript 62 | import { getTargetEvents, TargetTypes } from '../controllers/events.js'; 63 | 64 | // Get events for a project 65 | const events = await getTargetEvents( 66 | "project", // target type 67 | projectId, // target ID 68 | 10, // limit 69 | 0, // offset 70 | false // include private events? 71 | ); 72 | ``` 73 | 74 | To get events for a specific actor: 75 | 76 | ```javascript 77 | import { getActorEvents } from '../controllers/events.js'; 78 | 79 | // Get events for a user 80 | const events = await getActorEvents( 81 | userId, // actor ID 82 | 10, // limit 83 | 0, // offset 84 | false // include private events? 85 | ); 86 | ``` 87 | 88 | ## Internal Architecture 89 | 90 | ### Schema-Based Validation 91 | 92 | All event data is validated using Zod schemas defined in `models/events.js`. This ensures that event data is consistent and contains all required fields. 93 | 94 | ### Event Processing Flow 95 | 96 | 1. Controller receives event creation request 97 | 2. Data is validated against the schema 98 | 3. Event is stored in the database 99 | 4. Notifications are sent to relevant users 100 | 101 | ### Database Structure 102 | 103 | Events are stored in the `events` table with the following structure: 104 | 105 | - `id`: Unique identifier for the event 106 | - `event_type`: Type of event 107 | - `actor_id`: ID of the user who performed the action 108 | - `target_type`: Type of the target object (project, user, etc.) 109 | - `target_id`: ID of the target object 110 | - `event_data`: JSON data specific to the event type 111 | - `created_at`: Timestamp when the event was created 112 | - `public`: Whether the event is publicly visible 113 | 114 | ## Migration Notes 115 | 116 | The migration script `20240705000000_enhance_event_model` adds the following indices to improve query performance: 117 | 118 | - `idx_events_actor_id`: Index on actor_id 119 | - `idx_events_type_target`: Combined index on event_type and target_id 120 | - `idx_events_public`: Index on public field -------------------------------------------------------------------------------- /docs/notificationTypes.json: -------------------------------------------------------------------------------- 1 | { 2 | "notifications": { 3 | "project_related": [ 4 | { 5 | "id": 1, 6 | "name": "PROJECT_COMMENT", 7 | "description": "When someone comments on a project", 8 | "data_structure": { 9 | "project_id": "Project ID", 10 | "project_title": "Project title", 11 | "comment_text": "Comment text snippet", 12 | "comment_id": "Comment ID" 13 | } 14 | }, 15 | { 16 | "id": 2, 17 | "name": "PROJECT_STAR", 18 | "description": "When someone stars a project", 19 | "data_structure": { 20 | "project_id": "Project ID", 21 | "project_title": "Project title", 22 | "star_count": "Total star count after action" 23 | } 24 | }, 25 | { 26 | "id": 3, 27 | "name": "PROJECT_FORK", 28 | "description": "When someone forks a project", 29 | "data_structure": { 30 | "project_id": "Original project ID", 31 | "project_title": "Original project title", 32 | "fork_id": "New forked project ID", 33 | "fork_title": "New forked project title", 34 | "fork_count": "Total fork count after action" 35 | } 36 | }, 37 | { 38 | "id": 4, 39 | "name": "PROJECT_MENTION", 40 | "description": "When someone mentions a user in a project", 41 | "data_structure": { 42 | "project_id": "Project ID", 43 | "project_title": "Project title", 44 | "mention_text": "Text containing the mention" 45 | } 46 | }, 47 | { 48 | "id": 5, 49 | "name": "PROJECT_UPDATE", 50 | "description": "When a project is updated", 51 | "data_structure": { 52 | "project_id": "Project ID", 53 | "project_title": "Project title", 54 | "update_details": "Details of what was updated" 55 | } 56 | }, 57 | { 58 | "id": 6, 59 | "name": "PROJECT_COLLABORATION_INVITE", 60 | "description": "When a user is invited to collaborate on a project", 61 | "data_structure": { 62 | "project_id": "Project ID", 63 | "project_title": "Project title", 64 | "invite_id": "Invitation ID" 65 | } 66 | }, 67 | { 68 | "id": 7, 69 | "name": "PROJECT_COLLABORATION_ACCEPT", 70 | "description": "When a user accepts a collaboration invite", 71 | "data_structure": { 72 | "project_id": "Project ID", 73 | "project_title": "Project title" 74 | } 75 | } 76 | ], 77 | "user_related": [ 78 | { 79 | "id": 20, 80 | "name": "USER_FOLLOW", 81 | "description": "When someone follows a user", 82 | "data_structure": { 83 | "follower_count": "Total follower count after action" 84 | } 85 | }, 86 | { 87 | "id": 21, 88 | "name": "USER_MENTION", 89 | "description": "When someone mentions a user", 90 | "data_structure": { 91 | "mention_text": "Text containing the mention", 92 | "context_type": "Context type (comment, project, etc.)", 93 | "context_id": "ID of the context" 94 | } 95 | }, 96 | { 97 | "id": 25, 98 | "name": "USER_LIKE", 99 | "description": "When someone likes a user's content", 100 | "data_structure": { 101 | "content_type": "Type of content that was liked", 102 | "content_id": "ID of the content", 103 | "content_excerpt": "Excerpt of the content" 104 | } 105 | } 106 | ], 107 | "system_related": [ 108 | { 109 | "id": 50, 110 | "name": "SYSTEM_ANNOUNCEMENT", 111 | "description": "System-wide announcements", 112 | "data_structure": { 113 | "announcement_text": "Text of the announcement", 114 | "announcement_id": "ID of the announcement", 115 | "level": "Announcement importance level" 116 | } 117 | }, 118 | { 119 | "id": 51, 120 | "name": "SYSTEM_MAINTENANCE", 121 | "description": "System maintenance notifications", 122 | "data_structure": { 123 | "maintenance_text": "Text about the maintenance", 124 | "start_time": "Start time of maintenance", 125 | "end_time": "Expected end time of maintenance", 126 | "services_affected": "Services affected by the maintenance" 127 | } 128 | } 129 | ], 130 | "comment_related": [ 131 | { 132 | "id": 100, 133 | "name": "COMMENT_REPLY", 134 | "description": "When someone replies to a comment", 135 | "data_structure": { 136 | "reply_text": "Text of the reply", 137 | "reply_id": "ID of the reply", 138 | "original_comment_id": "ID of the original comment", 139 | "context_type": "Context where the comment was made", 140 | "context_id": "ID of the context" 141 | } 142 | }, 143 | { 144 | "id": 101, 145 | "name": "COMMENT_LIKE", 146 | "description": "When someone likes a comment", 147 | "data_structure": { 148 | "comment_id": "ID of the comment", 149 | "comment_excerpt": "Excerpt of the comment", 150 | "context_type": "Context where the comment was made", 151 | "context_id": "ID of the context" 152 | } 153 | }, 154 | { 155 | "id": 102, 156 | "name": "COMMENT_MENTION", 157 | "description": "When someone mentions a user in a comment", 158 | "data_structure": { 159 | "mention_text": "Text containing the mention", 160 | "comment_id": "ID of the comment", 161 | "context_type": "Context where the comment was made", 162 | "context_id": "ID of the context" 163 | } 164 | } 165 | ], 166 | "custom_related": [ 167 | { 168 | "id": 800, 169 | "name": "CUSTOM_NOTIFICATION", 170 | "description": "Generic custom notification", 171 | "data_structure": { 172 | "title": "Notification title", 173 | "body": "Notification content" 174 | } 175 | }, 176 | { 177 | "id": 801, 178 | "name": "CUSTOM_TOPIC_REPLY", 179 | "description": "Custom topic reply notification", 180 | "data_structure": { 181 | "topic_title": "Title of the topic", 182 | "topic_id": "ID of the topic", 183 | "reply_preview": "Preview of the reply", 184 | "post_number": "Post number in the topic" 185 | } 186 | }, 187 | { 188 | "id": 802, 189 | "name": "CUSTOM_TOPIC_MENTION", 190 | "description": "Custom topic mention notification", 191 | "data_structure": { 192 | "topic_title": "Title of the topic", 193 | "topic_id": "ID of the topic", 194 | "mention_text": "Text containing the mention", 195 | "post_number": "Post number in the topic" 196 | } 197 | } 198 | ] 199 | } 200 | } -------------------------------------------------------------------------------- /docs/notifications.md: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview 事件模型定义和相关常量 3 | */ 4 | 5 | /** 6 | * 事件类型定义 7 | * @enum {number} 8 | */ 9 | export const EventTypes = { 10 | // 用户事件 (1-99) 11 | USER_REGISTER: 1, 12 | USER_LOGIN: 2, 13 | USER_LOGOUT: 3, 14 | USER_PROFILE_UPDATE: 4, 15 | USER_PASSWORD_CHANGE: 5, 16 | USER_EMAIL_CHANGE: 6, 17 | USER_AVATAR_CHANGE: 7, 18 | USER_ACCOUNT_DELETE: 8, 19 | USER_ACCOUNT_SUSPEND: 9, 20 | USER_ACCOUNT_RESTORE: 10, 21 | 22 | USER_SETTINGS_CHANGE: 20, 23 | USER_NOTIFICATION_SETTINGS: 21, 24 | USER_PRIVACY_SETTINGS: 22, 25 | 26 | // 项目事件 (100-199) 27 | PROJECT_CREATE: 100, 28 | PROJECT_UPDATE: 101, 29 | PROJECT_DELETE: 102, 30 | PROJECT_VISIBILITY_CHANGE: 103, 31 | PROJECT_OWNERSHIP_TRANSFER: 104, 32 | 33 | PROJECT_VIEW: 110, 34 | PROJECT_STAR: 111, 35 | PROJECT_UNSTAR: 112, 36 | PROJECT_LIKE: 113, 37 | PROJECT_UNLIKE: 114, 38 | PROJECT_FORK: 115, 39 | PROJECT_DOWNLOAD: 116, 40 | PROJECT_SHARE: 117, 41 | 42 | PROJECT_COLLABORATOR_INVITE: 120, 43 | PROJECT_COLLABORATOR_JOIN: 121, 44 | PROJECT_COLLABORATOR_LEAVE: 122, 45 | PROJECT_COLLABORATOR_REMOVE: 123, 46 | PROJECT_PERMISSION_CHANGE: 124, 47 | 48 | PROJECT_COMMIT: 130, 49 | PROJECT_BRANCH_CREATE: 131, 50 | PROJECT_BRANCH_DELETE: 132, 51 | PROJECT_MERGE: 133, 52 | PROJECT_RELEASE: 134, 53 | 54 | // 社交事件 (200-299) 55 | USER_FOLLOW: 200, 56 | USER_UNFOLLOW: 201, 57 | USER_BLOCK: 202, 58 | USER_UNBLOCK: 203, 59 | 60 | USER_MENTION: 210, 61 | USER_DIRECT_MESSAGE: 211, 62 | 63 | // 内容事件 (300-399) 64 | COMMENT_CREATE: 300, 65 | COMMENT_UPDATE: 301, 66 | COMMENT_DELETE: 302, 67 | COMMENT_LIKE: 303, 68 | COMMENT_UNLIKE: 304, 69 | COMMENT_REPLY: 305, 70 | 71 | COLLECTION_CREATE: 320, 72 | COLLECTION_UPDATE: 321, 73 | COLLECTION_DELETE: 322, 74 | COLLECTION_ADD_ITEM: 323, 75 | COLLECTION_REMOVE_ITEM: 324, 76 | 77 | // 系统事件 (500-599) 78 | SYSTEM_CONFIG_CHANGE: 500, 79 | SYSTEM_MAINTENANCE_START: 501, 80 | SYSTEM_MAINTENANCE_END: 502, 81 | SYSTEM_BACKUP: 503, 82 | SYSTEM_RESTORE: 504, 83 | 84 | SYSTEM_ERROR: 510, 85 | SYSTEM_PERFORMANCE_ISSUE: 511, 86 | API_RATE_LIMIT_EXCEEDED: 512, 87 | 88 | // 安全事件 (600-699) 89 | SECURITY_LOGIN_ATTEMPT_FAILED: 600, 90 | SECURITY_PASSWORD_RESET: 601, 91 | SECURITY_SUSPICIOUS_ACTIVITY: 602, 92 | SECURITY_PERMISSION_CHANGE: 603, 93 | SECURITY_API_KEY_GENERATED: 604, 94 | SECURITY_API_KEY_REVOKED: 605 95 | }; 96 | 97 | /** 98 | * 目标类型枚举 99 | * @enum {string} 100 | */ 101 | export const TargetTypes = { 102 | USER: 'user', 103 | PROJECT: 'project', 104 | COMMENT: 'comment', 105 | TOPIC: 'topic', 106 | POST: 'post', 107 | SYSTEM: 'system', 108 | COLLECTION: 'collection', 109 | API: 'api' 110 | }; 111 | 112 | /** 113 | * 事件类型按分类的映射 114 | * 用于按类别检索事件类型 115 | */ 116 | export const EventCategories = { 117 | USER: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 21, 22], 118 | PROJECT: [100, 101, 102, 103, 104, 110, 111, 112, 113, 114, 115, 116, 117, 120, 121, 122, 123, 124, 130, 131, 132, 133, 134], 119 | SOCIAL: [200, 201, 202, 203, 210, 211], 120 | CONTENT: [300, 301, 302, 303, 304, 305, 320, 321, 322, 323, 324], 121 | SYSTEM: [500, 501, 502, 503, 504, 510, 511, 512], 122 | SECURITY: [600, 601, 602, 603, 604, 605] 123 | }; 124 | 125 | /** 126 | * 事件中需要记录敏感数据的类型 127 | * 这些事件在记录时应额外关注隐私保护 128 | */ 129 | export const SensitiveEventTypes = [ 130 | EventTypes.USER_PASSWORD_CHANGE, 131 | EventTypes.USER_EMAIL_CHANGE, 132 | EventTypes.SECURITY_LOGIN_ATTEMPT_FAILED, 133 | EventTypes.SECURITY_PASSWORD_RESET, 134 | EventTypes.SECURITY_SUSPICIOUS_ACTIVITY, 135 | EventTypes.SECURITY_API_KEY_GENERATED 136 | ]; 137 | 138 | /** 139 | * 需要发送实时通知的事件类型 140 | */ 141 | export const RealtimeNotificationEvents = [ 142 | EventTypes.PROJECT_COMMENT_CREATE, 143 | EventTypes.PROJECT_STAR, 144 | EventTypes.PROJECT_FORK, 145 | EventTypes.USER_FOLLOW, 146 | EventTypes.USER_MENTION, 147 | EventTypes.COMMENT_REPLY 148 | ]; 149 | 150 | export default { 151 | EventTypes, 152 | TargetTypes, 153 | EventCategories, 154 | SensitiveEventTypes, 155 | RealtimeNotificationEvents 156 | }; -------------------------------------------------------------------------------- /docs/oauth_temp_token.md: -------------------------------------------------------------------------------- 1 | # OAuth临时令牌认证流程 2 | 3 | ## 概述 4 | 5 | 为了提高安全性,OAuth登录流程现已更新为使用临时令牌(Temporary Token)模式。这种方式将敏感的用户数据安全地存储在Redis中,而不是直接在URL中传递。 6 | 7 | ## 认证流程 8 | 9 | 1. 用户点击OAuth登录按钮,后端生成state并重定向到OAuth提供商 10 | 2. 用户在OAuth提供商页面完成授权 11 | 3. OAuth提供商回调我们的系统 12 | 4. 后端验证OAuth信息并生成临时令牌,将用户数据存储在Redis中 13 | 5. 后端将临时令牌通过URL参数重定向到前端 14 | 6. 前端获取临时令牌,调用验证API获取正式登录凭证和用户信息 15 | 16 | ## 前端实现 17 | 18 | ### 1. 获取临时令牌 19 | 20 | 当用户完成OAuth授权后,系统会重定向到前端的回调页面,URL中包含临时令牌: 21 | 22 | ``` 23 | https://your-frontend.com/app/account/callback?temp_token=abcdef123456 24 | ``` 25 | 26 | ### 2. 使用临时令牌获取用户信息和正式令牌 27 | 28 | 前端需要从URL中提取临时令牌,然后调用API获取用户信息和正式的登录令牌: 29 | 30 | ```javascript 31 | // 从URL中提取临时令牌 32 | const urlParams = new URLSearchParams(window.location.search); 33 | const tempToken = urlParams.get('temp_token'); 34 | 35 | if (tempToken) { 36 | // 调用API验证临时令牌并获取用户信息 37 | fetch(`https://your-api.com/account/oauth/validate-token/${tempToken}`) 38 | .then(response => response.json()) 39 | .then(data => { 40 | if (data.status === 'success') { 41 | // 存储用户信息和令牌 42 | localStorage.setItem('token', data.token); 43 | localStorage.setItem('refresh_token', data.refresh_token); 44 | localStorage.setItem('user', JSON.stringify({ 45 | userid: data.userid, 46 | username: data.username, 47 | display_name: data.display_name, 48 | avatar: data.avatar, 49 | email: data.email 50 | })); 51 | 52 | // 登录成功后的操作... 53 | // 重定向到用户主页 54 | window.location.href = '/app/dashboard'; 55 | } else { 56 | // 处理错误 57 | console.error('登录失败:', data.message); 58 | // 显示错误消息 59 | showError(data.message); 60 | } 61 | }) 62 | .catch(error => { 63 | console.error('验证临时令牌时出错:', error); 64 | showError('登录过程中发生错误,请重试'); 65 | }); 66 | } 67 | ``` 68 | 69 | ## 临时令牌安全性 70 | 71 | - 临时令牌存储在Redis中,有效期为24小时 72 | - 临时令牌仅可使用一次,验证后自动失效 73 | - 用户数据存储在Redis中,而不是直接在URL中传递 74 | - 临时令牌只能通过特定的API端点验证,增加了安全性 75 | 76 | ## API参考 77 | 78 | ### 获取用户信息和令牌 79 | 80 | **请求** 81 | 82 | ``` 83 | GET /account/oauth/validate-token/:token 84 | ``` 85 | 86 | **响应** 87 | 88 | 成功: 89 | ```json 90 | { 91 | "status": "success", 92 | "message": "登录成功", 93 | "userid": 123, 94 | "username": "user123", 95 | "display_name": "用户名称", 96 | "avatar": "avatar.jpg", 97 | "email": "user@example.com", 98 | "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", 99 | "refresh_token": "abcdef123456789...", 100 | "expires_at": "2023-06-01T12:00:00Z", 101 | "refresh_expires_at": "2023-07-01T12:00:00Z" 102 | } 103 | ``` 104 | 105 | 失败: 106 | ```json 107 | { 108 | "status": "error", 109 | "message": "令牌不存在或已过期" 110 | } 111 | ``` -------------------------------------------------------------------------------- /docs/user-relationships.md: -------------------------------------------------------------------------------- 1 | # User Relationships System 2 | 3 | ## Overview 4 | 5 | The ZeroCat User Relationships system provides a flexible way to manage different types of relationships between users, such as follows, blocks, mutes, and favorites. This system replaces the previous follows-specific implementation with a more generalized approach that can handle multiple relationship types. 6 | 7 | ## Database Schema 8 | 9 | The system uses a `user_relationships` table with the following structure: 10 | 11 | | Field | Type | Description | 12 | |--------------------|--------------------------|-------------------------------------------------| 13 | | `id` | Int (auto-increment) | Primary key | 14 | | `source_user_id` | Int | The user initiating the relationship | 15 | | `target_user_id` | Int | The user receiving the relationship | 16 | | `relationship_type`| Enum | Type of relationship (follow, block, mute, etc.)| 17 | | `created_at` | DateTime | When the relationship was created | 18 | | `updated_at` | DateTime | When the relationship was last updated | 19 | | `metadata` | Json | Additional data for the relationship | 20 | 21 | The table has a unique constraint on `(source_user_id, target_user_id, relationship_type)` to ensure that a user can only have one relationship of each type with another user. 22 | 23 | ## Relationship Types 24 | 25 | The current supported relationship types are: 26 | 27 | - `follow`: User follows another user to see their content in feeds 28 | - `block`: User blocks another user to prevent interactions 29 | - `mute`: User mutes another user to hide their content but still allow interactions 30 | - `favorite`: User marks another user as a favorite 31 | 32 | Additional relationship types can be added to the enum as needed. 33 | 34 | ## API Endpoints 35 | 36 | ### Follow Management 37 | 38 | - `POST /api/follows/:userId` - Follow a user 39 | - `DELETE /api/follows/:userId` - Unfollow a user 40 | - `GET /api/follows/followers/:userId` - Get followers of a user 41 | - `GET /api/follows/following/:userId` - Get users followed by a user 42 | - `GET /api/follows/check/:userId` - Check if logged in user is following a user 43 | 44 | ### Block Management 45 | 46 | - `POST /api/follows/block/:userId` - Block a user 47 | - `DELETE /api/follows/block/:userId` - Unblock a user 48 | - `GET /api/follows/blocked` - Get users blocked by the logged in user 49 | - `GET /api/follows/check-block/:userId` - Check if logged in user has blocked a user 50 | 51 | ### General Relationship Management 52 | 53 | - `GET /api/follows/relationships/:userId` - Get all relationships between logged in user and another user 54 | 55 | ## Usage Examples 56 | 57 | ### Following a User 58 | 59 | ```javascript 60 | // Client-side 61 | const response = await fetch(`/api/follows/${userId}`, { 62 | method: 'POST', 63 | headers: { 64 | 'Content-Type': 'application/json' 65 | } 66 | }); 67 | 68 | const result = await response.json(); 69 | ``` 70 | 71 | ### Blocking a User 72 | 73 | ```javascript 74 | // Client-side 75 | const response = await fetch(`/api/follows/block/${userId}`, { 76 | method: 'POST', 77 | headers: { 78 | 'Content-Type': 'application/json' 79 | } 80 | }); 81 | 82 | const result = await response.json(); 83 | ``` 84 | 85 | ### Checking Relationships 86 | 87 | ```javascript 88 | // Client-side 89 | const response = await fetch(`/api/follows/relationships/${userId}`); 90 | const result = await response.json(); 91 | 92 | // Example result 93 | { 94 | "success": true, 95 | "data": { 96 | "isFollowing": true, 97 | "isFollowedBy": false, 98 | "isBlocking": false, 99 | "isBlockedBy": false, 100 | "isMuting": false, 101 | "hasFavorited": true, 102 | "relationships": { 103 | "outgoing": [...], 104 | "incoming": [...] 105 | } 106 | } 107 | } 108 | ``` 109 | 110 | ## Implementing Custom Relationship Types 111 | 112 | To add a new relationship type: 113 | 114 | 1. Add the new type to the `user_relationship_type` enum in `prisma/schema.prisma` 115 | 2. Add controller methods for the new relationship type in `controllers/follows.js` 116 | 3. Add route handlers for the new relationship type in `routes/follows.js` 117 | 118 | ## Data Migration 119 | 120 | When deploying this system to replace the previous follows system, run the migration script to transfer existing data: 121 | 122 | ``` 123 | node migrations/migrate-follows-to-relationships.js 124 | ``` 125 | 126 | This script will transfer all follows from the old `user_follows` table to the new `user_relationships` table with the relationship type set to `follow`. 127 | 128 | ## Code Example: Adding a New Relationship Type 129 | 130 | 1. First, add the new type to the enum in the Prisma schema: 131 | 132 | ```prisma 133 | enum user_relationship_type { 134 | follow 135 | block 136 | mute 137 | favorite 138 | super_follow // New type 139 | } 140 | ``` 141 | 142 | 2. Add controller methods: 143 | 144 | ```javascript 145 | // Add to controllers/follows.js 146 | export async function superFollowUser(followerId, followedId) { 147 | try { 148 | // Implementation 149 | const relationship = await prisma.ow_user_relationships.create({ 150 | data: { 151 | source_user_id: followerId, 152 | target_user_id: followedId, 153 | relationship_type: 'super_follow', 154 | metadata: { /* additional data */ } 155 | } 156 | }); 157 | 158 | return relationship; 159 | } catch (error) { 160 | logger.error("Error in superFollowUser:", error); 161 | throw error; 162 | } 163 | } 164 | ``` 165 | 166 | 3. Add route handlers: 167 | 168 | ```javascript 169 | // Add to routes/follows.js 170 | router.post('/super/:userId', needLogin, async (req, res) => { 171 | try { 172 | const followerId = req.user.id; 173 | const followedId = parseInt(req.params.userId); 174 | 175 | const result = await followsController.superFollowUser(followerId, followedId); 176 | res.json({ success: true, data: result }); 177 | } catch (error) { 178 | handleError(res, error); 179 | } 180 | }); 181 | ``` -------------------------------------------------------------------------------- /docs/user_status_migration.md: -------------------------------------------------------------------------------- 1 | # 用户状态迁移指南 2 | 3 | 本文档详细说明了如何将用户状态(`status`)字段从数字类型转换为描述性字符串类型,使代码更加直观和易于维护。 4 | 5 | ## 迁移概述 6 | 7 | 当前数据库中用户状态使用数字编码: 8 | - `0` = 待激活(新注册账户) 9 | - `1` = 正常(正常活跃账户) 10 | - `2` = 已暂停(临时禁用) 11 | - `3` = 已封禁(永久禁用) 12 | 13 | 迁移后,状态将使用更直观的字符串: 14 | - `pending` = 待激活 15 | - `active` = 正常 16 | - `suspended` = 已暂停 17 | - `banned` = 已封禁 18 | 19 | ## 迁移SQL脚本 20 | 21 | 下面是完整的SQL脚本,您可以直接在MySQL命令行或管理工具中执行。您也可以在项目根目录下找到此脚本`prisma/user_status_migration.sql`。 22 | 23 | ```sql 24 | -- 删除可能已存在的备份表 25 | DROP TABLE IF EXISTS `ow_users_backup`; 26 | 27 | -- 创建备份表 28 | CREATE TABLE `ow_users_backup` LIKE `ow_users`; 29 | INSERT INTO `ow_users_backup` SELECT * FROM `ow_users`; 30 | 31 | -- 添加临时列(如果原status列存在且为INT类型) 32 | ALTER TABLE `ow_users` ADD COLUMN IF NOT EXISTS `status_text` VARCHAR(20) NOT NULL DEFAULT 'pending'; 33 | 34 | -- 填充数据(仅当status为INT类型且status_text存在时需要) 35 | UPDATE `ow_users` SET `status_text` = 'pending' WHERE `status` = 0; 36 | UPDATE `ow_users` SET `status_text` = 'active' WHERE `status` = 1; 37 | UPDATE `ow_users` SET `status_text` = 'suspended' WHERE `status` = 2; 38 | UPDATE `ow_users` SET `status_text` = 'banned' WHERE `status` = 3; 39 | 40 | -- 删除旧列 41 | ALTER TABLE `ow_users` DROP COLUMN IF EXISTS `status`; 42 | 43 | -- 重命名新列 44 | ALTER TABLE `ow_users` CHANGE COLUMN `status_text` `status` VARCHAR(20) NOT NULL DEFAULT 'pending'; 45 | 46 | -- 添加索引(可选) 47 | DROP INDEX IF EXISTS `idx_user_status` ON `ow_users`; 48 | CREATE INDEX `idx_user_status` ON `ow_users` (`status`); 49 | ``` 50 | 51 | ## 执行步骤 52 | 53 | 1. **备份数据库** 54 | 在执行任何迁移操作前,务必先备份整个数据库 55 | 56 | 2. **连接到数据库** 57 | ```bash 58 | mysql -u 用户名 -p 数据库名 59 | ``` 60 | 61 | 3. **执行迁移脚本** 62 | 有两种方式: 63 | - 直接复制上述SQL语句到MySQL命令行中执行 64 | - 使用文件执行: 65 | ```bash 66 | mysql -u 用户名 -p 数据库名 < prisma/user_status_migration.sql 67 | ``` 68 | 69 | 4. **验证迁移** 70 | ```sql 71 | DESCRIBE ow_users; 72 | SELECT id, username, status FROM ow_users LIMIT 10; 73 | ``` 74 | 75 | 5. **更新Prisma模型** 76 | ```bash 77 | npx prisma db pull 78 | npx prisma generate 79 | ``` 80 | 81 | ## 常见错误及解决方案 82 | 83 | ### 错误:Duplicate entry for key 'id_UNIQUE' 84 | 85 | **错误消息**: 86 | ``` 87 | Error Code: 1062. Duplicate entry '1' for key 'ow_users_backup.id_UNIQUE' 88 | ``` 89 | 90 | **原因**:备份表已存在且包含数据,主键冲突。 91 | 92 | **解决方案**: 93 | 1. 确保在创建备份表前先删除已存在的备份表: 94 | ```sql 95 | DROP TABLE IF EXISTS `ow_users_backup`; 96 | ``` 97 | 98 | ### 错误:Column 'status_text' already exists 99 | 100 | **原因**:临时列已经存在,可能是之前迁移中断。 101 | 102 | **解决方案**: 103 | 1. 检查临时列是否存在: 104 | ```sql 105 | SHOW COLUMNS FROM `ow_users` LIKE 'status_text'; 106 | ``` 107 | 108 | 2. 如果存在,可以继续执行后续步骤,或先删除该列: 109 | ```sql 110 | ALTER TABLE `ow_users` DROP COLUMN `status_text`; 111 | ``` 112 | 113 | ### 错误:Unknown column 'status' in 'ow_users' 114 | 115 | **原因**:原始的status列可能已经被删除或已迁移完成。 116 | 117 | **解决方案**: 118 | 1. 检查当前表结构: 119 | ```sql 120 | DESCRIBE ow_users; 121 | ``` 122 | 123 | 2. 如果status列已是VARCHAR类型,说明迁移可能已完成,可以跳过这次迁移。 124 | 125 | ## 修复已知问题 126 | 127 | 如果您遇到事件表(`events`)索引相关的错误,可以执行以下SQL来修复: 128 | 129 | ```sql 130 | -- 删除可能存在的索引 131 | DROP INDEX IF EXISTS `idx_events_actor_id` ON `events`; 132 | DROP INDEX IF EXISTS `idx_events_type_target` ON `events`; 133 | DROP INDEX IF EXISTS `idx_events_public` ON `events`; 134 | 135 | -- 重新创建索引 136 | CREATE INDEX `idx_events_actor_id` ON `events` (`actor_id`); 137 | CREATE INDEX `idx_events_type_target` ON `events` (`event_type`, `target_id`); 138 | CREATE INDEX `idx_events_public` ON `events` (`public`); 139 | ``` 140 | 141 | ## 回滚方案 142 | 143 | 如果迁移出现问题,您可以使用备份表恢复数据: 144 | 145 | ```sql 146 | -- 删除修改后的表 147 | DROP TABLE `ow_users`; 148 | 149 | -- 从备份表恢复 150 | CREATE TABLE `ow_users` LIKE `ow_users_backup`; 151 | INSERT INTO `ow_users` SELECT * FROM `ow_users_backup`; 152 | 153 | -- 可选:删除备份表 154 | -- DROP TABLE `ow_users_backup`; 155 | ``` 156 | 157 | ## 代码适配 158 | 159 | 迁移数据库后,请确保更新相关代码,使用新的字符串状态值。特别是以下文件: 160 | 161 | 1. `src/middleware/auth.middleware.js` - 使用 `isActive()` 函数检查状态 162 | 2. `utils/userStatus.js` - 包含状态值常量和辅助函数 163 | 164 | ## 注意事项 165 | 166 | - 此迁移不支持自动回滚,请确保先备份数据库 167 | - 如迁移过程中断,可能需要手动清理临时列 168 | - 生产环境中请在低峰期执行迁移 169 | - 使用新字符串值的代码不应在迁移完成前部署 170 | - 如果您的MySQL版本不支持`IF EXISTS`或`IF NOT EXISTS`语法,请删除这些修饰符 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /middleware/captcha.js: -------------------------------------------------------------------------------- 1 | import { error as loggerError, debug } 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 | -------------------------------------------------------------------------------- /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("Failed to load Geetest config:", 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("Development mode: Bypassing captcha validation"); 45 | return next(); 46 | } 47 | 48 | // 如果未正确配置验证码,也跳过检查 49 | if (!GEE_CAPTCHA_ID || !GEE_CAPTCHA_KEY) { 50 | logger.warn("Geetest is not configured properly, bypassing captcha validation"); 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("Captcha Parsing Error:", 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("Captcha data missing"); 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("Sending request to Geetest server..."); 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(`Validate fail: ${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 server error:", error); 124 | // 极验服务器出错时放行,避免阻塞业务逻辑 125 | next(); 126 | } 127 | } 128 | 129 | export default geetestMiddleware; 130 | 131 | -------------------------------------------------------------------------------- /middleware/rateLimit.js: -------------------------------------------------------------------------------- 1 | import rateLimit from 'express-rate-limit'; 2 | import RedisStore from 'rate-limit-redis'; 3 | 4 | export const sensitiveActionLimiter = rateLimit({ 5 | store: new RedisStore({ 6 | client: redis 7 | }), 8 | windowMs: 15 * 60 * 1000, // 15分钟 9 | max: 5, // 限制5次请求 10 | message: { 11 | status: 'error', 12 | message: '请求过于频繁,请稍后再试' 13 | } 14 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zerocat", 3 | "version": "1.0.3", 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 | }, 13 | "dependencies": { 14 | "@aws-sdk/client-s3": "^3.782.0", 15 | "@maxmind/geoip2-node": "^4.2.0", 16 | "@prisma/client": "^6.8.2", 17 | "axios": "^1.8.4", 18 | "base32-encode": "^2.0.0", 19 | "bcrypt": "^5.1.1", 20 | "body-parser": "^2.2.0", 21 | "compression": "^1.8.0", 22 | "connect-multiparty": "^2.2.0", 23 | "cookie-parser": "^1.4.7", 24 | "cors": "^2.8.5", 25 | "crypto-js": "^4.1.1", 26 | "dotenv": "^16.4.7", 27 | "ejs": "^3.1.10", 28 | "express": "^5.1.0", 29 | "express-jwt": "^8.5.1", 30 | "express-session": "^1.18.1", 31 | "express-winston": "^4.2.0", 32 | "html-entities": "^2.6.0", 33 | "ioredis": "^5.6.1", 34 | "jsonwebtoken": "^9.0.0", 35 | "morgan": "^1.10.0", 36 | "multer": "1.4.5-lts.1", 37 | "mysql2": "^3.6.0", 38 | "nodemailer": "^6.10.0", 39 | "otpauth": "^9.4.0", 40 | "phpass": "^0.1.1", 41 | "tar": "^6.2.1", 42 | "ua-parser-js": "^2.0.3", 43 | "uuid": "^11.1.0", 44 | "winston": "^3.17.0", 45 | "winston-daily-rotate-file": "^5.0.0" 46 | }, 47 | "devDependencies": { 48 | "prisma": "^6.8.2" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /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/20250531113640_add_enhanced_projects_search_view/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateView 2 | CREATE OR REPLACE VIEW `ow_projects_search` AS 3 | SELECT 4 | p.*, 5 | ( 6 | SELECT pf.source 7 | FROM ow_projects_file pf 8 | INNER JOIN ow_projects_commits pc ON pc.commit_file = pf.sha256 9 | WHERE pc.project_id = p.id 10 | ORDER BY pc.commit_date DESC 11 | LIMIT 1 12 | ) as latest_source, 13 | ( 14 | SELECT GROUP_CONCAT(pt.name SEPARATOR ', ') 15 | FROM ow_projects_tags pt 16 | WHERE pt.projectid = p.id 17 | GROUP BY pt.projectid 18 | ) as tag_list, 19 | ( 20 | SELECT COUNT(*) 21 | FROM ow_comment c 22 | WHERE c.page_type = 'project' 23 | AND c.page_id = p.id 24 | ) as comment_count, 25 | ( 26 | SELECT JSON_ARRAYAGG( 27 | JSON_OBJECT( 28 | 'id', c.id, 29 | 'text', c.text, 30 | 'insertedAt', c.insertedAt, 31 | 'user', ( 32 | SELECT JSON_OBJECT( 33 | 'id', u.id, 34 | 'username', u.username, 35 | 'display_name', u.display_name, 36 | 'avatar', u.avatar, 37 | 'type', u.type 38 | ) 39 | FROM ow_users u 40 | WHERE u.id = c.user_id 41 | ) 42 | ) 43 | ) 44 | FROM ( 45 | SELECT * FROM ow_comment c 46 | WHERE c.page_type = 'project' 47 | AND c.page_id = p.id 48 | ORDER BY c.insertedAt DESC 49 | LIMIT 10 50 | ) c 51 | ) as recent_comments_full, 52 | ( 53 | SELECT JSON_ARRAYAGG( 54 | JSON_OBJECT( 55 | 'id', ps.id, 56 | 'createTime', ps.createTime, 57 | 'user', ( 58 | SELECT JSON_OBJECT( 59 | 'id', u.id, 60 | 'username', u.username, 61 | 'display_name', u.display_name, 62 | 'avatar', u.avatar, 63 | 'type', u.type, 64 | 'motto', u.motto 65 | ) 66 | FROM ow_users u 67 | WHERE u.id = ps.userid 68 | ) 69 | ) 70 | ) 71 | FROM ow_projects_stars ps 72 | WHERE ps.projectid = p.id 73 | ) as star_users_full, 74 | ( 75 | SELECT GROUP_CONCAT(DISTINCT u.display_name SEPARATOR ', ') 76 | FROM ow_projects_stars ps 77 | INNER JOIN ow_users u ON ps.userid = u.id 78 | WHERE ps.projectid = p.id 79 | ) as star_users_names, 80 | ( 81 | SELECT JSON_OBJECT( 82 | 'id', u.id, 83 | 'username', u.username, 84 | 'display_name', u.display_name, 85 | 'avatar', u.avatar, 86 | 'type', u.type, 87 | 'motto', u.motto, 88 | 'github', u.github, 89 | 'twitter', u.twitter, 90 | 'url', u.url 91 | ) 92 | FROM ow_users u 93 | WHERE u.id = p.authorid 94 | ) as author_info, 95 | ( 96 | SELECT JSON_ARRAYAGG( 97 | JSON_OBJECT( 98 | 'id', pc.id, 99 | 'commit_message', pc.commit_message, 100 | 'commit_date', pc.commit_date, 101 | 'commit_description', pc.commit_description, 102 | 'branch', pc.branch, 103 | 'author', ( 104 | SELECT JSON_OBJECT( 105 | 'id', u.id, 106 | 'username', u.username, 107 | 'display_name', u.display_name, 108 | 'avatar', u.avatar 109 | ) 110 | FROM ow_users u 111 | WHERE u.id = pc.author_id 112 | ) 113 | ) 114 | ) 115 | FROM ( 116 | SELECT * FROM ow_projects_commits pc 117 | WHERE pc.project_id = p.id 118 | ORDER BY pc.commit_date DESC 119 | LIMIT 5 120 | ) pc 121 | ) as recent_commits, 122 | ( 123 | SELECT COUNT(DISTINCT pc.id) 124 | FROM ow_projects_commits pc 125 | WHERE pc.project_id = p.id 126 | ) as commit_count, 127 | ( 128 | SELECT JSON_OBJECT( 129 | 'fork_count', ( 130 | SELECT COUNT(*) 131 | FROM ow_projects 132 | WHERE fork = p.id 133 | ), 134 | 'fork_info', CASE 135 | WHEN p.fork IS NOT NULL THEN ( 136 | SELECT JSON_OBJECT( 137 | 'id', op.id, 138 | 'name', op.name, 139 | 'author', ( 140 | SELECT JSON_OBJECT( 141 | 'id', u.id, 142 | 'username', u.username, 143 | 'display_name', u.display_name 144 | ) 145 | FROM ow_users u 146 | WHERE u.id = op.authorid 147 | ) 148 | ) 149 | FROM ow_projects op 150 | WHERE op.id = p.fork 151 | ) 152 | ELSE NULL 153 | END 154 | ) 155 | ) as fork_details, 156 | ( 157 | SELECT JSON_ARRAYAGG( 158 | JSON_OBJECT( 159 | 'id', pl.id, 160 | 'title', pl.title, 161 | 'description', pl.description, 162 | 'author', ( 163 | SELECT JSON_OBJECT( 164 | 'id', u.id, 165 | 'username', u.username, 166 | 'display_name', u.display_name 167 | ) 168 | FROM ow_users u 169 | WHERE u.id = pl.authorid 170 | ) 171 | ) 172 | ) 173 | FROM ow_projects_list_items pli 174 | INNER JOIN ow_projects_lists pl ON pli.listid = pl.id 175 | WHERE pli.projectid = p.id 176 | AND pl.state = 'public' 177 | ) as included_in_lists 178 | FROM ow_projects p; -------------------------------------------------------------------------------- /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/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/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/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'; -------------------------------------------------------------------------------- /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'; -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/Node.js.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroCatDev/ZeroCat/14a82aeede9e34fdbd7deb249ff54e837af131eb/public/Node.js.png -------------------------------------------------------------------------------- /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: unless-stopped 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 | -------------------------------------------------------------------------------- /routes/router_comment.js: -------------------------------------------------------------------------------- 1 | import logger from "../services/logger.js"; 2 | import { needLogin, strictTokenCheck } from "../middleware/auth.js"; 3 | import zcconfig from "../services/config/zcconfig.js"; 4 | import notificationUtils from "../controllers/notifications.js"; 5 | import { UAParser } from "ua-parser-js"; 6 | import ipLocation from "../services/ip/ipLocation.js"; 7 | 8 | import { Router } from "express"; 9 | const router = Router(); 10 | import { prisma } from "../services/global.js"; 11 | import { getUsersByList } from "../controllers/users.js"; 12 | import { createEvent } from "../controllers/events.js"; 13 | // 中间件,确保所有请求均经过该处理 14 | 15 | // 统一的错误处理函数 16 | const handleError = (res, err, message) => { 17 | logger.error(err); 18 | res.status(500).send({ errno: 1, errmsg: message, data: err }); 19 | }; 20 | 21 | // 获取排序条件 22 | const getSortCondition = (req) => { 23 | const sortBy = req.query.sortBy; 24 | if (sortBy == "insertedAt_desc") return { id: "desc" }; 25 | if (sortBy == "insertedAt_asc") return { id: "asc" }; 26 | if (sortBy == "like_desc") return { like: "desc" }; 27 | return {}; 28 | }; 29 | 30 | // 转换评论数据 31 | const transformComment = async (comments) => { 32 | return Promise.all( 33 | comments.map(async (comment) => { 34 | const time = new Date(comment.insertedAt).getTime(); 35 | const objectId = comment.id; 36 | 37 | // 使用 UAParser 解析 UA 38 | const parser = new UAParser(comment.user_ua || ""); 39 | const result = parser.getResult(); 40 | const browser = result.browser.name || "未知"; 41 | const os = result.os.name || "未知"; 42 | 43 | // 获取 IP 地址位置信息 44 | let ipInfo = await ipLocation.getIPLocation(comment.user_ip); 45 | 46 | return { 47 | ...comment, 48 | time, 49 | objectId, 50 | browser, 51 | os, 52 | addr: ipInfo.address, 53 | most_specific_country_or_region: ipInfo.most_specific_country_or_region, 54 | }; 55 | }) 56 | ); 57 | }; 58 | 59 | // 读取评论 60 | router.get("/api/comment", async (req, res, next) => { 61 | try { 62 | const { path, page, pageSize } = req.query; 63 | const sort = getSortCondition(req); 64 | 65 | const comments = await prisma.ow_comment.findMany({ 66 | where: { page_key: path, pid: null, rid: null, type: "comment" }, 67 | orderBy: sort, 68 | take: Number(pageSize) || 10, 69 | skip: (page - 1) * pageSize, 70 | }); 71 | 72 | const transformedComments = await transformComment(comments); 73 | 74 | const ids = transformedComments.map((comment) => comment.id); 75 | 76 | const childrenComments = await prisma.ow_comment.findMany({ 77 | where: { page_key: path, rid: { in: ids }, type: "comment" }, 78 | }); 79 | 80 | const transformedChildrenComments = await transformComment( 81 | childrenComments 82 | ); 83 | // 获取评论的用户id 84 | 85 | var user_ids = transformedComments.map((comment) => comment.user_id); 86 | user_ids = user_ids.concat( 87 | transformedChildrenComments.map((comment) => comment.user_id) 88 | ); 89 | //去重 90 | user_ids = Array.from(new Set(user_ids)); 91 | 92 | logger.debug(user_ids); 93 | const users = await getUsersByList(user_ids); 94 | const result = transformedComments.map((comment) => { 95 | const children = transformedChildrenComments.filter( 96 | (child) => child.rid == comment.id 97 | ); 98 | return { ...comment, children }; 99 | }); 100 | 101 | const count = await prisma.ow_comment.count({ 102 | where: { page_key: path, pid: null, rid: null, type: "comment" }, 103 | }); 104 | 105 | res.status(200).send({ 106 | errno: 0, 107 | errmsg: "", 108 | data: { 109 | page, 110 | totalPages: Math.ceil(count / pageSize), 111 | pageSize, 112 | count, 113 | data: result, 114 | }, 115 | users, 116 | }); 117 | } catch (err) { 118 | next(err); 119 | } 120 | }); 121 | 122 | // 创建评论 123 | router.post("/api/comment", needLogin, async (req, res, next) => { 124 | try { 125 | const { url, comment, pid, rid } = req.body; 126 | const { userid, display_name } = res.locals; 127 | const user_ua = req.headers["user-agent"] || ""; 128 | 129 | const newComment = await prisma.ow_comment.create({ 130 | data: { 131 | user_id: userid, 132 | type: "comment", 133 | user_ip: req.ip, 134 | page_type: url.split("-")[0], 135 | page_id: Number(url.split("-")[1]) || null, 136 | text: comment, 137 | link: `/user/${userid}`, 138 | user_ua, 139 | pid: pid || null, 140 | rid: rid || null, 141 | }, 142 | }); 143 | 144 | const transformedComment = (await transformComment([newComment]))[0]; 145 | res.status(200).send({ 146 | errno: 0, 147 | errmsg: "", 148 | data: transformedComment, 149 | }); 150 | 151 | let user_id, targetType, targetId; 152 | if (url.split("-")[0] == "user") { 153 | targetType = "user"; 154 | targetId = url.split("-")[1]; 155 | user_id = targetId; 156 | } else if (url.split("-")[0] == "project") { 157 | const project = await prisma.ow_projects.findUnique({ 158 | where: { 159 | id: Number(url.split("-")[1]), 160 | }, 161 | }); 162 | user_id = project.authorid; 163 | targetType = "project"; 164 | targetId = url.split("-")[1]; 165 | } else if (url.split("-")[0] == "projectlist") { 166 | targetType = "projectlist"; 167 | targetId = url.split("-")[1]; 168 | const projectlist = await prisma.ow_projectlists.findUnique({ 169 | where: { 170 | id: Number(url.split("-")[1]), 171 | }, 172 | }); 173 | user_id = projectlist.authorid; 174 | } else { 175 | user_id = userid; 176 | targetType = "user"; 177 | targetId = userid; 178 | } 179 | await createEvent({ 180 | eventType: "comment_reply", 181 | actorId: userid, 182 | targetType: targetType, 183 | targetId: targetId, 184 | data: { comment: newComment.text }, 185 | }); 186 | 187 | } catch (err) { 188 | next(err); 189 | } 190 | }); 191 | 192 | // 删除评论 193 | router.delete("/api/comment/:id", async (req, res, next) => { 194 | try { 195 | const { id } = req.params; 196 | const { user_id } = res.locals; 197 | 198 | const comment = await prisma.ow_comment.findFirst({ 199 | where: { id: Number(id) }, 200 | }); 201 | 202 | if (comment.user_id == user_id || true) { 203 | await prisma.ow_comment.delete({ 204 | where: { id: Number(id) }, 205 | }); 206 | } 207 | 208 | res.status(200).send({ errno: 0, errmsg: "", data: "" }); 209 | } catch (err) { 210 | next(err); 211 | } 212 | }); 213 | 214 | export default router; 215 | -------------------------------------------------------------------------------- /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 | getTargetEvents, 9 | getActorEvents, 10 | getProjectFollowersExternal, 11 | getUserFollowersExternal, 12 | } from "../controllers/events.js"; 13 | 14 | const router = express.Router(); 15 | 16 | /** 17 | * @route GET /events/target/:targetType/:targetId 18 | * @desc Get events for a specific target 19 | * @access Public/Private (depends on event privacy) 20 | */ 21 | router.get("/target/:targetType/:targetId", async (req, res, next) => { 22 | try { 23 | const { targetType, targetId } = req.params; 24 | const { limit = 10, offset = 0 } = req.query; 25 | const includePrivate = req.user ? true : false; 26 | 27 | const events = await getTargetEvents( 28 | targetType, 29 | targetId, 30 | Number(limit), 31 | Number(offset), 32 | includePrivate 33 | ); 34 | 35 | res.json({ 36 | status: "success", 37 | data: events, 38 | }); 39 | } catch (error) { 40 | next(error); 41 | } 42 | }); 43 | 44 | /** 45 | * @route GET /events/actor/:actorId 46 | * @desc Get events for a specific actor 47 | * @access Public/Private (depends on event privacy) 48 | */ 49 | router.get("/actor/:actorId", async (req, res, next) => { 50 | try { 51 | const { actorId } = req.params; 52 | const { limit = 10, offset = 0 } = req.query; 53 | const includePrivate = 54 | req.user && (req.user.id === Number(actorId) || req.user.isAdmin); 55 | 56 | const events = await getActorEvents( 57 | actorId, 58 | Number(limit), 59 | Number(offset), 60 | includePrivate 61 | ); 62 | 63 | res.json({ 64 | status: "success", 65 | data: events, 66 | }); 67 | } catch (error) { 68 | next(error); 69 | } 70 | }); 71 | 72 | /** 73 | * @route POST /events 74 | * @desc Create a new event 75 | * @access Private 76 | */ 77 | router.post("/", needLogin, async (req, res, next) => { 78 | try { 79 | const { eventType, targetType, targetId, ...eventData } = req.body; 80 | 81 | // Use current user as actor if not specified 82 | const actorId = eventData.actor_id || req.user.id; 83 | 84 | const event = await createEvent( 85 | eventType, 86 | actorId, 87 | targetType, 88 | targetId, 89 | eventData 90 | ); 91 | 92 | if (!event) { 93 | return res.status(400).json({ 94 | status: "error", 95 | message: "Failed to create event", 96 | }); 97 | } 98 | 99 | res.status(201).json({ 100 | status: "success", 101 | data: event, 102 | }); 103 | } catch (error) { 104 | next(error); 105 | } 106 | }); 107 | 108 | /** 109 | * @route GET /events/project-followers/:projectId 110 | * @desc Get followers of a project 111 | * @access Public 112 | */ 113 | router.get("/project-followers/:projectId", async (req, res, next) => { 114 | try { 115 | const { projectId } = req.params; 116 | const followers = await getProjectFollowersExternal(projectId); 117 | 118 | res.json({ 119 | status: "success", 120 | data: followers, 121 | }); 122 | } catch (error) { 123 | next(error); 124 | } 125 | }); 126 | 127 | /** 128 | * @route GET /events/user-followers/:userId 129 | * @desc Get followers of a user 130 | * @access Public 131 | */ 132 | router.get("/user-followers/:userId", async (req, res, next) => { 133 | try { 134 | const { userId } = req.params; 135 | const followers = await getUserFollowersExternal(userId); 136 | 137 | res.json({ 138 | status: "success", 139 | data: followers, 140 | }); 141 | } catch (error) { 142 | next(error); 143 | } 144 | }); 145 | 146 | export default router; 147 | -------------------------------------------------------------------------------- /routes/router_lists.js: -------------------------------------------------------------------------------- 1 | import logger from "../services/logger.js"; 2 | import { Router } from "express"; 3 | import { needLogin } from "../middleware/auth.js"; 4 | import { 5 | getProjectList, 6 | getUserListInfoAndCheak, 7 | createList, 8 | deleteList, 9 | addProjectToList, 10 | removeProjectFromList, 11 | getUserListInfo, 12 | getUserListInfoPublic, 13 | updateList, 14 | } from "../controllers/lists.js"; 15 | 16 | const router = Router(); 17 | 18 | // Get a specific list by ID 19 | router.get("/listid/:id", async (req, res) => { 20 | try { 21 | const list = await getProjectList(req.params.id, res.locals.userid); 22 | if (!list) { 23 | return res.status(404).send({ status: "error", message: "列表不存在" }); 24 | } 25 | 26 | res 27 | .status(200) 28 | .send({ status: "success", message: "获取成功", data: list }); 29 | } catch (err) { 30 | logger.error("Error getting project list:", err); 31 | res.status(500).send({ status: "error", message: "获取项目列表时出错" }); 32 | } 33 | }); 34 | 35 | // Get public lists for a user 36 | router.get("/userid/:id/public", async (req, res) => { 37 | try { 38 | const list = await getUserListInfoPublic(req.params.id, res.locals.userid); 39 | res 40 | .status(200) 41 | .send({ status: "success", message: "获取成功", data: list }); 42 | } catch (err) { 43 | logger.error("Error getting public user list info:", err); 44 | res 45 | .status(500) 46 | .send({ status: "error", message: "获取公共用户列表信息时出错" }); 47 | } 48 | }); 49 | 50 | // Get current user's lists 51 | router.get("/my", async (req, res) => { 52 | try { 53 | const list = await getUserListInfo(res.locals.userid); 54 | res 55 | .status(200) 56 | .send({ status: "success", message: "获取成功", data: list }); 57 | } catch (err) { 58 | logger.error("Error getting my list info:", err); 59 | res 60 | .status(500) 61 | .send({ status: "error", message: "获取我的列表信息时出错" }); 62 | } 63 | }); 64 | 65 | // Check if a project is in any of the user's lists 66 | router.get("/check", async (req, res) => { 67 | try { 68 | const { projectid } = req.query; 69 | 70 | if (!projectid) { 71 | return res 72 | .status(400) 73 | .send({ status: "error", message: "项目ID不能为空" }); 74 | } 75 | 76 | const result = await getUserListInfoAndCheak(res.locals.userid, projectid); 77 | res 78 | .status(200) 79 | .send({ status: "success", message: "获取成功", data: result }); 80 | } catch (err) { 81 | logger.error("Error checking user list info:", err); 82 | res 83 | .status(500) 84 | .send({ status: "error", message: "检查用户列表信息时出错" }); 85 | } 86 | }); 87 | 88 | // Create a new list 89 | router.post("/create", needLogin, async (req, res) => { 90 | try { 91 | const { title, description } = req.body; 92 | 93 | if (!title) { 94 | return res.status(400).send({ status: "error", message: "标题不能为空" }); 95 | } 96 | 97 | const list = await createList(res.locals.userid, title, description); 98 | res 99 | .status(200) 100 | .send({ status: "success", message: "创建成功", data: list }); 101 | } catch (err) { 102 | logger.error("Error creating list:", err); 103 | res.status(500).send({ status: "error", message: "创建列表时出错" }); 104 | } 105 | }); 106 | 107 | // Delete a list 108 | router.post("/delete", needLogin, async (req, res) => { 109 | try { 110 | const { id } = req.body; 111 | 112 | if (!id) { 113 | return res 114 | .status(400) 115 | .send({ status: "error", message: "列表ID不能为空" }); 116 | } 117 | 118 | const list = await deleteList(res.locals.userid, id); 119 | res 120 | .status(200) 121 | .send({ status: "success", message: "删除成功", data: list }); 122 | } catch (err) { 123 | logger.error("Error deleting list:", err); 124 | res.status(500).send({ status: "error", message: "删除列表时出错" }); 125 | } 126 | }); 127 | 128 | // Add a project to a list 129 | router.post("/add", needLogin, async (req, res) => { 130 | try { 131 | const { listid, projectid } = req.body; 132 | 133 | if (!listid || !projectid) { 134 | return res 135 | .status(400) 136 | .send({ status: "error", message: "列表ID和项目ID不能为空" }); 137 | } 138 | 139 | const list = await addProjectToList(res.locals.userid, listid, projectid); 140 | res 141 | .status(200) 142 | .send({ status: "success", message: "添加成功", data: list }); 143 | } catch (err) { 144 | logger.error("Error adding project to list:", err); 145 | res.status(500).send({ status: "error", message: "添加项目到列表时出错" }); 146 | } 147 | }); 148 | 149 | // Remove a project from a list 150 | router.post("/remove", needLogin, async (req, res) => { 151 | try { 152 | const { listid, projectid } = req.body; 153 | 154 | if (!listid || !projectid) { 155 | return res 156 | .status(400) 157 | .send({ status: "error", message: "列表ID和项目ID不能为空" }); 158 | } 159 | 160 | const list = await removeProjectFromList( 161 | res.locals.userid, 162 | listid, 163 | projectid 164 | ); 165 | res 166 | .status(200) 167 | .send({ status: "success", message: "删除成功", data: list }); 168 | } catch (err) { 169 | logger.error("Error removing project from list:", err); 170 | res 171 | .status(500) 172 | .send({ status: "error", message: "从列表中删除项目时出错" }); 173 | } 174 | }); 175 | 176 | // Update list details 177 | router.post("/update/:id", needLogin, async (req, res) => { 178 | try { 179 | const list = await updateList(res.locals.userid, req.params.id, req.body); 180 | res 181 | .status(200) 182 | .send({ status: "success", message: "修改成功", data: list }); 183 | } catch (err) { 184 | logger.error("Error updating list:", err); 185 | res.status(500).send({ status: "error", message: "修改列表时出错" }); 186 | } 187 | }); 188 | 189 | export default router; 190 | -------------------------------------------------------------------------------- /routes/router_my.js: -------------------------------------------------------------------------------- 1 | import logger from "../services/logger.js"; 2 | import { needLogin, strictTokenCheck } from "../middleware/auth.js"; 3 | import zcconfig from "../services/config/zcconfig.js"; 4 | import fs from "fs"; 5 | 6 | //个人中心 7 | import { Router } from "express"; 8 | var router = Router(); 9 | import { createReadStream } from "fs"; 10 | import { createHash } from "crypto"; 11 | //功能函数集 12 | import { S3update, checkhash, hash, prisma } from "../services/global.js"; 13 | //数据库 14 | import geetestMiddleware from "../middleware/geetest.js"; 15 | import multer from "multer"; 16 | import { createEvent } from "../controllers/events.js"; 17 | 18 | const upload = multer({ dest: "./usercontent" }); 19 | 20 | // Migrated to use the global parseToken middleware 21 | 22 | router.post("/set/avatar", upload.single("zcfile"), async (req, res) => { 23 | if (!req.file) { 24 | return res 25 | .status(400) 26 | .send({ status: "error", message: "No file uploaded" }); 27 | } 28 | 29 | try { 30 | const file = req.file; 31 | const hash = createHash("md5"); 32 | const chunks = createReadStream(file.path); 33 | 34 | chunks.on("data", (chunk) => { 35 | if (chunk) hash.update(chunk); 36 | }); 37 | 38 | chunks.on("end", async () => { 39 | const hashValue = hash.digest("hex"); 40 | const fileBuffer = await fs.promises.readFile(file.path); 41 | await S3update(`user/${hashValue}`, fileBuffer); 42 | await prisma.ow_users.update({ 43 | where: { id: res.locals.userid }, 44 | data: { images: hashValue }, 45 | }); 46 | res.status(200).send({ status: "success", message: "头像上传成功" }); 47 | }); 48 | 49 | chunks.on("error", (err) => { 50 | logger.error("Error processing file upload:", err); 51 | res.status(500).send({ status: "error", message: "图片上传失败" }); 52 | }); 53 | } catch (err) { 54 | logger.error("Unexpected error:", err); 55 | res.status(500).send({ status: "error", message: "图片上传失败" }); 56 | } 57 | }); 58 | 59 | router.use((err, req, res, next) => { 60 | if (err.code === "LIMIT_UNEXPECTED_FILE") { 61 | logger.error("Unexpected end of form: ", err); 62 | res.status(400).send({ status: "error", message: "数据传输异常" }); 63 | } else { 64 | next(err); 65 | } 66 | }); 67 | 68 | //修改个人信息 69 | router.post("/set/userinfo", async (req, res) => { 70 | try { 71 | await prisma.ow_users.update({ 72 | where: { id: res.locals.userid }, 73 | data: { 74 | display_name: req.body["display_name"], 75 | motto: req.body["aboutme"], 76 | sex: req.body["sex"], 77 | birthday: new Date(`2000-01-01 00:00:00`), 78 | }, 79 | }); 80 | 81 | // 添加个人资料更新事件 82 | await createEvent( 83 | "user_profile_update", 84 | res.locals.userid, 85 | "user", 86 | res.locals.userid, 87 | { 88 | event_type: "user_profile_update", 89 | actor_id: res.locals.userid, 90 | target_type: "user", 91 | target_id: res.locals.userid, 92 | update_type: "profile_update", 93 | updated_fields: ["display_name", "motto", "sex", "birthday"], 94 | old_value: null, 95 | new_value: JSON.stringify({ 96 | display_name: req.body["display_name"], 97 | motto: req.body["aboutme"], 98 | sex: req.body["sex"] 99 | }) 100 | } 101 | ); 102 | 103 | res.status(200).send({ status: "success", message: "个人信息修改成功" }); 104 | } catch (error) { 105 | logger.error("Error updating user info:", error); 106 | res.status(500).send({ status: "error", message: "修改个人信息失败" }); 107 | } 108 | }); 109 | 110 | //修改用户名 111 | router.post("/set/username", async (req, res) => { 112 | await prisma.ow_users.update({ 113 | where: { id: res.locals.userid }, 114 | data: { 115 | username: req.body.username, 116 | }, 117 | }); 118 | res.locals.username = req.body.username; 119 | 120 | res.status(200).send({ status: "success", message: "用户名修成成功" }); 121 | }); 122 | 123 | //修改密码:动作 124 | router.post("/set/pw", async (req, res) => { 125 | const USER = await prisma.ow_users.findUnique({ 126 | where: { id: res.locals.userid }, 127 | }); 128 | if (!USER) { 129 | return res.status(200).send({ status: "错误", message: "用户不存在" }); 130 | } 131 | if (checkhash(req.body["oldpw"], USER.password) == false) { 132 | return res.status(200).send({ status: "错误", message: "旧密码错误" }); 133 | } 134 | const newPW = hash(req.body["newpw"]); 135 | await prisma.ow_users.update({ 136 | where: { id: res.locals.userid }, 137 | data: { password: newPW }, 138 | }); 139 | res.status(200).send({ status: "success", message: "密码修改成功" }); 140 | }); 141 | 142 | export default router; 143 | -------------------------------------------------------------------------------- /routes/router_projectlist.js: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { needLogin, strictTokenCheck } from "../middleware/auth.js"; 3 | import starsRouter from "./router_stars.js"; 4 | import listsRouter from "./router_lists.js"; 5 | 6 | const router = Router(); 7 | 8 | // Mount the star and list routers 9 | router.use("/stars", starsRouter); 10 | router.use("/lists", listsRouter); 11 | 12 | export default router; 13 | -------------------------------------------------------------------------------- /routes/router_search.js: -------------------------------------------------------------------------------- 1 | import logger from "../services/logger.js"; 2 | import { needLogin, strictTokenCheck } from "../middleware/auth.js"; 3 | import zcconfig from "../services/config/zcconfig.js"; 4 | 5 | import { Router } from "express"; 6 | const router = Router(); 7 | import { prisma } from "../services/global.js"; // 功能函数集 8 | 9 | // 搜索:Scratch项目列表:数据(只搜索标题) 10 | router.get("/", async (req, res, next) => { 11 | try { 12 | const { 13 | search_userid: userid, 14 | search_type: type, 15 | search_title: title, 16 | search_source: source, 17 | search_description: description, 18 | search_orderby: orderbyQuery = "time_down", 19 | search_tag: tags, 20 | curr = 1, 21 | limit = 10, 22 | search_state: stateQuery = "", 23 | } = req.query; 24 | 25 | const isCurrentUser = 26 | userid && res.locals.userid && userid == res.locals.userid; 27 | let state = 28 | stateQuery == "" 29 | ? isCurrentUser 30 | ? ["private", "public"] 31 | : ["public"] 32 | : stateQuery == "private" 33 | ? isCurrentUser 34 | ? ["private"] 35 | : ["public"] 36 | : [stateQuery]; 37 | 38 | // 处理排序 39 | const [orderbyField, orderDirection] = orderbyQuery.split("_"); 40 | const orderbyMap = { view: "view_count", time: "time", id: "id",star:"star_count" }; 41 | const orderDirectionMap = { up: "asc", down: "desc" }; // 修正排序方向 42 | const orderBy = orderbyMap[orderbyField] || "time"; 43 | const order = orderDirectionMap[orderDirection] || "desc"; 44 | 45 | // 构建基本搜索条件 46 | const searchinfo = { 47 | title: title ? { contains: title } : undefined, 48 | source: source ? { contains: source } : undefined, 49 | description: description ? { contains: description } : undefined, 50 | type: type ? { contains: type } : undefined, 51 | state: state ? { in: state } : undefined, 52 | authorid: userid ? { equals: Number(userid) } : undefined, 53 | tags: tags ? { contains: tags } : undefined, 54 | }; 55 | 56 | // 查询项目总数 57 | const totalCount = await prisma.ow_projects.count({ 58 | where: searchinfo, 59 | }); 60 | 61 | // 查询项目结果 62 | const projectresult = await prisma.ow_projects.findMany({ 63 | where: searchinfo, 64 | orderBy: { [orderBy]: order }, 65 | select: { id: true }, 66 | skip: (Number(curr) - 1) * Number(limit), 67 | take: Number(limit), 68 | }); 69 | 70 | res.status(200).send({ 71 | projects: projectresult.map((item) => item.id), 72 | totalCount: totalCount, 73 | }); 74 | } catch (error) { 75 | next(error); 76 | } 77 | }); 78 | 79 | export default router; 80 | -------------------------------------------------------------------------------- /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 { 6 | starProject, 7 | unstarProject, 8 | getProjectStarStatus, 9 | getProjectStars, 10 | } from "../controllers/stars.js"; 11 | 12 | const router = Router(); 13 | 14 | /** 15 | * Star a project 16 | * @route POST /star 17 | * @access Private 18 | */ 19 | router.post("/star", needLogin, async (req, res) => { 20 | try { 21 | const projectId = parseInt(req.body.projectid); 22 | 23 | if (!projectId) { 24 | return res 25 | .status(400) 26 | .send({ status: "error", message: "项目ID不能为空" }); 27 | } 28 | 29 | await starProject(res.locals.userid, projectId); 30 | 31 | // Add star event 32 | await createEvent( 33 | "project_star", 34 | res.locals.userid, 35 | "project", 36 | projectId, 37 | { 38 | event_type: "project_star", 39 | actor_id: res.locals.userid, 40 | target_type: "project", 41 | target_id: projectId, 42 | action: "star" 43 | } 44 | ); 45 | 46 | res.status(200).send({ status: "success", message: "收藏成功", star: 1 }); 47 | } catch (err) { 48 | logger.error("Error starring project:", err); 49 | res.status(500).send({ status: "error", message: "收藏项目时出错" }); 50 | } 51 | }); 52 | 53 | /** 54 | * Unstar a project 55 | * @route POST /unstar 56 | * @access Private 57 | */ 58 | router.post("/unstar", needLogin, async (req, res) => { 59 | try { 60 | const projectId = parseInt(req.body.projectid); 61 | 62 | if (!projectId) { 63 | return res 64 | .status(400) 65 | .send({ status: "error", message: "项目ID不能为空" }); 66 | } 67 | 68 | await unstarProject(res.locals.userid, projectId); 69 | 70 | res 71 | .status(200) 72 | .send({ status: "success", message: "取消收藏成功", star: 0 }); 73 | } catch (err) { 74 | logger.error("Error unstarring project:", err); 75 | res.status(500).send({ status: "error", message: "取消收藏项目时出错" }); 76 | } 77 | }); 78 | 79 | /** 80 | * Check if a project is starred by the current user 81 | * @route GET /checkstar 82 | * @access Public 83 | */ 84 | router.get("/checkstar", async (req, res) => { 85 | try { 86 | const projectId = parseInt(req.query.projectid); 87 | 88 | if (!projectId) { 89 | return res 90 | .status(400) 91 | .send({ status: "error", message: "项目ID不能为空" }); 92 | } 93 | 94 | const status = await getProjectStarStatus(res.locals.userid, projectId); 95 | res.status(200).send({ 96 | status: "success", 97 | message: "获取成功", 98 | star: status, 99 | }); 100 | } catch (err) { 101 | logger.error("Error checking star status:", err); 102 | res.status(500).send({ status: "error", message: "检查收藏状态时出错" }); 103 | } 104 | }); 105 | 106 | /** 107 | * Get the number of stars for a project 108 | * @route GET /project/:id/stars 109 | * @access Public 110 | */ 111 | router.get("/project/:id/stars", async (req, res) => { 112 | try { 113 | const projectId = parseInt(req.params.id); 114 | 115 | if (!projectId) { 116 | return res.status(400).send({ status: "error", message: "项目ID不能为空" }); 117 | } 118 | 119 | const stars = await getProjectStars(projectId); 120 | res.status(200).send({ 121 | status: "success", 122 | message: "获取成功", 123 | data: stars, 124 | }); 125 | } catch (err) { 126 | logger.error("Error getting project stars:", err); 127 | res.status(500).send({ status: "error", message: "获取项目收藏数时出错" }); 128 | } 129 | }); 130 | 131 | export default router; 132 | -------------------------------------------------------------------------------- /routes/router_timeline.js: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { prisma } from "../services/global.js"; 3 | import logger from "../services/logger.js"; 4 | import { EventConfig } from "../controllers/events.js"; 5 | import { needLogin } from "../middleware/auth.js"; 6 | 7 | const router = Router(); 8 | 9 | // 新增一个函数来处理事件格式化 10 | async function formatEvents(events, actorMap) { 11 | return await Promise.all( 12 | events.map(async (event) => { 13 | try { 14 | const actor = actorMap.get(Number(event.actor_id)); 15 | if (!actor) { 16 | logger.warn( 17 | `Actor not found for event ${event.id}, actor_id: ${event.actor_id}` 18 | ); 19 | return null; 20 | } 21 | 22 | const eventConfig = EventConfig[event.event_type]; 23 | if (!eventConfig) { 24 | logger.warn(`Event type config not found: ${event.event_type}`); 25 | return null; 26 | } 27 | 28 | const formattedEvent = { 29 | id: event.id.toString(), 30 | type: event.event_type, 31 | actor: { 32 | id: actor.id, 33 | username: actor.username, 34 | display_name: actor.display_name, 35 | }, 36 | target: { 37 | type: event.target_type, 38 | id: Number(event.target_id), 39 | page: {}, 40 | }, 41 | created_at: event.created_at, 42 | event_data: event.event_data, 43 | public: event.public === 1, 44 | }; 45 | 46 | // 对于评论类型的事件,添加额外的定位信息到 page 中 47 | if (event.event_type === "comment_create" && event.event_data) { 48 | formattedEvent.target.id = 49 | event.event_data.page_id || event.event_data.page.id; 50 | formattedEvent.target.type = 51 | event.event_data.page_type || event.event_data.page.type; 52 | formattedEvent.target.page = { 53 | page_type: event.event_data.page_type, 54 | page_id: event.event_data.page_id, 55 | parent_id: event.event_data.parent_id, 56 | reply_id: event.event_data.reply_id, 57 | }; 58 | } 59 | 60 | return formattedEvent; 61 | } catch (error) { 62 | logger.error("Error formatting event:", { 63 | error, 64 | event_id: event.id, 65 | event_type: event.event_type, 66 | }); 67 | return null; 68 | } 69 | }) 70 | ); 71 | } 72 | 73 | // 获取用户时间线 74 | router.get("/user/:userid", async (req, res) => { 75 | try { 76 | const { userid } = req.params; 77 | const { page = 1, limit = 20 } = req.query; 78 | const isOwner = res.locals.userid === Number(userid); 79 | 80 | logger.debug("Fetching timeline for user", { 81 | userid, 82 | isOwner, 83 | currentUser: res.locals.userid, 84 | }); 85 | 86 | const where = { 87 | actor_id: Number(userid), 88 | ...(isOwner ? {} : { public: 1 }), 89 | }; 90 | 91 | const events = await prisma.ow_events.findMany({ 92 | where, 93 | orderBy: { created_at: "desc" }, 94 | skip: (Number(page) - 1) * Number(limit), 95 | take: Number(limit), 96 | }); 97 | 98 | const total = await prisma.ow_events.count({ where }); 99 | 100 | const actorIds = [ 101 | ...new Set(events.map((event) => Number(event.actor_id))), 102 | ]; 103 | const actors = await prisma.ow_users.findMany({ 104 | where: { id: { in: actorIds } }, 105 | select: { id: true, username: true, display_name: true }, 106 | }); 107 | 108 | const actorMap = new Map(actors.map((actor) => [actor.id, actor])); 109 | 110 | // 使用新函数格式化事件 111 | const formattedEvents = await formatEvents(events, actorMap); 112 | const filteredEvents = formattedEvents.filter((e) => e !== null); 113 | 114 | res.status(200).send({ 115 | status: "success", 116 | data: { 117 | events: filteredEvents, 118 | pagination: { 119 | current: Number(page), 120 | size: Number(limit), 121 | total, 122 | }, 123 | }, 124 | }); 125 | } catch (error) { 126 | logger.error("Error fetching timeline:", error); 127 | res.status(500).send({ 128 | status: "error", 129 | message: "获取时间线失败", 130 | details: error.message, 131 | }); 132 | } 133 | }); 134 | 135 | // 获取关注的用户的时间线(只显示公开事件) 136 | router.get("/following", needLogin, async (req, res) => { 137 | try { 138 | const { page = 1, limit = 20 } = req.query; 139 | 140 | const following = await prisma.ow_users_follows.findMany({ 141 | where: { follower_id: res.locals.userid }, 142 | }); 143 | 144 | const followingIds = following.map((f) => f.following_id); 145 | 146 | const events = await prisma.ow_events.findMany({ 147 | where: { 148 | actor_id: { in: followingIds.map((id) => BigInt(id)) }, 149 | public: 1, 150 | }, 151 | orderBy: { created_at: "desc" }, 152 | skip: (Number(page) - 1) * Number(limit), 153 | take: Number(limit), 154 | }); 155 | 156 | const actorIds = [ 157 | ...new Set(events.map((event) => Number(event.actor_id))), 158 | ]; 159 | const actors = await prisma.ow_users.findMany({ 160 | where: { id: { in: actorIds } }, 161 | select: { id: true, username: true, display_name: true }, 162 | }); 163 | 164 | const actorMap = new Map(actors.map((actor) => [actor.id, actor])); 165 | 166 | // 使用新函数格式化事件 167 | const formattedEvents = await formatEvents(events, actorMap); 168 | 169 | res.status(200).send({ 170 | status: "success", 171 | data: { 172 | events: formattedEvents.filter((e) => e !== null), 173 | }, 174 | }); 175 | } catch (error) { 176 | logger.error("Error fetching following timeline:", error); 177 | res.status(500).send({ 178 | status: "error", 179 | message: "获取关注时间线失败", 180 | }); 181 | } 182 | }); 183 | 184 | export default router; 185 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * ZeroCat Backend 服务器入口文件 5 | */ 6 | 7 | import "dotenv/config"; 8 | import logger from './services/logger.js'; 9 | import { serverConfig } from './src/index.js'; 10 | import { execSync } from 'child_process'; 11 | 12 | /** 13 | * 运行Prisma迁移和生成 14 | */ 15 | async function runPrismaMigrations() { 16 | // 在调试模式下跳过迁移 17 | if (process.env.NODE_ENV === 'development') { 18 | logger.info('调试模式:跳过Prisma迁移和生成'); 19 | return; 20 | } 21 | 22 | try { 23 | logger.info('开始运行Prisma迁移...'); 24 | execSync('npx prisma migrate deploy', { stdio: 'inherit' }); 25 | logger.info('Prisma迁移完成'); 26 | 27 | logger.info('开始生成Prisma客户端...'); 28 | execSync('npx prisma generate', { stdio: 'inherit' }); 29 | logger.info('Prisma客户端生成完成'); 30 | } catch (error) { 31 | logger.error('Prisma迁移或生成失败:', error); 32 | throw error; 33 | } 34 | } 35 | 36 | /** 37 | * 应用主函数 38 | */ 39 | async function main() { 40 | try { 41 | // 打印启动Banner 42 | printBanner(); 43 | 44 | // 运行Prisma迁移和生成 45 | await runPrismaMigrations(); 46 | 47 | // 启动HTTP服务器 48 | await serverConfig.start(); 49 | 50 | // 设置进程事件处理 51 | setupProcessHandlers(); 52 | } catch (error) { 53 | logger.error('应用启动失败:', error); 54 | process.exit(1); 55 | } 56 | } 57 | 58 | /** 59 | * 打印启动Banner 60 | */ 61 | function printBanner() { 62 | const banner = ` 63 | ============================================================= 64 | ZeroCat Backend Server 65 | 66 | Version: ${process.env.npm_package_version || '1.0.0'} 67 | Environment: ${process.env.NODE_ENV} 68 | Node.js: ${process.version} 69 | ============================================================= 70 | `; 71 | console.log(banner); 72 | } 73 | 74 | /** 75 | * 设置进程事件处理 76 | */ 77 | function setupProcessHandlers() { 78 | // 处理SIGTERM信号 79 | process.on('SIGTERM', async () => { 80 | logger.info('接收到SIGTERM信号,开始关闭...'); 81 | await gracefulShutdown(); 82 | }); 83 | 84 | // 处理SIGINT信号 85 | process.on('SIGINT', async () => { 86 | logger.info('接收到SIGINT信号,开始关闭...'); 87 | await gracefulShutdown(); 88 | }); 89 | } 90 | 91 | /** 92 | * 优雅关闭应用 93 | */ 94 | async function gracefulShutdown() { 95 | try { 96 | logger.info('开始关闭...'); 97 | 98 | // 等待15秒后强制退出 99 | const forceExitTimeout = setTimeout(() => { 100 | logger.error('关闭超时,强制退出'); 101 | process.exit(1); 102 | }, 15000); 103 | 104 | // 关闭服务器 105 | await serverConfig.stop(); 106 | 107 | // 取消强制退出定时器 108 | clearTimeout(forceExitTimeout); 109 | 110 | logger.info('应用已安全关闭'); 111 | process.exit(0); 112 | } catch (error) { 113 | logger.error('关闭过程中出错:', error); 114 | process.exit(1); 115 | } 116 | } 117 | 118 | // 运行应用 119 | main().catch(error => { 120 | logger.error('应用运行失败:', error); 121 | process.exit(1); 122 | }); -------------------------------------------------------------------------------- /services/auth/magiclink.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import jsonwebtoken from 'jsonwebtoken'; 3 | import zcconfig from '../config/zcconfig.js'; 4 | import redisClient from '../redis.js'; 5 | import logger from '../logger.js'; 6 | import { sendEmail } from '../email/emailService.js'; 7 | import { checkRateLimit, VerificationType } from './verification.js'; 8 | import { createJWT } from './tokenUtils.js'; 9 | 10 | // 生成魔术链接 11 | export async function generateMagicLinkForLogin(userId, email, options = {}) { 12 | try { 13 | // 默认10分钟过期 14 | const expiresIn = options.expiresIn || 600; 15 | 16 | // 客户端ID用于区分不同客户端的魔术链接 17 | const clientId = options.clientId || crypto.randomBytes(16).toString('hex'); 18 | 19 | // 使用统一的JWT创建函数 20 | const token = await createJWT({ 21 | id: userId, 22 | email, 23 | type: 'magic_link', 24 | clientId 25 | }, expiresIn); 26 | 27 | // 存储到Redis 28 | const redisKey = `magic_link:${token}`; 29 | await redisClient.set(redisKey, { 30 | userId, 31 | email, 32 | clientId, 33 | used: false, 34 | createdAt: Date.now() 35 | }, expiresIn); 36 | 37 | // 生成链接 38 | const frontendUrl = await zcconfig.get('urls.frontend'); 39 | const magicLink = `${frontendUrl}/app/account/magiclink/validate?token=${token}${options.redirect ? `&redirect=${encodeURIComponent(options.redirect)}` : ''}`; 40 | 41 | return { 42 | success: true, 43 | token, 44 | magicLink, 45 | expiresIn 46 | }; 47 | } catch (error) { 48 | logger.error('生成魔术链接失败:', error); 49 | return { 50 | success: false, 51 | message: '生成魔术链接失败' 52 | }; 53 | } 54 | } 55 | 56 | // 发送魔术链接邮件 57 | export async function sendMagicLinkEmail(email, magicLink, options = {}) { 58 | try { 59 | const templateType = options.templateType || 'login'; 60 | let subject, content; 61 | 62 | switch (templateType) { 63 | case 'register': 64 | subject = '完成您的账户注册'; 65 | content = ` 66 |

完成您的账户注册

67 |

您好,感谢您注册我们的服务!

68 |

请点击以下链接完成账户设置:

69 |

完成注册

70 |

或者您可以复制以下链接到浏览器地址栏:

71 |

${magicLink}

72 |

此链接将在10分钟内有效。

73 |

如果这不是您的操作,请忽略此邮件。

74 | `; 75 | break; 76 | 77 | case 'password_reset': 78 | subject = '重置您的密码'; 79 | content = ` 80 |

密码重置请求

81 |

您好,我们收到了重置您密码的请求。

82 |

请点击以下链接设置新密码:

83 |

重置密码

84 |

或者您可以复制以下链接到浏览器地址栏:

85 |

${magicLink}

86 |

此链接将在10分钟内有效。

87 |

如果这不是您的操作,请忽略此邮件并考虑修改您的密码。

88 | `; 89 | break; 90 | 91 | default: // login 92 | subject = '魔术链接登录'; 93 | content = ` 94 |

魔术链接登录请求

95 |

您好,您请求了使用魔术链接登录。

96 |

请点击以下链接登录:

97 |

登录

98 |

或者您可以复制以下链接到浏览器地址栏:

99 |

${magicLink}

100 |

此链接将在10分钟内有效。

101 |

如果这不是您的操作,请忽略此邮件并考虑修改您的密码。

102 | `; 103 | break; 104 | } 105 | 106 | await sendEmail(email, subject, content); 107 | 108 | return { 109 | success: true 110 | }; 111 | } catch (error) { 112 | logger.error('发送魔术链接邮件失败:', error); 113 | return { 114 | success: false, 115 | message: '发送魔术链接邮件失败' 116 | }; 117 | } 118 | } 119 | 120 | // 验证魔术链接 121 | export async function validateMagicLinkAndLogin(token) { 122 | try { 123 | // 检查Redis中的状态 124 | const redisKey = `magic_link:${token}`; 125 | const magicLinkData = await redisClient.get(redisKey); 126 | 127 | if (!magicLinkData) { 128 | return { 129 | success: false, 130 | message: '魔术链接不存在或已过期' 131 | }; 132 | } 133 | 134 | if (magicLinkData.used) { 135 | return { 136 | success: false, 137 | message: '此魔术链接已被使用' 138 | }; 139 | } 140 | 141 | // 验证JWT 142 | const jwtSecret = await zcconfig.get('security.jwttoken'); 143 | let decoded; 144 | 145 | try { 146 | decoded = jsonwebtoken.verify(token, jwtSecret); 147 | } catch (err) { 148 | return { 149 | success: false, 150 | message: '魔术链接已过期或无效' 151 | }; 152 | } 153 | 154 | return { 155 | success: true, 156 | userId: decoded.id, 157 | email: decoded.email, 158 | clientId: decoded.clientId, 159 | data: magicLinkData 160 | }; 161 | } catch (error) { 162 | logger.error('验证魔术链接失败:', error); 163 | return { 164 | success: false, 165 | message: '验证魔术链接失败' 166 | }; 167 | } 168 | } 169 | 170 | // 标记魔术链接为已使用 171 | export async function markMagicLinkAsUsed(token) { 172 | try { 173 | const redisKey = `magic_link:${token}`; 174 | const magicLinkData = await redisClient.get(redisKey); 175 | 176 | if (!magicLinkData) { 177 | return { 178 | success: false, 179 | message: '魔术链接不存在或已过期' 180 | }; 181 | } 182 | 183 | if (magicLinkData.used) { 184 | return { 185 | success: false, 186 | message: '此魔术链接已被使用' 187 | }; 188 | } 189 | 190 | // 标记为已使用 191 | magicLinkData.used = true; 192 | magicLinkData.usedAt = Date.now(); 193 | 194 | // 更新Redis,保持原过期时间 195 | const ttl = await redisClient.ttl(redisKey); 196 | if (ttl > 0) { 197 | await redisClient.set(redisKey, magicLinkData, ttl); 198 | } 199 | 200 | return { 201 | success: true 202 | }; 203 | } catch (error) { 204 | logger.error('标记魔术链接为已使用失败:', error); 205 | return { 206 | success: false, 207 | message: '标记魔术链接为已使用失败' 208 | }; 209 | } 210 | } 211 | 212 | // 检查魔术链接速率限制 213 | export async function checkMagicLinkRateLimit(email) { 214 | return checkRateLimit(email, VerificationType.LOGIN); 215 | } 216 | 217 | // 向后兼容 218 | export async function generateMagicLink(userId, email, options = {}) { 219 | logger.warn('generateMagicLink is deprecated, use generateMagicLinkForLogin instead'); 220 | return await generateMagicLinkForLogin(userId, email, options); 221 | } 222 | 223 | // 向后兼容 224 | export async function validateMagicLink(token) { 225 | logger.warn('validateMagicLink is deprecated, use validateMagicLinkAndLogin instead'); 226 | return await validateMagicLinkAndLogin(token); 227 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /services/auth/tokenManager.js: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import zcconfig from "../config/zcconfig.js"; 3 | import { createTypedJWT } from "./tokenUtils.js"; 4 | import logger from "../logger.js"; 5 | export async function generateFileAccessToken(sha256, userid) { 6 | return createTypedJWT("file", { 7 | action: "read", 8 | issuer: await zcconfig.get("site.domain"), 9 | sha256: sha256, 10 | userid: userid, 11 | }, 5 * 60); // 5分钟 12 | } 13 | 14 | export async function verifyFileAccessToken(token, userid) { 15 | const decoded = jwt.verify(token, await zcconfig.get("security.jwttoken")); 16 | if (!decoded) { 17 | throw new Error("Invalid token"); 18 | } 19 | const { sha256, action, userid: tokenUserid } = decoded.data; 20 | const type = decoded.type; 21 | if (type !== "file" || action !== "read" || (tokenUserid !== userid && tokenUserid !== 0)) { 22 | 23 | 24 | throw new Error("Invalid token"); 25 | } 26 | return sha256; 27 | } 28 | -------------------------------------------------------------------------------- /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("Missing required mail configuration"); 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 | }; 40 | }; 41 | 42 | const initializeTransporter = async () => { 43 | try { 44 | const mailConfig = await getMailConfig(); 45 | if (!mailConfig) { 46 | logger.info("Email service is disabled or not properly configured"); 47 | return false; 48 | } 49 | 50 | logger.debug("Initializing email transporter with config:", mailConfig.config); 51 | transporter = createTransport(mailConfig.config); 52 | 53 | // Test the connection 54 | await transporter.verify(); 55 | logger.info("Email service initialized successfully"); 56 | return true; 57 | } catch (error) { 58 | logger.error("Failed to initialize email service:", error); 59 | return false; 60 | } 61 | }; 62 | 63 | const sendEmail = async (to, subject, html) => { 64 | try { 65 | if (!transporter) { 66 | const initialized = await initializeTransporter(); 67 | if (!initialized) { 68 | throw new Error("Email service is not available or not properly configured"); 69 | } 70 | } 71 | 72 | const mailConfig = await getMailConfig(); 73 | if (!mailConfig) { 74 | throw new Error("Email service is disabled or not properly configured"); 75 | } 76 | 77 | await transporter.sendMail({ 78 | from: mailConfig.from, 79 | to: to, 80 | subject: subject, 81 | html: html, 82 | }); 83 | 84 | return true; 85 | } catch (error) { 86 | logger.error("Error sending email:", error); 87 | throw error; 88 | } 89 | }; 90 | 91 | // Initialize email service when the module is loaded 92 | initializeTransporter().catch(error => { 93 | logger.error("Failed to initialize email service on module load:", error); 94 | }); 95 | 96 | export { sendEmail }; -------------------------------------------------------------------------------- /services/errorHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview 全局错误处理服务 3 | * 提供统一的错误处理机制,包括未捕获异常、Express错误等 4 | */ 5 | import logger from './logger.js'; 6 | 7 | /** 8 | * 错误处理服务类 9 | */ 10 | class ErrorHandlerService { 11 | /** 12 | * 创建Express错误处理中间件 13 | * @returns {Function} Express错误处理中间件 14 | */ 15 | createExpressErrorHandler() { 16 | return (err, req, res, next) => { 17 | // 记录错误 18 | this.logError(err, req); 19 | 20 | // 获取错误状态码,默认500 21 | const statusCode = err.status || err.statusCode || 500; 22 | 23 | // 判断是否为生产环境 24 | const isProd = process.env.NODE_ENV === 'production'; 25 | 26 | // 构造错误响应 27 | const errorResponse = { 28 | status: 'error', 29 | code: err.code || 'server_error', 30 | message: err.message || '服务器内部错误' 31 | }; 32 | 33 | // 在非生产环境下,添加详细错误信息 34 | if (!isProd) { 35 | errorResponse.stack = err.stack; 36 | errorResponse.details = err.details || null; 37 | } 38 | 39 | // 发送错误响应 40 | res.status(statusCode).json(errorResponse); 41 | }; 42 | } 43 | 44 | /** 45 | * 注册全局未捕获异常处理器 46 | */ 47 | registerGlobalHandlers() { 48 | // 处理未捕获的Promise异常 49 | process.on('unhandledRejection', (reason, promise) => { 50 | logger.error('未捕获的Promise异常:', reason); 51 | }); 52 | 53 | // 处理未捕获的同步异常 54 | process.on('uncaughtException', (error) => { 55 | logger.error('未捕获的异常:', error); 56 | 57 | // 如果是严重错误,可能需要优雅退出 58 | if (this.isFatalError(error)) { 59 | logger.error('检测到严重错误,应用将在1秒后退出'); 60 | 61 | // 延迟退出,给日志写入时间 62 | setTimeout(() => { 63 | process.exit(1); 64 | }, 1000); 65 | } 66 | }); 67 | 68 | logger.info('全局错误处理器已注册'); 69 | } 70 | 71 | /** 72 | * 判断是否为致命错误 73 | * @param {Error} error - 错误对象 74 | * @returns {boolean} 是否为致命错误 75 | */ 76 | isFatalError(error) { 77 | // 这些类型的错误通常表明程序状态已不可靠 78 | const fatalErrorTypes = [ 79 | 'EvalError', 80 | 'RangeError', 81 | 'ReferenceError', 82 | 'SyntaxError', 83 | 'URIError' 84 | ]; 85 | 86 | // 一些系统错误也可能是致命的 87 | const fatalSystemErrors = [ 88 | 'EADDRINUSE', // 端口被占用 89 | 'ECONNREFUSED', // 连接被拒绝 90 | 'EACCES', // 权限拒绝 91 | 'ENOENT', // 找不到文件 92 | 'ESOCKETTIMEDOUT' // 套接字超时 93 | ]; 94 | 95 | return ( 96 | fatalErrorTypes.includes(error.name) || 97 | (error.code && fatalSystemErrors.includes(error.code)) 98 | ); 99 | } 100 | 101 | /** 102 | * 记录错误信息 103 | * @param {Error} error - 错误对象 104 | * @param {Object} req - Express请求对象 105 | */ 106 | logError(error, req = null) { 107 | // 构建基本错误信息 108 | const errorInfo = { 109 | message: error.message, 110 | stack: error.stack, 111 | name: error.name, 112 | code: error.code 113 | }; 114 | 115 | // 如果有请求对象,添加请求信息 116 | if (req) { 117 | errorInfo.request = { 118 | method: req.method, 119 | url: req.originalUrl || req.url, 120 | headers: this.sanitizeHeaders(req.headers), 121 | ip: req.ip || req.connection.remoteAddress 122 | }; 123 | } 124 | 125 | // 记录详细错误日志 126 | logger.error('应用错误:', errorInfo); 127 | } 128 | 129 | /** 130 | * 清理请求头中的敏感信息 131 | * @param {Object} headers - 请求头对象 132 | * @returns {Object} 清理后的请求头 133 | */ 134 | sanitizeHeaders(headers) { 135 | const sanitized = { ...headers }; 136 | 137 | // 移除敏感信息 138 | const sensitiveHeaders = [ 139 | 'authorization', 140 | 'cookie', 141 | 'set-cookie', 142 | 'x-api-key' 143 | ]; 144 | 145 | sensitiveHeaders.forEach(header => { 146 | if (sanitized[header]) { 147 | sanitized[header] = '[REDACTED]'; 148 | } 149 | }); 150 | 151 | return sanitized; 152 | } 153 | } 154 | 155 | // 创建单例 156 | const errorHandlerService = new ErrorHandlerService(); 157 | 158 | export default errorHandlerService; -------------------------------------------------------------------------------- /services/global.js: -------------------------------------------------------------------------------- 1 | import zcconfig from "./config/zcconfig.js"; 2 | import logger from "./logger.js"; 3 | import crypto from "crypto"; 4 | import jwt from "jsonwebtoken"; 5 | import { PasswordHash } from "phpass"; 6 | import fs from "fs"; 7 | 8 | //prisma client 9 | import { PrismaClient } from "@prisma/client"; 10 | 11 | const prisma = new PrismaClient() 12 | 13 | 14 | import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; 15 | 16 | const pwdHash = new PasswordHash(); 17 | const s3config = { 18 | endpoint: await zcconfig.get("s3.endpoint"), 19 | region: await zcconfig.get("s3.region"), 20 | credentials: { 21 | accessKeyId: await zcconfig.get("s3.AWS_ACCESS_KEY_ID"), 22 | secretAccessKey: await zcconfig.get("s3.AWS_SECRET_ACCESS_KEY"), 23 | }, 24 | }; 25 | logger.debug(s3config); 26 | 27 | const s3 = new S3Client(s3config); 28 | 29 | async function S3update(name, fileContent) { 30 | try { 31 | const command = new PutObjectCommand({ 32 | Bucket: await zcconfig.get("s3.bucket"), 33 | Key: name, 34 | Body: fileContent, 35 | }); 36 | 37 | const data = await s3.send(command); 38 | logger.debug(data); 39 | logger.debug( 40 | `成功上传了文件 ${await zcconfig.get("s3.bucket")}/${name}` 41 | ); 42 | } catch (err) { 43 | logger.error("S3 update Error:", err); 44 | } 45 | } 46 | 47 | async function S3updateFromPath(name, path) { 48 | try { 49 | const fileContent = fs.readFileSync(path); 50 | await S3update(name, fileContent); 51 | } catch (err) { 52 | logger.error("S3 update Error:", err); 53 | } 54 | } 55 | 56 | function md5(data) { 57 | return crypto.createHash("md5").update(data).digest("base64"); 58 | } 59 | 60 | function hash(data) { 61 | return pwdHash.hashPassword(data); 62 | } 63 | 64 | function checkhash(pwd, storeHash) { 65 | return pwdHash.checkPassword(pwd, storeHash); 66 | } 67 | 68 | function userpwTest(pw) { 69 | return /^(?:\d+|[a-zA-Z]+|[!@#$%^&*]+){6,16}$/.test(pw); 70 | } 71 | 72 | function emailTest(email) { 73 | return /^([a-zA-Z]|[0-9])(\w|\-)+@[a-zA-Z0-9]+\.[a-zA-Z]{2,4}$/.test(email); 74 | } 75 | 76 | 77 | function randomPassword(len = 12) { 78 | const chars = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"; 79 | const maxPos = chars.length; 80 | const password = Array.from({ length: len - 4 }, () => 81 | chars.charAt(Math.floor(Math.random() * maxPos)) 82 | ).join(""); 83 | return `${password}@Aa1`; 84 | } 85 | 86 | async function generateJwt(json) { 87 | try { 88 | const secret = await zcconfig.get("security.jwttoken"); 89 | logger.debug(secret); 90 | if (!secret) { 91 | throw new Error("JWT secret is not defined in the configuration"); 92 | } 93 | return jwt.sign(json, secret); 94 | } catch (error) { 95 | logger.error("Error generating JWT:", error); 96 | throw error; 97 | } 98 | } 99 | 100 | function isJSON(str) { 101 | if (typeof str !== "string") return false; 102 | try { 103 | const obj = JSON.parse(str); 104 | return obj && typeof obj === "object"; 105 | } catch (e) { 106 | logger.error("error:", str, e); 107 | return false; 108 | } 109 | } 110 | 111 | export { 112 | prisma, 113 | S3updateFromPath, 114 | S3update, 115 | md5, 116 | hash, 117 | checkhash, 118 | userpwTest, 119 | emailTest, 120 | randomPassword, 121 | generateJwt, 122 | isJSON, 123 | }; 124 | 125 | -------------------------------------------------------------------------------- /services/ip/ipLocation.js: -------------------------------------------------------------------------------- 1 | import logger from "../logger.js"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | import { fileURLToPath } from "url"; 5 | import { Reader } from "@maxmind/geoip2-node"; 6 | import zcconfig from "../config/zcconfig.js"; 7 | import downloadMaxmindDb from "./downloadMaxmindDb.js"; 8 | // 固定的数据库文件路径 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 10 | const DB_FILE = path.resolve(__dirname, "../../data/GeoLite2-City.mmdb"); 11 | 12 | // 配置参数 13 | const CONFIG = { 14 | enabled: false, // 是否启用MaxMind 15 | }; 16 | 17 | // 存储Reader实例 18 | let geoipReader = null; 19 | const defaultResponse = { 20 | address: "未知", 21 | most_specific_country_or_region: "未知", 22 | location: { 23 | accuracyRadius: -1, 24 | latitude: 0, 25 | longitude: 0, 26 | metroCode: -1, 27 | timeZone: "未知", 28 | }, 29 | }; 30 | // 从数据库加载配置 31 | const loadConfigFromDB = async () => { 32 | try { 33 | const enabled = await zcconfig.get("maxmind.enabled"); 34 | if (enabled !== null) { 35 | CONFIG.enabled = enabled === "true" || enabled === "1"; 36 | } 37 | logger.debug("已从数据库加载MaxMind配置", CONFIG); 38 | await initMaxMind(); 39 | return CONFIG; 40 | } catch (error) { 41 | logger.error("从数据库加载MaxMind配置失败:", error); 42 | } 43 | }; 44 | 45 | // 初始化MaxMind数据库 46 | const initMaxMind = async () => { 47 | if (geoipReader) { 48 | geoipReader = null; 49 | } 50 | 51 | if (!CONFIG.enabled) { 52 | logger.debug("MaxMind GeoIP未启用,跳过初始化"); 53 | return; 54 | } 55 | 56 | try { 57 | await downloadMaxmindDb.loadMaxmind(); 58 | 59 | // 加载数据库 60 | const dbBuffer = fs.readFileSync(DB_FILE); 61 | geoipReader = Reader.openBuffer(dbBuffer); 62 | logger.info("MaxMind GeoIP数据库加载成功"); 63 | } catch (error) { 64 | logger.error("初始化MaxMind GeoIP数据库失败:", error); 65 | geoipReader = null; 66 | } 67 | }; 68 | 69 | /** 70 | * 获取IP地址的地理位置信息 71 | * @param {string} ipAddress - 需要定位的IP地址 72 | * @returns {Object} 地理位置信息 73 | { 74 | * address: "未知", 75 | * most_specific_country_or_region: "未知", 76 | * location: { 77 | * accuracyRadius: -1, 78 | * latitude: 0, 79 | * longitude: 0, 80 | * metroCode: -1, 81 | * timeZone: "未知", 82 | * }, 83 | * } 84 | */ 85 | const getIPLocation = async (ipAddress) => { 86 | if (!ipAddress) { 87 | logger.warn("IP地址为空"); 88 | return defaultResponse; 89 | } 90 | 91 | if (CONFIG.enabled && geoipReader) { 92 | try { 93 | const response = geoipReader.city("128.101.101.101"); 94 | if (!response) { 95 | logger.debug(`MaxMind查询IP(${ipAddress})位置失败: 返回空响应`); 96 | return defaultResponse; 97 | } 98 | 99 | return { 100 | address: `${ 101 | response.city?.names?.["zh-CN"] || response.city?.names?.en || "" 102 | } ${ 103 | response.subdivisions?.[0]?.names?.["zh-CN"] || 104 | response.subdivisions?.[0]?.names?.en || 105 | "" 106 | } ${ 107 | response.country?.names?.["zh-CN"] || 108 | response.country?.names?.en || 109 | "未知" 110 | }(${response.country?.isoCode || ""}) ${ 111 | response.continent?.names?.["zh-CN"] || 112 | response.continent?.names?.en || 113 | "" 114 | }`, 115 | most_specific_country_or_region: 116 | response.city?.names?.["zh-CN"] || 117 | // response.city?.names?.en || 118 | response.subdivisions?.[0]?.names?.["zh-CN"] || 119 | // response.subdivisions?.[0]?.names?.en || 120 | response.country?.names?.["zh-CN"] || 121 | // response.country?.names?.en || 122 | response.continent?.names?.["zh-CN"] || 123 | // response.continent?.names?.en || 124 | response.registeredCountry?.names?.["zh-CN"] || 125 | response.registeredCountry?.names?.en || 126 | "未知", 127 | 128 | location: response.location, 129 | //response: response, 130 | }; 131 | } catch (error) { 132 | logger.debug(`MaxMind查询IP(${ipAddress})位置失败: ${error.message}`); 133 | } 134 | } 135 | 136 | return defaultResponse; 137 | }; 138 | 139 | // 导出模块 140 | export default { 141 | getIPLocation, 142 | loadConfigFromDB, 143 | }; 144 | -------------------------------------------------------------------------------- /services/logger.js: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports } from "winston"; 2 | const { combine, timestamp, printf, errors, colorize } = format; 3 | import DailyRotateFile from "winston-daily-rotate-file"; 4 | import { join } from "path"; 5 | 6 | // 获取环境变量中的日志级别和日志目录 7 | const logLevel = process.env.LOG_LEVEL || "info"; 8 | const logDirectory = process.env.LOG_DIR || "logs"; 9 | 10 | // 使用单例模式,确保只有一个logger实例 11 | let loggerInstance = null; 12 | 13 | // 自定义日志格式化方式 14 | const logFormat = printf(({ level, message, timestamp, stack }) => { 15 | // 确保 message 是一个字符串类型,如果是对象,则使用 JSON.stringify() 16 | let logMessage = `${timestamp} ${level.padEnd(7)}: ${typeof message === 'object' ? JSON.stringify(message) : message}`; 17 | 18 | // 如果存在 stack(通常是错误对象的堆栈),确保它是字符串 19 | if (stack) { 20 | logMessage += `\n${typeof stack === 'object' ? JSON.stringify(stack) : stack}`; 21 | } 22 | 23 | return logMessage; 24 | }); 25 | 26 | // 创建logger单例 27 | const createLoggerInstance = () => { 28 | if (loggerInstance) { 29 | return loggerInstance; 30 | } 31 | 32 | // 确定控制台日志级别 - 开发环境使用debug,生产环境使用配置的级别 33 | const consoleLogLevel = process.env.NODE_ENV === "development" ? "debug" : logLevel; 34 | 35 | loggerInstance = createLogger({ 36 | level: logLevel, 37 | format: combine( 38 | timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), // 自定义时间格式 39 | errors({ stack: true }), // 捕获错误堆栈信息 40 | logFormat // 自定义日志格式 41 | ), 42 | transports: [ 43 | // 控制台输出 - 根据环境配置级别 44 | new transports.Console({ 45 | level: consoleLogLevel, 46 | format: combine( 47 | colorize(), // 控制台输出颜色 48 | logFormat // 输出格式 49 | ), 50 | }), 51 | 52 | // 错误日志文件:每天生成一个错误日志文件 53 | new DailyRotateFile({ 54 | level: "error", 55 | filename: join(logDirectory, "error-%DATE%.log"), 56 | datePattern: "YYYY-MM-DD", 57 | zippedArchive: true, 58 | maxSize: "20m", 59 | maxFiles: "14d", 60 | }), 61 | 62 | // 综合日志文件:记录所有日志 63 | new DailyRotateFile({ 64 | level: logLevel, 65 | filename: join(logDirectory, "combined-%DATE%.log"), 66 | datePattern: "YYYY-MM-DD", 67 | zippedArchive: true, 68 | maxSize: "20m", 69 | maxFiles: "14d", 70 | }), 71 | ], 72 | }); 73 | 74 | return loggerInstance; 75 | }; 76 | 77 | // 导出logger单例 78 | export default createLoggerInstance(); 79 | -------------------------------------------------------------------------------- /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; -------------------------------------------------------------------------------- /services/redis.js: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | import logger from './logger.js'; 3 | import zcconfig from './config/zcconfig.js'; 4 | 5 | class RedisService { 6 | constructor() { 7 | this.client = null; 8 | this.isConnected = false; 9 | this.initConnection(); 10 | } 11 | 12 | async initConnection() { 13 | try { 14 | const host = await zcconfig.get('redis.host')||'localhost'; 15 | logger.debug(host); 16 | const port = await zcconfig.get('redis.port')||6379; 17 | logger.debug(port); 18 | const password = await zcconfig.get('redis.password')||''; 19 | logger.debug(password); 20 | const db = 0; 21 | 22 | const options = { 23 | host, 24 | port, 25 | db: parseInt(db), 26 | retryStrategy: (times) => { 27 | const delay = Math.min(times * 50, 2000); 28 | return delay; 29 | } 30 | }; 31 | 32 | if (password) { 33 | options.password = password; 34 | } 35 | 36 | this.client = new Redis(options); 37 | 38 | this.client.on('connect', () => { 39 | this.isConnected = true; 40 | logger.info('Redis连接成功'); 41 | }); 42 | 43 | this.client.on('error', (err) => { 44 | this.isConnected = false; 45 | logger.error('Redis连接错误:', err); 46 | }); 47 | 48 | this.client.on('reconnecting', () => { 49 | logger.info('正在重新连接Redis...'); 50 | }); 51 | } catch (error) { 52 | logger.error('初始化Redis连接失败:', error); 53 | } 54 | } 55 | 56 | // 设置键值,支持过期时间(秒) 57 | async set(key, value, ttlSeconds = null) { 58 | try { 59 | if (!this.client || !this.isConnected) { 60 | throw new Error('Redis未连接'); 61 | } 62 | 63 | if (typeof value !== 'string') { 64 | value = JSON.stringify(value); 65 | } 66 | 67 | if (ttlSeconds) { 68 | await this.client.setex(key, ttlSeconds, value); 69 | } else { 70 | await this.client.set(key, value); 71 | } 72 | return true; 73 | } catch (error) { 74 | logger.error(`Redis set错误 [${key}]:`, error); 75 | return false; 76 | } 77 | } 78 | 79 | // 获取键值 80 | async get(key) { 81 | try { 82 | if (!this.client || !this.isConnected) { 83 | throw new Error('Redis未连接'); 84 | } 85 | 86 | const value = await this.client.get(key); 87 | if (!value) return null; 88 | 89 | try { 90 | return JSON.parse(value); 91 | } catch (e) { 92 | return value; // 如果不是JSON则返回原始值 93 | } 94 | } catch (error) { 95 | logger.error(`Redis get错误 [${key}]:`, error); 96 | return null; 97 | } 98 | } 99 | 100 | // 删除键 101 | async delete(key) { 102 | try { 103 | if (!this.client || !this.isConnected) { 104 | throw new Error('Redis未连接'); 105 | } 106 | 107 | await this.client.del(key); 108 | return true; 109 | } catch (error) { 110 | logger.error(`Redis delete错误 [${key}]:`, error); 111 | return false; 112 | } 113 | } 114 | 115 | // 检查键是否存在 116 | async exists(key) { 117 | try { 118 | if (!this.client || !this.isConnected) { 119 | throw new Error('Redis未连接'); 120 | } 121 | 122 | const exists = await this.client.exists(key); 123 | return exists === 1; 124 | } catch (error) { 125 | logger.error(`Redis exists错误 [${key}]:`, error); 126 | return false; 127 | } 128 | } 129 | 130 | // 设置键的过期时间 131 | async expire(key, ttlSeconds) { 132 | try { 133 | if (!this.client || !this.isConnected) { 134 | throw new Error('Redis未连接'); 135 | } 136 | 137 | await this.client.expire(key, ttlSeconds); 138 | return true; 139 | } catch (error) { 140 | logger.error(`Redis expire错误 [${key}]:`, error); 141 | return false; 142 | } 143 | } 144 | 145 | // 获取键的过期时间 146 | async ttl(key) { 147 | try { 148 | if (!this.client || !this.isConnected) { 149 | throw new Error('Redis未连接'); 150 | } 151 | 152 | return await this.client.ttl(key); 153 | } catch (error) { 154 | logger.error(`Redis ttl错误 [${key}]:`, error); 155 | return -2; // -2表示键不存在 156 | } 157 | } 158 | 159 | // 递增 160 | async incr(key) { 161 | try { 162 | if (!this.client || !this.isConnected) { 163 | throw new Error('Redis未连接'); 164 | } 165 | 166 | return await this.client.incr(key); 167 | } catch (error) { 168 | logger.error(`Redis incr错误 [${key}]:`, error); 169 | return null; 170 | } 171 | } 172 | 173 | // 递增指定值 174 | async incrby(key, increment) { 175 | try { 176 | if (!this.client || !this.isConnected) { 177 | throw new Error('Redis未连接'); 178 | } 179 | 180 | return await this.client.incrby(key, increment); 181 | } catch (error) { 182 | logger.error(`Redis incrby错误 [${key}]:`, error); 183 | return null; 184 | } 185 | } 186 | } 187 | 188 | // 创建单例实例 189 | const redisClient = new RedisService(); 190 | 191 | export default redisClient; -------------------------------------------------------------------------------- /services/scheduler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileoverview 定时任务调度服务 3 | * 负责管理和执行系统中的各种定时任务 4 | */ 5 | import logger from './logger.js'; 6 | 7 | // 存储所有注册的任务 8 | const tasks = new Map(); 9 | 10 | // 存储任务执行句柄,用于停止任务 11 | const taskHandles = new Map(); 12 | 13 | /** 14 | * 任务调度器服务 15 | */ 16 | class SchedulerService { 17 | /** 18 | * 初始化调度器 19 | */ 20 | initialize() { 21 | logger.info('正在初始化调度器服务...'); 22 | 23 | // 注册默认任务 24 | this.registerDefaultTasks(); 25 | 26 | // 启动所有任务 27 | this.startAllTasks(); 28 | 29 | logger.info('调度器服务初始化完成'); 30 | 31 | return this; 32 | } 33 | 34 | /** 35 | * 注册默认任务 36 | */ 37 | registerDefaultTasks() { 38 | // 示例:注册一个每小时执行一次的清理任务 39 | this.registerTask('hourly-cleanup', { 40 | interval: 60 * 60 * 1000, // 1小时 41 | handler: async () => { 42 | try { 43 | logger.info('执行每小时清理任务'); 44 | // 实际清理逻辑 45 | } catch (error) { 46 | logger.error('每小时清理任务失败:', error); 47 | } 48 | } 49 | }); 50 | 51 | // 示例:注册一个每天执行一次的统计任务 52 | this.registerTask('daily-stats', { 53 | interval: 24 * 60 * 60 * 1000, // 24小时 54 | handler: async () => { 55 | try { 56 | logger.info('执行每日统计任务'); 57 | // 实际统计逻辑 58 | } catch (error) { 59 | logger.error('每日统计任务失败:', error); 60 | } 61 | } 62 | }); 63 | } 64 | 65 | /** 66 | * 注册一个新任务 67 | * @param {string} taskId - 任务ID 68 | * @param {Object} taskConfig - 任务配置 69 | * @param {number} taskConfig.interval - 任务执行间隔(毫秒) 70 | * @param {Function} taskConfig.handler - 任务处理函数 71 | * @param {boolean} [taskConfig.runImmediately=false] - 是否立即执行一次 72 | * @returns {boolean} 是否注册成功 73 | */ 74 | registerTask(taskId, taskConfig) { 75 | if (tasks.has(taskId)) { 76 | logger.warn(`任务 ${taskId} 已经存在,请先移除`); 77 | return false; 78 | } 79 | 80 | tasks.set(taskId, taskConfig); 81 | logger.info(`任务 ${taskId} 注册成功`); 82 | 83 | // 如果需要立即启动 84 | if (taskConfig.runImmediately) { 85 | this.startTask(taskId); 86 | } 87 | 88 | return true; 89 | } 90 | 91 | /** 92 | * 移除一个任务 93 | * @param {string} taskId - 任务ID 94 | * @returns {boolean} 是否移除成功 95 | */ 96 | removeTask(taskId) { 97 | if (!tasks.has(taskId)) { 98 | logger.warn(`任务 ${taskId} 不存在`); 99 | return false; 100 | } 101 | 102 | // 停止任务 103 | this.stopTask(taskId); 104 | 105 | // 从注册表中移除 106 | tasks.delete(taskId); 107 | logger.info(`任务 ${taskId} 已移除`); 108 | 109 | return true; 110 | } 111 | 112 | /** 113 | * 启动一个任务 114 | * @param {string} taskId - 任务ID 115 | * @returns {boolean} 是否启动成功 116 | */ 117 | startTask(taskId) { 118 | if (!tasks.has(taskId)) { 119 | logger.warn(`任务 ${taskId} 不存在`); 120 | return false; 121 | } 122 | 123 | if (taskHandles.has(taskId)) { 124 | logger.warn(`任务 ${taskId} 已经在运行`); 125 | return false; 126 | } 127 | 128 | const task = tasks.get(taskId); 129 | 130 | // 如果需要立即执行一次 131 | if (task.runImmediately) { 132 | task.handler().catch(err => logger.error(`任务 ${taskId} 立即执行失败:`, err)); 133 | } 134 | 135 | // 设置定时执行 136 | const handle = setInterval(() => { 137 | task.handler().catch(err => logger.error(`任务 ${taskId} 执行失败:`, err)); 138 | }, task.interval); 139 | 140 | // 保存任务句柄 141 | taskHandles.set(taskId, handle); 142 | logger.info(`任务 ${taskId} 已启动,间隔 ${task.interval}ms`); 143 | 144 | return true; 145 | } 146 | 147 | /** 148 | * 停止一个任务 149 | * @param {string} taskId - 任务ID 150 | * @returns {boolean} 是否停止成功 151 | */ 152 | stopTask(taskId) { 153 | if (!taskHandles.has(taskId)) { 154 | logger.warn(`任务 ${taskId} 未在运行`); 155 | return false; 156 | } 157 | 158 | // 清除定时器 159 | clearInterval(taskHandles.get(taskId)); 160 | 161 | // 从运行表中移除 162 | taskHandles.delete(taskId); 163 | logger.info(`任务 ${taskId} 已停止`); 164 | 165 | return true; 166 | } 167 | 168 | /** 169 | * 启动所有注册的任务 170 | */ 171 | startAllTasks() { 172 | logger.info('正在启动所有注册的任务...'); 173 | 174 | for (const taskId of tasks.keys()) { 175 | this.startTask(taskId); 176 | } 177 | 178 | logger.info(`已启动 ${taskHandles.size} 个任务`); 179 | } 180 | 181 | /** 182 | * 停止所有运行中的任务 183 | */ 184 | stopAllTasks() { 185 | logger.info('正在停止所有运行中的任务...'); 186 | 187 | for (const taskId of taskHandles.keys()) { 188 | this.stopTask(taskId); 189 | } 190 | 191 | logger.info('所有任务已停止'); 192 | } 193 | } 194 | 195 | // 创建单例实例 196 | const schedulerService = new SchedulerService(); 197 | 198 | export default schedulerService; -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import express from "express"; 3 | import path from "path"; 4 | import { fileURLToPath } from "url"; 5 | import logger from "../services/logger.js"; 6 | 7 | // 导入配置模块 8 | import { configureMiddleware } from "./index.js"; 9 | import { configureRoutes } from "./routes.js"; 10 | import zcconfigInstance from "../services/config/zcconfig.js"; 11 | 12 | // 导入服务 13 | import geoIpService from "../services/ip/ipLocation.js"; 14 | import schedulerService from "../services/scheduler.js"; 15 | import errorHandlerService from "../services/errorHandler.js"; 16 | 17 | // 全局初始化标志,防止重复初始化 18 | global.appInitialized = global.appInitialized || false; 19 | 20 | /** 21 | * 应用程序主类 22 | */ 23 | class Application { 24 | constructor() { 25 | this.app = express(); 26 | this._initPromise = this.configureApp(); 27 | } 28 | 29 | /** 30 | * 获取初始化完成的Promise 31 | * @returns {Promise} 初始化Promise 32 | */ 33 | get initialized() { 34 | return this._initPromise; 35 | } 36 | 37 | /** 38 | * 配置应用程序 39 | */ 40 | async configureApp() { 41 | try { 42 | logger.debug('开始配置应用程序...'); 43 | 44 | // 初始化配置并设置为全局变量 45 | await zcconfigInstance.initialize(); 46 | global.config = {}; 47 | 48 | // 设置全局配置访问器 49 | Object.defineProperty(global, 'config', { 50 | get: () => { 51 | const configs = {}; 52 | for (const [key, value] of zcconfigInstance.cache.entries()) { 53 | configs[key] = value; 54 | } 55 | return configs; 56 | }, 57 | configurable: false, 58 | enumerable: true 59 | }); 60 | 61 | // 设置全局公共配置访问器 62 | Object.defineProperty(global, 'publicconfig', { 63 | get: () => { 64 | return zcconfigInstance.getPublicConfigs(); 65 | }, 66 | configurable: false, 67 | enumerable: true 68 | }); 69 | 70 | // 配置中间件 71 | await configureMiddleware(this.app); 72 | logger.debug('中间件配置完成'); 73 | // 配置路由 74 | await configureRoutes(this.app); 75 | logger.debug('路由配置完成'); 76 | // 添加全局错误处理中间件 77 | this.app.use(errorHandlerService.createExpressErrorHandler()); 78 | logger.debug('全局错误处理中间件配置完成'); 79 | // 设置未捕获异常处理 80 | this.setupExceptionHandling(); 81 | logger.debug('未捕获异常处理配置完成'); 82 | logger.info('应用程序配置完成'); 83 | } catch (error) { 84 | logger.error('应用配置失败:', error); 85 | process.exit(1); 86 | } 87 | } 88 | 89 | /** 90 | * 设置全局异常处理 91 | */ 92 | setupExceptionHandling() { 93 | // 使用错误处理服务注册全局处理器 94 | errorHandlerService.registerGlobalHandlers(); 95 | } 96 | 97 | /** 98 | * 初始化服务 99 | */ 100 | async initializeServices() { 101 | try { 102 | // 防止重复初始化服务 103 | if (global.appInitialized) { 104 | logger.debug('服务已经初始化过,跳过重复初始化'); 105 | return; 106 | } 107 | 108 | logger.info('开始初始化服务...'); 109 | //TODO 初始化MaxMind GeoIP服务 110 | // 初始化GeoIP服务 111 | await geoIpService.loadConfigFromDB().catch(error => { 112 | logger.error('初始化MaxMind GeoIP失败:', error); 113 | }); 114 | 115 | // 初始化调度服务 116 | schedulerService.initialize(); 117 | 118 | logger.info('所有服务初始化完成'); 119 | 120 | // 标记应用已初始化 121 | global.appInitialized = true; 122 | } catch (error) { 123 | logger.error('服务初始化失败:', error); 124 | } 125 | } 126 | 127 | /** 128 | * 启动应用 129 | * @returns {express.Application} Express应用实例 130 | */ 131 | getApp() { 132 | return this.app; 133 | } 134 | } 135 | 136 | // 创建应用实例 137 | const application = new Application(); 138 | 139 | // 初始化服务 140 | Promise.all([ 141 | application.initialized, 142 | application.initializeServices() 143 | ]).catch(error => { 144 | logger.error('初始化失败:', error); 145 | }); 146 | 147 | // 导出Express应用实例 148 | export default application.getApp(); 149 | -------------------------------------------------------------------------------- /src/default_project.js: -------------------------------------------------------------------------------- 1 | const project = { 2 | scratch: "ddd5546735d2af86721d037443e4bb917040ae78f1778df6afb985957426dab7", 3 | python: "da7548a7a8cffc35d1ed73b39cf8b095b9be7bff30e74896b2288aebca20d10a", 4 | text: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 5 | }; 6 | export default project; -------------------------------------------------------------------------------- /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 | }; -------------------------------------------------------------------------------- /src/middleware.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import expressWinston from 'express-winston'; 3 | import cors from 'cors'; 4 | import bodyParser from 'body-parser'; 5 | import compress from 'compression'; 6 | import logger from '../services/logger.js'; 7 | import zcconfig from '../services/config/zcconfig.js'; 8 | 9 | /** 10 | * 配置Express应用的中间件 11 | * @param {express.Application} app Express应用实例 12 | */ 13 | export async function configureMiddleware(app) { 14 | // 日志中间件 - 只记录HTTP请求,避免重复记录应用日志 15 | app.use( 16 | expressWinston.logger({ 17 | winstonInstance: logger, 18 | meta: true, 19 | msg: "HTTP {{req.method}} {{res.statusCode}} {{res.responseTime}}ms {{req.url}} {{req.ip}}", 20 | colorize: false, 21 | ignoreRoute: (req, res) => false, 22 | level: "info", 23 | // 避免重复日志,只记录请求级别的元数据 24 | metaField: null, // 不要记录元数据的子对象 25 | expressFormat: false, // 不使用express默认格式避免重复 26 | dynamicMeta: (req, res) => { 27 | // 只记录必要的请求元数据,避免重复 28 | return { 29 | reqId: req.id, 30 | method: req.method, 31 | url: req.url 32 | }; 33 | } 34 | }) 35 | ); 36 | 37 | // CORS配置 38 | const corslist = (await zcconfig.get("cors")); 39 | const corsOptionsDelegate = (origin, callback) => { 40 | if (!origin || corslist.includes(new URL(origin).hostname)) { 41 | return callback(null, true); 42 | } else { 43 | logger.error("CORS限制,请求来源:" + origin); 44 | return callback(new Error("CORS限制,请求来源可能存在风险")); 45 | } 46 | }; 47 | 48 | app.use( 49 | cors({ 50 | credentials: true, 51 | origin: (origin, callback) => corsOptionsDelegate(origin, callback), 52 | }) 53 | ); 54 | 55 | // 请求体解析 56 | app.use(bodyParser.urlencoded({ limit: "100mb", extended: false })); 57 | app.use(bodyParser.json({ limit: "100mb" })); 58 | app.use(bodyParser.text({ limit: "100mb" })); 59 | app.use(bodyParser.raw({ limit: "100mb" })); 60 | 61 | // 压缩中间件 62 | app.use(compress()); 63 | 64 | // 认证中间件 - 使用动态导入避免循环依赖 65 | app.use(async (req, res, next) => { 66 | // 尝试从多种来源获取token: 67 | // 1. Authorization header (Bearer token) 68 | // 2. Query parameter 'token' 69 | // 3. Cookie 'token' 70 | let token = null; 71 | 72 | // 检查Authorization header 73 | const authHeader = req.headers["authorization"]; 74 | if (authHeader) { 75 | // 支持"Bearer token"格式或直接提供token 76 | const parts = authHeader.split(" "); 77 | if (parts.length === 2 && parts[0].toLowerCase() === "bearer") { 78 | token = parts[1]; 79 | } else { 80 | token = authHeader; 81 | } 82 | } 83 | 84 | // 如果header中没有token,检查query参数 85 | if (!token && req.query.token) { 86 | token = req.query.token; 87 | } 88 | 89 | // 如果query中没有token,检查cookies 90 | if (!token && req.cookies && req.cookies.token) { 91 | token = req.cookies.token; 92 | } 93 | 94 | if (!token) { 95 | // 没有令牌,继续处理请求但不设置用户信息 96 | return next(); 97 | } 98 | 99 | try { 100 | // 动态导入auth工具,避免循环依赖 101 | const authModule = await import('../services/auth/auth.js'); 102 | const authUtils = authModule.default; 103 | 104 | // 使用令牌验证系统,传递IP地址用于追踪 105 | const { valid, user, message } = await authUtils.verifyToken(token, req.ip); 106 | 107 | if (valid && user) { 108 | // 设置用户信息 109 | res.locals.userid = user.userid; 110 | res.locals.username = user.username; 111 | res.locals.display_name = user.display_name; 112 | res.locals.email = user.email; 113 | res.locals.tokenId = user.token_id; 114 | } else { 115 | logger.debug(`令牌验证失败: ${message}`); 116 | } 117 | } catch (err) { 118 | logger.error("解析令牌时出错:", err); 119 | } 120 | 121 | next(); 122 | }); 123 | } -------------------------------------------------------------------------------- /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 = path.resolve(__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 | PUBLIC_DIR: path.resolve(ROOT_DIR, 'public'), 12 | TOOLS_DIR: path.resolve(ROOT_DIR, 'tools'), 13 | }; -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import logger from '../services/logger.js'; 3 | import paths from './paths.js'; 4 | import zcconfig from '../services/config/zcconfig.js'; 5 | 6 | 7 | /** 8 | * 配置应用路由 9 | * @param {express.Application} app Express应用实例 10 | */ 11 | export async function configureRoutes(app) { 12 | // 加载配置信息到全局 13 | await zcconfig.loadConfigsFromDB(); 14 | logger.info('配置信息已加载到全局'); 15 | 16 | // 设置视图目录和引擎 17 | app.set("env", process.cwd()); 18 | app.set("data", paths.DATA_DIR); 19 | app.set("views", paths.VIEWS_DIR); 20 | app.set("view engine", "ejs"); 21 | 22 | logger.debug(paths.VIEWS_DIR); 23 | // 首页路由 24 | app.get("/", (req, res) => { 25 | res.render("index"); 26 | }); 27 | 28 | // 健康检查路由 29 | app.get("/check", (req, res) => { 30 | res.status(200).json({ 31 | message: "success", 32 | code: 200, 33 | }); 34 | }); 35 | 36 | // Scratch工具路由 37 | app.get("/scratchtool", (req, res) => { 38 | res.set("Content-Type", "application/javascript"); 39 | res.render("scratchtool"); 40 | }); 41 | 42 | // 注册业务路由 43 | await registerBusinessRoutes(app); 44 | 45 | // 404路由处理 46 | app.all("/{*path}", (req, res) => { 47 | res.status(404).json({ 48 | status: "error", 49 | code: "404", 50 | message: "找不到页面", 51 | }); 52 | }); 53 | } 54 | 55 | /** 56 | * 注册业务相关路由 57 | * @param {express.Application} app Express应用实例 58 | */ 59 | async function registerBusinessRoutes(app) { 60 | try { 61 | // 新的标准化路由注册 62 | const accountModule = await import('../routes/router_account.js'); 63 | app.use("/account", accountModule.default); 64 | 65 | const eventModule = await import('../routes/router_event.js'); 66 | app.use("/events", eventModule.default); 67 | // app.use("/users", userRoutes); 68 | 69 | // 使用新的通知路由 (获取绝对路径版本) 70 | const notificationModule = await import('../routes/router_notifications.js'); 71 | app.use("/notifications", notificationModule.default); 72 | 73 | // 以下路由暂时保持原有导入方式,等待迁移完成 74 | 75 | // 个人中心路由 76 | const myModule = await import('../routes/router_my.js'); 77 | app.use("/my", myModule.default); 78 | 79 | // 搜索API路由 80 | const searchModule = await import('../routes/router_search.js'); 81 | app.use("/searchapi", searchModule.default); 82 | 83 | // Scratch路由 84 | const scratchModule = await import('../routes/router_scratch.js'); 85 | app.use("/scratch", scratchModule.default); 86 | 87 | // API路由 88 | const apiModule = await import('../routes/router_api.js'); 89 | app.use("/api", apiModule.default); 90 | 91 | // 管理后台路由 92 | const adminModule = await import('../routes/router_admin.js'); 93 | app.use("/admin", adminModule.default); 94 | 95 | // 项目列表路由 96 | const projectlistModule = await import('../routes/router_projectlist.js'); 97 | app.use("/projectlist", projectlistModule.default); 98 | 99 | // 项目路由 100 | const projectModule = await import('../routes/router_project.js'); 101 | app.use("/project", projectModule.default); 102 | 103 | // 评论路由 104 | const commentModule = await import('../routes/router_comment.js'); 105 | app.use("/comment", commentModule.default); 106 | 107 | // 用户路由 108 | const userModule = await import('../routes/router_user.js'); 109 | app.use("/user", userModule.default); 110 | 111 | // 时间线路由 112 | const timelineModule = await import('../routes/router_timeline.js'); 113 | app.use("/timeline", timelineModule.default); 114 | 115 | // 关注路由 116 | const followsModule = await import('../routes/router_follows.js'); 117 | app.use("/follows", followsModule.default); 118 | 119 | logger.info('所有业务路由注册成功'); 120 | } catch (error) { 121 | logger.error('Error registering business routes:', error); 122 | throw error; 123 | } 124 | } -------------------------------------------------------------------------------- /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(`服务器已启动,监听 http://${this.host}:${this.port}`); 31 | resolve(this.server); 32 | }); 33 | } catch (error) { 34 | logger.error('启动服务器失败:', 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(`端口 ${this.port} 已被占用,请尝试不同端口`); 47 | } else { 48 | logger.error('服务器错误:', 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('尝试关闭未启动的服务器'); 62 | return; 63 | } 64 | 65 | return new Promise((resolve, reject) => { 66 | this.server.close((error) => { 67 | if (error) { 68 | logger.error('关闭服务器出错:', error); 69 | reject(error); 70 | } else { 71 | logger.info('服务器已优雅关闭'); 72 | resolve(); 73 | } 74 | }); 75 | }); 76 | } 77 | } 78 | 79 | // 导出配置类 80 | export default new ServerConfig(); -------------------------------------------------------------------------------- /usercontent/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroCatDev/ZeroCat/14a82aeede9e34fdbd7deb249ff54e837af131eb/usercontent/.gitkeep -------------------------------------------------------------------------------- /views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= global.config['site.name'] %> 9 | 10 | 11 | 12 | 17 | 18 | 19 | 20 | 21 | 22 | <% const config = global.publicconfig || {}; %> 23 | <% Object.entries(config).forEach(([key, value])=> { %> 24 | 26 | <% }); %> 27 | 28 | 打开网站 29 | 了解更多 30 | <% if (global.publicconfig['feedback.qq.group']) { %> 31 | href="<%= global.config['feedback.qq.link'] %>"<% } %> active 32 | rounded><%= global.config['feedback.qq.description'] %> 33 | <% } %> 34 | 35 | 36 | 37 | 38 | 39 | 40 | 43 | 44 | --------------------------------------------------------------------------------