├── .deepsource.toml ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── app.js ├── config ├── adminList.sample.yml ├── announcement.sample.md └── config.sample.yml ├── design ├── GHAuth.xd ├── refresh.svg └── skin.png ├── ecosystem.config.js ├── package.json ├── pnpm-lock.yaml ├── skin └── 9b155b4668427669ca9ed3828024531bc52fca1dcf8fbde8ccac3d9d9b53e3cf.png └── src ├── adminList.js ├── assets ├── logo.afdesign ├── logo.svg ├── logo_16.ico ├── logo_16.png └── renderimage-holder.svg ├── config.js ├── controller ├── admin │ └── user.js ├── api │ ├── captcha.js │ ├── genkey.js │ ├── ownskin.js │ ├── user.js │ └── yggdrasil.js ├── forgetpw.js ├── index.js ├── login.js ├── logout.js ├── register.js └── textures.js ├── db ├── models │ └── user.js ├── mongodb.js └── redis.js ├── index.js ├── install └── install.js ├── public ├── css │ ├── admin.css │ ├── bootstrap.min.css │ ├── notyf.css │ └── style.css ├── favicon.ico ├── images │ ├── logo.svg │ └── render │ │ ├── reading │ │ ├── background0001.webp │ │ ├── layer_illum0001.webp │ │ ├── layer_illum0002.webp │ │ ├── layer_matcolor0001.png │ │ └── layer_matcolor0002.png │ │ ├── render_data.json │ │ └── wrestler │ │ ├── background0000.webp │ │ ├── background0001.webp │ │ ├── layer_illum0001.webp │ │ ├── layer_illum0002.webp │ │ ├── layer_matcolor0001.png │ │ └── layer_matcolor0002.png └── js │ ├── admin.js │ ├── bs-custom-file-input.min.js │ ├── changepassword.js │ ├── crypto-js.js │ ├── forgetpw.js │ ├── forgetpw_change.js │ ├── login.js │ ├── main.js │ ├── main_loggedin.js │ ├── notyf.js │ ├── rawimage_control.js │ ├── register.js │ ├── rendergraph_control.js │ ├── skinview3d.bundle.js │ ├── skinviewer_control.js │ ├── uploadskin.js │ └── verify_email.js ├── routers ├── admin.js ├── api.js ├── forgetpw.js ├── index.js ├── login.js ├── logout.js ├── register.js ├── textures.js └── urls.js ├── service ├── email.js ├── forgetpw.js ├── token.js └── user.js ├── template ├── emailcheck.pug ├── forgetpw.pug ├── forgetpw_change.pug ├── index.pug ├── layout.pug ├── login.pug ├── register.pug └── widget │ └── admin.pug ├── utils.js └── utils ├── ResponseEnum.js └── print.js /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[analyzers]] 4 | name = "javascript" 5 | enabled = true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /test 2 | /public/js/bs-custom-file-input.min.js 3 | /public/js/crypto-js.js 4 | /public/js/skinview3d.bundle.js 5 | /public/js/notyf.js 6 | /libs/koa-logger/index.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | commonjs: true, 5 | es2021: true, 6 | node: true, 7 | }, 8 | globals: { 9 | CryptoJS: false, 10 | notyf: false, 11 | Notyf: false, 12 | skinview3d: false, 13 | bsCustomFileInput: false, 14 | }, 15 | plugins: [ 16 | 'pug', 17 | ], 18 | extends: [ 19 | 'prettier', 20 | ], 21 | parserOptions: { 22 | ecmaVersion: 2021, 23 | }, 24 | rules: { 25 | 'no-unused-vars': 'warn' 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.yarn/releases/** binary 2 | /.yarn/plugins/** binary -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | liberapay: daidr 4 | custom: ["https://afdian.net/@daidr"] 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug, help wanted 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/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature]" 5 | labels: enhancement, help wanted 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/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | - cron: '0 10 * * 1' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['javascript'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | 3 | /.vscode 4 | /test 5 | /config/config.yml 6 | /config/announcement.md 7 | /config/adminList.js 8 | /config/adminList.yml 9 | 10 | **/skin/* 11 | !**/skin/9b155b4668427669ca9ed3828024531bc52fca1dcf8fbde8ccac3d9d9b53e3cf.png 12 | install.lock 13 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | pnpm lint-staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | .husky 4 | public 5 | node_modules 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "none" 6 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.7.2] - 2022-06-25 11 | 12 | ### Added 13 | - 添加配置项 `ignoreEmailVerification` 来忽略邮箱验证 14 | - 一键配置增加了 `ignoreEmailVerification` 选项 15 | - 优化首页模板 16 | - 优化 Yggdrasil Meta 中的 `implementationName` 字段 17 | 18 | ### Fixed 19 | - 移动设备上 SkinViewer 的交互问题 20 | - 未设定管理员时报错的问题 21 | - 配置项 `extra.port` 未生效的问题 22 | 23 | ### Changed 24 | - 升级 SkinViewer 25 | - 更新 `README.md` 中对 NodeJS 版本号的说明 26 | 27 | ## [0.7.1] - 2022-06-17 28 | 29 | ### Added 30 | - helper 将自动生成 adminList.yml 和 announcement.yml 31 | - 未验证邮箱的用户将显示更加友好的提示 32 | 33 | ### Fixed 34 | - Redis 过期时间问题 #20 35 | 36 | ### Changed 37 | - 升级依赖 38 | 39 | ## [0.7.0] - 2022-02-08 40 | 41 | ### Added 42 | - 实现忘记密码功能 43 | 44 | ### Fixed 45 | - 修复一处css路径错误 46 | - 修复几处语法错误 47 | - 修复几处注释错误 48 | - 修复了一处异步处理的问题 49 | 50 | ## [0.6.1] - 2021-08-19 51 | 52 | ### Added 53 | - 实现了首次部署配置指引 54 | 55 | ## [0.6.0] - 2021-07-03 56 | 57 | ### Added 58 | - 实现邮箱验证功能 59 | - 配置文件增加smtp相关设置 60 | 61 | ### Changed 62 | - 升级依赖 63 | - ioredis 4.23.0 -> 4.27.5 64 | - js-yaml 4.0.0 -> 4.1.0 65 | - mongoose 5.11.19 -> 5.12.13 66 | - pm2 4.5.5 -> 5.0.4 67 | - koa-views 7.0.0 -> 7.0.1 68 | - koa-session 6.1.0 -> 6.2.0 69 | - eslint 7.23.0 -> 7.28.0 70 | - eslint-plugin-import 2.22.1 -> 2.23.4 71 | - 规范代码风格 72 | - 润色注册提示文本 73 | - *[开发]* 使用nodemon代替了supervisor 74 | 75 | ### Fixed 76 | - 修复 [#11](https://github.com/GHAuth-Team/ghauth/issues/11) 77 | 78 | ## [0.5.1] - 2021-03-07 79 | ### Added 80 | - 添加更改日志([CHANGELOG.md]) 81 | - 实现接口 `/api/yggdrasil/api/profiles/minecraft`,修复 [#10](https://github.com/GHAuth-Team/ghauth/issues/10) 82 | 83 | ### Changed 84 | - 升级依赖 85 | - ioredis 4.17.3 -> 4.23.0 86 | - js-yaml 3.14.0 -> 4.0.0 87 | - koa 2.13.0 -> 2.13.1 88 | - mongoose 5.9.25 -> 5.11.19 89 | - pm2 4.5.0 -> 4.5.5 90 | - pug 3.0.0 -> 3.0.2 91 | 92 | ## 0.5.0 - 2020-09-21 93 | ### Added 94 | - 实现基础的Yggdrasil协议 95 | 96 | [CHANGELOG.md]: /CHANGELOG.md 97 | [Unreleased]: https://github.com/GHAuth-Team/ghauth/compare/v0.7.2...main 98 | [0.5.1]: https://github.com/GHAuth-Team/ghauth/releases/tag/v0.5.1 99 | [0.6.0]: https://github.com/GHAuth-Team/ghauth/releases/tag/v0.6.0 100 | [0.6.1]: https://github.com/GHAuth-Team/ghauth/releases/tag/v0.6.1 101 | [0.7.0]: https://github.com/GHAuth-Team/ghauth/releases/tag/v0.7.0 102 | [0.7.1]: https://github.com/GHAuth-Team/ghauth/releases/tag/v0.7.1 103 | [0.7.2]: https://github.com/GHAuth-Team/ghauth/releases/tag/v0.7.2 104 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at daidr@daidr.me. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 daidr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GHAuth 2 | 3 | ![GitHub package.json version](https://img.shields.io/github/package-json/v/daidr/ghauth?style=flat-square) 4 | [![GitHub commit](https://img.shields.io/github/last-commit/daidr/ghauth?style=flat-square)](https://github.com/daidr/ghauth/commit/master) 5 | [![MIT License](https://img.shields.io/badge/license-MIT-yellowgreen.svg?style=flat-square)](https://github.com/daidr/ghauth/blob/master/LICENSE) 6 | [![GitHub issues](https://img.shields.io/github/issues/daidr/ghauth?style=flat-square)](https://github.com/daidr/ghauth/issues) 7 | 8 | 轻量的 MC 服务器 yggdrasil 验证/皮肤加载解决方案 9 | 10 | [GHAuth](https://auth.daidr.me) 11 | 12 | [更改日志](/CHANGELOG.md) 13 | 14 | ## 功能 15 | 16 | - 完整的 yggdrasil 协议支持 17 | - 完整的皮肤管理 18 | - 简易的用户管理 19 | - 邮箱验证 20 | 21 | ## 暂未实现 22 | 23 | - 站点的可视化设置 24 | - 玩家名称修改 25 | - 修改邮箱功能 26 | - FIDO 支持 27 | 28 | ## 环境 29 | 30 | - MongoDB 31 | - Redis 32 | - NodeJS v16+ 33 | - pnpm (请不要使用另外的包管理器 npm/cnpm/yarn) 34 | 35 | ## 部署(辅助配置) 36 | 37 | - 克隆本仓库 `git clone https://github.com/GHAuth-Team/ghauth.git` 38 | - 安装依赖 `pnpm install` 39 | - 启动配置程序 `pnpm helper` 40 | - 根据提示完成基础配置 41 | - 进入 `config` 目录 42 | - 修改 `config.yml` (配置项注释可参考`config.sample.yml`)以完成高级配置(页脚配置、邮件服务器配置、资源可信域配置) 43 | - [可选] 进入 `adminList.yml` 以添加更多管理员 44 | - 启动 `pnpm start` 45 | 46 | ## 部署(手动配置) 47 | 48 | - 克隆本仓库 `git clone https://github.com/GHAuth-Team/ghauth.git` 49 | - 安装依赖 `pnpm install` 50 | - 进入 `config` 目录 51 | - 复制一份 `config.sample.yml` 并将其重命名为 `config.yml` 52 | - 修改 `config.yml` 以完成站点配置 53 | - 复制一份 `adminList.sample.yml` 并将其重命名为 `adminList.yml` 54 | - 修改 `adminList.yml` 以定义管理员邮箱列表 55 | - 启动 `pnpm start` 56 | 57 | ## 常用命令 58 | 59 | - 启动: `pnpm start` 60 | - 停止: `pnpm stop` 61 | - 重启: `pnpm restart` 62 | - 查看日志: `pnpm logs` 63 | - 实时监控: `pnpm monit` 64 | 65 | ## 关于管理权限 66 | 67 | - 具有管理权限的账号由 `config/adminList.yml` 控制 68 | - 用户管理 Widget 会对拥有管理权限的用户显示 69 | 70 | ## 站点公告 71 | 72 | - 站点公告默认**关闭** 73 | - 进入 `config` 目录 74 | - 复制一份 `announcement.sample.md` 并将其重命名为 `announcement.md` 75 | - 修改 `announcement.md` 76 | - 修改 `config.yml` 内 `common` 配置节点的 `showAnnouncement` 配置项为 `true` 来启用公告 77 | - 支持 Markdown 语法 78 | 79 | ## NodeJS 版本兼容 80 | 81 | - 建议 NodeJS 版本 >= v16.0.0 82 | - 倘若系统 OpenSSL 版本 >= v3.0.0, 则要求 NodeJS 版本 >= v18.0.0 83 | 84 | ## 版本差异 85 | 86 | GHAuth v0.7.2 版本之后(不包含 v0.7.2),将使用 pnpm 进行依赖管理。 87 | 88 | ## 建议 89 | 90 | - 建议使用 nginx 等类似服务器代理程序代理 public 目录,减轻后端压力 91 | 92 | ## 安全警告 93 | 94 | - yggdrasil 验证时明文传递密码(协议限制),你需要启用 https 以提升安全性 95 | 96 | ## 生成签名验证密钥 97 | 98 | - 从 `0.6.1` 版本开始辅助配置程序能够自动生成密钥。倘若需要手动配置 rsa 公私钥,可以参考下面的内容。 99 | 100 | > 以下内容引用自[https://github.com/yushijinhun/authlib-injector/wiki/签名密钥对#密钥对的生成和处理](https://github.com/yushijinhun/authlib-injector/wiki/%E7%AD%BE%E5%90%8D%E5%AF%86%E9%92%A5%E5%AF%B9#%E5%AF%86%E9%92%A5%E5%AF%B9%E7%9A%84%E7%94%9F%E6%88%90%E5%92%8C%E5%A4%84%E7%90%86) 101 | 102 | > 开始引用 103 | 104 | ### 密钥对的生成和处理 105 | 106 | 下面对 OpenSSL 的调用都是使用标准输入和标准输出进行输入输出的。 107 | 如果要使用文件,可使用参数 `-in ` 和 `-out `。 108 | 109 | #### 生成私钥 110 | 111 | 密钥算法为 RSA,推荐长度为 4096 位。 112 | 113 | ``` 114 | openssl genrsa 4096 115 | ``` 116 | 117 | 生成的私钥将输出到标准输出。 118 | 119 | #### 从私钥生成公钥 120 | 121 | ``` 122 | openssl rsa -pubout 123 | ``` 124 | 125 | 私钥从标准输入读入,公钥将输出到标准输出。 126 | 127 | > 结束引用 128 | 129 | ## 交流 130 | 131 | Q 群:850093647(同时也是“基佬之家”基友服交流群) 132 | 133 | ## 协议 134 | 135 | MIT Licence 136 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | require('./src/index.js') 2 | -------------------------------------------------------------------------------- /config/adminList.sample.yml: -------------------------------------------------------------------------------- 1 | - daidr@daidr.me 2 | - example@example.com 3 | 4 | # 拥有管理权限的账号邮箱列表 -------------------------------------------------------------------------------- /config/announcement.sample.md: -------------------------------------------------------------------------------- 1 | ## 这是一个测试公告 2 | 3 | GHAuth活跃开发中,遇到问题可以向戴兜反馈。 -------------------------------------------------------------------------------- /config/config.sample.yml: -------------------------------------------------------------------------------- 1 | common: 2 | #站点名称 3 | sitename: GHAuth 4 | 5 | #站点描述 6 | description: 轻量的MC服务器yggdrasil验证 7 | 8 | #是否显示公告 9 | #公告文件请编辑config/announcement.md 10 | #支持markdown语法 11 | showAnnouncement: false 12 | 13 | #是否忽略用户邮箱验证 14 | ignoreEmailVerification: false 15 | 16 | #站点链接,主要用于Yggdrasil认证(末尾不加/) 17 | url: "http://example.com" 18 | 19 | #页脚相关设置 20 | footer: 21 | copyright: Powered By DaiDR. 22 | 23 | #页脚链接 24 | links: 25 | - title: GHAuth 26 | link: "https://auth.daidr.me" 27 | target: _self 28 | 29 | - title: Github 30 | link: "https://github.com/GHAuth-Team/ghauth" 31 | target: _blank 32 | 33 | extra: 34 | #监听端口(默认3000) 35 | port: 3000 36 | 37 | #密码加盐,使用前务必修改为随机字串 38 | slat: aQ7LU7stHKd0GaYfL64JRpH791keAumQTFCXE0 39 | 40 | #mongodb数据库相关设置 41 | mongodb: 42 | host: 127.0.0.1 43 | port: 27017 44 | db: ghauth 45 | hasAuth: false 46 | username: "" 47 | password: "" 48 | 49 | #redis数据库相关设置 50 | redis: 51 | host: 127.0.0.1 52 | port: 6379 53 | #储存session使用的数据库 54 | sessiondb: 1 55 | #储存入服验证请求使用的数据库 56 | authdb: 1 57 | 58 | #会话密钥,使用前务必修改为随机字串 59 | session: 60 | key: V7tf9zzIxryQfRSDEpL8pboBmbx340pfJOoSwswK 61 | 62 | #签名所需要的密钥字串,生成方式请阅读README 63 | signature: 64 | private: |- 65 | -----BEGIN RSA PRIVATE KEY----- 66 | 假装是私钥 67 | -----END RSA PRIVATE KEY----- 68 | public: |- 69 | -----BEGIN PUBLIC KEY----- 70 | 假装是公钥 71 | -----END PUBLIC KEY----- 72 | 73 | #资源可信域列表,删除样例域名,然后将你网站的域名加入到下面列表中 74 | #游戏会拒绝非可信域的材质加载 75 | #加句点.能够使用泛域(如.example2.com) 76 | skinDomains: 77 | - auth.daidr.me 78 | - example.com 79 | - .example2.com 80 | 81 | #用于发送验证邮件的smtp服务器配置 82 | smtp: 83 | host: "example.com" 84 | port: 465 85 | secure: true 86 | auth: 87 | user: "example@example.com" 88 | pass: "1234567" 89 | -------------------------------------------------------------------------------- /design/GHAuth.xd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/design/GHAuth.xd -------------------------------------------------------------------------------- /design/refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /design/skin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/design/skin.png -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'ghauth', 5 | script: 'app.js', 6 | instances: 2, 7 | exec_mode: 'cluster', 8 | env: { 9 | NODE_ENV: 'production' 10 | } 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ghauth", 3 | "version": "0.7.2", 4 | "description": "轻量的MC服务器yggdrasil验证", 5 | "main": "app.js", 6 | "author": "戴兜(daidr@daidr.me)", 7 | "license": "MIT", 8 | "scripts": { 9 | "start": "pm2 start ecosystem.config.js", 10 | "restart": "pm2 restart ecosystem.config.js", 11 | "reload": "pm2 reload ecosystem.config.js", 12 | "stop": "pm2 stop ecosystem.config.js", 13 | "logs": "pm2 logs ghauth", 14 | "monit": "pm2 monit ecosystem.config.js", 15 | "helper": "node ./src/install/install.js", 16 | "dev": "nodemon -i ./test/", 17 | "prepare": "husky install", 18 | "lint": "eslint ./ --fix", 19 | "format": "prettier . --write" 20 | }, 21 | "dependencies": { 22 | "colors": "^1.4.0", 23 | "crypto-js": "^4.1.1", 24 | "global": "^4.4.0", 25 | "inquirer": "8.0.0", 26 | "ioredis": "^5.2.3", 27 | "js-yaml": "^4.1.0", 28 | "jstransformer-markdown-it": "^3.0.0", 29 | "koa": "^2.13.4", 30 | "koa-bodyparser": "^4.3.0", 31 | "koa-logger": "^3.2.1", 32 | "koa-redis": "^4.0.1", 33 | "koa-router": "^12.0.0", 34 | "koa-session": "^6.2.0", 35 | "koa-static-cache": "^5.1.4", 36 | "koa-views": "^8.0.0", 37 | "koa2-ratelimit": "^1.1.2", 38 | "mongoose": "^6.5.3", 39 | "node-rsa": "^1.1.1", 40 | "nodemailer": "^6.7.8", 41 | "pm2": "^5.2.0", 42 | "pngjs-nozlib": "^1.0.0", 43 | "pug": "^3.0.2", 44 | "svg-captcha": "^1.4.0", 45 | "uuid": "^8.3.2" 46 | }, 47 | "devDependencies": { 48 | "eslint": "^8.23.0", 49 | "eslint-config-prettier": "^8.5.0", 50 | "eslint-plugin-import": "^2.26.0", 51 | "eslint-plugin-pug": "^1.2.4", 52 | "husky": "^8.0.1", 53 | "lint-staged": "^13.0.3", 54 | "nodemon": "^2.0.19", 55 | "prettier": "^2.7.1" 56 | }, 57 | "lint-staged": { 58 | "**/*": "prettier --write --ignore-unknown" 59 | }, 60 | "nodemonConfig": { 61 | "restartable": "rs", 62 | "ignore": [ 63 | ".git", 64 | ".svn", 65 | "node_modules/**/node_modules", 66 | "public" 67 | ], 68 | "verbose": true, 69 | "watch": [], 70 | "env": { 71 | "NODE_ENV": "development" 72 | }, 73 | "script": "app.js", 74 | "ext": "js json" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /skin/9b155b4668427669ca9ed3828024531bc52fca1dcf8fbde8ccac3d9d9b53e3cf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/skin/9b155b4668427669ca9ed3828024531bc52fca1dcf8fbde8ccac3d9d9b53e3cf.png -------------------------------------------------------------------------------- /src/adminList.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const yaml = require('js-yaml') 3 | 4 | try { 5 | const configFile = fs.readFileSync('./config/adminList.yml', 'utf8') 6 | const config = yaml.load(configFile) || [] 7 | module.exports = config 8 | } catch (e) { 9 | throw new Error('AdminList file not found.') 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/logo.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/src/assets/logo.afdesign -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/logo_16.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/src/assets/logo_16.ico -------------------------------------------------------------------------------- /src/assets/logo_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/src/assets/logo_16.png -------------------------------------------------------------------------------- /src/assets/renderimage-holder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const yaml = require('js-yaml') 3 | 4 | try { 5 | const configFile = fs.readFileSync('./config/config.yml', 'utf8') 6 | const config = yaml.load(configFile) 7 | module.exports = config 8 | } catch (e) { 9 | console.error(e) 10 | throw new Error('Configuration file not found.') 11 | } 12 | -------------------------------------------------------------------------------- /src/controller/admin/user.js: -------------------------------------------------------------------------------- 1 | /* controller/user.js */ 2 | 3 | const Uuid = require('uuid') 4 | const User = require('../../service/user') 5 | const { GHAuthResponse } = require('../../utils/ResponseEnum') 6 | 7 | module.exports = { 8 | getUserList: async (ctx) => { 9 | const userData = await User.getUserInfo(ctx).then((ret) => ret) 10 | if (!userData.isAdmin) { 11 | // 非法访问 12 | GHAuthResponse(ctx).forbidden('No permissions.') 13 | return 14 | } 15 | const currectPage = Number.parseInt(ctx.request.query.currectPage, 10) || 1 16 | const pageSize = Number.parseInt(ctx.request.query.pageSize, 10) || 5 17 | let { filter } = ctx.request.query 18 | if (filter) { 19 | filter = { playername: { $regex: new RegExp(filter.toString()) } } 20 | } 21 | const userList = await User.genUserList(filter, currectPage, pageSize).then((ret) => ret) 22 | GHAuthResponse(ctx).success(userList) 23 | }, 24 | switchUserStatus: async (ctx) => { 25 | const userData = await User.getUserInfo(ctx).then((ret) => ret) 26 | if (!userData.isAdmin) { 27 | // 非法访问 28 | GHAuthResponse(ctx).forbidden('No permissions.') 29 | return 30 | } 31 | const { uuid } = ctx.request.query 32 | if (!Uuid.validate(uuid)) { 33 | // uuid不合法 34 | GHAuthResponse(ctx).forbidden('Invalid uuid.') 35 | return 36 | } 37 | const result = await User.switchUserStatusByUUID(uuid).then((ret) => ret) 38 | GHAuthResponse(ctx).success(result) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/controller/api/captcha.js: -------------------------------------------------------------------------------- 1 | /* controller/captcha.js */ 2 | 3 | const svgCaptcha = require('svg-captcha') 4 | 5 | const randomInt = (min, max) => { 6 | const random = max - min + 1 7 | return Math.floor(Math.random() * random + min) 8 | } 9 | 10 | module.exports = { 11 | captcha: async (ctx) => { 12 | const captcha = svgCaptcha.createMathExpr({ 13 | inverse: false, 14 | fontSize: randomInt(35, 50), 15 | noise: randomInt(2, 4), 16 | mathMin: 10, 17 | mathMax: 30, 18 | mathOperator: '+' 19 | }) 20 | ctx.session.captcha = {} 21 | ctx.session.captcha.ts = Date.now() 22 | ctx.session.captcha.text = captcha.text.toLocaleLowerCase() 23 | ctx.set('Content-Type', 'image/svg+xml') 24 | ctx.body = captcha.data 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/controller/api/genkey.js: -------------------------------------------------------------------------------- 1 | /* controller/genkey.js */ 2 | const getRandomHex = (len = 32) => { 3 | const chars = 'abcdef0123456789' 4 | const maxPos = chars.length 5 | let pwd = '' 6 | for (let i = 0; i < len; i += 1) { 7 | pwd += chars.charAt(Math.floor(Math.random() * maxPos)) 8 | } 9 | return pwd 10 | } 11 | 12 | const hexToChar = (hex) => { 13 | let str = '' 14 | for (let i = 0; i < hex.length; i += 2) { 15 | str += String.fromCharCode(parseInt(hex.substr(i, 2), 16)) 16 | } 17 | return str 18 | } 19 | 20 | module.exports = { 21 | genkey: async (ctx) => { 22 | let secret = getRandomHex(32) 23 | let iv = getRandomHex(32) 24 | ctx.session.key = {} 25 | ctx.session.key.ts = Date.now() 26 | ctx.session.key.secret = secret 27 | ctx.session.key.iv = iv 28 | let data = '' 29 | for (let i = 0; i < secret.length; i += 1) { 30 | data += secret[i] + iv[i] 31 | } 32 | ctx.set('Content-Type', 'text/plain') 33 | ctx.body = hexToChar(data) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/controller/api/ownskin.js: -------------------------------------------------------------------------------- 1 | /* controller/ownskin.js */ 2 | const path = require('path') 3 | const fs = require('fs') 4 | const User = require('../../service/user') 5 | const utils = require('../../utils') 6 | 7 | module.exports = { 8 | ownskin: async (ctx) => { 9 | const data = {} 10 | const userInfo = await User.getUserInfo(ctx).then((ret) => ret) 11 | if (userInfo.isLoggedIn) { 12 | const result = await User.getUserSkin(userInfo.email).then((ret) => ret) 13 | const skinPath = path.join(utils.getRootPath(), '../skin') 14 | result.skin = fs.readFileSync(path.join(skinPath, result.skin)) 15 | result.skin = Buffer.from(result.skin).toString('base64') 16 | result.skin = `data:image/png;base64,${result.skin}` 17 | data.data = result 18 | data.code = 1000 19 | } else { 20 | data.code = -1 21 | data.msg = '你还没有登录' 22 | } 23 | ctx.set('Content-Type', 'application/json') 24 | ctx.body = JSON.stringify(data) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/controller/api/user.js: -------------------------------------------------------------------------------- 1 | /* controller/user.js */ 2 | const CryptoJS = require('crypto-js') 3 | const config = require('../../config') 4 | const User = require('../../service/user') 5 | const Email = require('../../service/email') 6 | const utils = require('../../utils') 7 | 8 | module.exports = { 9 | changepassword: async (ctx) => { 10 | const data = {} 11 | const time = Date.now() 12 | let body = {} 13 | const userData = await User.getUserInfo(ctx).then((ret) => ret) 14 | if (!userData.isLoggedIn) { 15 | data.code = -1 16 | data.msg = '你未登录' 17 | ctx.set('Content-Type', 'application/json') 18 | ctx.body = JSON.stringify(data) 19 | return 20 | } 21 | if (!ctx.request.body.data) { 22 | data.code = -1 23 | data.msg = '解析数据发生错误' 24 | ctx.set('Content-Type', 'application/json') 25 | ctx.body = JSON.stringify(data) 26 | return 27 | } 28 | if ( 29 | !ctx.session.key || 30 | !ctx.session.key.secret || 31 | !ctx.session.key.iv || 32 | !ctx.session.key.ts || 33 | time - ctx.session.key.ts > 300000 34 | ) { 35 | data.code = -1 36 | data.msg = '传输凭证无效' 37 | ctx.set('Content-Type', 'application/json') 38 | ctx.body = JSON.stringify(data) 39 | return 40 | } 41 | body = ctx.request.body.data 42 | const secret = CryptoJS.enc.Hex.parse(ctx.session.key.secret) 43 | const iv = CryptoJS.enc.Hex.parse(ctx.session.key.iv) 44 | try { 45 | body = JSON.parse( 46 | CryptoJS.AES.decrypt(body, secret, { iv, padding: CryptoJS.pad.ZeroPadding }).toString(CryptoJS.enc.Utf8) 47 | ) 48 | } catch (error) { 49 | data.code = -1 50 | data.msg = '解密数据发生错误' 51 | ctx.set('Content-Type', 'application/json') 52 | ctx.body = JSON.stringify(data) 53 | return 54 | } 55 | 56 | if (!body.oldPassword || !body.newPassword) { 57 | data.code = -1 58 | data.msg = '旧密码/新密码不能为空' 59 | ctx.set('Content-Type', 'application/json') 60 | ctx.body = JSON.stringify(data) 61 | return 62 | } 63 | 64 | // 检查旧密码是否正确 65 | const pwCorrect = await User.isUserPasswordCorrect( 66 | userData.email, 67 | CryptoJS.HmacSHA256(body.oldPassword, config.extra.slat).toString() 68 | ).then((ret) => ret) 69 | if (!pwCorrect) { 70 | data.code = -1 71 | data.msg = '旧密码不正确' 72 | ctx.set('Content-Type', 'application/json') 73 | ctx.body = JSON.stringify(data) 74 | return 75 | } 76 | 77 | // 修改用户密码 78 | const result = await User.changeUserPassword(userData.email, body.newPassword).then((ret) => ret) 79 | 80 | if (!result) { 81 | data.code = -1 82 | data.msg = '未知错误' 83 | ctx.set('Content-Type', 'application/json') 84 | ctx.body = JSON.stringify(data) 85 | return 86 | } 87 | 88 | data.code = 1000 89 | data.msg = '密码修改成功' 90 | ctx.set('Content-Type', 'application/json') 91 | ctx.body = JSON.stringify(data) 92 | }, 93 | uploadskin: async (ctx) => { 94 | const data = {} 95 | const time = Date.now() 96 | let body = {} 97 | const userData = await User.getUserInfo(ctx).then((ret) => ret) 98 | if (!userData.isLoggedIn) { 99 | data.code = -1 100 | data.msg = '你未登录' 101 | ctx.set('Content-Type', 'application/json') 102 | ctx.body = JSON.stringify(data) 103 | return 104 | } 105 | if (!config.common.ignoreEmailVerification) { 106 | if (!userData.verified) { 107 | data.code = -1 108 | data.msg = '邮箱未验证,无法上传皮肤' 109 | ctx.set('Content-Type', 'application/json') 110 | ctx.body = JSON.stringify(data) 111 | return 112 | } 113 | } 114 | if (!ctx.request.body.data) { 115 | data.code = -1 116 | data.msg = '解析数据发生错误' 117 | ctx.set('Content-Type', 'application/json') 118 | ctx.body = JSON.stringify(data) 119 | return 120 | } 121 | if ( 122 | !ctx.session.key || 123 | !ctx.session.key.secret || 124 | !ctx.session.key.iv || 125 | !ctx.session.key.ts || 126 | time - ctx.session.key.ts > 300000 127 | ) { 128 | data.code = -1 129 | data.msg = '传输凭证无效' 130 | ctx.set('Content-Type', 'application/json') 131 | ctx.body = JSON.stringify(data) 132 | return 133 | } 134 | body = ctx.request.body.data 135 | const secret = CryptoJS.enc.Hex.parse(ctx.session.key.secret) 136 | const iv = CryptoJS.enc.Hex.parse(ctx.session.key.iv) 137 | try { 138 | body = JSON.parse( 139 | CryptoJS.AES.decrypt(body, secret, { iv, padding: CryptoJS.pad.ZeroPadding }).toString(CryptoJS.enc.Utf8) 140 | ) 141 | } catch (error) { 142 | data.code = -1 143 | data.msg = '解密数据发生错误' 144 | ctx.set('Content-Type', 'application/json') 145 | ctx.body = JSON.stringify(data) 146 | return 147 | } 148 | 149 | if (!Object.prototype.hasOwnProperty.call(body, 'type') || !body.skin) { 150 | data.code = -1 151 | data.msg = '参数错误' 152 | ctx.set('Content-Type', 'application/json') 153 | ctx.body = JSON.stringify(data) 154 | return 155 | } 156 | 157 | if (body.type !== 0 && body.type !== 1) { 158 | data.code = -1 159 | data.msg = '皮肤模型选择错误' 160 | ctx.set('Content-Type', 'application/json') 161 | ctx.body = JSON.stringify(data) 162 | return 163 | } 164 | 165 | // 处理皮肤图片 166 | const skinData = utils.handleSkinImage(Buffer.from(body.skin, 'base64')) 167 | 168 | if (!skinData) { 169 | data.code = -1 170 | data.msg = '服务器无法处理你的皮肤' 171 | ctx.set('Content-Type', 'application/json') 172 | ctx.body = JSON.stringify(data) 173 | return 174 | } 175 | 176 | // 修改用户皮肤 177 | const result = await User.changeUserSkinByEmail(userData.email, body.type, skinData).then((ret) => ret) 178 | 179 | if (!result) { 180 | data.code = -1 181 | data.msg = '未知错误' 182 | ctx.set('Content-Type', 'application/json') 183 | ctx.body = JSON.stringify(data) 184 | return 185 | } 186 | 187 | data.code = 1000 188 | data.msg = '皮肤修改成功' 189 | ctx.set('Content-Type', 'application/json') 190 | ctx.body = JSON.stringify(data) 191 | }, 192 | sendverifyemail: async (ctx) => { 193 | const data = {} 194 | const userData = await User.getUserInfo(ctx).then((ret) => ret) 195 | 196 | // 未登录账号 197 | if (!userData.isLoggedIn) { 198 | data.code = -1 199 | data.msg = '你未登录' 200 | ctx.set('Content-Type', 'application/json') 201 | ctx.body = JSON.stringify(data) 202 | return 203 | } 204 | 205 | // 账号已经通过验证 206 | if (userData.verified) { 207 | data.code = -1 208 | data.msg = '无需重复验证' 209 | ctx.set('Content-Type', 'application/json') 210 | ctx.body = JSON.stringify(data) 211 | return 212 | } 213 | 214 | const isVerifyTokenExists = await Email.isVerifyTokenExists(userData.id).then((ret) => ret) 215 | 216 | // 账号的验证token还未过期 217 | if (isVerifyTokenExists) { 218 | const verifyTokenTime = await Email.getVerifyTokenTime(userData.id).then((ret) => ret) 219 | if (!verifyTokenTime) { 220 | data.code = -1 221 | data.msg = '未知错误,请稍后重试' 222 | ctx.set('Content-Type', 'application/json') 223 | ctx.body = JSON.stringify(data) 224 | return 225 | } 226 | const timeNow = Math.floor(new Date().getTime() / 1000) 227 | data.code = -1 228 | data.msg = `约${Math.floor((verifyTokenTime - timeNow) / 60)}分钟后才能再次发送验证邮件` 229 | ctx.set('Content-Type', 'application/json') 230 | ctx.body = JSON.stringify(data) 231 | return 232 | } 233 | 234 | const token = Email.genVerifyToken() 235 | await Email.storeVerifyTokenToRedis(userData.id, token).then((ret) => ret) 236 | 237 | const result = await Email.sendVerifyUrl(userData.email, userData.playername, userData.id, token).then((ret) => ret) 238 | 239 | if (!result) { 240 | data.code = -1 241 | data.msg = '未知错误' 242 | ctx.set('Content-Type', 'application/json') 243 | ctx.body = JSON.stringify(data) 244 | return 245 | } 246 | 247 | data.code = 1000 248 | data.msg = '邮件已发送,请查看您的收件箱' 249 | ctx.set('Content-Type', 'application/json') 250 | ctx.body = JSON.stringify(data) 251 | }, 252 | emailcheck: async (ctx) => { 253 | const userData = await User.getUserInfo(ctx).then((ret) => ret) 254 | const playerId = ctx.params.id 255 | const { token } = ctx.params 256 | const result = await Email.isVerifyTokenCorrect(playerId, token).then((ret) => ret) 257 | let isCorrect = false 258 | if (result) { 259 | isCorrect = true 260 | await Email.delVerifyToken(playerId) 261 | await User.changeUserVerifiedStatusById(playerId, true) 262 | } 263 | 264 | await ctx.render('emailcheck', { 265 | config: config.common, 266 | user: userData, 267 | isCorrect 268 | }) 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /src/controller/api/yggdrasil.js: -------------------------------------------------------------------------------- 1 | /* controller/yggdrasil.js */ 2 | 3 | const CryptoJS = require('crypto-js') 4 | const config = require('../../config') 5 | const pkg = require('../../../package.json') 6 | const utils = require('../../utils') 7 | const User = require('../../service/user') 8 | const stoken = require('../../service/token') 9 | const { YggdrasilResponse } = require('../../utils/ResponseEnum') 10 | 11 | module.exports = { 12 | yggdrasil: async (ctx) => { 13 | ctx.set('Content-Type', 'application/json') 14 | const data = { 15 | meta: { 16 | implementationName: `${config.common.sitename}(GHAuth Yggdrasil)`, // Yggdrasil协议名称 17 | implementationVersion: pkg.version, // Yggdrasil协议版本 18 | serverName: config.common.sitename, // Yggdrasil认证站点名称 19 | links: { 20 | homepage: config.common.url, 21 | register: `${config.common.url}/register` 22 | } 23 | }, 24 | skinDomains: config.extra.skinDomains, // 可信域(皮肤加载所信任的域名) 25 | signaturePublickey: config.extra.signature.public // 签名公钥 26 | } 27 | YggdrasilResponse(ctx).success(data) 28 | }, 29 | api: { 30 | profiles: { 31 | minecraft: async (ctx) => { 32 | const data = ctx.request.body 33 | 34 | // 校验是否传入了数组 35 | if (!(data instanceof Array)) { 36 | YggdrasilResponse(ctx).success([]) 37 | return 38 | } 39 | 40 | // 去除重复及无效数据 41 | data.filter((item, index, arr) => arr.indexOf(item, 0) === index && typeof item === 'string') 42 | 43 | // 一次最多查询3个玩家的数据 44 | if (data.length >= 3) { 45 | YggdrasilResponse(ctx).success([]) 46 | return 47 | } 48 | 49 | const playerList = [] 50 | 51 | // 遍历玩家列表 52 | for (let i = 0; i < data.length; i += 1) { 53 | // 搜索玩家信息 54 | const userData = await User.searchUserInfoByPlayerName(data[i]).then((result) => result) 55 | 56 | // 如果存在则生成玩家Profile,并加入到playerList 57 | // 无需提供详细角色属性,第二个参数设置为false 58 | if (userData) { 59 | playerList.push(User.genUserProfile(userData, false)) 60 | } 61 | } 62 | 63 | // 返回数据 64 | YggdrasilResponse(ctx).success(playerList) 65 | } 66 | } 67 | }, 68 | authserver: { 69 | authenticate: async (ctx) => { 70 | const data = ctx.request.body 71 | 72 | // 用户名/密码不存在,返回403 73 | if (!data.username || !data.password) { 74 | YggdrasilResponse(ctx).invalidCredentials() 75 | return 76 | } 77 | const { username } = data 78 | let { password } = data 79 | const clientToken = data.clientToken || utils.genUUID().replace(/-/g, '') 80 | const requestUser = data.requestUser || false 81 | 82 | // 密码预处理 83 | password += 'dKfkZh' 84 | password = CryptoJS.SHA3(password) 85 | password = password.toString(CryptoJS.enc.Hex) 86 | password = CryptoJS.HmacSHA256(password, config.extra.slat).toString() 87 | 88 | // 查找用户信息 89 | const userData = await User.searchUserInfoByEmail(username).then((result) => result) 90 | 91 | // 未找到用户 92 | if (!userData) { 93 | YggdrasilResponse(ctx).invalidCredentials() 94 | return 95 | } 96 | 97 | // 用户被封禁 98 | if (userData.isBanned) { 99 | YggdrasilResponse(ctx).invalidCredentials() 100 | return 101 | } 102 | 103 | // 用户密码不正确 104 | if (userData.password !== password) { 105 | YggdrasilResponse(ctx).invalidCredentials() 106 | return 107 | } 108 | 109 | // 生成accessToken 110 | const accessToken = await stoken.genAccessToken(username, clientToken).then((result) => result) 111 | const profileData = { 112 | id: userData.uuid.replace(/-/g, ''), 113 | name: userData.playername 114 | } 115 | const responseData = { 116 | accessToken, 117 | clientToken, 118 | availableProfiles: [profileData], 119 | selectedProfile: profileData 120 | } 121 | if (requestUser) { 122 | responseData.user = { 123 | id: userData.uuid.replace(/-/g, '') 124 | } 125 | } 126 | YggdrasilResponse(ctx).success(responseData) 127 | }, 128 | refresh: async (ctx) => { 129 | const data = ctx.request.body 130 | // 属性accessToken不存在,返回403 131 | if (!data.accessToken) { 132 | YggdrasilResponse(ctx).invalidToken() 133 | return 134 | } 135 | 136 | const { accessToken } = data 137 | const { clientToken } = data 138 | const requestUser = data.requestUser || false 139 | 140 | // 刷新令牌 141 | const result = await stoken.refreshAccessToken(accessToken, clientToken).then((ret) => ret) 142 | 143 | // 刷新操作失败 144 | if (!result) { 145 | YggdrasilResponse(ctx).invalidToken() 146 | return 147 | } 148 | 149 | const profileData = { 150 | id: result.uuid.replace(/-/g, ''), 151 | name: result.playername 152 | } 153 | 154 | const responseData = { 155 | accessToken: result.accessToken, 156 | clientToken: result.clientToken, 157 | selectedProfile: profileData 158 | } 159 | if (requestUser) { 160 | responseData.user = { 161 | id: result.uuid.replace(/-/g, '') 162 | } 163 | } 164 | YggdrasilResponse(ctx).success(responseData) 165 | }, 166 | validate: async (ctx) => { 167 | const data = ctx.request.body 168 | 169 | // 属性accessToken不存在 170 | if (!data.accessToken) { 171 | YggdrasilResponse(ctx).invalidToken() 172 | return 173 | } 174 | 175 | const { accessToken } = data 176 | const { clientToken } = data 177 | 178 | // 验证令牌 179 | const result = await stoken.validateAccessToken(accessToken, clientToken).then((ret) => ret) 180 | 181 | // 验证失败或令牌无效 182 | if (!result) { 183 | YggdrasilResponse(ctx).invalidToken() 184 | return 185 | } 186 | 187 | // 令牌有效,返回204 188 | YggdrasilResponse(ctx).noContent() 189 | }, 190 | invalidate: async (ctx) => { 191 | const data = ctx.request.body 192 | 193 | if (data.accessToken) { 194 | await stoken.invalidateAccessToken(data.accessToken) 195 | } 196 | 197 | // 吊销令牌(无论如何都返回204) 198 | YggdrasilResponse(ctx).noContent() 199 | }, 200 | signout: async (ctx) => { 201 | const data = ctx.request.body 202 | if (!data.username || !data.password) { 203 | YggdrasilResponse(ctx).invalidCredentials() 204 | return 205 | } 206 | const { username } = data 207 | let { password } = data 208 | 209 | // 密码预处理 210 | password += 'dKfkZh' 211 | password = CryptoJS.SHA3(password) 212 | password = password.toString(CryptoJS.enc.Hex) 213 | password = CryptoJS.HmacSHA256(password, config.extra.slat).toString() 214 | 215 | // 删除用户所有token 216 | const result = await stoken.invalidateAllAccessToken(username, password).then((ret) => ret) 217 | 218 | // 操作失败,返回403 219 | if (!result) { 220 | YggdrasilResponse(ctx).invalidCredentials() 221 | return 222 | } 223 | 224 | // 操作成功,返回204 225 | YggdrasilResponse(ctx).noContent() 226 | } 227 | }, 228 | sessionserver: { 229 | session: { 230 | minecraft: { 231 | join: async (ctx) => { 232 | const data = ctx.request.body 233 | if ( 234 | !data.accessToken || 235 | !data.selectedProfile || 236 | !data.serverId || 237 | data.selectedProfile.length !== 32 || 238 | data.accessToken.length !== 32 239 | ) { 240 | YggdrasilResponse(ctx).invalidToken() 241 | return 242 | } 243 | const { accessToken } = data 244 | const { selectedProfile } = data 245 | const { serverId } = data 246 | const clientIP = utils.getUserIp(ctx.req) 247 | 248 | // 比对并储存数据 249 | const result = await stoken 250 | .clientToServerValidate(accessToken, selectedProfile, serverId, clientIP) 251 | .then((ret) => ret) 252 | 253 | // 操作失败,返回403 254 | if (!result) { 255 | YggdrasilResponse(ctx).invalidToken() 256 | return 257 | } 258 | 259 | // 操作成功,返回204 260 | YggdrasilResponse(ctx).noContent() 261 | }, 262 | hasJoined: async (ctx) => { 263 | const { username } = ctx.query 264 | const { serverId } = ctx.query 265 | const { ip } = ctx.query 266 | 267 | // 比对授权 生成玩家信息 268 | const result = await stoken.serverToClientValidate(username, serverId, ip).then((ret) => ret) 269 | 270 | if (!result) { 271 | // 操作失败,返回204 272 | YggdrasilResponse(ctx).noContent() 273 | return 274 | } 275 | 276 | // 操作成功 返回完整玩家信息 277 | YggdrasilResponse(ctx).success(result) 278 | }, 279 | profile: async (ctx) => { 280 | // 获取uuid参数 281 | let { uuid } = ctx.params 282 | 283 | // uuid格式错误,返回204 284 | if (uuid.length !== 32) { 285 | YggdrasilResponse(ctx).noContent() 286 | return 287 | } 288 | 289 | // 处理无符号uuid为有符号uuid 290 | uuid = utils.convertUUIDwithHyphen(uuid) 291 | 292 | // 根据UUID获取玩家信息 293 | const userData = await User.searchUserInfoByUUID(uuid).then((result) => result) 294 | 295 | // 玩家不存在,返回204 296 | if (!userData) { 297 | YggdrasilResponse(ctx).noContent() 298 | return 299 | } 300 | 301 | YggdrasilResponse(ctx).success(User.genUserProfile(userData)) 302 | } 303 | } 304 | } 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /src/controller/forgetpw.js: -------------------------------------------------------------------------------- 1 | /* controller/forgetpw.js */ 2 | 3 | const CryptoJS = require('crypto-js') 4 | const config = require('../config') 5 | const User = require('../service/user') 6 | const Forgetpw = require('../service/forgetpw') 7 | 8 | module.exports = { 9 | frontend: async (ctx) => { 10 | const userData = await User.getUserInfo(ctx).then((ret) => ret) 11 | await ctx.render('forgetpw', { 12 | config: config.common, 13 | user: userData 14 | }) 15 | }, 16 | handle: async (ctx) => { 17 | const data = {} 18 | const time = Date.now() 19 | let body = {} 20 | const userData = await User.getUserInfo(ctx).then((ret) => ret) 21 | if (userData.isLoggedIn) { 22 | data.code = -1 23 | data.msg = '你已登录' 24 | ctx.set('Content-Type', 'application/json') 25 | ctx.body = JSON.stringify(data) 26 | return 27 | } 28 | if (!ctx.request.body.data) { 29 | data.code = -1 30 | data.msg = '解析数据发生错误' 31 | ctx.set('Content-Type', 'application/json') 32 | ctx.body = JSON.stringify(data) 33 | return 34 | } 35 | if ( 36 | !ctx.session.key || 37 | !ctx.session.key.secret || 38 | !ctx.session.key.iv || 39 | !ctx.session.key.ts || 40 | time - ctx.session.key.ts > 300000 41 | ) { 42 | data.code = -1 43 | data.msg = '传输凭证无效' 44 | ctx.set('Content-Type', 'application/json') 45 | ctx.body = JSON.stringify(data) 46 | return 47 | } 48 | body = ctx.request.body.data 49 | const secret = CryptoJS.enc.Hex.parse(ctx.session.key.secret) 50 | const iv = CryptoJS.enc.Hex.parse(ctx.session.key.iv) 51 | try { 52 | body = JSON.parse( 53 | CryptoJS.AES.decrypt(body, secret, { iv, padding: CryptoJS.pad.ZeroPadding }).toString(CryptoJS.enc.Utf8) 54 | ) 55 | } catch (error) { 56 | data.code = -1 57 | data.msg = '解密数据发生错误' 58 | ctx.set('Content-Type', 'application/json') 59 | ctx.body = JSON.stringify(data) 60 | return 61 | } 62 | 63 | if ( 64 | !ctx.session.captcha || 65 | !ctx.session.captcha.text || 66 | !ctx.session.captcha.ts || 67 | time - ctx.session.captcha.ts > 300000 68 | ) { 69 | ctx.session.captcha.text = Math.random() 70 | data.code = -1 71 | data.msg = '验证码超时/无效' 72 | ctx.set('Content-Type', 'application/json') 73 | ctx.body = JSON.stringify(data) 74 | return 75 | } 76 | 77 | if (body.captcha !== ctx.session.captcha.text) { 78 | ctx.session.captcha.text = Math.random() 79 | data.code = -1 80 | data.msg = '验证码无效' 81 | ctx.set('Content-Type', 'application/json') 82 | ctx.body = JSON.stringify(data) 83 | return 84 | } 85 | if (!body.email) { 86 | data.code = -1 87 | data.msg = '邮箱不能为空' 88 | ctx.set('Content-Type', 'application/json') 89 | ctx.body = JSON.stringify(data) 90 | return 91 | } 92 | 93 | if ( 94 | !/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( 95 | body.email 96 | ) 97 | ) { 98 | data.code = -1 99 | data.msg = '邮箱格式不正确' 100 | ctx.set('Content-Type', 'application/json') 101 | ctx.body = JSON.stringify(data) 102 | return 103 | } 104 | 105 | try { 106 | const emailExists = await User.isUserExists('email', body.email).then((exists) => exists) 107 | if (!emailExists) { 108 | data.code = -1 109 | data.msg = '该邮箱还未注册' 110 | ctx.set('Content-Type', 'application/json') 111 | ctx.body = JSON.stringify(data) 112 | return 113 | } 114 | 115 | const userInfo = await User.searchUserInfoByEmail(body.email).then((ret) => ret) 116 | 117 | const isVerifyTokenExists = await Forgetpw.isVerifyTokenExists(userInfo.id).then((ret) => ret) 118 | 119 | // 密码重置token还未过期 120 | if (isVerifyTokenExists) { 121 | const verifyTokenTime = await Forgetpw.getVerifyTokenTime(userInfo.id).then((ret) => ret) 122 | if (!verifyTokenTime) { 123 | data.code = -1 124 | data.msg = '未知错误,请稍后重试' 125 | ctx.set('Content-Type', 'application/json') 126 | ctx.body = JSON.stringify(data) 127 | return 128 | } 129 | const timeNow = Math.floor(new Date().getTime() / 1000) 130 | data.code = -1 131 | data.msg = `约${Math.ceil((verifyTokenTime - timeNow) / 60)}分钟后才能再次发送密码重置邮件` 132 | ctx.set('Content-Type', 'application/json') 133 | ctx.body = JSON.stringify(data) 134 | return 135 | } 136 | 137 | const token = Forgetpw.genVerifyToken() 138 | await Forgetpw.storeVerifyTokenToRedis(userInfo.id, token).then((ret) => ret) 139 | 140 | const result = await Forgetpw.sendVerifyUrl(userInfo.email, userInfo.playername, userInfo.id, token).then( 141 | (ret) => ret 142 | ) 143 | 144 | if (!result) { 145 | data.code = -1 146 | data.msg = '未知错误' 147 | ctx.set('Content-Type', 'application/json') 148 | ctx.body = JSON.stringify(data) 149 | return 150 | } 151 | 152 | data.code = 1000 153 | data.msg = '邮件已发送,请查看您的收件箱' 154 | ctx.set('Content-Type', 'application/json') 155 | ctx.body = JSON.stringify(data) 156 | 157 | ctx.session.captcha.text = Math.random() 158 | } catch (error) { 159 | data.code = -1 160 | data.msg = '未知错误' 161 | ctx.set('Content-Type', 'application/json') 162 | ctx.body = JSON.stringify(data) 163 | } 164 | }, 165 | changeFrontend: async (ctx) => { 166 | const userData = await User.getUserInfo(ctx).then((ret) => ret) 167 | const playerId = ctx.params.id 168 | const { token } = ctx.params 169 | const result = await Forgetpw.isVerifyTokenCurrect(playerId, token).then((ret) => ret) 170 | let isCorrect = false 171 | if (result) { 172 | isCorrect = true 173 | } 174 | 175 | await ctx.render('forgetpw_change', { 176 | config: config.common, 177 | user: userData, 178 | isCorrect 179 | }) 180 | }, 181 | changeHandle: async (ctx) => { 182 | const data = {} 183 | const time = Date.now() 184 | let body = {} 185 | const userData = await User.getUserInfo(ctx).then((ret) => ret) 186 | if (userData.isLoggedIn) { 187 | data.code = -1 188 | data.msg = '你已登录' 189 | ctx.set('Content-Type', 'application/json') 190 | ctx.body = JSON.stringify(data) 191 | return 192 | } 193 | if (!ctx.request.body.data) { 194 | data.code = -1 195 | data.msg = '解析数据发生错误' 196 | ctx.set('Content-Type', 'application/json') 197 | ctx.body = JSON.stringify(data) 198 | return 199 | } 200 | if ( 201 | !ctx.session.key || 202 | !ctx.session.key.secret || 203 | !ctx.session.key.iv || 204 | !ctx.session.key.ts || 205 | time - ctx.session.key.ts > 300000 206 | ) { 207 | data.code = -1 208 | data.msg = '传输凭证无效' 209 | ctx.set('Content-Type', 'application/json') 210 | ctx.body = JSON.stringify(data) 211 | return 212 | } 213 | body = ctx.request.body.data 214 | const secret = CryptoJS.enc.Hex.parse(ctx.session.key.secret) 215 | const iv = CryptoJS.enc.Hex.parse(ctx.session.key.iv) 216 | try { 217 | body = JSON.parse( 218 | CryptoJS.AES.decrypt(body, secret, { iv, padding: CryptoJS.pad.ZeroPadding }).toString(CryptoJS.enc.Utf8) 219 | ) 220 | } catch (error) { 221 | data.code = -1 222 | data.msg = '解密数据发生错误' 223 | ctx.set('Content-Type', 'application/json') 224 | ctx.body = JSON.stringify(data) 225 | return 226 | } 227 | 228 | if ( 229 | !ctx.session.captcha || 230 | !ctx.session.captcha.text || 231 | !ctx.session.captcha.ts || 232 | time - ctx.session.captcha.ts > 300000 233 | ) { 234 | ctx.session.captcha.text = Math.random() 235 | data.code = -1 236 | data.msg = '验证码超时/无效' 237 | ctx.set('Content-Type', 'application/json') 238 | ctx.body = JSON.stringify(data) 239 | return 240 | } 241 | 242 | if (body.captcha !== ctx.session.captcha.text) { 243 | ctx.session.captcha.text = Math.random() 244 | data.code = -1 245 | data.msg = '验证码无效' 246 | ctx.set('Content-Type', 'application/json') 247 | ctx.body = JSON.stringify(data) 248 | return 249 | } 250 | if (!body.password) { 251 | data.code = -1 252 | data.msg = '密码不能为空' 253 | ctx.set('Content-Type', 'application/json') 254 | ctx.body = JSON.stringify(data) 255 | return 256 | } 257 | 258 | if (body.password.length > 150) { 259 | data.code = -1 260 | data.msg = '密码不合法' 261 | ctx.set('Content-Type', 'application/json') 262 | ctx.body = JSON.stringify(data) 263 | return 264 | } 265 | 266 | const playerId = ctx.params.id 267 | const { token } = ctx.params 268 | const result = await Forgetpw.isVerifyTokenCurrect(playerId, token).then((ret) => ret) 269 | if (!result) { 270 | data.code = -1 271 | data.msg = '链接无效或已过期' 272 | ctx.set('Content-Type', 'application/json') 273 | ctx.body = JSON.stringify(data) 274 | return 275 | } 276 | 277 | await Forgetpw.delVerifyToken(playerId) 278 | 279 | try { 280 | // 获取用户信息 281 | const userInfo = await User.searchUserInfoByID(playerId).then((ret) => ret) 282 | 283 | const changepwResult = await User.changeUserPassword(userInfo.email, body.password).then((ret) => ret) 284 | if (!changepwResult) { 285 | data.code = -1 286 | data.msg = '修改密码时发生错误' 287 | ctx.set('Content-Type', 'application/json') 288 | ctx.body = JSON.stringify(data) 289 | return 290 | } 291 | ctx.session.captcha.text = Math.random() 292 | data.code = 1000 293 | data.msg = '密码修改成功' 294 | ctx.set('Content-Type', 'application/json') 295 | ctx.body = JSON.stringify(data) 296 | } catch (error) { 297 | data.code = -1 298 | data.msg = '未知错误' 299 | ctx.set('Content-Type', 'application/json') 300 | ctx.body = JSON.stringify(data) 301 | } 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /src/controller/index.js: -------------------------------------------------------------------------------- 1 | /* controller/index.js */ 2 | 3 | const config = require('../config') 4 | const User = require('../service/user') 5 | 6 | const options = { 7 | year: 'numeric', 8 | month: 'numeric', 9 | day: 'numeric' 10 | } 11 | const dateFormatter = new Intl.DateTimeFormat('zh-CN', options) 12 | 13 | module.exports = { 14 | index: async (ctx) => { 15 | const userData = await User.getUserInfo(ctx).then((ret) => ret) 16 | await ctx.render('index', { 17 | config: config.common, 18 | user: userData, 19 | dateFormatter 20 | }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/controller/login.js: -------------------------------------------------------------------------------- 1 | /* controller/login.js */ 2 | 3 | const CryptoJS = require('crypto-js') 4 | const config = require('../config') 5 | const User = require('../service/user') 6 | 7 | module.exports = { 8 | frontend: async (ctx) => { 9 | const userData = await User.getUserInfo(ctx).then((ret) => ret) 10 | await ctx.render('login', { 11 | config: config.common, 12 | user: userData 13 | }) 14 | }, 15 | handle: async (ctx) => { 16 | const data = {} 17 | const time = Date.now() 18 | let body = {} 19 | const userData = await User.getUserInfo(ctx).then((ret) => ret) 20 | if (userData.isLoggedIn) { 21 | data.code = -1 22 | data.msg = '你已登录' 23 | ctx.set('Content-Type', 'application/json') 24 | ctx.body = JSON.stringify(data) 25 | return 26 | } 27 | if (!ctx.request.body.data) { 28 | data.code = -1 29 | data.msg = '解析数据发生错误' 30 | ctx.set('Content-Type', 'application/json') 31 | ctx.body = JSON.stringify(data) 32 | return 33 | } 34 | if ( 35 | !ctx.session.key || 36 | !ctx.session.key.secret || 37 | !ctx.session.key.iv || 38 | !ctx.session.key.ts || 39 | time - ctx.session.key.ts > 300000 40 | ) { 41 | data.code = -1 42 | data.msg = '传输凭证无效' 43 | ctx.set('Content-Type', 'application/json') 44 | ctx.body = JSON.stringify(data) 45 | return 46 | } 47 | body = ctx.request.body.data 48 | const secret = CryptoJS.enc.Hex.parse(ctx.session.key.secret) 49 | const iv = CryptoJS.enc.Hex.parse(ctx.session.key.iv) 50 | try { 51 | body = JSON.parse( 52 | CryptoJS.AES.decrypt(body, secret, { iv, padding: CryptoJS.pad.ZeroPadding }).toString(CryptoJS.enc.Utf8) 53 | ) 54 | } catch (error) { 55 | data.code = -1 56 | data.msg = '解密数据发生错误' 57 | ctx.set('Content-Type', 'application/json') 58 | ctx.body = JSON.stringify(data) 59 | return 60 | } 61 | 62 | if ( 63 | !ctx.session.captcha || 64 | !ctx.session.captcha.text || 65 | !ctx.session.captcha.ts || 66 | time - ctx.session.captcha.ts > 300000 67 | ) { 68 | ctx.session.captcha.text = Math.random() 69 | data.code = -1 70 | data.msg = '验证码超时/无效' 71 | ctx.set('Content-Type', 'application/json') 72 | ctx.body = JSON.stringify(data) 73 | return 74 | } 75 | 76 | if (body.captcha !== ctx.session.captcha.text) { 77 | ctx.session.captcha.text = Math.random() 78 | data.code = -1 79 | data.msg = '验证码无效' 80 | ctx.set('Content-Type', 'application/json') 81 | ctx.body = JSON.stringify(data) 82 | return 83 | } 84 | if (!body.email || !body.password) { 85 | data.code = -1 86 | data.msg = '邮箱/密码不能为空' 87 | ctx.set('Content-Type', 'application/json') 88 | ctx.body = JSON.stringify(data) 89 | return 90 | } 91 | 92 | if ( 93 | !/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( 94 | body.email 95 | ) 96 | ) { 97 | data.code = -1 98 | data.msg = '邮箱格式不正确' 99 | ctx.set('Content-Type', 'application/json') 100 | ctx.body = JSON.stringify(data) 101 | return 102 | } 103 | 104 | try { 105 | const emailExists = await User.isUserExists('email', body.email).then((exists) => exists) 106 | if (!emailExists) { 107 | data.code = -1 108 | data.msg = '该邮箱还未注册' 109 | ctx.set('Content-Type', 'application/json') 110 | ctx.body = JSON.stringify(data) 111 | return 112 | } 113 | 114 | const pwCorrect = await User.isUserPasswordCorrect( 115 | body.email, 116 | CryptoJS.HmacSHA256(body.password, config.extra.slat).toString() 117 | ).then((ret) => ret) 118 | if (!pwCorrect) { 119 | data.code = -1 120 | data.msg = '密码不正确' 121 | ctx.set('Content-Type', 'application/json') 122 | ctx.body = JSON.stringify(data) 123 | return 124 | } 125 | ctx.session.captcha.text = Math.random() 126 | await User.letUserLoggedIn(ctx, body.email).then((ret) => ret) 127 | data.code = 1000 128 | data.msg = '登录成功' 129 | ctx.set('Content-Type', 'application/json') 130 | ctx.body = JSON.stringify(data) 131 | } catch (error) { 132 | data.code = -1 133 | data.msg = '未知错误' 134 | ctx.set('Content-Type', 'application/json') 135 | ctx.body = JSON.stringify(data) 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/controller/logout.js: -------------------------------------------------------------------------------- 1 | /* controller/logout.js */ 2 | 3 | module.exports = { 4 | logout: async (ctx) => { 5 | ctx.session = null 6 | ctx.response.redirect('/') 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/controller/register.js: -------------------------------------------------------------------------------- 1 | /* controller/register.js */ 2 | 3 | const CryptoJS = require('crypto-js') 4 | const config = require('../config') 5 | const User = require('../service/user') 6 | const utils = require('../utils') 7 | 8 | module.exports = { 9 | frontend: async (ctx) => { 10 | const userData = await User.getUserInfo(ctx).then((ret) => ret) 11 | await ctx.render('register', { 12 | config: config.common, 13 | user: userData 14 | }) 15 | }, 16 | handle: async (ctx) => { 17 | const data = {} 18 | const time = Date.now() 19 | let body = {} 20 | const userData = await User.getUserInfo(ctx).then((ret) => ret) 21 | if (userData.isLoggedIn) { 22 | data.code = -1 23 | data.msg = '你已登录' 24 | ctx.set('Content-Type', 'application/json') 25 | ctx.body = JSON.stringify(data) 26 | return 27 | } 28 | if (!ctx.request.body.data) { 29 | data.code = -1 30 | data.msg = '解析数据发生错误' 31 | ctx.set('Content-Type', 'application/json') 32 | ctx.body = JSON.stringify(data) 33 | return 34 | } 35 | if ( 36 | !ctx.session.key || 37 | !ctx.session.key.secret || 38 | !ctx.session.key.iv || 39 | !ctx.session.key.ts || 40 | time - ctx.session.key.ts > 300000 41 | ) { 42 | data.code = -1 43 | data.msg = '传输凭证无效' 44 | ctx.set('Content-Type', 'application/json') 45 | ctx.body = JSON.stringify(data) 46 | return 47 | } 48 | body = ctx.request.body.data 49 | const secret = CryptoJS.enc.Hex.parse(ctx.session.key.secret) 50 | const iv = CryptoJS.enc.Hex.parse(ctx.session.key.iv) 51 | try { 52 | body = JSON.parse( 53 | CryptoJS.AES.decrypt(body, secret, { iv, padding: CryptoJS.pad.ZeroPadding }).toString(CryptoJS.enc.Utf8) 54 | ) 55 | } catch (error) { 56 | data.code = -1 57 | data.msg = '解密数据发生错误' 58 | ctx.set('Content-Type', 'application/json') 59 | ctx.body = JSON.stringify(data) 60 | return 61 | } 62 | 63 | if ( 64 | !ctx.session.captcha || 65 | !ctx.session.captcha.text || 66 | !ctx.session.captcha.ts || 67 | time - ctx.session.captcha.ts > 300000 68 | ) { 69 | ctx.session.captcha.text = Math.random() 70 | data.code = -1 71 | data.msg = '验证码超时/无效' 72 | ctx.set('Content-Type', 'application/json') 73 | ctx.body = JSON.stringify(data) 74 | return 75 | } 76 | 77 | if (body.captcha !== ctx.session.captcha.text) { 78 | ctx.session.captcha.text = Math.random() 79 | data.code = -1 80 | data.msg = '验证码无效' 81 | ctx.set('Content-Type', 'application/json') 82 | ctx.body = JSON.stringify(data) 83 | return 84 | } 85 | if (!body.email || !body.password || !body.playername) { 86 | data.code = -1 87 | data.msg = '邮箱/密码/游戏昵称不能为空' 88 | ctx.set('Content-Type', 'application/json') 89 | ctx.body = JSON.stringify(data) 90 | return 91 | } 92 | 93 | if ( 94 | !/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( 95 | body.email 96 | ) 97 | ) { 98 | data.code = -1 99 | data.msg = '邮箱格式不正确' 100 | ctx.set('Content-Type', 'application/json') 101 | ctx.body = JSON.stringify(data) 102 | return 103 | } 104 | 105 | if (body.email.length < 5 || body.email.length > 40) { 106 | data.code = -1 107 | data.msg = '邮箱不合法' 108 | ctx.set('Content-Type', 'application/json') 109 | ctx.body = JSON.stringify(data) 110 | return 111 | } 112 | 113 | if (body.password.length > 150) { 114 | data.code = -1 115 | data.msg = '密码不合法' 116 | ctx.set('Content-Type', 'application/json') 117 | ctx.body = JSON.stringify(data) 118 | return 119 | } 120 | 121 | if (!/^[A-Za-z0-9_]+$/.test(body.playername) || body.playername.length < 4 || body.playername.length > 12) { 122 | data.code = -1 123 | data.msg = '游戏昵称不合法' 124 | ctx.set('Content-Type', 'application/json') 125 | ctx.body = JSON.stringify(data) 126 | return 127 | } 128 | 129 | try { 130 | const emailExists = await User.isUserExists('email', body.email).then((exists) => exists) 131 | if (emailExists) { 132 | data.code = -1 133 | data.msg = '邮箱地址已存在' 134 | ctx.set('Content-Type', 'application/json') 135 | ctx.body = JSON.stringify(data) 136 | return 137 | } 138 | 139 | const playerNameExists = await User.isUserExists('playername', body.playername).then((exists) => exists) 140 | if (playerNameExists) { 141 | data.code = -1 142 | data.msg = '游戏昵称已存在' 143 | ctx.set('Content-Type', 'application/json') 144 | ctx.body = JSON.stringify(data) 145 | return 146 | } 147 | 148 | // 获取用户总数 149 | const userNumber = await User.getUserNumber().then((ret) => ret) 150 | 151 | if (userNumber === -1) { 152 | data.code = -1 153 | data.msg = '处理用户ID时发生错误' 154 | ctx.set('Content-Type', 'application/json') 155 | ctx.body = JSON.stringify(data) 156 | return 157 | } 158 | 159 | const userInfo = { 160 | id: userNumber + 1, 161 | email: body.email, 162 | verified: false, 163 | playername: body.playername, 164 | uuid: utils.genUUID(), 165 | password: CryptoJS.HmacSHA256(body.password, config.extra.slat).toString(), 166 | ip: { 167 | register: utils.getUserIp(ctx.req), 168 | lastLogged: utils.getUserIp(ctx.req) 169 | }, 170 | time: { 171 | register: Date.now(), 172 | lastLogged: Date.now() 173 | } 174 | } 175 | const registerResult = await User.createNewUser(userInfo).then((ret) => ret) 176 | if (!registerResult) { 177 | data.code = -1 178 | data.msg = '写入用户数据时发生错误' 179 | ctx.set('Content-Type', 'application/json') 180 | ctx.body = JSON.stringify(data) 181 | return 182 | } 183 | ctx.session.captcha.text = Math.random() 184 | await User.letUserLoggedIn(ctx, body.email).then((ret) => ret) 185 | data.code = 1000 186 | data.msg = '注册成功' 187 | ctx.set('Content-Type', 'application/json') 188 | ctx.body = JSON.stringify(data) 189 | } catch (error) { 190 | data.code = -1 191 | data.msg = '未知错误' 192 | ctx.set('Content-Type', 'application/json') 193 | ctx.body = JSON.stringify(data) 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/controller/textures.js: -------------------------------------------------------------------------------- 1 | /* controller/textures.js */ 2 | const fs = require('fs') 3 | const path = require('path') 4 | const utils = require('../utils') 5 | const { YggdrasilResponse } = require('../utils/ResponseEnum') 6 | 7 | module.exports = { 8 | textures: async (ctx) => { 9 | const { hash } = ctx.params 10 | if (hash.length === 64) { 11 | const skinPath = path.join(utils.getRootPath(), '../skin') 12 | try { 13 | const skin = fs.readFileSync(path.join(skinPath, `${hash.replace(/\\|\/|\./g, '')}.png`)) 14 | ctx.set('Content-Type', 'image/png') 15 | ctx.body = skin 16 | } catch (e) { 17 | YggdrasilResponse(ctx).noContent() 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/db/models/user.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | const { Schema } = mongoose 4 | 5 | const UserSchema = new Schema({ 6 | id: { 7 | type: Number, 8 | unique: true, 9 | index: true 10 | }, 11 | email: { 12 | type: String, 13 | unique: true 14 | }, 15 | verified: { 16 | type: Boolean, 17 | default: false 18 | }, 19 | playername: { 20 | type: String, 21 | unique: true 22 | }, 23 | uuid: { 24 | type: String, 25 | unique: true 26 | }, 27 | password: String, 28 | skin: { 29 | type: { 30 | type: Number, 31 | default: 0 // 皮肤模型 0:默认 1:纤细 32 | }, 33 | hash: { 34 | type: String, 35 | default: '9b155b4668427669ca9ed3828024531bc52fca1dcf8fbde8ccac3d9d9b53e3cf' 36 | } 37 | }, 38 | isBanned: { 39 | type: Boolean, 40 | default: false 41 | }, 42 | tokens: [ 43 | { 44 | accessToken: String, 45 | clientToken: String, 46 | status: { 47 | type: Number, 48 | default: 1 // 令牌状态 1:可用 0:失效 49 | }, 50 | createAt: { 51 | type: Number, 52 | default: Date.now 53 | } 54 | } 55 | ], 56 | ip: { 57 | register: String, 58 | lastLogged: String 59 | }, 60 | time: { 61 | register: Number, 62 | lastLogged: Number 63 | } 64 | }) 65 | 66 | const User = mongoose.model('User', UserSchema) 67 | module.exports = User 68 | -------------------------------------------------------------------------------- /src/db/mongodb.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | const Print = require('../utils/print') 3 | const config = require('../config') 4 | 5 | const connect = () => { 6 | mongoose 7 | .connect( 8 | `mongodb://${ 9 | config.extra.mongodb.hasAuth ? `${config.extra.mongodb.username}:${config.extra.mongodb.password}@` : '' 10 | }${config.extra.mongodb.host}:${config.extra.mongodb.port}/${config.extra.mongodb.db}`, 11 | { 12 | useNewUrlParser: true, 13 | useUnifiedTopology: true, 14 | connectTimeoutMS: 5000, 15 | socketTimeoutMS: 5000 16 | } 17 | ) 18 | .catch() 19 | } 20 | 21 | const init = () => { 22 | // 连接数据库 23 | connect() 24 | 25 | const maxConnectTimes = 3 26 | let connectTimes = 0 27 | let lastConnectTime = 0 28 | 29 | return new Promise((resolve) => { 30 | mongoose.connection.on('disconnected', () => { 31 | Print.error('mongoDB断开连接') 32 | if (Date.now() - lastConnectTime < 1000) { 33 | lastConnectTime = Date.now() 34 | return 35 | } 36 | if (connectTimes < maxConnectTimes) { 37 | connectTimes += 1 38 | Print.info(`mongoDB尝试重连...(${connectTimes}/${maxConnectTimes})`) 39 | connect() 40 | } else { 41 | throw new Error('无法连接至MongoDB,请尝试修复问题后再次启动GHAuth') 42 | } 43 | }) 44 | 45 | mongoose.connection.on('error', (err) => { 46 | Print.error('mongoDB数据库错误', err) 47 | if (Date.now() - lastConnectTime < 1000) { 48 | lastConnectTime = Date.now() 49 | return 50 | } 51 | if (connectTimes < maxConnectTimes) { 52 | connectTimes += 1 53 | Print.info(`mongoDB尝试重连...(${connectTimes}/${maxConnectTimes})`) 54 | connect() 55 | } else { 56 | throw new Error('无法连接至MongoDB,请尝试修复问题后再次启动GHAuth') 57 | } 58 | }) 59 | 60 | mongoose.connection.once('open', () => { 61 | mongoose.connection.on('connected', () => { 62 | Print.success('mongoDB连接成功') 63 | }) 64 | Print.success('mongoDB连接成功') 65 | resolve() 66 | }) 67 | }) 68 | } 69 | 70 | exports.init = init 71 | -------------------------------------------------------------------------------- /src/db/redis.js: -------------------------------------------------------------------------------- 1 | const Redis = require('ioredis') 2 | const config = require('../config') 3 | 4 | module.exports = { 5 | auth: new Redis({ 6 | port: config.extra.redis.port, 7 | host: config.extra.redis.host, 8 | db: config.extra.redis.authdb 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa') 2 | const bodyParser = require('koa-bodyparser') 3 | const views = require('koa-views') 4 | const session = require('koa-session') 5 | const path = require('path') 6 | const redisStore = require('koa-redis') 7 | 8 | const config = require('./config') 9 | const logger = require('koa-logger') 10 | const Print = require('./utils/print') 11 | 12 | const mongoose = require('mongoose') 13 | 14 | Print.info('GHAuth 准备初始化,请稍后...') 15 | 16 | process.on('uncaughtException', (err) => { 17 | Print.error(err) 18 | }) 19 | 20 | process.on('SIGINT', async function () { 21 | let err = false 22 | try { 23 | await mongoose.disconnect() 24 | } catch (error) { 25 | err = true 26 | } 27 | process.exit(err ? 1 : 0) 28 | }) 29 | 30 | function init() { 31 | Print.info('初始化 Koa...') 32 | const app = new Koa() 33 | 34 | app.keys = [config.extra.session.key] 35 | const sessionConfig = { 36 | key: 'ghauth.sid', 37 | prefix: 'ghauth:session:', 38 | maxAge: 86400000, 39 | store: redisStore({ 40 | host: config.extra.redis.host, 41 | port: config.extra.redis.port, 42 | db: config.extra.redis.sessiondb 43 | }) 44 | } 45 | 46 | Print.info('载入中间件...') 47 | if (process.env.NODE_ENV === 'development') { 48 | // 仅在开发环境下打印日志 49 | app.use(logger()) // 日志记录 50 | } 51 | app.use(bodyParser()) // 数据解析 52 | app.use(session(sessionConfig, app)) // session 53 | app.use( 54 | views(path.resolve(__dirname, 'template'), { 55 | extension: 'pug' 56 | }) 57 | ) // pug模板引擎 58 | 59 | app.use(async (ctx, next) => { 60 | // yggdrasil ALI header 61 | ctx.set('X-Authlib-Injector-API-Location', `${config.common.url}/api/yggdrasil/`) 62 | await next() 63 | }) 64 | 65 | Print.info('载入路由...') 66 | const mainRouter = require('./routers/urls') // 引入根路由 67 | mainRouter(app) 68 | 69 | app.listen(config.extra.port ? config.extra.port : 3000, () => { 70 | Print.success(`GHAuth 现已成功运行在 http://localhost:${config.extra.port ? config.extra.port : 3000}`) 71 | }) 72 | } 73 | 74 | Print.info('尝试连接到MongoDB...') 75 | require('./db/mongodb') 76 | .init() 77 | .then(() => { 78 | init() 79 | }) 80 | -------------------------------------------------------------------------------- /src/install/install.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const inquirer = require('inquirer') 4 | const yaml = require('js-yaml') 5 | const RSA = require('node-rsa') 6 | const Print = require('../utils/print') 7 | 8 | // 获取启动参数 -generate-cert 9 | const generateCert = process.argv.indexOf('-generate-cert') > -1 10 | 11 | const isInstallLocked = fs.existsSync('./install.lock') 12 | 13 | function run() { 14 | if (generateCert) { 15 | if (isInstallLocked) { 16 | const questions = [ 17 | { 18 | type: 'list', 19 | name: 'signaturesize', 20 | filter: Number, 21 | message: '签名密钥长度:', 22 | default: 1, 23 | choices: [1024, 2048, 4096] 24 | } 25 | ] 26 | inquirer.prompt(questions).then((answers) => { 27 | const configFile = fs.readFileSync('./config/config.yml', 'utf8') 28 | const config = yaml.load(configFile) 29 | 30 | Print.info(`生成RSA签名公私钥(${answers.signaturesize})中,可能需要花费较长时间,请耐心等待...`) 31 | const key = new RSA({ b: answers.signaturesize }) 32 | key.setOptions({ encryptionScheme: 'pkcs1' }) 33 | const publicPem = key.exportKey('pkcs8-public-pem') 34 | const privatePem = key.exportKey('pkcs8-private-pem') 35 | config.extra.signature.private = privatePem 36 | config.extra.signature.public = publicPem 37 | 38 | Print.info('更新配置文件中,请稍等...') 39 | // 生成 config.yml 40 | const final = yaml.dump(config) 41 | fs.writeFileSync(path.join(__dirname, '../config/config.yml'), final) 42 | 43 | Print.success('公私钥更新完成。') 44 | }) 45 | } else { 46 | Print.error('请先安装GHAuth,然后再生成证书') 47 | process.exit(1) 48 | } 49 | return 50 | } 51 | 52 | if (isInstallLocked) { 53 | Print.error('GHAuth的首次安装配置已经完成,无需再次进入安装引导。') 54 | } else { 55 | const genRandomString = (length) => { 56 | let result = '' 57 | const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' 58 | for (let i = length; i > 0; i -= 1) result += chars[Math.floor(Math.random() * chars.length)] 59 | return result 60 | } 61 | 62 | const questions = [ 63 | { 64 | type: 'input', 65 | name: 'sitename', 66 | message: '站点名称:', 67 | default: 'GHAuth' 68 | }, 69 | { 70 | type: 'input', 71 | name: 'description', 72 | message: '站点描述:', 73 | default: '轻量的MC服务器yggdrasil验证' 74 | }, 75 | { 76 | type: 'confirm', 77 | name: 'showAnnouncement', 78 | message: '站点公告:', 79 | default: true 80 | }, 81 | { 82 | type: 'confirm', 83 | name: 'ignoreEmailVerification', 84 | message: '不强制进行邮箱验证:', 85 | default: false 86 | }, 87 | { 88 | type: 'input', 89 | name: 'port', 90 | message: '监听端口:', 91 | filter: Number, 92 | default: 3000, 93 | validate(value) { 94 | const pass = !Number.isNaN(value) && value >= 1024 && value <= 65535 95 | if (pass) { 96 | return true 97 | } 98 | 99 | return '请输入有效的端口(1024-65535)' 100 | } 101 | }, 102 | { 103 | type: 'input', 104 | name: 'url', 105 | message: '站点链接:', 106 | default(answers) { 107 | return `http://127.0.0.1:${answers.port}` 108 | } 109 | }, 110 | { 111 | type: 'input', 112 | name: 'adminEmail', 113 | message: '管理员邮箱:', 114 | default: 'example@example.com', 115 | validate(value) { 116 | const pass = value.match( 117 | /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ 118 | ) 119 | if (pass) { 120 | return true 121 | } 122 | 123 | return '请输入有效的邮箱地址' 124 | } 125 | }, 126 | { 127 | type: 'input', 128 | name: 'mongodb.host', 129 | message: 'MongoDB 主机:', 130 | default: '127.0.0.1' 131 | }, 132 | { 133 | type: 'input', 134 | name: 'mongodb.port', 135 | message: 'MongoDB 端口:', 136 | filter: Number, 137 | default: 27017, 138 | validate(value) { 139 | const pass = !Number.isNaN(value) && value >= 1024 && value <= 65535 140 | if (pass) { 141 | return true 142 | } 143 | 144 | return '请输入有效的端口(1024-65535)' 145 | } 146 | }, 147 | { 148 | type: 'input', 149 | name: 'mongodb.db', 150 | message: 'MongoDB 数据库名称:', 151 | default: 'ghauth' 152 | }, 153 | { 154 | type: 'confirm', 155 | name: 'mongodb.hasAuth', 156 | message: 'MongoDB 是否有身份验证:', 157 | default: false 158 | }, 159 | { 160 | type: 'input', 161 | name: 'mongodb.username', 162 | message: 'MongoDB 用户名:', 163 | when(answers) { 164 | return answers.mongodb.hasAuth 165 | } 166 | }, 167 | { 168 | type: 'password', 169 | name: 'mongodb.password', 170 | message: 'MongoDB 密码:', 171 | mask: '*', 172 | when(answers) { 173 | return answers.mongodb.hasAuth 174 | } 175 | }, 176 | { 177 | type: 'input', 178 | name: 'redis.host', 179 | message: 'Redis 主机:', 180 | default: '127.0.0.1' 181 | }, 182 | { 183 | type: 'input', 184 | name: 'redis.port', 185 | message: 'Redis 端口:', 186 | filter: Number, 187 | default: 6379, 188 | validate(value) { 189 | const pass = !Number.isNaN(value) && value >= 1024 && value <= 65535 190 | if (pass) { 191 | return true 192 | } 193 | 194 | return '请输入有效的端口(1024-65535)' 195 | } 196 | }, 197 | { 198 | type: 'input', 199 | name: 'redis.sessiondb', 200 | filter: Number, 201 | message: 'Redis 数据库(储存会话数据):', 202 | default: 1 203 | }, 204 | { 205 | type: 'input', 206 | name: 'redis.authdb', 207 | filter: Number, 208 | message: 'Redis 数据库(储存入服验证数据):', 209 | default: 1 210 | }, 211 | { 212 | type: 'list', 213 | name: 'signaturesize', 214 | filter: Number, 215 | message: '签名密钥长度:', 216 | default: 1, 217 | choices: [1024, 2048, 4096] 218 | } 219 | ] 220 | 221 | inquirer.prompt(questions).then((answers) => { 222 | const configFile = fs.readFileSync('./config/config.sample.yml', 'utf8') 223 | const config = yaml.load(configFile) 224 | 225 | config.common.sitename = answers.sitename 226 | config.common.description = answers.description 227 | config.common.showAnnouncement = answers.showAnnouncement 228 | config.common.ignoreEmailVerification = answers.ignoreEmailVerification 229 | config.common.url = answers.url 230 | 231 | config.extra.port = answers.port 232 | 233 | config.extra.mongodb.host = answers.mongodb.host 234 | config.extra.mongodb.port = answers.mongodb.port 235 | config.extra.mongodb.db = answers.mongodb.db 236 | config.extra.mongodb.hasAuth = answers.mongodb.hasAuth 237 | config.extra.mongodb.username = answers.mongodb.username || '' 238 | config.extra.mongodb.password = answers.mongodb.password || '' 239 | 240 | config.extra.redis.host = answers.redis.host 241 | config.extra.redis.port = answers.redis.port 242 | config.extra.redis.sessiondb = answers.redis.sessiondb 243 | config.extra.redis.authdb = answers.redis.authdb 244 | 245 | Print.info('生成slat中,请稍后...') 246 | config.extra.slat = genRandomString(38) 247 | 248 | Print.info('生成会话密钥中,请稍后...') 249 | config.extra.session.key = genRandomString(40) 250 | 251 | Print.info(`生成RSA签名公私钥(${answers.signaturesize})中,可能需要花费较长时间,请耐心等待...`) 252 | const key = new RSA({ b: answers.signaturesize }) 253 | key.setOptions({ encryptionScheme: 'pkcs1' }) 254 | const publicPem = key.exportKey('pkcs8-public-pem') 255 | const privatePem = key.exportKey('pkcs8-private-pem') 256 | config.extra.signature.private = privatePem 257 | config.extra.signature.public = publicPem 258 | 259 | Print.info('生成配置文件中,请稍等...') 260 | // 生成 config.yml 261 | const final = yaml.dump(config) 262 | fs.writeFileSync(path.join(__dirname, '../config/config.yml'), final) 263 | 264 | // 生成 adminList.yml 265 | const adminList = [answers.adminEmail] 266 | const adminListFile = yaml.dump(adminList) 267 | fs.writeFileSync(path.join(__dirname, '../config/adminList.yml'), adminListFile) 268 | 269 | // 复制 announcement.sample.md 到 announcement.md 270 | fs.copyFileSync( 271 | path.join(__dirname, '../config/announcement.sample.md'), 272 | path.join(__dirname, '../config/announcement.md') 273 | ) 274 | 275 | // 写入 install.lock 276 | fs.writeFileSync(path.join(__dirname, '../install.lock'), '1') 277 | 278 | Print.success('基础配置全部完成,高级配置(页脚配置、邮件服务器配置、资源可信域配置)请修改 /config/config.yml') 279 | }) 280 | } 281 | } 282 | 283 | run() 284 | -------------------------------------------------------------------------------- /src/public/css/admin.css: -------------------------------------------------------------------------------- 1 | .admin-widget-container { 2 | opacity: 0; 3 | display: none; 4 | transition: opacity 0.2s cubic-bezier(0.3, 0, 0.5, 1); 5 | } 6 | 7 | .admin-widget-container.active { 8 | opacity: 1; 9 | display: block; 10 | } 11 | 12 | .admin-widget-container .nav a { 13 | cursor: pointer; 14 | } 15 | 16 | .admin-widget-container .admin-pane { 17 | padding: 5px 0; 18 | } 19 | 20 | .admin-widget-container .table-container { 21 | overflow: auto; 22 | } 23 | 24 | .admin-widget-container *:not(button):not(a):not(em):not(.previous_page):not(.next_page):not(.badge) { 25 | user-select: text !important; 26 | } 27 | 28 | .admin-widget-container table { 29 | table-layout: fixed; 30 | min-width: 630px; 31 | } 32 | 33 | .admin-widget-container table th, 34 | .admin-widget-container table td { 35 | vertical-align: middle; 36 | overflow: hidden; 37 | text-overflow: ellipsis; 38 | white-space: nowrap; 39 | } 40 | 41 | .userlist-btn { 42 | min-width: 46px; 43 | min-height: 31px; 44 | } 45 | 46 | .userlist-btn span { 47 | pointer-events: none; 48 | } 49 | 50 | .userlist-btn .spinner-border { 51 | display: none; 52 | margin: 0 auto; 53 | } 54 | 55 | .userlist-btn:not(.no-action):disabled .spinner-border { 56 | display: block; 57 | } 58 | 59 | .userlist-btn .btn-text { 60 | display: block; 61 | } 62 | 63 | .userlist-btn:disabled { 64 | cursor: default; 65 | } 66 | 67 | .userlist-btn:not(.no-action):disabled .btn-text { 68 | display: none; 69 | } 70 | 71 | .user-filter-container { 72 | display: flex; 73 | justify-content: flex-end; 74 | padding: 2px 14px; 75 | } 76 | 77 | .user-filter-container .form-inline { 78 | width: auto; 79 | } 80 | 81 | #userFilterInput { 82 | width: 180px; 83 | border-radius: 6px; 84 | } 85 | 86 | #userFilterBtn { 87 | margin-left: 5px; 88 | min-width: 46px; 89 | min-height: 31px; 90 | } 91 | 92 | #userFilterBtn span { 93 | pointer-events: none; 94 | } 95 | 96 | #userFilterBtn .spinner-border { 97 | display: none; 98 | margin: 0 auto; 99 | } 100 | 101 | #userFilterBtn:disabled .spinner-border { 102 | display: block; 103 | } 104 | 105 | #userFilterBtn .btn-text { 106 | display: block; 107 | } 108 | 109 | #userFilterBtn:disabled { 110 | cursor: default; 111 | } 112 | 113 | #userFilterBtn:disabled .btn-text { 114 | display: none; 115 | } -------------------------------------------------------------------------------- /src/public/css/notyf.css: -------------------------------------------------------------------------------- 1 | @-webkit-keyframes notyf-fadeinup { 2 | 0% { 3 | opacity: 0; 4 | transform: translateY(25%); 5 | } 6 | to { 7 | opacity: 1; 8 | transform: translateY(0); 9 | } 10 | } 11 | @keyframes notyf-fadeinup { 12 | 0% { 13 | opacity: 0; 14 | transform: translateY(25%); 15 | } 16 | to { 17 | opacity: 1; 18 | transform: translateY(0); 19 | } 20 | } 21 | @-webkit-keyframes notyf-fadeinleft { 22 | 0% { 23 | opacity: 0; 24 | transform: translateX(25%); 25 | } 26 | to { 27 | opacity: 1; 28 | transform: translateX(0); 29 | } 30 | } 31 | @keyframes notyf-fadeinleft { 32 | 0% { 33 | opacity: 0; 34 | transform: translateX(25%); 35 | } 36 | to { 37 | opacity: 1; 38 | transform: translateX(0); 39 | } 40 | } 41 | @-webkit-keyframes notyf-fadeoutright { 42 | 0% { 43 | opacity: 1; 44 | transform: translateX(0); 45 | } 46 | to { 47 | opacity: 0; 48 | transform: translateX(25%); 49 | } 50 | } 51 | @keyframes notyf-fadeoutright { 52 | 0% { 53 | opacity: 1; 54 | transform: translateX(0); 55 | } 56 | to { 57 | opacity: 0; 58 | transform: translateX(25%); 59 | } 60 | } 61 | @-webkit-keyframes notyf-fadeoutdown { 62 | 0% { 63 | opacity: 1; 64 | transform: translateY(0); 65 | } 66 | to { 67 | opacity: 0; 68 | transform: translateY(25%); 69 | } 70 | } 71 | @keyframes notyf-fadeoutdown { 72 | 0% { 73 | opacity: 1; 74 | transform: translateY(0); 75 | } 76 | to { 77 | opacity: 0; 78 | transform: translateY(25%); 79 | } 80 | } 81 | @-webkit-keyframes ripple { 82 | 0% { 83 | transform: scale(0) translateY(-45%) translateX(13%); 84 | } 85 | to { 86 | transform: scale(1) translateY(-45%) translateX(13%); 87 | } 88 | } 89 | @keyframes ripple { 90 | 0% { 91 | transform: scale(0) translateY(-45%) translateX(13%); 92 | } 93 | to { 94 | transform: scale(1) translateY(-45%) translateX(13%); 95 | } 96 | } 97 | .notyf { 98 | opacity: 0.9; 99 | position: fixed; 100 | top: 0; 101 | left: 0; 102 | height: 100%; 103 | width: 100%; 104 | color: #fff; 105 | z-index: 9999; 106 | display: flex; 107 | flex-direction: column; 108 | align-items: flex-end; 109 | justify-content: flex-end; 110 | pointer-events: none; 111 | box-sizing: border-box; 112 | padding: 20px; 113 | } 114 | .notyf__toast { 115 | display: block; 116 | overflow: hidden; 117 | pointer-events: auto; 118 | -webkit-animation: notyf-fadeinup 0.3s ease-in forwards; 119 | animation: notyf-fadeinup 0.3s ease-in forwards; 120 | position: relative; 121 | padding: 0 15px; 122 | border-radius: 8px; 123 | max-width: 300px; 124 | transform: translateY(25%); 125 | box-sizing: border-box; 126 | flex-shrink: 0; 127 | transition: box-shadow 0.15s cubic-bezier(0.3, 0, 0.5, 1); 128 | } 129 | .notyf__toast--disappear { 130 | transform: translateY(0); 131 | -webkit-animation: notyf-fadeoutdown 0.3s forwards; 132 | animation: notyf-fadeoutdown 0.3s forwards; 133 | -webkit-animation-delay: 0.25s; 134 | animation-delay: 0.25s; 135 | } 136 | .notyf__toast--disappear .notyf__message { 137 | -webkit-animation: notyf-fadeoutdown 0.3s forwards; 138 | animation: notyf-fadeoutdown 0.3s forwards; 139 | opacity: 1; 140 | transform: translateY(0); 141 | } 142 | .notyf__toast--disappear .notyf__dismiss { 143 | -webkit-animation: notyf-fadeoutright 0.3s forwards; 144 | animation: notyf-fadeoutright 0.3s forwards; 145 | opacity: 1; 146 | transform: translateX(0); 147 | } 148 | .notyf__toast--disappear .notyf__message { 149 | -webkit-animation-delay: 0.05s; 150 | animation-delay: 0.05s; 151 | } 152 | .notyf__toast--upper { 153 | margin-bottom: 20px; 154 | } 155 | .notyf__toast--lower { 156 | margin-top: 20px; 157 | } 158 | .notyf__toast--dismissible .notyf__wrapper { 159 | padding-right: 30px; 160 | } 161 | .notyf__ripple { 162 | height: 400px; 163 | width: 400px; 164 | position: absolute; 165 | transform-origin: bottom right; 166 | right: 0; 167 | top: 0; 168 | border-radius: 50%; 169 | transform: scale(0) translateY(-51%) translateX(13%); 170 | z-index: 5; 171 | -webkit-animation: ripple 0.4s ease-out forwards; 172 | animation: ripple 0.4s ease-out forwards; 173 | } 174 | .notyf__wrapper { 175 | display: flex; 176 | align-items: center; 177 | padding-top: 17px; 178 | padding-bottom: 17px; 179 | padding-right: 15px; 180 | border-radius: 8px; 181 | position: relative; 182 | z-index: 10; 183 | } 184 | 185 | .notyf__dismiss { 186 | position: absolute; 187 | top: 0; 188 | right: 0; 189 | height: 100%; 190 | width: 26px; 191 | margin-right: -15px; 192 | -webkit-animation: notyf-fadeinleft 0.3s forwards; 193 | animation: notyf-fadeinleft 0.3s forwards; 194 | -webkit-animation-delay: 0.35s; 195 | animation-delay: 0.35s; 196 | opacity: 0; 197 | } 198 | .notyf__dismiss-btn { 199 | background-color: rgba(0, 0, 0, 0.25); 200 | border: none; 201 | cursor: pointer; 202 | transition: opacity 0.2s ease, background-color 0.2s ease; 203 | outline: none; 204 | opacity: 0.35; 205 | height: 100%; 206 | width: 100%; 207 | outline: none !important; 208 | } 209 | .notyf__dismiss-btn:after, 210 | .notyf__dismiss-btn:before { 211 | content: ""; 212 | background: #fff; 213 | height: 12px; 214 | width: 2px; 215 | border-radius: 8px; 216 | position: absolute; 217 | left: calc(50% - 1px); 218 | top: calc(50% - 5px); 219 | } 220 | .notyf__dismiss-btn:after { 221 | transform: rotate(-45deg); 222 | } 223 | .notyf__dismiss-btn:before { 224 | transform: rotate(45deg); 225 | } 226 | .notyf__dismiss-btn:hover { 227 | opacity: 0.7; 228 | background-color: rgba(0, 0, 0, 0.15); 229 | } 230 | .notyf__dismiss-btn:active { 231 | opacity: 0.8; 232 | } 233 | .notyf__message { 234 | vertical-align: middle; 235 | position: relative; 236 | opacity: 0; 237 | -webkit-animation: notyf-fadeinup 0.3s forwards; 238 | animation: notyf-fadeinup 0.3s forwards; 239 | -webkit-animation-delay: 0.25s; 240 | animation-delay: 0.25s; 241 | line-height: 1.5em; 242 | } 243 | @media only screen and (max-width: 480px) { 244 | .notyf { 245 | padding: 0; 246 | } 247 | .notyf__ripple { 248 | height: 600px; 249 | width: 600px; 250 | -webkit-animation-duration: 0.5s; 251 | animation-duration: 0.5s; 252 | } 253 | .notyf__toast { 254 | max-width: none; 255 | border-radius: 0; 256 | width: 100%; 257 | } 258 | .notyf__dismiss { 259 | width: 56px; 260 | } 261 | } -------------------------------------------------------------------------------- /src/public/css/style.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | height: 100%; 6 | } 7 | 8 | footer { 9 | font-size: 85%; 10 | text-align: center; 11 | background-color: #f7f7f7; 12 | margin-top: 3rem; 13 | text-align: left; 14 | } 15 | 16 | .footer-links { 17 | padding-left: 0; 18 | margin-bottom: 1rem; 19 | } 20 | 21 | .footer-links li { 22 | display: inline-block; 23 | } 24 | 25 | .footer-links li + li { 26 | margin-left: 1rem; 27 | } 28 | 29 | footer p { 30 | margin-bottom: 0; 31 | } 32 | 33 | footer a { 34 | font-weight: 600; 35 | color: #495057; 36 | } 37 | 38 | .main-container { 39 | display: flex; 40 | flex-direction: column; 41 | height: 100%; 42 | } 43 | 44 | header { 45 | flex: 0 0 auto; 46 | } 47 | 48 | main { 49 | flex: 1 0 auto; 50 | } 51 | 52 | footer { 53 | flex: 0 0 auto; 54 | } 55 | 56 | .announcement p { 57 | font-weight: 100; 58 | } 59 | 60 | #browser-warning,#email-warning { 61 | border: 4px solid #f7630c; 62 | border-radius: 8px; 63 | } 64 | 65 | #browser-warning { 66 | display: none; 67 | } 68 | 69 | .browser-warning-label{ 70 | font-weight: 100; 71 | margin-bottom: 0; 72 | } 73 | 74 | @media screen and (max-width: 320px) { 75 | /* 在极小屏幕下的特殊处理(如Galaxy Fold) */ 76 | .navbar-brand-text { 77 | display: none; 78 | } 79 | } 80 | 81 | ::selection { 82 | background: #7c8388; 83 | color: #fff; 84 | } 85 | 86 | .viewer-container.active { 87 | display: block !important; 88 | } 89 | 90 | .skinviewer-container { 91 | height: 370px; 92 | margin: 0 auto; 93 | outline: 0; 94 | } 95 | 96 | .skinviewer-container canvas { 97 | outline: 0; 98 | touch-action: none; 99 | } 100 | 101 | .rawimage-main-container.active { 102 | display: flex !important; 103 | justify-content: center; 104 | align-items: center; 105 | } 106 | 107 | .viewer-group { 108 | width: 100%; 109 | } 110 | 111 | #rendergraph-image { 112 | width: 100%; 113 | border-radius: 20px; 114 | } 115 | 116 | .rendergraph-main-container { 117 | padding: 0 !important; 118 | background: transparent !important; 119 | } 120 | 121 | .rendergraph-main-container.active { 122 | display: flex !important; 123 | flex-direction: column; 124 | will-change: height; 125 | min-height: 90px; 126 | align-items: center; 127 | justify-content: center; 128 | } 129 | 130 | .rendergraph-canvas-container { 131 | position: relative; 132 | opacity: 0; 133 | transition: opacity 0.2s cubic-bezier(0.3, 0, 0.5, 1); 134 | } 135 | 136 | .rendergraph-canvas-container.show { 137 | opacity: 1; 138 | } 139 | 140 | .rendergraph-canvas-container:after { 141 | content: attr(data-copyright); 142 | position: absolute; 143 | bottom: 50%; 144 | color: #fff; 145 | background: rgba(0, 0, 0, 0.6); 146 | padding: 9px 15px; 147 | opacity: 0; 148 | border-radius: 20px; 149 | font-weight: 100; 150 | left: 50%; 151 | transition: opacity 0.2s cubic-bezier(0.3, 0, 0.5, 1); 152 | transform: translate(-50%, 50%); 153 | pointer-events: none; 154 | } 155 | 156 | .rendergraph-canvas-container:hover:after { 157 | opacity: 1; 158 | } 159 | 160 | .rendergraph-holder { 161 | height: 5px; 162 | flex-wrap: wrap; 163 | } 164 | 165 | .rendergraph-control-container { 166 | width: 100%; 167 | margin-bottom: 5px; 168 | flex-wrap: wrap; 169 | display: flex; 170 | justify-content: space-between; 171 | } 172 | 173 | .viewer-loading { 174 | top: 0; 175 | left: 0; 176 | right: 0; 177 | bottom: 0; 178 | position: absolute; 179 | display: flex; 180 | align-items: center; 181 | justify-content: center; 182 | opacity: 0; 183 | pointer-events: none; 184 | transition: opacity 0.3s cubic-bezier(0.3, 0, 0.5, 1); 185 | } 186 | 187 | .viewer-loading.active { 188 | opacity: 1; 189 | } 190 | 191 | .viewer-loading::before { 192 | content: ""; 193 | background: #000; 194 | opacity: 0.5; 195 | position: absolute; 196 | top: 0; 197 | bottom: 0; 198 | left: 0; 199 | right: 0; 200 | z-index: 0; 201 | } 202 | 203 | .viewer-loading strong { 204 | margin-left: 5px; 205 | z-index: 1; 206 | } 207 | 208 | .viewer-loading.spinner-border { 209 | z-index: 1; 210 | } 211 | 212 | .viewer-container { 213 | position: relative; 214 | overflow: hidden; 215 | background: #dddfe2; 216 | border-radius: 20px; 217 | padding: 10px 20px; 218 | margin-top: 10px; 219 | width: 100%; 220 | display: none !important; 221 | } 222 | 223 | .viewer-control-container { 224 | background: #dddfe2; 225 | border-radius: 20px; 226 | padding: 10px 20px; 227 | margin-bottom: 5px; 228 | } 229 | 230 | #skin-refresh { 231 | transform-origin: center center; 232 | transform: rotate(30deg) translate(3px, -5px); 233 | cursor: pointer; 234 | transition: stroke 0.2s cubic-bezier(0.3, 0, 0.5, 1); 235 | stroke: #6c757d; 236 | } 237 | 238 | #skin-refresh:hover { 239 | stroke: #212529; 240 | } 241 | 242 | mark { 243 | user-select: all; 244 | } 245 | 246 | * { 247 | user-select: none; 248 | } 249 | 250 | .uploadskin-model-radio-label { 251 | margin-right: 10px; 252 | } 253 | 254 | .custom-file { 255 | overflow: hidden; 256 | } 257 | 258 | .uploadskin-model-radio-container { 259 | background: #dddfe2; 260 | margin-top: 10px; 261 | border-radius: 20px; 262 | padding: 10px 20px; 263 | } 264 | 265 | .paginate-container .pagination { 266 | display: inline-block; 267 | } 268 | 269 | .paginate-container { 270 | margin-top: 16px; 271 | margin-bottom: 16px; 272 | text-align: center; 273 | } 274 | 275 | .pagination .disabled, 276 | .pagination .disabled:hover, 277 | .pagination .gap, 278 | .pagination .gap:hover, 279 | .pagination [aria-disabled="true"], 280 | .pagination [aria-disabled="true"]:hover { 281 | color: #8d959c !important; 282 | cursor: default; 283 | border-color: transparent; 284 | } 285 | 286 | .pagination .next_page, 287 | .pagination .previous_page { 288 | color: #6c757d; 289 | } 290 | 291 | .pagination a, 292 | .pagination em, 293 | .pagination span { 294 | display: inline-block; 295 | min-width: 32px; 296 | padding: 5px 10px; 297 | margin: 0 2px; 298 | line-height: 20px; 299 | color: #24292e; 300 | font-style: normal; 301 | text-align: center; 302 | white-space: nowrap; 303 | vertical-align: middle; 304 | cursor: pointer; 305 | user-select: none; 306 | border: 2px solid transparent; 307 | border-radius: 8px; 308 | transition: border-color 0.2s cubic-bezier(0.3, 0, 0.5, 1); 309 | } 310 | 311 | .pagination .current, 312 | .pagination .current:hover, 313 | .pagination [aria-current]:not([aria-current="false"]) { 314 | cursor: default; 315 | color: #fff; 316 | background-color: #6c757d; 317 | border-color: transparent; 318 | } 319 | 320 | .pagination a:focus, 321 | .pagination a:hover, 322 | .pagination em:focus, 323 | .pagination em:hover, 324 | .pagination span:focus, 325 | .pagination span:hover { 326 | text-decoration: none; 327 | border-color: #dddddd; 328 | outline: 0; 329 | transition-duration: 0.1s; 330 | } 331 | 332 | .pagination .previous_page:before { 333 | margin-right: 4px; 334 | -webkit-clip-path: polygon( 335 | 9.8px 12.8px, 336 | 8.7px 12.8px, 337 | 4.5px 8.5px, 338 | 4.5px 7.5px, 339 | 8.7px 3.2px, 340 | 9.8px 4.3px, 341 | 6.1px 8px, 342 | 9.8px 11.7px, 343 | 9.8px 12.8px 344 | ); 345 | clip-path: polygon( 346 | 9.8px 12.8px, 347 | 8.7px 12.8px, 348 | 4.5px 8.5px, 349 | 4.5px 7.5px, 350 | 8.7px 3.2px, 351 | 9.8px 4.3px, 352 | 6.1px 8px, 353 | 9.8px 11.7px, 354 | 9.8px 12.8px 355 | ); 356 | } 357 | 358 | .pagination .next_page:after { 359 | margin-left: 4px; 360 | -webkit-clip-path: polygon( 361 | 6.2px 3.2px, 362 | 7.3px 3.2px, 363 | 11.5px 7.5px, 364 | 11.5px 8.5px, 365 | 7.3px 12.8px, 366 | 6.2px 11.7px, 367 | 9.9px 8px, 368 | 6.2px 4.3px, 369 | 6.2px 3.2px 370 | ); 371 | clip-path: polygon( 372 | 6.2px 3.2px, 373 | 7.3px 3.2px, 374 | 11.5px 7.5px, 375 | 11.5px 8.5px, 376 | 7.3px 12.8px, 377 | 6.2px 11.7px, 378 | 9.9px 8px, 379 | 6.2px 4.3px, 380 | 6.2px 3.2px 381 | ); 382 | } 383 | 384 | .pagination .next_page:after, 385 | .pagination .previous_page:before { 386 | display: inline-block; 387 | width: 16px; 388 | height: 16px; 389 | vertical-align: text-bottom; 390 | content: ""; 391 | background-color: currentColor; 392 | } 393 | 394 | @media (max-width: 544px) { 395 | .userlist-pagination-container .gap, 396 | .userlist-pagination-container a:not(.next_page):not(.previous_page) { 397 | display: none; 398 | } 399 | } -------------------------------------------------------------------------------- /src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/src/public/favicon.ico -------------------------------------------------------------------------------- /src/public/images/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/images/render/reading/background0001.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/src/public/images/render/reading/background0001.webp -------------------------------------------------------------------------------- /src/public/images/render/reading/layer_illum0001.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/src/public/images/render/reading/layer_illum0001.webp -------------------------------------------------------------------------------- /src/public/images/render/reading/layer_illum0002.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/src/public/images/render/reading/layer_illum0002.webp -------------------------------------------------------------------------------- /src/public/images/render/reading/layer_matcolor0001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/src/public/images/render/reading/layer_matcolor0001.png -------------------------------------------------------------------------------- /src/public/images/render/reading/layer_matcolor0002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/src/public/images/render/reading/layer_matcolor0002.png -------------------------------------------------------------------------------- /src/public/images/render/render_data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "reading", 4 | "width": "3840", 5 | "height": "2160", 6 | "copyright": "原图来自 icrdr", 7 | "images": { 8 | "background0001": "background0001.webp", 9 | "layer_illum0001": "layer_illum0001.webp", 10 | "layer_illum0002": "layer_illum0002.webp", 11 | "layer_matcolor0001": "layer_matcolor0001.png", 12 | "layer_matcolor0002": "layer_matcolor0002.png" 13 | }, 14 | "pos": { 15 | "background0001": [ 16 | 0, 17 | 0 18 | ], 19 | "first": [ 20 | 2598, 21 | 1043 22 | ], 23 | "second": [ 24 | 2875, 25 | 1023 26 | ] 27 | } 28 | }, 29 | { 30 | "name": "wrestler", 31 | "width": "1920", 32 | "height": "1080", 33 | "copyright": "原图来自 icrdr", 34 | "images": { 35 | "background0000": "background0000.webp", 36 | "background0001": "background0001.webp", 37 | "layer_illum0001": "layer_illum0001.webp", 38 | "layer_illum0002": "layer_illum0002.webp", 39 | "layer_matcolor0001": "layer_matcolor0001.png", 40 | "layer_matcolor0002": "layer_matcolor0002.png" 41 | }, 42 | "pos": { 43 | "background0001": [ 44 | 232, 45 | 49 46 | ], 47 | "first": [ 48 | 232, 49 | 49 50 | ], 51 | "second": [ 52 | 197, 53 | 30 54 | ] 55 | }, 56 | "filter": "sepia(.2)" 57 | } 58 | ] -------------------------------------------------------------------------------- /src/public/images/render/wrestler/background0000.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/src/public/images/render/wrestler/background0000.webp -------------------------------------------------------------------------------- /src/public/images/render/wrestler/background0001.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/src/public/images/render/wrestler/background0001.webp -------------------------------------------------------------------------------- /src/public/images/render/wrestler/layer_illum0001.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/src/public/images/render/wrestler/layer_illum0001.webp -------------------------------------------------------------------------------- /src/public/images/render/wrestler/layer_illum0002.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/src/public/images/render/wrestler/layer_illum0002.webp -------------------------------------------------------------------------------- /src/public/images/render/wrestler/layer_matcolor0001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/src/public/images/render/wrestler/layer_matcolor0001.png -------------------------------------------------------------------------------- /src/public/images/render/wrestler/layer_matcolor0002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GHAuth-Team/ghauth/b3247e27fdd5a057f595e5d397962efab0c93794/src/public/images/render/wrestler/layer_matcolor0002.png -------------------------------------------------------------------------------- /src/public/js/bs-custom-file-input.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * bsCustomFileInput v1.3.4 (https://github.com/Johann-S/bs-custom-file-input) 3 | * Copyright 2018 - 2020 Johann-S 4 | * Licensed under MIT (https://github.com/Johann-S/bs-custom-file-input/blob/master/LICENSE) 5 | */ 6 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).bsCustomFileInput=t()}(this,function(){"use strict";var s={CUSTOMFILE:'.custom-file input[type="file"]',CUSTOMFILELABEL:".custom-file-label",FORM:"form",INPUT:"input"},l=function(e){if(0 { 2 | function postData(data) { 3 | fetch('/api/changepassword', { 4 | method: 'POST', 5 | headers: { 6 | 'Content-Type': 'application/x-www-form-urlencoded', 7 | }, 8 | body: `data=${encodeURIComponent(data)}`, 9 | }) 10 | .then((result) => result.json()) 11 | .then((json) => { 12 | switch (json.code) { 13 | case -1: 14 | notyf.open({ 15 | type: 'error', 16 | message: json.msg, 17 | }); 18 | break; 19 | case 1000: 20 | notyf.open({ 21 | type: 'success', 22 | message: '密码修改成功,2秒后刷新...', 23 | }); 24 | setTimeout(() => { 25 | window.location.href = '/'; 26 | }, 2000); 27 | break; 28 | default: 29 | throw new Error('未知错误'); 30 | } 31 | document.querySelector('.btn-changepassword').removeAttribute('disabled'); 32 | }) 33 | .catch((e) => { 34 | notyf.open({ 35 | type: 'error', 36 | message: e, 37 | }); 38 | document.querySelector('.btn-changepassword').removeAttribute('disabled'); 39 | }); 40 | } 41 | 42 | document.querySelector('.btn-changepassword').addEventListener('click', () => { 43 | let oldPassword = document.querySelector('#old-password').value; 44 | let newPassword = document.querySelector('#new-password').value; 45 | const repeatPassword = document.querySelector('#repeat-new-password').value; 46 | 47 | if (!oldPassword || !newPassword || !repeatPassword) { 48 | notyf.open({ 49 | type: 'error', 50 | message: '邮箱/密码/游戏昵称不能为空', 51 | }); 52 | return; 53 | } 54 | 55 | if (newPassword !== repeatPassword) { 56 | notyf.open({ 57 | type: 'error', 58 | message: '两次密码输入不一致', 59 | }); 60 | return; 61 | } 62 | 63 | document.querySelector('.btn-changepassword').setAttribute('disabled', 'true'); 64 | notyf.open({ 65 | type: 'info', 66 | message: '提交中,请稍后...', 67 | }); 68 | 69 | fetch('/api/genkey', { method: 'POST' }) 70 | .then((result) => result.text()) 71 | .then((text) => { 72 | if (text.length === 32) { 73 | let oriHex = ''; 74 | for (let i = 0; i < text.length; i++) { 75 | oriHex += text.charCodeAt(i).toString(16).padStart(2, '0'); 76 | } 77 | let secret = ''; 78 | let iv = ''; 79 | for (let i = 0; i < oriHex.length; i += 1) { 80 | if (i % 2 === 0) { 81 | secret += oriHex[i]; 82 | } else { 83 | iv += oriHex[i]; 84 | } 85 | } 86 | 87 | secret = CryptoJS.enc.Hex.parse(secret); 88 | iv = CryptoJS.enc.Hex.parse(iv); 89 | oldPassword += 'dKfkZh'; 90 | oldPassword = CryptoJS.SHA3(oldPassword); 91 | oldPassword = oldPassword.toString(CryptoJS.enc.Hex); 92 | 93 | newPassword = `${newPassword}dKfkZh`; 94 | newPassword = CryptoJS.SHA3(newPassword); 95 | newPassword = newPassword.toString(CryptoJS.enc.Hex); 96 | const data = { 97 | oldPassword, 98 | newPassword, 99 | }; 100 | const encrypted = CryptoJS.AES.encrypt(JSON.stringify(data), secret, { iv, padding: CryptoJS.pad.ZeroPadding }); 101 | postData(encrypted.ciphertext.toString(CryptoJS.enc.Base64)); 102 | } else { 103 | throw new Error('传输凭证获取失败'); 104 | } 105 | }) 106 | .catch((e) => { 107 | notyf.open({ 108 | type: 'error', 109 | message: e, 110 | }); 111 | document.querySelector('.btn-changepassword').removeAttribute('disabled'); 112 | }); 113 | }); 114 | })(); 115 | -------------------------------------------------------------------------------- /src/public/js/forgetpw.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | // allow press enter to submit 3 | (() => { 4 | const emailEl = document.querySelector('#inputEmail'); 5 | const captchaEl = document.querySelector('#inputCaptcha'); 6 | 7 | const submitAction = (e) => { 8 | if (e.code !== 'Enter' && e.charCode !== 13 && e.key !== 'Enter') return; 9 | document.querySelector('.btn-forgetpw').click(); 10 | }; 11 | 12 | emailEl.addEventListener('keypress', submitAction); 13 | captchaEl.addEventListener('keypress', submitAction); 14 | })(); 15 | 16 | function refreshCaptcha() { 17 | document.querySelector('#img-captcha').src = `/api/captcha?t=${Date.now()}`; 18 | } 19 | 20 | function postData(data) { 21 | fetch('/forgetpw', { 22 | method: 'POST', 23 | headers: { 24 | 'Content-Type': 'application/x-www-form-urlencoded', 25 | }, 26 | body: `data=${encodeURIComponent(data)}`, 27 | }) 28 | .then((result) => result.json()) 29 | .then((json) => { 30 | switch (json.code) { 31 | case -1: 32 | notyf.open({ 33 | type: 'error', 34 | message: json.msg, 35 | }); 36 | break; 37 | case 1000: 38 | notyf.open({ 39 | type: 'success', 40 | message: '邮件已发送,有效期5分钟,请查收', 41 | }); 42 | break; 43 | default: 44 | throw new Error('未知错误'); 45 | } 46 | refreshCaptcha(); 47 | document.querySelector('.btn-forgetpw').removeAttribute('disabled'); 48 | }) 49 | .catch((e) => { 50 | notyf.open({ 51 | type: 'error', 52 | message: e, 53 | }); 54 | refreshCaptcha(); 55 | document.querySelector('.btn-forgetpw').removeAttribute('disabled'); 56 | }); 57 | } 58 | 59 | document.querySelector('#img-captcha').addEventListener('click', refreshCaptcha); 60 | 61 | document.querySelector('.btn-forgetpw').addEventListener('click', () => { 62 | const email = document.querySelector('#inputEmail').value; 63 | const captcha = document.querySelector('#inputCaptcha').value; 64 | if (!email || !captcha) { 65 | notyf.open({ 66 | type: 'error', 67 | message: '邮箱/验证码不能为空', 68 | }); 69 | return; 70 | } 71 | 72 | if (!/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(email)) { 73 | notyf.open({ 74 | type: 'error', 75 | message: '输入的邮箱格式不正确', 76 | }); 77 | return; 78 | } 79 | 80 | document.querySelector('.btn-forgetpw').setAttribute('disabled', 'true'); 81 | notyf.open({ 82 | type: 'info', 83 | message: '提交中,请稍后...', 84 | }); 85 | 86 | fetch('/api/genkey', { method: 'POST' }) 87 | .then((result) => result.text()) 88 | .then((text) => { 89 | if (text.length === 32) { 90 | let oriHex = ''; 91 | for (let i = 0; i < text.length; i += 1) { 92 | oriHex += text.charCodeAt(i).toString(16).padStart(2, '0'); 93 | } 94 | let secret = ''; 95 | let iv = ''; 96 | for (let i = 0; i < oriHex.length; i += 1) { 97 | if (i % 2 === 0) { 98 | secret += oriHex[i]; 99 | } else { 100 | iv += oriHex[i]; 101 | } 102 | } 103 | 104 | secret = CryptoJS.enc.Hex.parse(secret); 105 | iv = CryptoJS.enc.Hex.parse(iv); 106 | const data = { 107 | email, 108 | captcha, 109 | }; 110 | const encrypted = CryptoJS.AES.encrypt(JSON.stringify(data), secret, { iv, padding: CryptoJS.pad.ZeroPadding }); 111 | postData(encrypted.ciphertext.toString(CryptoJS.enc.Base64)); 112 | } else { 113 | throw new Error('传输凭证获取失败'); 114 | } 115 | }) 116 | .catch((e) => { 117 | notyf.open({ 118 | type: 'error', 119 | message: e, 120 | }); 121 | document.querySelector('.btn-forgetpw').removeAttribute('disabled'); 122 | }); 123 | }); 124 | })(); 125 | -------------------------------------------------------------------------------- /src/public/js/forgetpw_change.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | function refreshCaptcha() { 3 | document.querySelector('#img-captcha').src = `/api/captcha?t=${Date.now()}`; 4 | } 5 | 6 | function postData(data) { 7 | fetch('', { 8 | method: 'POST', 9 | headers: { 10 | 'Content-Type': 'application/x-www-form-urlencoded', 11 | }, 12 | body: `data=${encodeURIComponent(data)}`, 13 | }) 14 | .then((result) => result.json()) 15 | .then((json) => { 16 | switch (json.code) { 17 | case -1: 18 | notyf.open({ 19 | type: 'error', 20 | message: json.msg, 21 | }); 22 | break; 23 | case 1000: 24 | notyf.open({ 25 | type: 'success', 26 | message: '密码修改成功,2秒后跳转到登录页...', 27 | }); 28 | setTimeout(() => { 29 | window.location.href = '/login'; 30 | }, 2000); 31 | break; 32 | default: 33 | throw new Error('未知错误'); 34 | } 35 | refreshCaptcha(); 36 | document.querySelector('.btn-forgetpw').removeAttribute('disabled'); 37 | }) 38 | .catch((e) => { 39 | notyf.open({ 40 | type: 'error', 41 | message: e, 42 | }); 43 | refreshCaptcha(); 44 | document.querySelector('.btn-forgetpw').removeAttribute('disabled'); 45 | }); 46 | } 47 | 48 | document.querySelector('#img-captcha').addEventListener('click', refreshCaptcha); 49 | 50 | document.querySelector('.btn-forgetpw').addEventListener('click', () => { 51 | const rawPassword = document.querySelector('#inputPassword').value; 52 | const repeatPassword = document.querySelector('#inputRepeatPassword').value; 53 | const captcha = document.querySelector('#inputCaptcha').value; 54 | if (!rawPassword || !repeatPassword || !captcha) { 55 | notyf.open({ 56 | type: 'error', 57 | message: '密码/验证码不能为空', 58 | }); 59 | return; 60 | } 61 | 62 | if (rawPassword !== repeatPassword) { 63 | notyf.open({ 64 | type: 'error', 65 | message: '两次密码输入不一致', 66 | }); 67 | return; 68 | } 69 | 70 | document.querySelector('.btn-forgetpw').setAttribute('disabled', 'true'); 71 | notyf.open({ 72 | type: 'info', 73 | message: '提交中,请稍后...', 74 | }); 75 | 76 | fetch('/api/genkey', { method: 'POST' }) 77 | .then((result) => result.text()) 78 | .then((text) => { 79 | if (text.length === 32) { 80 | let oriHex = ''; 81 | for (let i = 0; i < text.length; i++) { 82 | oriHex += text.charCodeAt(i).toString(16).padStart(2, '0'); 83 | } 84 | let secret = ''; 85 | let iv = ''; 86 | for (let i = 0; i < oriHex.length; i += 1) { 87 | if (i % 2 === 0) { 88 | secret += oriHex[i]; 89 | } else { 90 | iv += oriHex[i]; 91 | } 92 | } 93 | 94 | secret = CryptoJS.enc.Hex.parse(secret); 95 | iv = CryptoJS.enc.Hex.parse(iv); 96 | let password = `${rawPassword}dKfkZh`; 97 | password = CryptoJS.SHA3(password); 98 | password = password.toString(CryptoJS.enc.Hex); 99 | const data = { 100 | password, 101 | captcha, 102 | }; 103 | const encrypted = CryptoJS.AES.encrypt(JSON.stringify(data), secret, { iv, padding: CryptoJS.pad.ZeroPadding }); 104 | postData(encrypted.ciphertext.toString(CryptoJS.enc.Base64)); 105 | } else { 106 | throw new Error('传输凭证获取失败'); 107 | } 108 | }) 109 | .catch((e) => { 110 | notyf.open({ 111 | type: 'error', 112 | message: e, 113 | }); 114 | document.querySelector('.btn-forgetpw').removeAttribute('disabled'); 115 | }); 116 | }); 117 | })(); 118 | -------------------------------------------------------------------------------- /src/public/js/login.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | // allow press enter to submit 3 | (() => { 4 | const emailEl = document.querySelector('#inputEmail'); 5 | const rawPasswordEl = document.querySelector('#inputPassword'); 6 | const captchaEl = document.querySelector('#inputCaptcha'); 7 | 8 | const submitAction = (e) => { 9 | if (e.code !== 'Enter' && e.charCode !== 13 && e.key !== 'Enter') return; 10 | document.querySelector('.btn-login').click(); 11 | }; 12 | 13 | emailEl.addEventListener('keypress', submitAction); 14 | rawPasswordEl.addEventListener('keypress', submitAction); 15 | captchaEl.addEventListener('keypress', submitAction); 16 | })(); 17 | 18 | function refreshCaptcha() { 19 | document.querySelector('#img-captcha').src = `/api/captcha?t=${Date.now()}`; 20 | } 21 | 22 | function postData(data) { 23 | fetch('/login', { 24 | method: 'POST', 25 | headers: { 26 | 'Content-Type': 'application/x-www-form-urlencoded', 27 | }, 28 | body: `data=${encodeURIComponent(data)}`, 29 | }) 30 | .then((result) => result.json()) 31 | .then((json) => { 32 | switch (json.code) { 33 | case -1: 34 | notyf.open({ 35 | type: 'error', 36 | message: json.msg, 37 | }); 38 | break; 39 | case 1000: 40 | notyf.open({ 41 | type: 'success', 42 | message: '登录成功,2秒后跳转到首页...', 43 | }); 44 | setTimeout(() => { 45 | window.location.href = '/'; 46 | }, 2000); 47 | break; 48 | default: 49 | throw new Error('未知错误'); 50 | } 51 | refreshCaptcha(); 52 | document.querySelector('.btn-login').removeAttribute('disabled'); 53 | }) 54 | .catch((e) => { 55 | notyf.open({ 56 | type: 'error', 57 | message: e, 58 | }); 59 | refreshCaptcha(); 60 | document.querySelector('.btn-login').removeAttribute('disabled'); 61 | }); 62 | } 63 | 64 | document.querySelector('#img-captcha').addEventListener('click', refreshCaptcha); 65 | 66 | document.querySelector('.btn-login').addEventListener('click', () => { 67 | const email = document.querySelector('#inputEmail').value; 68 | const rawPassword = document.querySelector('#inputPassword').value; 69 | const captcha = document.querySelector('#inputCaptcha').value; 70 | if (!email || !rawPassword || !captcha) { 71 | notyf.open({ 72 | type: 'error', 73 | message: '邮箱/密码/验证码不能为空', 74 | }); 75 | return; 76 | } 77 | 78 | if (!/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(email)) { 79 | notyf.open({ 80 | type: 'error', 81 | message: '输入的邮箱格式不正确', 82 | }); 83 | return; 84 | } 85 | 86 | document.querySelector('.btn-login').setAttribute('disabled', 'true'); 87 | notyf.open({ 88 | type: 'info', 89 | message: '提交中,请稍后...', 90 | }); 91 | 92 | fetch('/api/genkey', { method: 'POST' }) 93 | .then((result) => result.text()) 94 | .then((text) => { 95 | if (text.length === 32) { 96 | let oriHex = ''; 97 | for (let i = 0; i < text.length; i += 1) { 98 | oriHex += text.charCodeAt(i).toString(16).padStart(2, '0'); 99 | } 100 | let secret = ''; 101 | let iv = ''; 102 | for (let i = 0; i < oriHex.length; i += 1) { 103 | if (i % 2 === 0) { 104 | secret += oriHex[i]; 105 | } else { 106 | iv += oriHex[i]; 107 | } 108 | } 109 | 110 | secret = CryptoJS.enc.Hex.parse(secret); 111 | iv = CryptoJS.enc.Hex.parse(iv); 112 | let password = `${rawPassword}dKfkZh`; 113 | password = CryptoJS.SHA3(password); 114 | password = password.toString(CryptoJS.enc.Hex); 115 | const data = { 116 | email, 117 | password, 118 | captcha, 119 | }; 120 | const encrypted = CryptoJS.AES.encrypt(JSON.stringify(data), secret, { iv, padding: CryptoJS.pad.ZeroPadding }); 121 | postData(encrypted.ciphertext.toString(CryptoJS.enc.Base64)); 122 | } else { 123 | throw new Error('传输凭证获取失败'); 124 | } 125 | }) 126 | .catch((e) => { 127 | notyf.open({ 128 | type: 'error', 129 | message: e, 130 | }); 131 | document.querySelector('.btn-login').removeAttribute('disabled'); 132 | }); 133 | }); 134 | })(); 135 | -------------------------------------------------------------------------------- /src/public/js/main.js: -------------------------------------------------------------------------------- 1 | /* exported notyf */ 2 | const notyf = new Notyf({ 3 | duration: 2000, 4 | position: { 5 | x: 'right', 6 | y: 'top', 7 | }, 8 | types: [ 9 | { 10 | type: 'success', 11 | background: 'green', 12 | className: 'notyf__toast--success', 13 | 14 | dismissible: true, 15 | icon: false, 16 | }, 17 | { 18 | type: 'warning', 19 | background: 'orange', 20 | className: 'notyf__toast--warning', 21 | dismissible: true, 22 | icon: false, 23 | }, 24 | { 25 | type: 'error', 26 | background: 'indianred', 27 | className: 'notyf__toast--error', 28 | dismissible: true, 29 | icon: false, 30 | }, 31 | { 32 | type: 'info', 33 | background: '#2196f3', 34 | className: 'notyf__toast--info', 35 | dismissible: true, 36 | icon: false, 37 | }, 38 | ], 39 | }); 40 | 41 | window.notyf = notyf; 42 | -------------------------------------------------------------------------------- /src/public/js/main_loggedin.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | function checkBrowser() { 3 | try { 4 | // canvas toBlob检测 5 | var tempCanvas = document.createElement("canvas"); 6 | tempCanvas.toBlob(function () { 7 | tempCanvas.remove(); 8 | }); 9 | } catch (error) { 10 | document.querySelector("#browser-warning").style.display = "block"; 11 | console.log(error) 12 | } 13 | } 14 | checkBrowser(); 15 | 16 | /* eslint-disable */ 17 | // yggdrasil配置按钮 拖拽 18 | document.querySelector('.btn-dnd-button').addEventListener('dragstart', (e) => { 19 | const btn = e.target; 20 | const content = `authlib-injector:yggdrasil-server:${encodeURIComponent(btn.dataset.clipboardText)}`; 21 | e.dataTransfer && (e.dataTransfer.setData('text/plain', content), e.dataTransfer.dropEffect = 'copy'); 22 | }); 23 | 24 | // yggdrasil配置按钮 点击 25 | document.querySelector('.btn-dnd-button').addEventListener('click', (e) => { 26 | const btn = e.target; 27 | const content = btn.dataset.clipboardText; 28 | const tInput = document.createElement('input'); 29 | tInput.style.visibility = 'none', tInput.value = content, document.body.appendChild(tInput), tInput.select(), document.execCommand('copy'), tInput.remove(); 30 | const rawText = btn.textContent; 31 | btn.disabled = !0, btn.innerHTML = '已复制', setTimeout(() => { 32 | btn.textContent = rawText, btn.disabled = !1; 33 | }, 1e3); 34 | }); 35 | /* eslint-enable */ 36 | 37 | // 切换皮肤浏览至 模型 38 | document.querySelector('#viewer3DModelRadio').onchange = (e) => { 39 | if (e.target.value === 'on') { 40 | window.pauseSkinviewer(false); 41 | document.querySelector('#skinData').dataset.type = '0'; 42 | window.refreshSkinviewer(); 43 | document.querySelector('.viewer-container.active').classList.remove('active'); 44 | document.querySelector('.skinviewer-main-container').classList.add('active'); 45 | } 46 | }; 47 | 48 | // 切换皮肤浏览至 原始图片 49 | document.querySelector('#viewerRawImage').onchange = (e) => { 50 | if (e.target.value === 'on') { 51 | window.pauseSkinviewer(true); 52 | document.querySelector('#skinData').dataset.type = '1'; 53 | window.refreshRawImage(); 54 | document.querySelector('.viewer-container.active').classList.remove('active'); 55 | document.querySelector('.rawimage-main-container').classList.add('active'); 56 | } 57 | }; 58 | 59 | // 切换皮肤浏览至 渲染图 60 | document.querySelector('#viewerRenderGraph').onchange = (e) => { 61 | if (e.target.value === 'on') { 62 | window.pauseSkinviewer(true); 63 | document.querySelector('#skinData').dataset.type = '2'; 64 | window.refreshRenderGraph(); 65 | document.querySelector('.viewer-container.active').classList.remove('active'); 66 | document.querySelector('.rendergraph-main-container').classList.add('active'); 67 | } 68 | }; 69 | window.refreshViewer = (remote) => { 70 | if (remote) { 71 | fetch('/api/ownskin', { method: 'GET' }) 72 | .then((result) => result.json()) 73 | .then((json) => { 74 | if (json.code === '-1') { 75 | notyf.open({ 76 | type: 'error', 77 | message: '皮肤信息获取失败', 78 | }); 79 | } else { 80 | document.querySelector('#skinData').data = json.data; 81 | const { type } = document.querySelector('#skinData').dataset; 82 | if (type === '0') { 83 | window.refreshSkinviewer(); 84 | } else if (type === '1') { 85 | window.refreshRawImage(); 86 | } else { 87 | window.refreshRenderGraph(); 88 | } 89 | } 90 | }) 91 | .catch(() => { 92 | notyf.open({ 93 | type: 'error', 94 | message: '皮肤信息获取失败', 95 | }); 96 | }); 97 | } else { 98 | const { type } = document.querySelector('#skinData').dataset; 99 | if (type === '0') { 100 | window.refreshSkinviewer(); 101 | } else if (type === '1') { 102 | window.refreshRawImage(); 103 | } else { 104 | window.refreshRenderGraph(); 105 | } 106 | } 107 | }; 108 | document.querySelector('#skin-refresh').addEventListener('click', () => { 109 | window.refreshViewer(true); 110 | }); 111 | })(); 112 | -------------------------------------------------------------------------------- /src/public/js/notyf.js: -------------------------------------------------------------------------------- 1 | var Notyf=function(){"use strict";var n,t,o=function(){return(o=Object.assign||function(t){for(var i,e=1,n=arguments.length;e { 2 | window.refreshRawImage = () => { 3 | document.querySelector('#rawimage-loading').classList.add('active'); 4 | document.querySelector('#raw-image').src = document.querySelector('#skinData').data.skin; 5 | setTimeout(() => { 6 | document.querySelector('#rawimage-loading').classList.remove('active'); 7 | }, 200); 8 | }; 9 | })(); 10 | -------------------------------------------------------------------------------- /src/public/js/register.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | // allow press enter to submit 3 | (() => { 4 | const emailEl = document.querySelector('#inputEmail'); 5 | const playernameEl = document.querySelector('#inputPlayerName'); 6 | const rawPasswordEl = document.querySelector('#inputPassword'); 7 | const repeatPasswordEl = document.querySelector('#inputRepeatPassword'); 8 | const captchaCodeEl = document.querySelector('#inputCaptcha'); 9 | 10 | const submitAction = (e) => { 11 | if (e.code !== 'Enter' && e.charCode !== 13 && e.key !== 'Enter') return; 12 | document.querySelector('.btn-register').click(); 13 | }; 14 | 15 | emailEl.addEventListener('keypress', submitAction); 16 | playernameEl.addEventListener('keypress', submitAction); 17 | rawPasswordEl.addEventListener('keypress', submitAction); 18 | repeatPasswordEl.addEventListener('keypress', submitAction); 19 | captchaCodeEl.addEventListener('keypress', submitAction); 20 | })(); 21 | 22 | function refreshCaptcha() { 23 | document.querySelector('#img-captcha').src = `/api/captcha?t=${Date.now()}`; 24 | } 25 | 26 | function postData(data) { 27 | fetch('/register', { 28 | method: 'POST', 29 | headers: { 30 | 'Content-Type': 'application/x-www-form-urlencoded', 31 | }, 32 | body: `data=${encodeURIComponent(data)}`, 33 | }) 34 | .then((result) => result.json()) 35 | .then((json) => { 36 | switch (json.code) { 37 | case -1: 38 | notyf.open({ 39 | type: 'error', 40 | message: json.msg, 41 | }); 42 | break; 43 | case 1000: 44 | notyf.open({ 45 | type: 'success', 46 | message: '注册成功,2秒后跳转到首页...', 47 | }); 48 | setTimeout(() => { 49 | window.location.href = '/'; 50 | }, 2000); 51 | break; 52 | default: 53 | throw new Error('未知错误'); 54 | } 55 | refreshCaptcha(); 56 | document.querySelector('.btn-register').removeAttribute('disabled'); 57 | }) 58 | .catch((e) => { 59 | notyf.open({ 60 | type: 'error', 61 | message: e, 62 | }); 63 | refreshCaptcha(); 64 | document.querySelector('.btn-register').removeAttribute('disabled'); 65 | }); 66 | } 67 | 68 | document.querySelector('#img-captcha').addEventListener('click', refreshCaptcha); 69 | document.querySelector('.btn-register').addEventListener('click', () => { 70 | const email = document.querySelector('#inputEmail').value; 71 | const playername = document.querySelector('#inputPlayerName').value; 72 | const rawPassword = document.querySelector('#inputPassword').value; 73 | const repeatPassword = document.querySelector('#inputRepeatPassword').value; 74 | const captchaCode = document.querySelector('#inputCaptcha').value; 75 | if (!captchaCode) { 76 | notyf.open({ 77 | type: 'error', 78 | message: '验证码不能为空', 79 | }); 80 | return; 81 | } 82 | 83 | if (!email || !rawPassword || !playername || !repeatPassword) { 84 | notyf.open({ 85 | type: 'error', 86 | message: '邮箱/密码/游戏昵称不能为空', 87 | }); 88 | return; 89 | } 90 | 91 | if (rawPassword !== repeatPassword) { 92 | notyf.open({ 93 | type: 'error', 94 | message: '两次密码输入不一致', 95 | }); 96 | return; 97 | } 98 | 99 | if (!/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test(email)) { 100 | notyf.open({ 101 | type: 'error', 102 | message: '输入的邮箱格式不正确', 103 | }); 104 | return; 105 | } 106 | 107 | if (!/^[A-Za-z0-9_]+$/.test(playername) || playername.length < 4 || playername.length > 12) { 108 | notyf.open({ 109 | type: 'error', 110 | message: '输入的游戏昵称不合法', 111 | }); 112 | return; 113 | } 114 | 115 | document.querySelector('.btn-register').setAttribute('disabled', 'true'); 116 | notyf.open({ 117 | type: 'info', 118 | message: '提交中,请稍后...', 119 | }); 120 | fetch('/api/genkey', { method: 'POST' }) 121 | .then((result) => result.text()) 122 | .then((text) => { 123 | if (text.length === 32) { 124 | let oriHex = ''; 125 | for (let i = 0; i < text.length; i += 1) { 126 | oriHex += text.charCodeAt(i).toString(16).padStart(2, '0'); 127 | } 128 | let secret = ''; 129 | let iv = ''; 130 | for (let i = 0; i < oriHex.length; i += 1) { 131 | if (i % 2 === 0) { 132 | secret += oriHex[i]; 133 | } else { 134 | iv += oriHex[i]; 135 | } 136 | } 137 | 138 | secret = CryptoJS.enc.Hex.parse(secret); 139 | iv = CryptoJS.enc.Hex.parse(iv); 140 | let password = `${rawPassword}dKfkZh`; 141 | password = CryptoJS.SHA3(password); 142 | password = password.toString(CryptoJS.enc.Hex); 143 | const data = { 144 | email, 145 | playername, 146 | password, 147 | captcha: captchaCode, 148 | }; 149 | const encrypted = CryptoJS.AES.encrypt(JSON.stringify(data), secret, { iv, padding: CryptoJS.pad.ZeroPadding }); 150 | postData(encrypted.ciphertext.toString(CryptoJS.enc.Base64)); 151 | } else { 152 | throw new Error('传输凭证获取失败'); 153 | } 154 | }) 155 | .catch((e) => { 156 | notyf.open({ 157 | type: 'error', 158 | message: e, 159 | }); 160 | document.querySelector('.btn-register').removeAttribute('disabled'); 161 | }); 162 | }); 163 | })(); 164 | -------------------------------------------------------------------------------- /src/public/js/rendergraph_control.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const images = {}; 3 | const skinImage = new Image(); 4 | const texturecontainer = document.createElement('div'); 5 | const rendercanvas = document.querySelector('#rendergraph-image'); 6 | const remapcanvas = document.createElement('canvas'); 7 | const blurcanvas = document.createElement('canvas'); 8 | const blurctx = blurcanvas.getContext('2d'); 9 | let currectImage = 0; 10 | let canRenderDirectly = true; 11 | let resourcesNeedToLoad = 0; 12 | let resourcesLoadedCounter = 0; 13 | let renderlist = {}; 14 | const resizecanvas = document.createElement('canvas'); 15 | function resizeSkin(image, zoom) { 16 | const ctx = resizecanvas.getContext('2d'); 17 | 18 | resizecanvas.width = image.width * zoom | 0; 19 | resizecanvas.height = resizecanvas.width; 20 | 21 | ctx.mozImageSmoothingEnabled = false; 22 | ctx.webkitImageSmoothingEnabled = false; 23 | ctx.msImageSmoothingEnabled = false; 24 | ctx.imageSmoothingEnabled = false; 25 | ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, image.width * zoom | 0, image.height * zoom | 0); 26 | } 27 | 28 | function blurCanvas(canvas) { 29 | // 将传入的 canvas 复制到 blurcanvas 上,并将其模糊 30 | blurcanvas.height = canvas.height; 31 | blurcanvas.width = canvas.width; 32 | blurctx.clearRect(0, 0, blurcanvas.width, blurcanvas.height); 33 | blurctx.filter = 'blur(1px)'; 34 | blurctx.drawImage(canvas, 0, 0); 35 | } 36 | 37 | function remap(skin, map, illum) { 38 | // 将皮肤缩放至合适大小 39 | resizeSkin(skin, 256 / skin.width); 40 | const SkinPixelData = resizecanvas.getContext('2d').getImageData(0, 0, resizecanvas.width, resizecanvas.height).data; 41 | 42 | // 将皮肤映射至图层 43 | const remapctx = remapcanvas.getContext('2d'); 44 | remapcanvas.width = map.width; 45 | remapcanvas.height = map.height; 46 | remapctx.drawImage(map, 0, 0); 47 | 48 | const mapImageData = remapctx.getImageData(0, 0, remapcanvas.width, remapcanvas.height); 49 | const mapPixelData = mapImageData.data; 50 | 51 | for (let i = 0; i < mapPixelData.length; i += 4) { 52 | if (mapPixelData[i + 3] > 0) { 53 | const loc = (mapPixelData[i + 1] * resizecanvas.width + mapPixelData[i]) * 4; 54 | mapPixelData[i] = (mapPixelData[i + 3] * SkinPixelData[loc + 0]) / 255; 55 | mapPixelData[i + 1] = (mapPixelData[i + 3] * SkinPixelData[loc + 1]) / 255; 56 | mapPixelData[i + 2] = (mapPixelData[i + 3] * SkinPixelData[loc + 2]) / 255; 57 | mapPixelData[i + 3] = (mapPixelData[i + 3] * SkinPixelData[loc + 3]) / 255; 58 | } 59 | } 60 | remapctx.putImageData(mapImageData, 0, 0); 61 | 62 | // 绘制光影 63 | remapctx.globalCompositeOperation = 'multiply'; 64 | remapctx.drawImage(illum, 0, 0); 65 | remapctx.globalCompositeOperation = 'source-over'; 66 | 67 | // 处理光影Alpha通道 68 | const finalImageData = remapctx.getImageData(0, 0, remapcanvas.width, remapcanvas.height); 69 | const finalPixelData = finalImageData.data; 70 | 71 | for (let i = 3; i < finalPixelData.length; i += 4) { 72 | finalPixelData[i] = mapPixelData[i]; 73 | } 74 | 75 | remapctx.putImageData(finalImageData, 0, 0); 76 | } 77 | 78 | function compose() { 79 | const ctx = rendercanvas.getContext('2d'); 80 | rendercanvas.width = renderlist[currectImage].width; 81 | rendercanvas.height = renderlist[currectImage].height; 82 | 83 | // 使用滤镜 84 | if (renderlist[currectImage].filter) { 85 | ctx.filter = renderlist[currectImage].filter; 86 | } else { 87 | ctx.filter = ''; 88 | } 89 | 90 | // 绘制背景1(临时绘制,定位用) 91 | ctx.drawImage( 92 | images[renderlist[currectImage].name].background0001, // 背景层(可能不完整) 93 | renderlist[currectImage].pos.background0001[0], // x 94 | renderlist[currectImage].pos.background0001[1], // y 95 | ); 96 | 97 | // 模糊背景1 98 | blurCanvas(resizecanvas); 99 | 100 | // 绘制模糊的背景1 101 | ctx.drawImage( 102 | blurcanvas, 103 | renderlist[currectImage].pos.background0001[0], // x 104 | renderlist[currectImage].pos.background0001[1], // y 105 | ); 106 | 107 | // 绘制背景1(衔接玩家主体)(必须) 108 | ctx.drawImage( 109 | images[renderlist[currectImage].name].background0001, // 背景层(可能不完整) 110 | renderlist[currectImage].pos.background0001[0], // x 111 | renderlist[currectImage].pos.background0001[1], // y 112 | ); 113 | 114 | // 玩家一层皮肤映射(必须) 115 | remap( 116 | skinImage, 117 | images[renderlist[currectImage].name].layer_matcolor0001, // 映射层 118 | images[renderlist[currectImage].name].layer_illum0001, // 灯光层 119 | ); 120 | 121 | // 模糊第一层皮肤 122 | blurCanvas(remapcanvas); 123 | 124 | // 绘制模糊的第一层皮肤 125 | ctx.drawImage( 126 | blurcanvas, 127 | renderlist[currectImage].pos.first[0], // x 128 | renderlist[currectImage].pos.first[1], // y 129 | ); 130 | 131 | // 绘制玩家一层皮肤,透明度为0.5 132 | ctx.globalAlpha = 0.5; 133 | ctx.drawImage( 134 | remapcanvas, 135 | renderlist[currectImage].pos.first[0], // x 136 | renderlist[currectImage].pos.first[1], // y 137 | ); 138 | ctx.globalAlpha = 1; 139 | 140 | // 绘制背景2(用于抗锯齿,若提供则必须完整大小)(可选) 141 | if (images[renderlist[currectImage].name].background0000) { 142 | ctx.drawImage(images[renderlist[currectImage].name].background0000, 0, 0); 143 | } 144 | 145 | // 玩家二层皮肤映射 146 | remap( 147 | skinImage, 148 | images[renderlist[currectImage].name].layer_matcolor0002, // 映射层 149 | images[renderlist[currectImage].name].layer_illum0002, // 灯光层 150 | ); 151 | 152 | // 模糊第二层皮肤 153 | blurCanvas(remapcanvas); 154 | 155 | // 绘制模糊的第二层皮肤 156 | ctx.drawImage( 157 | blurcanvas, 158 | renderlist[currectImage].pos.second[0], // x 159 | renderlist[currectImage].pos.second[1], // y 160 | ); 161 | 162 | // 绘制玩家二层皮肤,透明度为0.5 163 | ctx.globalAlpha = 0.5; 164 | ctx.drawImage( 165 | remapcanvas, 166 | renderlist[currectImage].pos.second[0], // x 167 | renderlist[currectImage].pos.second[1], // y 168 | ); 169 | ctx.globalAlpha = 1; 170 | 171 | // 设置版权信息 172 | document.querySelector('.rendergraph-canvas-container').dataset.copyright = renderlist[currectImage].copyright; 173 | 174 | setTimeout(() => { 175 | document.querySelector('#rendergraph-loading').classList.remove('active'); 176 | document.querySelector('.rendergraph-canvas-container').classList.add('show'); 177 | }, 200); 178 | } 179 | 180 | function onResourcesLoaded() { 181 | resourcesLoadedCounter += 1; 182 | if (resourcesLoadedCounter === resourcesNeedToLoad) { 183 | resourcesLoadedCounter = 0; 184 | setTimeout(() => { 185 | compose(); 186 | }, 100); 187 | } 188 | } 189 | 190 | function onSkinImageLoad() { 191 | document.querySelector('#rendergraph-loading').classList.add('active'); 192 | document.querySelector('.rendergraph-canvas-container').classList.remove('show'); 193 | canRenderDirectly = true; 194 | if (!images[renderlist[currectImage].name]) { 195 | canRenderDirectly = false; 196 | resourcesNeedToLoad = Object.keys(renderlist[currectImage].images).length; 197 | images[renderlist[currectImage].name] = {}; 198 | for (const key of Object.keys(renderlist[currectImage].images)) { 199 | const image = new Image(); 200 | images[renderlist[currectImage].name][key] = image; 201 | texturecontainer.appendChild(image); 202 | image.onload = onResourcesLoaded; 203 | image.src = `/images/render/${renderlist[currectImage].name}/${renderlist[currectImage].images[key]}`; 204 | } 205 | } 206 | if (canRenderDirectly) { 207 | setTimeout(() => { 208 | compose(); 209 | }, 200); 210 | } 211 | } 212 | 213 | skinImage.onload = onSkinImageLoad; 214 | 215 | function init() { 216 | fetch('./images/render/render_data.json') 217 | .then((result) => result.json()) 218 | .then((result) => { 219 | renderlist = result; 220 | }) 221 | .catch(() => { 222 | notyf.open({ 223 | type: 'error', 224 | message: '拉取渲染图数据时发生错误', 225 | }); 226 | }); 227 | } 228 | 229 | window.refreshRenderGraph = () => { 230 | skinImage.src = document.querySelector('#skinData').data.skin; 231 | }; 232 | 233 | try { 234 | init(); 235 | } catch (error) { 236 | notyf.open({ 237 | type: 'error', 238 | message: '皮肤渲染图模块发生错误', 239 | }); 240 | } 241 | 242 | const rendergraphPreviousButton = document.querySelector('#rendergraphPreviousButton'); 243 | const rendergraphNextButton = document.querySelector('#rendergraphNextButton'); 244 | 245 | function checkCurrectImage() { 246 | if (currectImage > 0) { 247 | rendergraphPreviousButton.classList.remove('disabled'); 248 | } else { 249 | rendergraphPreviousButton.classList.add('disabled'); 250 | } 251 | 252 | if (currectImage < renderlist.length - 1) { 253 | rendergraphNextButton.classList.remove('disabled'); 254 | } else { 255 | rendergraphNextButton.classList.add('disabled'); 256 | } 257 | 258 | window.refreshRenderGraph(); 259 | } 260 | 261 | rendergraphPreviousButton.addEventListener('click', (e) => { 262 | if (e.target.classList.contains('disabled')) return; 263 | currectImage -= 1; 264 | checkCurrectImage(); 265 | }); 266 | 267 | rendergraphNextButton.addEventListener('click', (e) => { 268 | if (e.target.classList.contains('disabled')) return; 269 | currectImage += 1; 270 | checkCurrectImage(); 271 | }); 272 | })(); 273 | -------------------------------------------------------------------------------- /src/public/js/skinviewer_control.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const skinViewer = { 3 | el: document.querySelector('.skinviewer-container'), 4 | handle: null, 5 | action: { 6 | walk: null, 7 | run: null, 8 | }, 9 | rotate: null, 10 | }; 11 | 12 | function resizeSkinViewer() { 13 | skinViewer.handle.width = skinViewer.el.offsetWidth; 14 | skinViewer.handle.height = skinViewer.el.offsetHeight; 15 | } 16 | 17 | fetch('/api/ownskin', { method: 'GET' }) 18 | .then((result) => result.json()) 19 | .then((json) => { 20 | if (json.code === '-1') { 21 | notyf.open({ 22 | type: 'error', 23 | message: '皮肤信息获取失败', 24 | }); 25 | } else { 26 | try { 27 | document.querySelector('#skinData').data = json.data; 28 | skinViewer.handle = new skinview3d.FXAASkinViewer({ 29 | canvas: document.querySelector('.skinviewer-canvas'), 30 | alpha: false, 31 | width: skinViewer.el.offsetWidth, 32 | height: skinViewer.el.offsetWHeight, 33 | }); 34 | skinViewer.handle.background = 0xdddfe2; 35 | skinViewer.handle.loadSkin(json.data.skin, json.data.skinType ? 'slim' : 'default'); 36 | resizeSkinViewer(); 37 | const control = skinview3d.createOrbitControls(skinViewer.handle); 38 | control.enableRotate = true; 39 | control.enableZoom = false; 40 | control.enablePan = false; 41 | skinViewer.action.walk = skinViewer.handle.animations.add(skinview3d.WalkingAnimation); 42 | skinViewer.action.walk.speed = 0.6; 43 | skinViewer.rotate = skinViewer.handle.animations.add(skinview3d.RotatingAnimation); 44 | setTimeout(() => { 45 | document.querySelector('#skinviewer-loading').classList.remove('active'); 46 | }, 200); 47 | } catch (error) { 48 | notyf.open({ 49 | type: 'error', 50 | message: '皮肤模块加载错误', 51 | }); 52 | } 53 | } 54 | }) 55 | .catch(() => { 56 | notyf.open({ 57 | type: 'error', 58 | message: '皮肤模块加载错误', 59 | }); 60 | }); 61 | 62 | // 监听窗口大小变化,以修改skinviewer大小 63 | window.addEventListener('resize', () => { 64 | if (skinViewer.handle.width !== skinViewer.el.offsetWidth || skinViewer.handle.height !== skinViewer.el.offsetHeight) { 65 | resizeSkinViewer(); 66 | } 67 | }, false); 68 | 69 | // 刷新皮肤 70 | window.refreshSkinviewer = function refreshSkinviewer() { 71 | document.querySelector('#skinviewer-loading').classList.add('active'); 72 | skinViewer.handle.loadSkin(document.querySelector('#skinData').data.skin, document.querySelector('#skinData').data.skinType ? 'slim' : 'default'); 73 | setTimeout(() => { 74 | document.querySelector('#skinviewer-loading').classList.remove('active'); 75 | }, 200); 76 | }; 77 | 78 | // 临时暂停,传入false后恢复原始状态 79 | window.pauseSkinviewer = function pauseSkinviewer(state) { 80 | skinViewer.handle.animations.paused = state || document.querySelector('#skinviewerPauseCheckbox').checked; 81 | }; 82 | 83 | document.querySelector('#skinviewerWalkRadio').onchange = function skinviewerWalkRadioOnchange(e) { 84 | if (e.target.value === 'on') { 85 | skinViewer.action.walk = skinViewer.handle.animations.add(skinview3d.WalkingAnimation); 86 | skinViewer.action.walk.speed = 0.6; 87 | if (skinViewer.action.run) { 88 | skinViewer.action.run.remove(); 89 | skinViewer.action.run = null; 90 | } 91 | } 92 | }; 93 | 94 | document.querySelector('#skinviewerRunRadio').onchange = function skinviewerRunRadioOnchange(e) { 95 | if (e.target.value === 'on') { 96 | skinViewer.action.run = skinViewer.handle.animations.add(skinview3d.RunningAnimation); 97 | skinViewer.action.run.speed = 0.6; 98 | if (skinViewer.action.walk) { 99 | skinViewer.action.walk.remove(); 100 | skinViewer.action.walk = null; 101 | } 102 | } 103 | }; 104 | document.querySelector('#skinviewerRotateCheckbox').onchange = function skinviewerRotateCheckboxOnchange(e) { 105 | if (e.target.checked) { 106 | skinViewer.rotate.paused = false; 107 | } else { 108 | skinViewer.rotate.paused = true; 109 | } 110 | }; 111 | document.querySelector('#skinviewerPauseCheckbox').onchange = function skinviewerPauseCheckboxOnchange(e) { 112 | if (e.target.checked) { 113 | skinViewer.handle.animations.paused = true; 114 | } else { 115 | skinViewer.handle.animations.paused = false; 116 | } 117 | }; 118 | })(); 119 | -------------------------------------------------------------------------------- /src/public/js/uploadskin.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | function postData(data) { 3 | fetch('/api/uploadskin', { 4 | method: 'POST', 5 | headers: { 6 | 'Content-Type': 'application/x-www-form-urlencoded', 7 | }, 8 | body: `data=${encodeURIComponent(data)}`, 9 | }) 10 | .then((result) => result.json()) 11 | .then((json) => { 12 | switch (json.code) { 13 | case -1: 14 | notyf.open({ 15 | type: 'error', 16 | message: json.msg, 17 | }); 18 | break; 19 | case 1000: 20 | notyf.open({ 21 | type: 'success', 22 | message: '皮肤修改成功', 23 | }); 24 | window.refreshViewer(true); 25 | break; 26 | default: 27 | throw new Error('未知错误'); 28 | } 29 | document.querySelector('.btn-uploadskin').removeAttribute('disabled'); 30 | }) 31 | .catch((e) => { 32 | notyf.open({ 33 | type: 'error', 34 | message: e, 35 | }); 36 | document.querySelector('.btn-uploadskin').removeAttribute('disabled'); 37 | }); 38 | } 39 | 40 | function postSkinData(canvas) { 41 | const skinType = Number.parseInt(document.querySelector("input[name='skinUploadTypeRadio']:checked").value, 10); 42 | let skinData = ''; 43 | if (canvas.width !== 64 || (canvas.height !== 64 && canvas.height !== 32)) { 44 | notyf.open({ 45 | type: 'error', 46 | message: '皮肤格式错误', 47 | }); 48 | return; 49 | } 50 | try { 51 | skinData = canvas.toDataURL('image/png'); 52 | skinData = skinData.slice(22); 53 | canvas.remove(); 54 | } catch (error) { 55 | canvas.remove(); 56 | notyf.open({ 57 | type: 'error', 58 | message: '皮肤上传错误', 59 | }); 60 | return; 61 | } 62 | 63 | if (skinType !== 0 && skinType !== 1) { 64 | notyf.open({ 65 | type: 'error', 66 | message: '皮肤模型选择错误', 67 | }); 68 | return; 69 | } 70 | 71 | document.querySelector('.btn-uploadskin').setAttribute('disabled', 'true'); 72 | notyf.open({ 73 | type: 'info', 74 | message: '提交中,请稍后...', 75 | }); 76 | 77 | fetch('/api/genkey', { method: 'POST' }) 78 | .then((result) => result.text()) 79 | .then((text) => { 80 | if (text.length === 32) { 81 | let oriHex = ''; 82 | for (let i = 0; i < text.length; i++) { 83 | oriHex += text.charCodeAt(i).toString(16).padStart(2, '0'); 84 | } 85 | let secret = ''; 86 | let iv = ''; 87 | for (let i = 0; i < oriHex.length; i += 1) { 88 | if (i % 2 === 0) { 89 | secret += oriHex[i]; 90 | } else { 91 | iv += oriHex[i]; 92 | } 93 | } 94 | 95 | secret = CryptoJS.enc.Hex.parse(secret); 96 | iv = CryptoJS.enc.Hex.parse(iv); 97 | const data = { 98 | type: skinType, 99 | skin: skinData, 100 | }; 101 | const encrypted = CryptoJS.AES.encrypt(JSON.stringify(data), secret, { iv, padding: CryptoJS.pad.ZeroPadding }); 102 | postData(encrypted.ciphertext.toString(CryptoJS.enc.Base64)); 103 | } else { 104 | throw new Error('传输凭证获取失败'); 105 | } 106 | }) 107 | .catch((e) => { 108 | notyf.open({ 109 | type: 'error', 110 | message: e, 111 | }); 112 | document.querySelector('.btn-uploadskin').removeAttribute('disabled'); 113 | }); 114 | } 115 | 116 | const skinHandleCanvas = document.createElement('canvas'); 117 | const skinHandleImage = new Image(); 118 | skinHandleImage.onload = function skinImageOnload() { 119 | if (this.width === 64 && (this.height === 64 || this.height === 32)) { 120 | skinHandleCanvas.width = this.width; 121 | skinHandleCanvas.height = this.height; 122 | skinHandleCanvas.getContext('2d').drawImage(this, 0, 0); 123 | window.URL.revokeObjectURL(this.src); 124 | document.querySelector('#skinData').data.skinType = Number.parseInt(document.querySelector("input[name='skinUploadTypeRadio']:checked").value, 10); 125 | document.querySelector('#skinData').data.skin = skinHandleCanvas.toDataURL('image/png'); 126 | window.refreshViewer(false); 127 | } else { 128 | window.URL.revokeObjectURL(this.src); 129 | notyf.open({ 130 | type: 'error', 131 | message: '皮肤尺寸必须为64*6464*32', 132 | }); 133 | } 134 | }; 135 | 136 | skinHandleImage.onerror = function skinImageOnerror() { 137 | window.URL.revokeObjectURL(this.src); 138 | notyf.open({ 139 | type: 'error', 140 | message: '读取皮肤时发生错误', 141 | }); 142 | }; 143 | 144 | document.addEventListener('DOMContentLoaded', () => { 145 | bsCustomFileInput.init(); 146 | }); 147 | 148 | document.querySelector('#viewSelectedSkin').addEventListener('click', () => { 149 | const { files } = document.querySelector('#skinFileInput'); 150 | if (files[0]) { 151 | const imgsrc = window.URL.createObjectURL(files[0]); 152 | skinHandleImage.src = imgsrc; 153 | } else { 154 | notyf.open({ 155 | type: 'error', 156 | message: '你还没有选择文件', 157 | }); 158 | } 159 | }); 160 | 161 | document.querySelector('.btn-uploadskin').addEventListener('click', () => { 162 | const skinUploadCanvas = document.createElement('canvas'); 163 | const skinUploadImage = new Image(); 164 | skinUploadImage.onload = function skinUploadImageOnload() { 165 | if (this.width === 64 && (this.height === 64 || this.height === 32)) { 166 | skinUploadCanvas.width = this.width; 167 | skinUploadCanvas.height = this.height; 168 | skinUploadCanvas.getContext('2d').drawImage(this, 0, 0); 169 | window.URL.revokeObjectURL(this.src); 170 | this.remove(); 171 | postSkinData(skinUploadCanvas); 172 | } else { 173 | window.URL.revokeObjectURL(this.src); 174 | notyf.open({ 175 | type: 'error', 176 | message: '皮肤尺寸必须为64*6464*32', 177 | }); 178 | } 179 | }; 180 | 181 | skinUploadImage.onerror = function skinUploadImagOnerror() { 182 | window.URL.revokeObjectURL(this.src); 183 | notyf.open({ 184 | type: 'error', 185 | message: '读取皮肤时发生错误', 186 | }); 187 | }; 188 | 189 | const { files } = document.querySelector('#skinFileInput'); 190 | if (files[0]) { 191 | const imgsrc = window.URL.createObjectURL(files[0]); 192 | skinUploadImage.src = imgsrc; 193 | } else { 194 | notyf.open({ 195 | type: 'error', 196 | message: '你还没有选择文件', 197 | }); 198 | } 199 | }); 200 | })(); 201 | -------------------------------------------------------------------------------- /src/public/js/verify_email.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const verifyEmailBtn = document.querySelector('.btn-verifyemail'); 3 | verifyEmailBtn.addEventListener('click', () => { 4 | console.log(1); 5 | verifyEmailBtn.setAttribute('disabled', 'disabled'); 6 | fetch('./api/sendverifyemail') 7 | .then((ret) => ret.json()) 8 | .then((json) => { 9 | switch (json.code) { 10 | case -1: 11 | notyf.open({ 12 | type: 'error', 13 | message: json.msg, 14 | }); 15 | break; 16 | case 1000: 17 | notyf.open({ 18 | type: 'success', 19 | message: json.msg, 20 | }); 21 | break; 22 | default: 23 | notyf.open({ 24 | type: 'error', 25 | message: '未知错误', 26 | }); 27 | } 28 | verifyEmailBtn.removeAttribute('disabled'); 29 | }) 30 | .catch(() => { 31 | notyf.open({ 32 | type: 'error', 33 | message: '未知错误', 34 | }); 35 | verifyEmailBtn.removeAttribute('disabled'); 36 | }); 37 | }); 38 | })(); 39 | -------------------------------------------------------------------------------- /src/routers/admin.js: -------------------------------------------------------------------------------- 1 | /* routers/admin.js */ 2 | 3 | const router = require('koa-router')() 4 | 5 | /* ---------- ROUTES START ---------- */ 6 | 7 | const userController = require('../controller/admin/user') 8 | // ./admin/getUserList 9 | router.get('/getUserList', userController.getUserList) 10 | // ./admin/switchUserStatus 11 | router.post('/switchUserStatus', userController.switchUserStatus) 12 | 13 | /* ---------- ROUTES END ---------- */ 14 | 15 | module.exports = router 16 | -------------------------------------------------------------------------------- /src/routers/api.js: -------------------------------------------------------------------------------- 1 | /* routers/api.js */ 2 | const { RateLimit } = require('koa2-ratelimit') 3 | const router = require('koa-router')() 4 | 5 | const captchaController = require('../controller/api/captcha') // captchaController 6 | const genkeyController = require('../controller/api/genkey') // genkeyController 7 | const ownskinController = require('../controller/api/ownskin') // ownskinController 8 | const userController = require('../controller/api/user') // userController 9 | const yggdrasilController = require('../controller/api/yggdrasil') // yggdrasilController 10 | 11 | // 频率限制中间件 12 | const yggdrasilAuthLimiter = (type) => 13 | RateLimit.middleware({ 14 | interval: 60 * 1000, 15 | delayAfter: 3, 16 | timeWait: 2 * 1000, 17 | max: 5, // 每分钟最多5次请求 18 | async keyGenerator(ctx) { 19 | const data = ctx.request.body 20 | const prefixKey = `yggdrasil/${type}` 21 | if (data.username) { 22 | return `${prefixKey}|${data.username}` 23 | } 24 | return `${prefixKey}|${ctx.request.ip}` 25 | }, 26 | async handler(ctx) { 27 | ctx.status = 418 28 | ctx.set('Content-Type', 'text/html') 29 | ctx.body = "418 I'm a teapot" 30 | } 31 | }) 32 | 33 | /* ---------- ROUTES START ---------- */ 34 | 35 | // GET ./api/captcha 36 | // 生成验证码图像 37 | router.get('/captcha', captchaController.captcha) 38 | 39 | // POST ./api/genkey 40 | // 生成数据传输所需密钥 41 | router.post('/genkey', genkeyController.genkey) 42 | 43 | // GET ./api/ownskin 44 | // 返回登录用户自己的皮肤图片 45 | router.get('/ownskin', ownskinController.ownskin) 46 | 47 | // POST ./api/changepassword 48 | // 修改用户密码 49 | router.post('/changepassword', userController.changepassword) 50 | 51 | // POST ./api/uploadskin 52 | // 修改用户皮肤 53 | router.post('/uploadskin', yggdrasilAuthLimiter('uploadskin'), userController.uploadskin) 54 | 55 | // GET ./api/sendverifyemail 56 | // 发送验证邮件 57 | router.get('/sendverifyemail', userController.sendverifyemail) 58 | 59 | // GET ./api/emailcheck/:token/:id 60 | // 邮件验证 61 | router.get('/emailcheck/:token/:id', userController.emailcheck) 62 | 63 | // GET ./api/yggdrasil 64 | // Yggdrasil信息获取接口 65 | router.get('/yggdrasil', yggdrasilAuthLimiter('yggdrasil'), yggdrasilController.yggdrasil) 66 | 67 | // POST ./api/yggdrasil/authserver/authenticate 68 | // Yggdrasil登录接口 69 | router.post( 70 | '/yggdrasil/authserver/authenticate', 71 | yggdrasilAuthLimiter('auth'), 72 | yggdrasilController.authserver.authenticate 73 | ) 74 | 75 | // POST ./api/yggdrasil/authserver/refresh 76 | // Yggdrasil令牌刷新接口 77 | router.post('/yggdrasil/authserver/refresh', yggdrasilAuthLimiter('refresh'), yggdrasilController.authserver.refresh) 78 | 79 | // POST ./api/yggdrasil/authserver/validate 80 | // Yggdrasil令牌验证接口 81 | router.post('/yggdrasil/authserver/validate', yggdrasilController.authserver.validate) 82 | 83 | // POST ./api/yggdrasil/authserver/invalidate 84 | // Yggdrasil单令牌吊销接口 85 | router.post('/yggdrasil/authserver/invalidate', yggdrasilController.authserver.invalidate) 86 | 87 | // POST ./api/yggdrasil/authserver/signout 88 | // Yggdrasil登出(吊销所有令牌)接口 89 | router.post('/yggdrasil/authserver/signout', yggdrasilAuthLimiter('signout'), yggdrasilController.authserver.signout) 90 | 91 | // POST ./api/yggdrasil/sessionserver/session/minecraft/join 92 | // Yggdrasil客户端请求入服接口 93 | router.post('/yggdrasil/sessionserver/session/minecraft/join', yggdrasilController.sessionserver.session.minecraft.join) 94 | 95 | // GET ./api/yggdrasil/sessionserver/session/minecraft/hasJoined?username={username}&serverId={serverId}&ip={ip} 96 | // Yggdrasil服务器验证客户端接口 97 | router.get( 98 | '/yggdrasil/sessionserver/session/minecraft/hasJoined', 99 | yggdrasilController.sessionserver.session.minecraft.hasJoined 100 | ) 101 | 102 | // GET ./api/yggdrasil/sessionserver/session/minecraft/profile/{uuid} 103 | // Yggdrasil角色查询接口 104 | router.get( 105 | '/yggdrasil/sessionserver/session/minecraft/profile/:uuid', 106 | yggdrasilController.sessionserver.session.minecraft.profile 107 | ) 108 | 109 | // POST ./api/yggdrasil/api/profiles/minecraft 110 | // Yggdrasil按名称批量查询角色接口 111 | router.post('/yggdrasil/api/profiles/minecraft', yggdrasilController.api.profiles.minecraft) 112 | 113 | /* ---------- ROUTES END ---------- */ 114 | 115 | module.exports = router 116 | -------------------------------------------------------------------------------- /src/routers/forgetpw.js: -------------------------------------------------------------------------------- 1 | /* routers/forgetpw.js */ 2 | 3 | const router = require('koa-router')() 4 | const forgetpwController = require('../controller/forgetpw') // Controller 5 | 6 | /* ---------- ROUTES START ---------- */ 7 | 8 | // GET ./forgetpw/ 9 | // Front-end 10 | router.get('/', forgetpwController.frontend) 11 | 12 | // POST ./forgetpw/ 13 | // Back-end 14 | router.post('/', forgetpwController.handle) 15 | 16 | // GET ./forgetpw/:token/:id 17 | // 修改密码前端 18 | router.get('/:token/:id', forgetpwController.changeFrontend) 19 | 20 | // POST ./forgetpw/:token/:id 21 | // 修改密码后端 22 | router.post('/:token/:id', forgetpwController.changeHandle) 23 | 24 | /* ---------- ROUTES END ---------- */ 25 | 26 | module.exports = router 27 | -------------------------------------------------------------------------------- /src/routers/index.js: -------------------------------------------------------------------------------- 1 | /* routers/index.js */ 2 | 3 | const router = require('koa-router')() 4 | 5 | /* ---------- ROUTES START ---------- */ 6 | 7 | // ./ 8 | const indexController = require('../controller/index') 9 | 10 | router.get('/', indexController.index) 11 | 12 | /* ---------- ROUTES END ---------- */ 13 | 14 | module.exports = router 15 | -------------------------------------------------------------------------------- /src/routers/login.js: -------------------------------------------------------------------------------- 1 | /* routers/login.js */ 2 | 3 | const router = require('koa-router')() 4 | const loginController = require('../controller/login') // Controller 5 | 6 | /* ---------- ROUTES START ---------- */ 7 | 8 | // GET ./login/ 9 | // Front-end 10 | router.get('/', loginController.frontend) 11 | 12 | // POST ./login/ 13 | // Back-end 14 | router.post('/', loginController.handle) 15 | 16 | /* ---------- ROUTES END ---------- */ 17 | 18 | module.exports = router 19 | -------------------------------------------------------------------------------- /src/routers/logout.js: -------------------------------------------------------------------------------- 1 | /* routers/logout.js */ 2 | 3 | const router = require('koa-router')() 4 | 5 | /* ---------- ROUTES START ---------- */ 6 | 7 | // ./logout/ 8 | const logoutController = require('../controller/logout') 9 | 10 | router.get('/', logoutController.logout) 11 | 12 | /* ---------- ROUTES END ---------- */ 13 | 14 | module.exports = router 15 | -------------------------------------------------------------------------------- /src/routers/register.js: -------------------------------------------------------------------------------- 1 | /* routers/register.js */ 2 | 3 | const router = require('koa-router')() 4 | const registerController = require('../controller/register') // Controller 5 | 6 | /* ---------- ROUTES START ---------- */ 7 | 8 | // GET ./register/ 9 | // Front-end 10 | router.get('/', registerController.frontend) 11 | 12 | // POST ./register/ 13 | // Back-end 14 | router.post('/', registerController.handle) 15 | 16 | /* ---------- ROUTES END ---------- */ 17 | 18 | module.exports = router 19 | -------------------------------------------------------------------------------- /src/routers/textures.js: -------------------------------------------------------------------------------- 1 | /* routers/textures.js */ 2 | 3 | const router = require('koa-router')() 4 | const texturesController = require('../controller/textures') // texturesController 5 | 6 | /* ---------- ROUTES START ---------- */ 7 | 8 | // GET ./textures/ 9 | // 根据材质hash返回对应材质 10 | router.get('/:hash', texturesController.textures) 11 | 12 | /* ---------- ROUTES END ---------- */ 13 | 14 | module.exports = router 15 | -------------------------------------------------------------------------------- /src/routers/urls.js: -------------------------------------------------------------------------------- 1 | /* routers/urls.js */ 2 | 3 | const Router = require('koa-router') 4 | const staticCache = require('koa-static-cache') 5 | 6 | module.exports = (app) => { 7 | const rootRouter = new Router() 8 | 9 | // ./ 10 | const indexRouter = require('./index') 11 | rootRouter.use('/', indexRouter.routes(), indexRouter.allowedMethods()) 12 | 13 | // ./login/ 14 | const loginRouter = require('./login') 15 | rootRouter.use('/login', loginRouter.routes(), loginRouter.allowedMethods()) 16 | 17 | // ./logout/ 18 | const logoutRouter = require('./logout') 19 | rootRouter.use('/logout', logoutRouter.routes(), logoutRouter.allowedMethods()) 20 | 21 | // ./register/ 22 | const registerRouter = require('./register') 23 | rootRouter.use('/register', registerRouter.routes(), registerRouter.allowedMethods()) 24 | 25 | // ./api/ 26 | const apiRouter = require('./api') 27 | rootRouter.use('/api', apiRouter.routes(), apiRouter.allowedMethods()) 28 | 29 | // ./admin/ 30 | const adminRouter = require('./admin') 31 | rootRouter.use('/admin', adminRouter.routes(), adminRouter.allowedMethods()) 32 | 33 | // ./textures/ 34 | const texturesRouter = require('./textures') 35 | rootRouter.use('/textures', texturesRouter.routes(), texturesRouter.allowedMethods()) 36 | 37 | // ./forgetpw/ 38 | const forgetpwRouter = require('./forgetpw') 39 | rootRouter.use('/forgetpw', forgetpwRouter.routes(), forgetpwRouter.allowedMethods()) 40 | 41 | // 静态文件路由 42 | app.use( 43 | staticCache('./src/public', { 44 | gzip: true, 45 | buffer: false 46 | }) 47 | ) 48 | 49 | app.use(rootRouter.routes()) 50 | } 51 | -------------------------------------------------------------------------------- /src/service/email.js: -------------------------------------------------------------------------------- 1 | const nodemailer = require('nodemailer') 2 | const { auth: redis } = require('../db/redis') 3 | const config = require('../config') 4 | 5 | const getRandomHex = (len = 32) => { 6 | const chars = 'abcdef0123456789' 7 | const maxPos = chars.length 8 | let pwd = '' 9 | for (let i = 0; i < len; i += 1) { 10 | pwd += chars.charAt(Math.floor(Math.random() * maxPos)) 11 | } 12 | return pwd 13 | } 14 | 15 | const transporter = nodemailer.createTransport(config.extra.smtp) 16 | 17 | module.exports = { 18 | storeVerifyTokenToRedis: (playerId, token) => 19 | new Promise((resolve) => { 20 | // 过期时间5分钟 21 | const time = 5 * 60 22 | // 储存对应玩家的邮箱验证Token 23 | const storeToken = redis.set(`verifyemail_${playerId}`, token, 'ex', time) 24 | // 储存对应玩家的邮箱验证Token的到期时间戳(10位),过期时间5分钟 25 | const storeTokenTime = redis.set( 26 | `verifyemailtime_${playerId}`, 27 | Math.floor(new Date().getTime() / 1000) + time, 28 | 'ex', 29 | time 30 | ) 31 | 32 | Promise.all([storeToken, storeTokenTime]).then(() => { 33 | resolve(true) 34 | }) 35 | }), 36 | genVerifyToken: () => getRandomHex(), 37 | isVerifyTokenExists: (playerId) => 38 | new Promise((resolve) => { 39 | // 判断对应玩家的邮箱验证字串是否存在 40 | redis.exists(`verifyemail_${playerId}`).then((result) => { 41 | resolve(result) 42 | }) 43 | }), 44 | isVerifyTokenCorrect: (playerId, token) => 45 | new Promise((resolve) => { 46 | // 判断对应玩家的邮箱验证字串是否正确 47 | redis.get(`verifyemail_${playerId}`, (err, response) => { 48 | // 未找到对应玩家的Token 49 | if (err || !response) { 50 | resolve(false) 51 | return 52 | } 53 | if (response === token) { 54 | resolve(true) 55 | } else { 56 | resolve(false) 57 | } 58 | }) 59 | }), 60 | delVerifyToken: (playerId) => 61 | new Promise((resolve) => { 62 | const storeToken = redis.del(`verifyemail_${playerId}`) 63 | const storeTokenTime = redis.del(`verifyemailtime_${playerId}`) 64 | 65 | Promise.all([storeToken, storeTokenTime]).then(() => { 66 | resolve(true) 67 | }) 68 | }), 69 | getVerifyTokenTime: (playerId) => 70 | new Promise((resolve) => { 71 | redis.get(`verifyemailtime_${playerId}`, (err, response) => { 72 | // 未找到对应玩家的Token过期时间 73 | if (err || !response) { 74 | resolve(false) 75 | return 76 | } 77 | 78 | const result = new Date(parseInt(response, 10)).getTime() 79 | resolve(result) 80 | }) 81 | }), 82 | sendVerifyUrl: (email, playername, playerId, token) => 83 | new Promise((resolve) => { 84 | const mailOptions = { 85 | from: `"${config.common.sitename}" <${config.extra.smtp.auth.user}>`, 86 | to: email, 87 | subject: `[${config.common.sitename}] Email 地址验证`, 88 | text: `您好,${playername} 89 | \n这是来自 ${config.common.sitename} 的一封账户验证邮件 90 | \n如果这不是您本人的操作,请忽略这封邮件 91 | \n如果这是您本人的操作,请访问下面的链接来进行验证 92 | \n${config.common.url}/api/emailcheck/${token}/${playerId} 93 | \n链接有效期5分钟,请及时验证 94 | \n此致 95 | \n${config.common.sitename} 管理团队.`, 96 | html: `您好,${playername} 97 |
这是来自 ${config.common.sitename} 的一封账户验证邮件 98 |
如果这不是您本人的操作,请忽略这封邮件 99 |
如果这是您本人的操作,请访问下面的链接来进行验证 100 |
${config.common.url}/api/emailcheck/${token}/${playerId} 101 |
链接有效期5分钟,请及时验证 102 |
此致 103 |
${config.common.sitename} 管理团队.` 104 | } 105 | 106 | // 发送函数 107 | transporter.sendMail(mailOptions, (error) => { 108 | if (error) { 109 | console.info(error) 110 | resolve(false) 111 | } else { 112 | resolve(true) 113 | } 114 | }) 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /src/service/forgetpw.js: -------------------------------------------------------------------------------- 1 | const nodemailer = require('nodemailer') 2 | const { auth: redis } = require('../db/redis') 3 | const config = require('../config') 4 | 5 | const getRandomHex = (len = 32) => { 6 | const chars = 'abcdef0123456789' 7 | const maxPos = chars.length 8 | let pwd = '' 9 | for (let i = 0; i < len; i += 1) { 10 | pwd += chars.charAt(Math.floor(Math.random() * maxPos)) 11 | } 12 | return pwd 13 | } 14 | 15 | const transporter = nodemailer.createTransport(config.extra.smtp) 16 | 17 | module.exports = { 18 | storeVerifyTokenToRedis: (playerId, token) => 19 | new Promise((resolve) => { 20 | // 过期时间5分钟 21 | const time = 5 * 60 22 | // 储存对应玩家的忘记密码验证Token 23 | const storeToken = redis.set(`forgetpw_${playerId}`, token, 'EX', time) 24 | // 储存对应玩家的忘记密码Token的到期时间戳(10位),过期时间5分钟 25 | const storeTokenTime = redis.set( 26 | `forgetpwtime_${playerId}`, 27 | Math.floor(new Date().getTime() / 1000) + time, 28 | 'EX', 29 | time 30 | ) 31 | 32 | Promise.all([storeToken, storeTokenTime]).then(() => { 33 | resolve(true) 34 | }) 35 | }), 36 | genVerifyToken: () => getRandomHex(), 37 | isVerifyTokenExists: (playerId) => 38 | new Promise((resolve) => { 39 | // 判断对应玩家的忘记密码Token是否存在 40 | redis.exists(`forgetpw_${playerId}`).then((result) => { 41 | resolve(result) 42 | }) 43 | }), 44 | isVerifyTokenCurrect: (playerId, token) => 45 | new Promise((resolve) => { 46 | // 判断对应玩家的忘记密码Token是否正确 47 | redis.get(`forgetpw_${playerId}`, (err, response) => { 48 | // 未找到对应玩家的Token 49 | if (err || !response) { 50 | resolve(false) 51 | return 52 | } 53 | if (response === token) { 54 | resolve(true) 55 | } else { 56 | resolve(false) 57 | } 58 | }) 59 | }), 60 | delVerifyToken: (playerId) => 61 | new Promise((resolve) => { 62 | const storeToken = redis.del(`forgetpw_${playerId}`) 63 | const storeTokenTime = redis.del(`forgetpwtime_${playerId}`) 64 | 65 | Promise.all([storeToken, storeTokenTime]).then(() => { 66 | resolve(true) 67 | }) 68 | }), 69 | getVerifyTokenTime: (playerId) => 70 | new Promise((resolve) => { 71 | redis.get(`forgetpwtime_${playerId}`, (err, response) => { 72 | // 未找到对应玩家的Token过期时间 73 | if (err || !response) { 74 | resolve(false) 75 | return 76 | } 77 | 78 | const result = new Date(parseInt(response, 10)).getTime() 79 | resolve(result) 80 | }) 81 | }), 82 | sendVerifyUrl: (email, playername, playerId, token) => 83 | new Promise((resolve) => { 84 | const mailOptions = { 85 | from: `"${config.common.sitename}" <${config.extra.smtp.auth.user}>`, 86 | to: email, 87 | subject: `[${config.common.sitename}] 账户密码重置`, 88 | text: `您好,${playername} 89 | \n这是来自 ${config.common.sitename} 的一封账户密码重置邮件 90 | \n如果这不是您本人的操作,请忽略这封邮件 91 | \n如果这是您本人的操作,请访问下面的链接来重置密码重置 92 | \n${config.common.url}/forgetpw/${token}/${playerId} 93 | \n链接有效期5分钟,请及时进行密码重置 94 | \n此致 95 | \n${config.common.sitename} 管理团队.`, 96 | html: `您好,${playername} 97 |
这是来自 ${config.common.sitename} 的一封账户密码重置邮件 98 |
如果这不是您本人的操作,请忽略这封邮件 99 |
如果这是您本人的操作,请访问下面的链接来重置密码 100 |
${config.common.url}/forgetpw/${token}/${playerId} 101 |
链接有效期5分钟,请及时进行密码重置 102 |
此致 103 |
${config.common.sitename} 管理团队.` 104 | } 105 | 106 | // 发送函数 107 | transporter.sendMail(mailOptions, (error) => { 108 | if (error) { 109 | resolve(false) 110 | } else { 111 | resolve(true) 112 | } 113 | }) 114 | }) 115 | } 116 | -------------------------------------------------------------------------------- /src/service/token.js: -------------------------------------------------------------------------------- 1 | const USER = require('../db/models/user') 2 | const User = require('./user') 3 | const utils = require('../utils') 4 | const { auth: redis } = require('../db/redis') 5 | const config = require('../config') 6 | 7 | module.exports = { 8 | genAccessToken: (email, clientToken) => 9 | new Promise((resolve) => { 10 | USER.findOne({ email }, 'tokens isBanned', (err, user) => { 11 | if (err) throw err 12 | if (user && !user.isBanned) { 13 | const { tokens } = user 14 | 15 | // 遍历所有旧token并使其暂时失效 16 | // (仅能同时存在一个有效token) 17 | for (let i = 0; i < tokens.length; i += 1) { 18 | tokens[i].status = 0 19 | } 20 | 21 | // 遍历所有旧token并删除过期token 22 | for (let i = 0; i < tokens.length; i += 1) { 23 | if (Date.now() - tokens[i].createAt >= 432000000) { 24 | tokens[i].remove() 25 | } 26 | } 27 | 28 | // 生成新token 29 | const newToken = { 30 | accessToken: utils.genUUID().replace(/-/g, ''), 31 | clientToken 32 | } 33 | tokens.push(newToken) 34 | user.save() 35 | resolve(newToken.accessToken) 36 | } else { 37 | resolve(false) 38 | } 39 | }) 40 | }), 41 | searchUserByAccessToken: (accessToken) => 42 | new Promise((resolve) => { 43 | const query = { tokens: { $elemMatch: { accessToken } } } 44 | USER.findOne(query, '', (err, user) => { 45 | if (err) throw err 46 | if (user) { 47 | resolve(user) 48 | } else { 49 | resolve(false) 50 | } 51 | }) 52 | }), 53 | refreshAccessToken: (accessToken, clientToken) => 54 | new Promise((resolve) => { 55 | const query = { tokens: { $elemMatch: { accessToken } } } 56 | USER.findOne(query, 'tokens uuid playername', (err, user) => { 57 | if (err) throw err 58 | if (user && !user.isBanned) { 59 | let tokenIndex = -1 60 | for (let i = 0; i < user.tokens.length; i += 1) { 61 | if (user.tokens[i].accessToken === accessToken) { 62 | tokenIndex = i 63 | break 64 | } 65 | } 66 | 67 | // 未找到指定accessToken 68 | if (tokenIndex === -1) { 69 | resolve(false) 70 | return 71 | } 72 | 73 | // clientToken不匹配 74 | if (clientToken) { 75 | if (clientToken !== user.tokens[tokenIndex].clientToken) { 76 | resolve(false) 77 | return 78 | } 79 | } 80 | 81 | // accessToken已过期 82 | if (Date.now() - user.tokens[tokenIndex].createAt >= 432000000) { 83 | user.tokens[tokenIndex].remove() 84 | user.save() 85 | resolve(false) 86 | return 87 | } 88 | 89 | Object.assign(user.tokens[tokenIndex], { status: 1 }) 90 | user.save() 91 | resolve({ 92 | accessToken: user.tokens[tokenIndex].accessToken, 93 | clientToken: user.tokens[tokenIndex].clientToken, 94 | uuid: user.uuid, 95 | playername: user.playername 96 | }) 97 | return 98 | } 99 | resolve(false) 100 | }) 101 | }), 102 | validateAccessToken: (accessToken, clientToken) => 103 | new Promise((resolve) => { 104 | const query = { tokens: { $elemMatch: { accessToken } } } 105 | USER.findOne(query, 'tokens uuid playername', (err, user) => { 106 | if (err) throw err 107 | if (user && !user.isBanned) { 108 | let tokenIndex = -1 109 | for (let i = 0; i < user.tokens.length; i += 1) { 110 | if (user.tokens[i].accessToken === accessToken) { 111 | tokenIndex = i 112 | break 113 | } 114 | } 115 | 116 | // 未找到指定accessToken 117 | if (tokenIndex === -1) { 118 | resolve(false) 119 | return 120 | } 121 | 122 | // clientToken不匹配 123 | if (clientToken) { 124 | if (clientToken !== user.tokens[tokenIndex].clientToken) { 125 | resolve(false) 126 | return 127 | } 128 | } 129 | 130 | // accessToken已过期 131 | if (Date.now() - user.tokens[tokenIndex].createAt >= 432000000) { 132 | user.tokens[tokenIndex].remove() 133 | user.save() 134 | resolve(false) 135 | return 136 | } 137 | 138 | // accessToken暂时失效 139 | if (user.tokens[tokenIndex].status !== 1) { 140 | resolve(false) 141 | return 142 | } 143 | 144 | resolve(true) 145 | return 146 | } 147 | resolve(false) 148 | }) 149 | }), 150 | invalidateAccessToken: (accessToken) => 151 | new Promise((resolve) => { 152 | const query = { tokens: { $elemMatch: { accessToken } } } 153 | USER.findOne(query, 'tokens _id', (err, user) => { 154 | if (err) throw err 155 | if (user && !user.isBanned) { 156 | let tokenIndex = -1 157 | for (let i = 0; i < user.tokens.length; i += 1) { 158 | if (user.tokens[i].accessToken === accessToken) { 159 | tokenIndex = i 160 | break 161 | } 162 | } 163 | 164 | // 未找到指定accessToken 165 | if (tokenIndex === -1) { 166 | resolve(false) 167 | return 168 | } 169 | user.tokens[tokenIndex].remove() 170 | user.save() 171 | resolve(true) 172 | return 173 | } 174 | resolve(false) 175 | }) 176 | }), 177 | invalidateAllAccessToken: (email, password) => 178 | new Promise((resolve) => { 179 | USER.findOne({ email }, 'tokens password', (err, user) => { 180 | if (err) throw err 181 | if (user && !user.isBanned) { 182 | const { tokens } = user 183 | 184 | // 密码不正确 185 | if (password !== user.password) { 186 | resolve(false) 187 | } 188 | 189 | // 遍历所有token并删除 190 | for (let i = 0; i < tokens.length; i += 1) { 191 | tokens[i].remove() 192 | } 193 | Object.assign(user, { tokens }) 194 | user.save() 195 | resolve(true) 196 | } else { 197 | resolve(false) 198 | } 199 | }) 200 | }), 201 | clientToServerValidate: (accessToken, selectedProfile, serverId, ip) => 202 | new Promise((resolve) => { 203 | // 根据accessToken获取对应用户 204 | module.exports.searchUserByAccessToken(accessToken).then((result) => { 205 | // 无法找到accessToken 206 | if (!result) { 207 | resolve(false) 208 | return 209 | } 210 | 211 | // 令牌对应用户已被封禁 212 | if (result.isBanned) { 213 | resolve(false) 214 | return 215 | } 216 | 217 | if (!config.common.ignoreEmailVerification) { 218 | // 令牌对应用户未验证邮箱 219 | if (!result.verified) { 220 | resolve(false) 221 | return 222 | } 223 | } 224 | 225 | // 令牌对应玩家uuid不一致 226 | if (result.uuid.replace(/-/g, '') !== selectedProfile) { 227 | resolve(false) 228 | return 229 | } 230 | 231 | let data = { 232 | accessToken, 233 | selectedProfile, 234 | username: result.playername, 235 | ip 236 | } 237 | 238 | data = JSON.stringify(data) 239 | // 将授权信息储存至redis,15秒过期 240 | redis.set(`serverId_${serverId}`, data, 'EX', 15).then(() => { 241 | resolve(true) 242 | }) 243 | }) 244 | }), 245 | serverToClientValidate: (username, serverId, ip) => 246 | new Promise((resolve) => { 247 | // 根据serverId获取对应授权信息 248 | redis.get(`serverId_${serverId}`, (err, response) => { 249 | // 未找到对应授权信息或发生错误 250 | if (err || !response) { 251 | resolve(false) 252 | return 253 | } 254 | 255 | const clientData = JSON.parse(response) 256 | 257 | // 玩家名称与授权不对应 258 | if (clientData.username !== username) { 259 | resolve(false) 260 | return 261 | } 262 | 263 | // 若提供了客户端ip,则需要判断储存的客户端ip与其是否一致 264 | if (ip) { 265 | if (clientData.ip !== ip) { 266 | resolve(false) 267 | return 268 | } 269 | } 270 | 271 | // 根据accessToken获取玩家资料 272 | module.exports.searchUserByAccessToken(clientData.accessToken).then((result) => { 273 | // 生成玩家完整Profile 274 | const data = User.genUserProfile(result) 275 | resolve(data) 276 | }) 277 | }) 278 | }) 279 | } 280 | -------------------------------------------------------------------------------- /src/template/emailcheck.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | block pagevar 4 | - var pagename="邮箱验证" 5 | 6 | block content 7 | main(style="display: flex;align-items: center;flex-direction: column;justify-content: center;") 8 | h1.display-4(style="margin-top:20px;") 邮箱验证 9 | if !isCorrect 10 | p.lead ❌链接无效,4秒后跳转首页 11 | else 12 | p.lead ✔邮箱验证通过,4秒后跳转首页 13 | 14 | append scripts 15 | script. 16 | setTimeout(()=>{window.location.href = '/';},4000) -------------------------------------------------------------------------------- /src/template/forgetpw.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | block pagevar 4 | - var pagename="忘记密码" 5 | if user.isLoggedIn 6 | script. 7 | window.location.href="/" 8 | 9 | block content 10 | unless user.isLoggedIn 11 | main(style="display: flex;align-items: center;flex-direction: column;justify-content: center;") 12 | h1.display-4(style="margin-top:20px;") 忘记密码 13 | form 14 | .form-group 15 | label(for="inputEmail") 邮箱 16 | input#inputEmail.form-control(tabindex="1" type="email" aria-describedby="emailHelp" placeholder="你的邮箱" autocomplete="email") 17 | small#emailHelp.form-text.text-muted 我们会向你的邮箱发送一封带有密码重置链接的邮件。 18 | .form-group 19 | label(for="inputCaptcha") 人机验证 20 | input#inputCaptcha.form-control(tabindex="3" type="text" maxlength="3" placeholder="请输入下图表达式计算结果" autocomplete="off") 21 | img.img-fluid#img-captcha(src="/api/captcha" alt="验证码图片" title="点击以刷新" style="cursor: pointer;") 22 | button.btn.btn-primary.btn-block.btn-forgetpw(tabindex="4" type="button") 发送邮件 23 | 24 | append scripts 25 | unless user.isLoggedIn 26 | script(src="/js/forgetpw.js") 27 | script(src="/js/crypto-js.js") -------------------------------------------------------------------------------- /src/template/forgetpw_change.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | block pagevar 4 | - var pagename="修改密码" 5 | if user.isLoggedIn 6 | script. 7 | window.location.href="/" 8 | 9 | block content 10 | unless user.isLoggedIn 11 | main(style="display: flex;align-items: center;flex-direction: column;justify-content: center;") 12 | h1.display-4(style="margin-top:20px;") 修改密码 13 | if !isCorrect 14 | p.lead ❌链接无效或已过期 15 | else 16 | form 17 | .form-group 18 | label(for="inputPassword") 密码 19 | input#inputPassword.form-control(tabindex="1" type="password" placeholder="密码" autocomplete="off") 20 | .form-group 21 | label(for="inputRepeatPassword") 重复密码 22 | input#inputRepeatPassword.form-control(tabindex="2" type="password" placeholder="确认你的密码正确无误" autocomplete="off") 23 | .form-group 24 | label(for="inputCaptcha") 人机验证 25 | input#inputCaptcha.form-control(tabindex="3" type="text" maxlength="3" placeholder="请输入下图表达式计算结果" autocomplete="off") 26 | img.img-fluid#img-captcha(src="/api/captcha" alt="验证码图片" title="点击以刷新" style="cursor: pointer;") 27 | button.btn.btn-primary.btn-block.btn-forgetpw(tabindex="4" type="button") 修改密码 28 | 29 | append scripts 30 | unless user.isLoggedIn 31 | script(src="/js/forgetpw_change.js") 32 | script(src="/js/crypto-js.js") -------------------------------------------------------------------------------- /src/template/layout.pug: -------------------------------------------------------------------------------- 1 | doctype 2 | html(lang="zh-cn") 3 | head 4 | meta(charset="utf-8") 5 | meta(name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=0") 6 | link(rel="icon", href="/favicon.ico", type="image/x-icon") 7 | link(rel="shortcut icon", href="/favicon.ico", type="image/x-icon") 8 | block pagevar 9 | block title 10 | title #{pagename} | #{config.sitename} 11 | block styles 12 | link(rel="stylesheet", href="/css/bootstrap.min.css") 13 | link(rel="stylesheet", href="/css/notyf.css") 14 | link(rel="stylesheet", href="/css/style.css") 15 | body 16 | .main-container 17 | header 18 | nav.navbar.navbar-light.navbar-expand.bg-light.justify-content-between 19 | a.navbar-brand.mb-0.h1(href="/") 20 | img.d-inline-block.align-top(src="/images/logo.svg" width="30" height="30" alt="")/ 21 | span.navbar-brand-text #{config.sitename} 22 | div 23 | if user.isLoggedIn 24 | span.navbar-text 好久不见, 25 | if user.isBanned 26 | span.badge.badge-pill.badge-danger 封禁中 27 | else if user.isAdmin 28 | span.badge.badge-pill.badge-info 管理员 29 | else 30 | span.badge.badge-pill.badge-success 玩家 31 | | 32 | | 33 | span.navbar-text #{user.playername} 34 | span.navbar-text ! 35 | a.navbar-text(href="/logout" style="color:rgba(255,22,22,0.32);") 登出 36 | else 37 | ul.navbar-nav 38 | li.nav-item 39 | a.nav-link(href="/login") 登录 40 | li.nav-item 41 | a.nav-link(href="/register") 注册 42 | block content 43 | 44 | footer.text-muted 45 | div.container-fluid.p-3.p-md-5 46 | ul.footer-links 47 | for item in config.footer.links 48 | li 49 | a(href=item.link target=item.target?item.target:"_blank")=item.title 50 | p!=config.footer.copyright 51 | p!="Designed with all the love in the world by @daidr." 52 | block scripts 53 | script(src="/js/notyf.js" aysnc) 54 | script(src="/js/main.js" aysnc) 55 | 56 | -------------------------------------------------------------------------------- /src/template/login.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | block pagevar 4 | - var pagename="登录" 5 | if user.isLoggedIn 6 | script. 7 | window.location.href="/" 8 | 9 | block content 10 | unless user.isLoggedIn 11 | main(style="display: flex;align-items: center;flex-direction: column;justify-content: center;") 12 | h1.display-4(style="margin-top:20px;") 登录 13 | form 14 | .form-group 15 | label(for="inputEmail") 邮箱 16 | input#inputEmail.form-control(tabindex="1" type="email" aria-describedby="emailHelp" placeholder="你的邮箱" autocomplete="email") 17 | small#emailHelp.form-text.text-muted 放心,我们不会将你的邮箱泄露给任何人。 18 | .form-group 19 | label(for="inputPassword") 密码 20 | input#inputPassword.form-control(tabindex="2" type="password" placeholder="你的密码" autocomplete="current-password") 21 | .form-group 22 | label(for="inputCaptcha") 人机验证 23 | input#inputCaptcha.form-control(tabindex="3" type="text" maxlength="3" placeholder="请输入下图表达式计算结果" autocomplete="off") 24 | img.img-fluid#img-captcha(src="/api/captcha" alt="验证码图片" title="点击以刷新" style="cursor: pointer;") 25 | button.btn.btn-primary.btn-block.btn-login(tabindex="4" type="button") 登录 26 | a.float-right(href="/forgetpw") 忘记密码? 27 | 28 | append scripts 29 | unless user.isLoggedIn 30 | script(src="/js/login.js") 31 | script(src="/js/crypto-js.js") -------------------------------------------------------------------------------- /src/template/register.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | block pagevar 4 | - var pagename="注册" 5 | if user.isLoggedIn 6 | script. 7 | window.location.href="/" 8 | 9 | block content 10 | unless user.isLoggedIn 11 | main(style="display: flex;align-items: center;flex-direction: column;justify-content: center;") 12 | h1.display-4(style="margin-top:20px;") 注册 13 | 14 | form 15 | .form-group 16 | label(for="inputEmail") 邮箱 17 | input#inputEmail.form-control(tabindex="1" type="email" aria-describedby="emailHelp" placeholder="你的邮箱" autocomplete="email") 18 | small#emailHelp.form-text.text-muted 放心,我们不会将你的邮箱泄露给任何人。
不合法的邮箱可能会导致无法加入游戏。 19 | .form-group 20 | label(for="inputPlayerName") 游戏昵称 21 | input#inputPlayerName.form-control(tabindex="2" type="text" aria-describedby="playerNameHelp" placeholder="游戏昵称" autocomplete="off") 22 | small#playerNameHelp.form-text.text-muted 这将会是你游戏内的玩家名称。
支持字母、数字、下划线。
合理的游戏ID应为4-12个字符。 23 | .form-group 24 | label(for="inputPassword") 密码 25 | input#inputPassword.form-control(tabindex="3" type="password" placeholder="密码" autocomplete="off") 26 | .form-group 27 | label(for="inputRepeatPassword") 重复密码 28 | input#inputRepeatPassword.form-control(tabindex="4" type="password" placeholder="确认你的密码正确无误" autocomplete="off") 29 | .form-group 30 | label(for="inputCaptcha") 人机验证 31 | input#inputCaptcha.form-control(tabindex="5" type="text" maxlength="3" placeholder="请输入下图表达式计算结果" autocomplete="off") 32 | img.img-fluid#img-captcha(src="/api/captcha" alt="验证码图片" title="点击以刷新" style="cursor: pointer;") 33 | button.btn.btn-primary.btn-block.btn-register(tabindex="6" type="button") 注册 34 | 35 | append scripts 36 | unless user.isLoggedIn 37 | script(src="/js/crypto-js.js") 38 | script(src="/js/register.js") -------------------------------------------------------------------------------- /src/template/widget/admin.pug: -------------------------------------------------------------------------------- 1 | .jumbotron 2 | .admin-widget-container 3 | nav.nav.nav-pills 4 | a.nav-item.nav-link.active 用户管理 5 | a.nav-item.nav-link 站点配置 6 | .admin-pane.admin-pane-user 7 | .user-filter-container.form-inline 8 | input#userFilterInput.form-control.form-control-sm(type='text' placeholder='搜索') 9 | button#userFilterBtn.btn.btn-sm.btn-primary(type='button') 10 | span.spinner-border.spinner-border-sm(role="status" aria-hidden="true") 11 | span.btn-text 搜索 12 | 13 | 14 | .table-container 15 | table.table.table-sm.table-hover 16 | thead 17 | th(scope='col' width="130px") 昵称 18 | th(scope='col' width="180px") 邮箱 19 | th(scope='col' width="90px") 注册时间 20 | th(scope='col' width="110px") 徽章 21 | th(scope='col' width="55px") 操作 22 | tbody#user-list 23 | .paginate-container.userlist-pagination-container 24 | .d-inline-block.pagination.userlist-pagination(role='navigation' aria-label='Pagination') 25 | 26 | button.admin-widget-load-btn.btn.btn-primary.btn-block(type='button') 载入管理面板 27 | append block scripts 28 | script(src="/js/admin.js" aysnc) 29 | append block styles 30 | link(rel="stylesheet", href="/css/admin.css") 31 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const { v1: uuidv1 } = require('uuid') 2 | const crypto = require('crypto') 3 | const { PNG } = require('pngjs-nozlib') 4 | const config = require('./config') 5 | 6 | function computeTextureHash(image) { 7 | const bufSize = 8192 8 | const hash = crypto.createHash('sha256') 9 | const buf = Buffer.allocUnsafe(bufSize) 10 | const { width } = image 11 | const { height } = image 12 | buf.writeUInt32BE(width, 0) 13 | buf.writeUInt32BE(height, 4) 14 | let pos = 8 15 | for (let x = 0; x < width; x += 1) { 16 | for (let y = 0; y < height; y += 1) { 17 | const imgidx = (width * y + x) << 2 18 | const alpha = image.data[imgidx + 3] 19 | buf.writeUInt8(alpha, pos + 0) 20 | if (alpha === 0) { 21 | buf.writeUInt8(0, pos + 1) 22 | buf.writeUInt8(0, pos + 2) 23 | buf.writeUInt8(0, pos + 3) 24 | } else { 25 | buf.writeUInt8(image.data[imgidx + 0], pos + 1) 26 | buf.writeUInt8(image.data[imgidx + 1], pos + 2) 27 | buf.writeUInt8(image.data[imgidx + 2], pos + 3) 28 | } 29 | pos += 4 30 | if (pos === bufSize) { 31 | pos = 0 32 | hash.update(buf) 33 | } 34 | } 35 | } 36 | if (pos > 0) { 37 | hash.update(buf.slice(0, pos)) 38 | } 39 | return hash.digest('hex') 40 | } 41 | 42 | module.exports = { 43 | getRootPath: () => __dirname, 44 | genUUID: () => uuidv1(), 45 | getUserIp: (req) => 46 | req.headers['x-forwarded-for'] || 47 | req.connection.remoteAddress || 48 | req.socket.remoteAddress || 49 | req.connection.socket.remoteAddress, 50 | getSkinHash: (imgdata) => computeTextureHash(PNG.sync.read(imgdata)), 51 | genSignedData: (data) => { 52 | const sign = crypto.createSign('SHA1') 53 | sign.update(data) 54 | sign.end() 55 | const signature = sign.sign(config.extra.signature.private) 56 | return signature.toString('base64') 57 | }, 58 | convertUUIDwithHyphen: (uuid) => 59 | `${uuid.slice(0, 8)}-${uuid.slice(8, 12)}-${uuid.slice(12, 16)}-${uuid.slice(16, 20)}-${uuid.slice(20)}`, 60 | handleSkinImage: (imgdata) => { 61 | const png = PNG.sync.read(imgdata) 62 | if (png.width !== 64) { 63 | return false 64 | } 65 | if (png.height !== 64 && png.height !== 32) { 66 | return false 67 | } 68 | return PNG.sync.write(png) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/utils/ResponseEnum.js: -------------------------------------------------------------------------------- 1 | const YggdrasilResponse = (ctx) => { 2 | return { 3 | success(data) { 4 | ctx.status = 200 5 | ctx.body = data 6 | }, 7 | forbidden(errorMessage) { 8 | ctx.status = 403 9 | ctx.body = { 10 | error: 'ForbiddenOperationException', 11 | errorMessage 12 | } 13 | }, 14 | noContent() { 15 | ctx.status = 204 16 | }, 17 | invalidToken() { 18 | ctx.status = 403 19 | ctx.body = { 20 | error: 'ForbiddenOperationException', 21 | errorMessage: 'Invalid token.' 22 | } 23 | }, 24 | invalidCredentials() { 25 | ctx.status = 403 26 | ctx.body = { 27 | error: 'ForbiddenOperationException', 28 | errorMessage: 'Invalid credentials. Invalid username or password.' 29 | } 30 | }, 31 | tokenAssigned() { 32 | ctx.status = 400 33 | ctx.body = { 34 | error: 'IllegalArgumentException', 35 | errorMessage: 'Access token already has a profile assigned.' 36 | } 37 | } 38 | } 39 | } 40 | 41 | const GHAuthResponse = (ctx) => { 42 | return { 43 | success(data) { 44 | ctx.status = 200 45 | ctx.body = data 46 | }, 47 | forbidden(errorMessage) { 48 | ctx.status = 403 49 | ctx.body = { 50 | error: 'ForbiddenOperationException', 51 | errorMessage 52 | } 53 | } 54 | } 55 | } 56 | 57 | module.exports = { YggdrasilResponse, GHAuthResponse } 58 | -------------------------------------------------------------------------------- /src/utils/print.js: -------------------------------------------------------------------------------- 1 | const color = require('colors/safe') 2 | 3 | module.exports = { 4 | info(...args) { 5 | console.log(color.blue.bold(`[信息] `), ...args) 6 | }, 7 | success(...args) { 8 | console.log(color.green.bold(`[成功] `), ...args) 9 | }, 10 | warn(...args) { 11 | console.log(color.yellow.bold(`[警告] `), ...args) 12 | }, 13 | error(...args) { 14 | console.log(color.red.bold(`[错误] `), ...args) 15 | }, 16 | debug: { 17 | info(...args) { 18 | if (process.env.NODE_ENV === 'development') { 19 | console.log(color.blue.bold(`[调试][信息] `), ...args) 20 | } 21 | }, 22 | success(...args) { 23 | if (process.env.NODE_ENV === 'development') { 24 | console.log(color.green.bold(`[调试][成功] `), ...args) 25 | } 26 | }, 27 | warn(...args) { 28 | if (process.env.NODE_ENV === 'development') { 29 | console.log(color.yellow.bold(`[调试][警告] `), ...args) 30 | } 31 | }, 32 | error(...args) { 33 | if (process.env.NODE_ENV === 'development') { 34 | console.log(color.red.bold(`[调试][错误] `), ...args) 35 | } 36 | } 37 | } 38 | } 39 | --------------------------------------------------------------------------------