Compare commits
28 Commits
cf9f9bea7c
...
master
Author | SHA1 | Date | |
---|---|---|---|
815d22d50c
|
|||
9fb90bef0e
|
|||
5f52abba97
|
|||
904b906b63
|
|||
32f7577a07
|
|||
e143d3aa6c
|
|||
877e1e4587
|
|||
db72ad3694
|
|||
f23a607148
|
|||
4d448d876e
|
|||
5f5918e830
|
|||
373ce3d003
|
|||
1a3e37f39a
|
|||
98cee5bd95
|
|||
d27773cbf2
|
|||
ffe187417c
|
|||
c8891848f1
|
|||
c94563f5c8
|
|||
b5111370a3
|
|||
324d8b3b54
|
|||
95b30485ea
|
|||
4aedaea646
|
|||
6575af4665
|
|||
5ca196e5d2
|
|||
24342022dd
|
|||
bd8b866f07
|
|||
99d304899d
|
|||
d8782c6b1f
|
29
.drone.yml
29
.drone.yml
@ -6,13 +6,21 @@
|
||||
kind: pipeline
|
||||
name: test
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: arm64
|
||||
|
||||
services:
|
||||
- name: setup-database
|
||||
image: mongo:5
|
||||
|
||||
steps:
|
||||
- name: installgit p
|
||||
- name: test
|
||||
image: node:16
|
||||
commands:
|
||||
- yarn install
|
||||
- yarn test
|
||||
- name: telgram_notify
|
||||
- env CI=1 yarn test
|
||||
- name: notify
|
||||
image: appleboy/drone-telegram
|
||||
when:
|
||||
status:
|
||||
@ -25,20 +33,23 @@ steps:
|
||||
format: markdown
|
||||
message: >
|
||||
{{#success build.status}}
|
||||
✅ `{{repo.name}}` #{{build.number}} 号构建测试已通过 .
|
||||
✅ `{{repo.name}}` #{{build.number}} 号构建测试已通过
|
||||
|
||||
📝 {{commit.author}} 在 `{{commit.branch}}` 的提交:
|
||||
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
|
||||
🌐 {{ build.link }}
|
||||
{{else}}
|
||||
❌ `{{repo.name}}` #{{build.number}} 号构建测试已失败 .
|
||||
❌ `{{repo.name}}` #{{build.number}} 号构建测试已失败
|
||||
|
||||
📝 {{commit.author}} 在 `{{commit.branch}}` 的提交:
|
||||
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
|
||||
🌐 {{ build.link }}
|
||||
{{/success}}
|
||||
|
||||
|
||||
|
||||
{{/success}}
|
7
.gitignore
vendored
7
.gitignore
vendored
@ -1,3 +1,8 @@
|
||||
yarn-error.log
|
||||
node_modules
|
||||
production
|
||||
production
|
||||
**/*.key
|
||||
**/*.pem
|
||||
|
||||
# You should NEVER EVER UPLOAD THIS FILE
|
||||
launch.ps1
|
||||
|
72
README.MD
72
README.MD
@ -1,10 +1,76 @@
|
||||
# 老色批世界树((
|
||||
<div align='center'>
|
||||
<img src='logo.png'/>
|
||||
<h1>LSP Yggdrasil</h1>
|
||||
</div>
|
||||
|
||||
老色批世界树 —— 一个高性能麻将、奥苏力不-印寨克托接口的实现。使用fastify来把处理速度加速到老色批的速度(
|
||||
|
||||
[](https://996.icu)
|
||||
[](https://github.com/996icu/996.ICU/blob/master/LICENSE)
|
||||
[](https://ci.186526.xyz/Lama3L9R/lsp-yggdrasil)
|
||||
|
||||
老色批世界树 —— 一个高性能麻将、奥苏力不-印寨克托接口的实现。使用fastify来把处理速度加速到老色批的速度(
|
||||
|
||||
具体有多快呢?登录处理从数据包发出到接收到服务端响应仅需要 __***5ms***__(根据机器不同可能会有浮动,以实际情况为准)!
|
||||
|
||||
|
||||
---
|
||||
|
||||
## WIP
|
||||
## 开发计划:
|
||||
|
||||
#### Beta 1.0:
|
||||
- [x] 基础世界树 API
|
||||
+ [x] /authserver
|
||||
+ [x] /sessionserver
|
||||
+ [x] 测试
|
||||
+ [x] /api
|
||||
- [x] 进阶 API
|
||||
- [x] 皮肤上传和安全检查
|
||||
+ [x] 皮肤数据的RSA签名
|
||||
+ [x] 皮肤上传
|
||||
+ [x] 安全检查
|
||||
- [x] 兼容S3后端
|
||||
- [x] 服务器状态接口
|
||||
- [x] authlib-injector 元数据接口
|
||||
|
||||
#### Release 1.0
|
||||
- [ ] 单元测试
|
||||
+ [ ] API
|
||||
- [x] /authserver
|
||||
- [ ] /sessionserver
|
||||
- [ ] /api
|
||||
- [ ] Advanced API
|
||||
+ [ ] Utils
|
||||
- [ ] 完整 web 管理
|
||||
---
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 安装Node.js、yarn、并下载源代码
|
||||
+ 推荐 Node.js 版本:`16.15.0 LTS (Latest LTS)`,最低兼容 `14 LTS`
|
||||
+ 使用 `$ npm install -g yarn` 来安装yarn
|
||||
+ 使用 `$ git clone https://git.186526.xyz/Lama3L9R/lsp-yggdrasil.git` 下载源代码
|
||||
2. 使用 `$ yarn install` 安装依赖库
|
||||
3. 配置 `src/config.js`
|
||||
4. 使用 `$ node build.js` 创建运行时构建
|
||||
5. 使用 `$ node path/to/lsp-yggdrasil.full.cjs` 起飞
|
||||
|
||||
###### 若需要开发者环境您还需要额外几步:
|
||||
|
||||
1. 执行 `$ yarn dev:mklaunch` 来生成启动脚本
|
||||
2. 使用 `$ yarn dev` 启动开发模式服务器
|
||||
|
||||
> 注:对于开发者的首次运行,您可能需要创建一个测试账户,添加环境参数:`DEVEL_FIRST_RUN=1` 即可创建一个测试玩家
|
||||
> 测试玩家的账户为: `i@lama.icu` 密码为 `123456` 您可以到 `index.js` 中自由修改
|
||||
|
||||
## 常见问题
|
||||
|
||||
- Q:支持 `https` 嘛?
|
||||
- A:不支持,请使用反代来使用`https`,或者您也可以修改 `index.js` 中初始化代码。
|
||||
|
||||
- Q: 支持 `IPv6` 嘛?
|
||||
- A: 完美支持 `IPv6`
|
||||
|
||||
## 内置的 yarn 指令
|
||||
|
||||
+ `dev` —— 启动开发环境服务器
|
||||
+ `test` —— 单元测试
|
||||
|
18
build.js
18
build.js
@ -3,9 +3,11 @@ import exec from 'shelljs.exec'
|
||||
|
||||
const productionVersion = "1.0"
|
||||
|
||||
const build = exec('git log -n 1 --pretty=format:"%h on %as with key %GK"', { async: false }).stdout
|
||||
const build = exec('git log -n 1 --pretty=format:"%h-%GK"', { async: false }).stdout
|
||||
const type = exec('git log -n 1 --pretty=format:"%D"', { async: false }).stdout
|
||||
const buildDate = new Date().toTimeString()
|
||||
|
||||
|
||||
const banner = `
|
||||
==========================================================================
|
||||
__ _____ ______ __ __ _ __
|
||||
@ -24,18 +26,22 @@ const banner = `
|
||||
`
|
||||
|
||||
console.log(banner)
|
||||
console.log("Creating a production build...")
|
||||
|
||||
if(type.indexOf('stable') === -1) {
|
||||
console.warn("⚠ 警告: 此版本不是正式版,运行可能会存在性能、安全、稳定度等严重风险,请谨慎使用!\n")
|
||||
}
|
||||
|
||||
console.log("正在创建运行时文件...")
|
||||
|
||||
esbuild.build({
|
||||
entryPoints: ['./src/index.js'],
|
||||
outfile: './production/lsp-yggdrasil.full.js',
|
||||
outfile: './production/lsp-yggdrasil.full.cjs',
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'es2018',
|
||||
external: ['./config.js'],
|
||||
banner: {
|
||||
js: `/*\n${banner}\n*/`
|
||||
js: `/*\n${banner}\n*/;const PROGRAM_PRODUCTION = true;`
|
||||
},
|
||||
}).then(() => {
|
||||
console.log("Done! Enjoy the yggdrasil server as FAST as LSP")
|
||||
console.log("一份运行时文件已保存在 production/lsp-yggdrasil.full.js,您可以尽情的体验和LSP一样快的世界树服务器了!")
|
||||
})
|
@ -4,6 +4,8 @@
|
||||
*/
|
||||
|
||||
export default {
|
||||
testTimeout: 15 * 1000,
|
||||
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
|
13
package.json
13
package.json
@ -13,13 +13,20 @@
|
||||
"shelljs.exec": "^1.1.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastify": "^3.29.0",
|
||||
"@aws-sdk/client-s3": "^3.121.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.121.0",
|
||||
"@fastify/swagger": "^7.4.1",
|
||||
"axios": "^0.27.2",
|
||||
"fastify": "^4.2.0",
|
||||
"hex-to-uuid": "^1.1.1",
|
||||
"mongoose": "^6.3.1",
|
||||
"pino-pretty": "^7.6.1"
|
||||
"pino": "^8.1.0",
|
||||
"pino-pretty": "^7.6.1",
|
||||
"pngjs": "^6.0.0",
|
||||
"telegraf": "^4.8.2"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "nodemon --watch src/index.js",
|
||||
"dev": "node launch-development.js",
|
||||
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules UNIT_TEST=1 NODE_NO_WARNINGS=1 jest"
|
||||
}
|
||||
}
|
||||
|
@ -1,23 +1,66 @@
|
||||
const defaultPrehandler = async function (req, rep) { }
|
||||
|
||||
export const config = {
|
||||
database: {
|
||||
url: 'mongodb://localhost:27017/yggdrasil?readPreference=primary&appname=MongoDB%20Compass&directConnection=true&ssl=false',
|
||||
database: { // MONGODB IS THE BEST DATABASE
|
||||
url: '您的mongodb链接',
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
url: '',
|
||||
port: 30,
|
||||
skinDomain: [ "assets.lama.icu", 'textures.minecraft.net' ],
|
||||
serverName: "老色批世界树",
|
||||
advanced: { // 详情可见 -> https://github.com/yushijinhun/authlib-injector/wiki/Yggdrasil-%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%8A%80%E6%9C%AF%E8%A7%84%E8%8C%83#meta-%E4%B8%AD%E7%9A%84%E5%85%83%E6%95%B0%E6%8D%AE
|
||||
links: {
|
||||
homepage: "",
|
||||
register: ""
|
||||
},
|
||||
"feature.non_email_login": false,
|
||||
"feature.legojang_namespace": false,
|
||||
"feature.enable_mojang_anti_features": false,
|
||||
"feature.enable_profile_key": false
|
||||
}
|
||||
},
|
||||
signing: {
|
||||
public: '/path/to/public.pem',
|
||||
private: '/path/to/private.key'
|
||||
storage: {
|
||||
endpoint: "",
|
||||
bucket: "",
|
||||
key: "",
|
||||
secret: "",
|
||||
extra: { // these configs will pass to s3client directly
|
||||
region: 'us-ashburn-02',
|
||||
forcePathStyle: true,
|
||||
signatureVersion: 'v4',
|
||||
}
|
||||
|
||||
},
|
||||
signing: { // 签名材质信息使用
|
||||
public: 'public.pem',
|
||||
private: 'private.key'
|
||||
},
|
||||
custom: {
|
||||
overridePrehandler: (url) => {
|
||||
return defaultPrehandler
|
||||
override: { // 使用这个来覆写一个路径的处理器,使用function而不是箭头函数可以用this访问fastify server
|
||||
preHandler: {
|
||||
/*
|
||||
"/example/route": async function(req, rep) {}
|
||||
*/
|
||||
},
|
||||
|
||||
handler: {
|
||||
/*
|
||||
"/example/route": async function(req, rep) {}
|
||||
*/
|
||||
}
|
||||
},
|
||||
preHooks: (fastify) => {},
|
||||
preRouting: (fastify) => {},
|
||||
postRouting: (fastify) => {},
|
||||
preHooks: (fastify) => {}, // 在这里添加自定义hook
|
||||
preRouting: (fastify) => {}, // 在这里添加自定义路由
|
||||
postRouting: (fastify) => {}, // 我也不知道你在这里写了有啥用...
|
||||
},
|
||||
telegram: {
|
||||
disable: undefined, // 设置为任意非null、undefined、false、0、""等值(可以设置为true就禁用了),则禁用telegram功能
|
||||
token: '你的telegrambot的apitoken',
|
||||
}
|
||||
}
|
||||
|
||||
export function getOverrideHandler(route) {
|
||||
return config.custom.override.handler[route]
|
||||
}
|
||||
|
||||
export function getOverridePreHandler(route) {
|
||||
return config.custom.override.preHandler[route]
|
||||
}
|
27
src/hooks.js
27
src/hooks.js
@ -1,11 +1,11 @@
|
||||
|
||||
export async function headerValidation(req, rep) {
|
||||
if(!(/(authserver)|(sessionserver)|(api)/g).test(req.url)) {
|
||||
if(!(/(authserver)|(sessionserver)|(api)/g).test(req.url) || req.method !== 'POST') {
|
||||
return
|
||||
}
|
||||
|
||||
if(Object.keys(req.headers).some(key => {
|
||||
return key.toLowerCase() === "content-type" && req.headers[key].toLowerCase() !== "application/json"
|
||||
return key.toLowerCase() === "content-type" && req.headers[key].toLowerCase().indexOf("application/json") === -1
|
||||
})) {
|
||||
return rep.code(400).send({
|
||||
error: "IllegalArgumentException",
|
||||
@ -15,10 +15,19 @@ export async function headerValidation(req, rep) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleError(err, req, rep) {
|
||||
return rep.code(500).send({
|
||||
error: "InternalServerError",
|
||||
errorMessage: "服务器内部错误",
|
||||
cause: err.message
|
||||
})
|
||||
}
|
||||
export async function handleError(err, req, rep) {
|
||||
req.log.info(err)
|
||||
if(!(/(authserver)|(sessionserver)|(api)/g).test(req.url)) {
|
||||
return rep.code(500).send({
|
||||
error: "InternalServerError",
|
||||
errorMessage: "服务器内部错误",
|
||||
cause: err.message
|
||||
})
|
||||
} else {
|
||||
return await rep.code(500).send({
|
||||
err: 0.000000,
|
||||
msg: "服务器内部错误",
|
||||
extra: err
|
||||
})
|
||||
}
|
||||
}
|
||||
|
215
src/index.js
215
src/index.js
@ -1,59 +1,204 @@
|
||||
import { fastify } from 'fastify'
|
||||
import { mongoose } from 'mongoose'
|
||||
import { registerModels } from './models/index.js';
|
||||
import * as Hooks from './hooks.js'
|
||||
import * as AuthenticateRoutings from './routes/authenticate.js'
|
||||
import * as SessionServerRoutings from './routes/session.js'
|
||||
import * as WebAPIRoutings from './routes/web-api.js'
|
||||
import { config } from './config.js'
|
||||
import { readFileSync } from 'fs'
|
||||
import { Scenes, session, Telegraf } from 'telegraf'
|
||||
import { registerAllPlayerCommands } from './telegram/player-commands.js'
|
||||
import { Player } from './models/player.js'
|
||||
import fastifySwagger from '@fastify/swagger'
|
||||
import { S3Client } from '@aws-sdk/client-s3'
|
||||
import pino from 'pino'
|
||||
|
||||
String.prototype._split = String.prototype.split
|
||||
|
||||
String.prototype.split = function(separator, limit) {
|
||||
if (separator === undefined && limit === 0) return []
|
||||
|
||||
if(limit === undefined) {
|
||||
return String.prototype._split.call(this, separator, limit)
|
||||
}
|
||||
|
||||
const arr = []
|
||||
let lastBegin = -1
|
||||
for(let i = 0; i < limit - 1; i++) {
|
||||
const end = String.prototype.indexOf.call(this, separator, ++lastBegin)
|
||||
if(end == -1) {
|
||||
arr.push(undefined)
|
||||
continue
|
||||
}
|
||||
arr.push(String.prototype.substring.call(this, lastBegin, end))
|
||||
lastBegin = end
|
||||
}
|
||||
arr.push(String.prototype.substring.call(this, ++lastBegin))
|
||||
|
||||
return arr
|
||||
}
|
||||
|
||||
for(let i = 0; i < process.argv.length; i++) {
|
||||
const curr = process.argv[i]
|
||||
if(curr.startsWith('--')) {
|
||||
switch(curr.substring(2)){
|
||||
case 'override': {
|
||||
const [next, value] = process.argv[i + 1].split(":", 2)
|
||||
if(next || !next.startsWith('--')) {
|
||||
eval(`config.${next} = '${value}'`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
default: {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const serverLogger = pino({
|
||||
transport: {
|
||||
target: 'pino-pretty'
|
||||
}
|
||||
})
|
||||
|
||||
export const server = fastify({
|
||||
logger: {
|
||||
prettyPrint: true
|
||||
}
|
||||
logger: serverLogger
|
||||
})
|
||||
|
||||
export const telegraf = new Telegraf(config.telegram.token)
|
||||
|
||||
export const s3Instance = new S3Client({
|
||||
credentials: {
|
||||
accessKeyId: config.storage.key,
|
||||
secretAccessKey: config.storage.secret,
|
||||
},
|
||||
endpoint: config.storage.endpoint,
|
||||
...config.storage.extra
|
||||
})
|
||||
|
||||
|
||||
//export const logger = server.log
|
||||
export const setup = async () => {
|
||||
server.log.info("老色批世界树 > 初始化中...")
|
||||
|
||||
;(async () => {
|
||||
const { config } = await import('./config.js')
|
||||
await mongoose.connect(config.database.url)
|
||||
const publicKey = readFileSync(config.signing.public).toString()
|
||||
const privateKey = readFileSync(config.signing.private).toString()
|
||||
|
||||
server.decorate('conf', config)
|
||||
|
||||
const mongooseClient = await mongoose.connect(config.database.url)
|
||||
const models = registerModels(mongooseClient)
|
||||
|
||||
server.decorate('mongoose', mongooseClient)
|
||||
server.decorate('models', models)
|
||||
server.decorate('keys', { publicKey, privateKey })
|
||||
|
||||
config.custom.preHooks(server)
|
||||
|
||||
server.addHook('preHandler', Hooks.headerValidation)
|
||||
server.setErrorHandler(Hooks.handleError)
|
||||
|
||||
server.addContentTypeParser('image/png', (_, payload, done) => {
|
||||
done(null, payload)
|
||||
})
|
||||
server.register(fastifySwagger, {
|
||||
routePrefix: '/docs',
|
||||
swagger: {
|
||||
title: "lsp-yggdrasil 接口文档",
|
||||
version: "1.0.0",
|
||||
tags: [
|
||||
{ name: "Authserver", description: "Yggdrasil Authserver 协议定义的接口"},
|
||||
{ name: "Sessionserver", description: "Yggdrasil Authserver 协议定义的接口"},
|
||||
{ name: "api", description: "Yggdrasil Authserver 协议定义的接口"},
|
||||
{ name: "webapi", description: "web前端接口"},
|
||||
]
|
||||
},
|
||||
exposeRoute: true,
|
||||
})
|
||||
|
||||
config.custom.preRouting(server)
|
||||
|
||||
// Authserver routings
|
||||
server.route(AuthenticateRoutings.authenticate)
|
||||
server.route(AuthenticateRoutings.refresh)
|
||||
server.route(AuthenticateRoutings.validate)
|
||||
server.route(AuthenticateRoutings.invalidate)
|
||||
server.route(AuthenticateRoutings.signout)
|
||||
|
||||
server.route(SessionServerRoutings.join)
|
||||
server.route(SessionServerRoutings.hasJoined)
|
||||
server.route(SessionServerRoutings.profile)
|
||||
server.route(SessionServerRoutings.profiles)
|
||||
|
||||
server.route(WebAPIRoutings.CORS_BYPASS)
|
||||
server.route(WebAPIRoutings.meta)
|
||||
server.route(WebAPIRoutings.status)
|
||||
server.route(WebAPIRoutings.telegramBind)
|
||||
server.route(WebAPIRoutings.login)
|
||||
server.route(WebAPIRoutings.register)
|
||||
server.route(WebAPIRoutings.textures)
|
||||
server.route(WebAPIRoutings.uploadTexture)
|
||||
|
||||
config.custom.postRouting(server)
|
||||
|
||||
/*
|
||||
if(process.env["UNIT_TEST"] || process.env["DEVEL_FIRST_RUN"]) {
|
||||
// Create a test player
|
||||
await new models.Player({
|
||||
username: 'test',
|
||||
password: '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92',
|
||||
email: 'i@lama.icu',
|
||||
uuid: '098f6bcd-4621-3373-8ade-4e832627b4f6',
|
||||
texture: {
|
||||
skin: 'assets.lama.icu/textures/skin/steve.png',
|
||||
cape: 'assets.lama.icu/textures/cape/default.png'
|
||||
},
|
||||
registerDate: Date.now(),
|
||||
permissions: [{ node: 'login', allowed: true, duration: 0, eternal: true, startDate: Date.now(), highPriority: false }]
|
||||
}).save()
|
||||
*/
|
||||
|
||||
if(!process.env["UNIT_TEST"]) {
|
||||
await launch(config)
|
||||
await new Player({
|
||||
username: 'test',
|
||||
password: '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92',
|
||||
email: 'i@lama.icu',
|
||||
uuid: '098f6bcd-4621-3373-8ade-4e832627b4f6',
|
||||
textures: {
|
||||
skin: 'assets.lama.icu/textures/skin/steve.png',
|
||||
cape: 'assets.lama.icu/textures/cape/default.png'
|
||||
},
|
||||
registerDate: Date.now(),
|
||||
permissions: ['login'],
|
||||
binding: {
|
||||
platform: 'telegram',
|
||||
username: 'Qumolama',
|
||||
verified: true,
|
||||
}
|
||||
}).save()
|
||||
}
|
||||
})()
|
||||
registerAllPlayerCommands()
|
||||
}
|
||||
|
||||
const launch = async (config) => {
|
||||
await server.listen(config.server.port, config.server.url)
|
||||
const launch = async () => {
|
||||
process.on('SIGINT', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
|
||||
await telegraf.launch()
|
||||
|
||||
await server.listen({ port: config.server.port, url: config.server.url })
|
||||
|
||||
server.log.info("老色批世界树 > 基于 fastify 的高性能 HTTP 服务器已启动")
|
||||
}
|
||||
}
|
||||
|
||||
export const shutdown = async () => {
|
||||
await server.close()
|
||||
server.log.info("老色批世界树 > HTTP 服务器已关闭")
|
||||
try {
|
||||
telegraf.stop()
|
||||
server.log.info("老色批世界树 > Telegram Bot 已关闭")
|
||||
} catch(err) {
|
||||
server.log.info("老色批世界树 > Telegram Bot 未运行,已跳过")
|
||||
}
|
||||
mongoose.disconnect()
|
||||
server.log.info("老色批世界树 > 数据库连接已断开,服务器已关闭")
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if(!process.env["UNIT_TEST"]) {
|
||||
console.log(`
|
||||
================================================================
|
||||
__ _____ ______ __ __ _ __
|
||||
/ / / ___// __ \\ \\/ /___ _____ _____/ /________ ______(_) /
|
||||
/ / \\__ \\/ /_/ /\\ / __ \`/ __ \`/ __ / ___/ __ \`/ ___/ / /
|
||||
/ /______/ / ____/ / / /_/ / /_/ / /_/ / / / /_/ (__ ) / /
|
||||
/_____/____/_/ /_/\\__, /\\__, /\\__,_/_/ \\__,_/____/_/_/
|
||||
/____//____/
|
||||
================================================================\n`)
|
||||
|
||||
if(typeof PROGRAM_PRODUCTION === 'undefined') {
|
||||
console.warn("⚠ 警告: 您运行的不是正式版本,可能会不稳定,仅限开发环境运行!\n")
|
||||
}
|
||||
await setup()
|
||||
await launch()
|
||||
}
|
||||
})()
|
||||
|
@ -1,9 +0,0 @@
|
||||
import { TokenSchema } from "./token.js";
|
||||
import { PlayerSchema } from "./player.js";
|
||||
|
||||
export function registerModels(mongoose) {
|
||||
return {
|
||||
Token: mongoose.model("Token", TokenSchema),
|
||||
Player: mongoose.model("Player", PlayerSchema),
|
||||
}
|
||||
}
|
@ -1,7 +1,12 @@
|
||||
import mongoose from 'mongoose'
|
||||
const { Schema } = mongoose
|
||||
import { uuidToNoSymboUUID } from '../generator.js'
|
||||
import { ImageSecurity } from '../secure.js'
|
||||
import { s3Instance } from '../index.js'
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3'
|
||||
import { config } from '../config.js'
|
||||
|
||||
export const PlayerSchema = new Schema({
|
||||
export const Player = mongoose.model("Player", new mongoose.Schema({
|
||||
username: String, // 有符号 UUID
|
||||
password: String,
|
||||
email: String,
|
||||
@ -11,8 +16,15 @@ export const PlayerSchema = new Schema({
|
||||
cape: String,
|
||||
},
|
||||
registerDate: Number,
|
||||
permissions: [{ node: String, allowed: Boolean, duration: Number, startDate: Number, highPriority: Boolean }], // ban -> true
|
||||
})
|
||||
permissions: [{
|
||||
type: String
|
||||
}],
|
||||
binding: {
|
||||
username: String,
|
||||
platform: String,
|
||||
verified: Boolean,
|
||||
}
|
||||
}))
|
||||
|
||||
export const PlayerSeriliazationSchema = {
|
||||
"type": "object",
|
||||
@ -64,4 +76,56 @@ export const PlayerAccountSerializationSchema = {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlayerSerialization(player) {
|
||||
const textures = {
|
||||
timestamp: 0,
|
||||
profileId: uuidToNoSymboUUID(player.uuid),
|
||||
profileName: player.username,
|
||||
textures: { }
|
||||
}
|
||||
|
||||
if(player.textures.skin && player.textures.skin != 0) { // Must be '!=' if this change to '!==' will never works
|
||||
if(player.textures.skin.indexOf("http") === -1) {
|
||||
textures.textures.SKIN = {
|
||||
url: await getSignedUrl(s3Instance, new GetObjectCommand({
|
||||
Bucket: config.storage.bucket,
|
||||
Key: player.textures.skin
|
||||
}), { expiresIn: 3 * 24 * 60 * 60 }) // 3 days
|
||||
}
|
||||
} else {
|
||||
textures.textures.SKIN = {
|
||||
url: player.textures.skin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(player.textures.cape && player.textures.cape != 0) { // Must be '!=' if this change to '!==' will never works
|
||||
if(player.textures.cape.indexOf("http") === -1) {
|
||||
textures.textures.CAPE = {
|
||||
url: await getSignedUrl(s3Instance, new GetObjectCommand({
|
||||
Bucket: config.storage.bucket,
|
||||
Key: player.textures.cape
|
||||
}), { expiresIn: 3 * 24 * 60 * 60 }) // 3 days
|
||||
}
|
||||
} else {
|
||||
textures.textures.CAPE = {
|
||||
url: player.textures.cape
|
||||
}
|
||||
}
|
||||
}
|
||||
const val = Buffer.from(JSON.stringify(textures)).toString('base64')
|
||||
|
||||
return {
|
||||
id: uuidToNoSymboUUID(player.uuid),
|
||||
name: player.username,
|
||||
properties: [
|
||||
{
|
||||
name: "textures",
|
||||
value: val,
|
||||
signature: ImageSecurity.sign(val),
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -1,10 +1,9 @@
|
||||
import mongoose from 'mongoose'
|
||||
const { Schema } = mongoose
|
||||
|
||||
export const TokenSchema = new Schema({
|
||||
export const Token = mongoose.model("Token", new mongoose.Schema({
|
||||
uuid: String,
|
||||
token: String,
|
||||
clientToken: String,
|
||||
expireDate: Number,
|
||||
deadDate: Number,
|
||||
state: String, // alive, linbo, dead
|
||||
})
|
||||
}))
|
@ -1,11 +1,16 @@
|
||||
import * as PlayerModel from '../models/player.js'
|
||||
import { createHash } from 'crypto'
|
||||
import { generateToken, uuidToNoSymboUUID } from '../generator.js'
|
||||
import { getOverrideHandler, getOverridePreHandler } from '../config.js'
|
||||
import { Token } from '../models/token.js'
|
||||
|
||||
export const authenticate = {
|
||||
method: 'POST',
|
||||
url: '/authserver/authenticate',
|
||||
schema: {
|
||||
summary: "登录",
|
||||
description: "登陆账号,用于获取 accessToken。如果账号没有绑定 Telegram 则会被禁止登陆。agent.name 必须为 'minecraft'。详情请见 authlib-injector 文档。",
|
||||
tags: [ "Authserver" ],
|
||||
body: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -46,7 +51,7 @@ export const authenticate = {
|
||||
},
|
||||
"availableProfiles": {
|
||||
"type": "array",
|
||||
"items": [{...PlayerModel.PlayerSeriliazationSchema}]
|
||||
"items": [PlayerModel.PlayerSeriliazationSchema]
|
||||
},
|
||||
"selectedProfile": PlayerModel.PlayerSeriliazationSchema,
|
||||
"user": PlayerModel.PlayerAccountSerializationSchema
|
||||
@ -54,15 +59,20 @@ export const authenticate = {
|
||||
}
|
||||
}
|
||||
},
|
||||
preHandler: async function(req, rep) {
|
||||
this.conf.custom.overridePrehandler('/authserver/authenticate')
|
||||
},
|
||||
handler: async function (req, rep) {
|
||||
preHandler: getOverridePreHandler("/authserver/authenticate"),
|
||||
handler: getOverrideHandler("/authserver/authenticate") ?? async function (req, rep) {
|
||||
let { username, password, clientToken, requestUser, agent } = req.body
|
||||
const player = await this.models.Player.findOne({ email: username, password: createHash('sha256').update(password).digest().toString('hex').toLowerCase() })
|
||||
if(!player || !player.permissions.some((it) => {
|
||||
return it.node === 'login' && it.allowed && (it.duration === 0 || it.startDate + it.duration > Date.now())
|
||||
})) {
|
||||
|
||||
if( !agent || agent.name.toLowerCase() !== 'minecraft') {
|
||||
return await rep.code(418).send({
|
||||
error: "ForbiddenOperationException",
|
||||
errorMessage: "无效应用名,此服务端仅支援 Minecraft",
|
||||
cause: "此服务器只支持 agent.name: minecraft"
|
||||
})
|
||||
}
|
||||
|
||||
const player = await PlayerModel.Player.findOne({ email: username, password: createHash('sha256').update(password).digest().toString('hex').toLowerCase() })
|
||||
if(!player || player.permissions.indexOf("login") === -1) {
|
||||
return await rep.code(401).send({
|
||||
error: "Unauthorized",
|
||||
errorMessage: "用户名或密码错误",
|
||||
@ -70,6 +80,14 @@ export const authenticate = {
|
||||
})
|
||||
}
|
||||
|
||||
if(!player.binding || !player.binding.verified) {
|
||||
return await rep.code(401).send({
|
||||
error: "Unauthorized",
|
||||
errorMessage: "您的账号未验证,登录请求已禁止",
|
||||
cause: "您的账号未验证,登录请求已禁止"
|
||||
})
|
||||
}
|
||||
|
||||
if(!clientToken) {
|
||||
clientToken = createHash('sha256').update( "" + Math.random() * 1.048596).digest().toString('hex')
|
||||
}
|
||||
@ -81,50 +99,324 @@ export const authenticate = {
|
||||
id: uuidToNoSymboUUID(player.uuid),
|
||||
properties: [
|
||||
{
|
||||
preferredLanguage: "zh_CN"
|
||||
name: "preferredLanguage",
|
||||
value: "zh_CN"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const textures = {
|
||||
timestamp: 0,
|
||||
profileId: uuidToNoSymboUUID(player.uuid),
|
||||
profileName: player.username,
|
||||
textures: { }
|
||||
}
|
||||
const profile = await PlayerModel.getPlayerSerialization(player)
|
||||
|
||||
if(player.textures.skin && player.textures.skin != 0) { // Must be '!=' if this change to '!==' will never works
|
||||
textures.textures.SKIN = {
|
||||
url: player.textures.skin,
|
||||
metadata
|
||||
}
|
||||
}
|
||||
await Token.deleteMany({ player: player.uuid }).exec()
|
||||
|
||||
const profile = {
|
||||
uuid: uuidToNoSymboUUID(player.uuid),
|
||||
name: player.username,
|
||||
properties: [
|
||||
{
|
||||
name: "texturs",
|
||||
value: Buffer.from(JSON.stringify(textures)).toString('base64')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
new this.models.Token({
|
||||
new Token({
|
||||
uuid: player.uuid,
|
||||
token: token,
|
||||
clientToken: clientToken,
|
||||
expireDate: Date.now() + 1000 * 60 * 60 * 24 * 15,
|
||||
deadDate: Date.now() + 1000 * 60 * 60 * 24 * 30,
|
||||
state: 'alive'
|
||||
}).save()
|
||||
|
||||
return await rep.send({
|
||||
|
||||
const response = {
|
||||
accessToken: token,
|
||||
clientToken: clientToken,
|
||||
availableProfiles: [ profile ],
|
||||
selectedProfile: profile,
|
||||
user: account
|
||||
}
|
||||
req.log.info("响应请求中: " + JSON.stringify(response))
|
||||
return await rep.send(response)
|
||||
}
|
||||
}
|
||||
|
||||
export const refresh = {
|
||||
method: 'POST',
|
||||
url: '/authserver/refresh',
|
||||
schema: {
|
||||
summary: "刷新令牌",
|
||||
description: "可以刷新的令牌仅限令牌处于 'limbo(半吊销状态)',默认为3天有效期,3天后进入 'limbo',7天后进入 'dead(失效)'。详情请见 authlib-injector 文档。",
|
||||
tags: [ "Authserver" ],
|
||||
body: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accessToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"clientToken": {
|
||||
anyOf: [
|
||||
{ type: 'string' },
|
||||
{ type: 'null' }
|
||||
],
|
||||
},
|
||||
"requestUser": {
|
||||
anyOf: [
|
||||
{ type: 'boolean' },
|
||||
{ type: 'null' }
|
||||
]
|
||||
},
|
||||
"selectedProfile": {
|
||||
anyOf: [
|
||||
{ ...PlayerModel.PlayerSeriliazationSchema },
|
||||
{ type: 'null' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accessToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"clientToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"selectedProfile": PlayerModel.PlayerSeriliazationSchema,
|
||||
"user": PlayerModel.PlayerAccountSerializationSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
preHandler: getOverridePreHandler("/authserver/refresh"),
|
||||
handler: getOverrideHandler("/authserver/refresh") ?? async function (req, rep) {
|
||||
const { accessToken, clientToken, requestUser, selectedProfile } = req.body
|
||||
|
||||
const query = {
|
||||
token: accessToken
|
||||
}
|
||||
|
||||
if(clientToken) {
|
||||
query.clientToken = clientToken
|
||||
}
|
||||
const token = await Token.findOne(query)
|
||||
|
||||
if(!token) {
|
||||
return await rep.code(401).send({
|
||||
error: "Unauthorized",
|
||||
errorMessage: "accessToken无效",
|
||||
cause: "accessToken无效"
|
||||
})
|
||||
}
|
||||
|
||||
const { deadDate, uuid } = token
|
||||
|
||||
if(deadDate < Date.now()) {
|
||||
return await rep.code(401).send({
|
||||
error: "Unauthorized",
|
||||
errorMessage: "accessToken无效",
|
||||
cause: "accessToken无效"
|
||||
})
|
||||
}
|
||||
|
||||
const [newToken, key] = generateToken(token.uuid)
|
||||
this.log.info(`/authserver/authenticate > 为玩家 ${token.uuid} 刷新令牌: ${token.uuid} 为 ${newToken} | 随机 key = ${key}`)
|
||||
|
||||
await Token.updateOne({
|
||||
token: accessToken,
|
||||
clientToken: clientToken ?? undefined
|
||||
}, {
|
||||
$set: {
|
||||
expireDate: 0,
|
||||
deadDate: 0
|
||||
}
|
||||
})
|
||||
|
||||
new Token({
|
||||
uuid: uuid,
|
||||
token: newToken,
|
||||
clientToken: clientToken ?? token.clientToken,
|
||||
expireDate: Date.now() + 1000 * 60 * 60 * 24 * 15,
|
||||
deadDate: Date.now() + 1000 * 60 * 60 * 24 * 30,
|
||||
}).save()
|
||||
|
||||
const response = {
|
||||
accessToken: newToken,
|
||||
clientToken: clientToken ?? token.clientToken,
|
||||
}
|
||||
|
||||
if(requestUser || selectedProfile) {
|
||||
const player = await PlayerModel.Player.findOne({ uuid })
|
||||
|
||||
if(requestUser) {
|
||||
response.user = {
|
||||
id: uuidToNoSymboUUID(player.uuid),
|
||||
properties: [
|
||||
{
|
||||
name: "preferredLanguage",
|
||||
value: "zh_CN"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
if(selectedProfile) {
|
||||
response.selectedProfile = await PlayerModel.getPlayerSerialization(player)
|
||||
}
|
||||
}
|
||||
|
||||
return await rep.send(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const validate = {
|
||||
method: 'POST',
|
||||
url: '/authserver/validate',
|
||||
schema: {
|
||||
summary: "验证令牌",
|
||||
description: "验证令牌是否有效,若携带 clientToken 则会检查 clientToken是否正确,否则不会检查。详情请见 authlib-injector 文档。",
|
||||
tags: [ "Authserver" ],
|
||||
body: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accessToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"clientToken": {
|
||||
anyOf: [
|
||||
{ "type": "string" },
|
||||
{ type: 'null' },
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
response: {
|
||||
204: {
|
||||
"type": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
preHandler: getOverridePreHandler("/authserver/validate"),
|
||||
handler: getOverrideHandler("/authserver/validate") ?? async function (req, rep) {
|
||||
const { accessToken, clientToken } = req.body
|
||||
|
||||
const query = {
|
||||
token: accessToken
|
||||
}
|
||||
|
||||
if(clientToken) {
|
||||
query.clientToken = clientToken
|
||||
}
|
||||
const token = await Token.findOne(query)
|
||||
|
||||
if(!token) {
|
||||
return await rep.code(401).send({
|
||||
error: "Unauthorized",
|
||||
errorMessage: "accessToken无效",
|
||||
cause: "accessToken无效"
|
||||
})
|
||||
}
|
||||
|
||||
const { expireDate } = token
|
||||
|
||||
if(expireDate < Date.now()) {
|
||||
return await rep.code(401).send({
|
||||
error: "Unauthorized",
|
||||
errorMessage: "accessToken无效",
|
||||
cause: "accessToken无效"
|
||||
})
|
||||
}
|
||||
|
||||
return await rep.code(204).send()
|
||||
}
|
||||
}
|
||||
|
||||
export const invalidate = {
|
||||
method: 'POST',
|
||||
url: '/authserver/invalidate',
|
||||
schema: {
|
||||
summary: "主动吊销令牌",
|
||||
description: "吊销指定令牌,处理时根据规范永远忽略 clientToken 参数。详情请见 authlib-injector 文档。",
|
||||
tags: [ "Authserver" ],
|
||||
body: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"accessToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"clientToken": {
|
||||
anyOf: [
|
||||
{ "type": "string" },
|
||||
{ type: 'null' },
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
response: {
|
||||
204: {
|
||||
"type": "null"
|
||||
}
|
||||
}
|
||||
}
|
||||
,
|
||||
preHandler: getOverridePreHandler("/authserver/invalidate"),
|
||||
handler: getOverrideHandler("/authserver/authenticate") ?? async function (req, rep) {
|
||||
const { accessToken } = req.body
|
||||
|
||||
const { modifiedCount } = await Token.updateOne({
|
||||
token: accessToken
|
||||
}, {
|
||||
$set: {
|
||||
expireDate: 0,
|
||||
deadDate: 0
|
||||
}
|
||||
})
|
||||
if(modifiedCount === 0) {
|
||||
return await rep.code(401).send({
|
||||
error: "Unauthorized",
|
||||
errorMessage: "accessToken无效",
|
||||
cause: "accessToken无效"
|
||||
})
|
||||
}
|
||||
|
||||
return await rep.code(204).send()
|
||||
}
|
||||
}
|
||||
|
||||
export const signout = {
|
||||
method: 'POST',
|
||||
url: '/authserver/signout',
|
||||
schema: {
|
||||
summary: "登出",
|
||||
description: "登出账号,吊销所有令牌。详情请见 authlib-injector 文档。",
|
||||
tags: [ "Authserver" ],
|
||||
body: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
anyOf: [
|
||||
{ "type": "string" },
|
||||
{ type: 'null' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
response: {
|
||||
204: {
|
||||
"type": "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
preHandler: getOverridePreHandler("/authserver/signout"),
|
||||
handler: getOverrideHandler("/authserver/signout") ?? async function (req, rep) {
|
||||
const { username, password } = req.body
|
||||
|
||||
const player = await PlayerModel.Player.findOne({ email: username, password: createHash('sha256').update(password).digest().toString('hex').toLowerCase() })
|
||||
if(!player) {
|
||||
return await rep.code(401).send({
|
||||
error: "Unauthorized",
|
||||
errorMessage: "用户名或密码错误",
|
||||
cause: "用户名或密码错误"
|
||||
})
|
||||
}
|
||||
|
||||
await Token.deleteMany({
|
||||
uuid: player.uuid
|
||||
})
|
||||
|
||||
rep.code(204).send()
|
||||
}
|
||||
}
|
||||
|
228
src/routes/session.js
Normal file
228
src/routes/session.js
Normal file
@ -0,0 +1,228 @@
|
||||
import { getOverrideHandler, getOverridePreHandler } from '../config.js'
|
||||
import { toSymboUUID, uuidToNoSymboUUID } from '../generator.js'
|
||||
import { getPlayerSerialization, Player, PlayerSeriliazationSchema } from '../models/player.js'
|
||||
import { Token } from '../models/token.js'
|
||||
|
||||
/*
|
||||
Key: string Username
|
||||
Value: {
|
||||
accessToken: string,
|
||||
serverId: string,
|
||||
ip: string
|
||||
}
|
||||
*/
|
||||
const joinServerRequest = new Map()
|
||||
|
||||
export const join = {
|
||||
method: 'POST',
|
||||
url: '/sessionserver/session/minecraft/join',
|
||||
schema: {
|
||||
summary: "加入服务器",
|
||||
description: "详情请见 authlib-injector 文档。",
|
||||
tags: [ "Sessionserver" ],
|
||||
body: {
|
||||
"accessToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"selectedProfile": {
|
||||
"type": "string"
|
||||
},
|
||||
"serverId": {
|
||||
"type": "string"
|
||||
},
|
||||
},
|
||||
response: {
|
||||
204: {
|
||||
type: "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
preHandler: getOverridePreHandler('/sessionserver/session/minecraft/join'),
|
||||
handler: getOverrideHandler('/sessionserver/session/minecraft/join') ?? async function (req, rep) {
|
||||
const { accessToken, selectedProfile, serverId } = req.body
|
||||
const user = await await Player.findOne({ uuid: toSymboUUID(selectedProfile) })
|
||||
if (!user) {
|
||||
return await rep.code(400).send({
|
||||
error: "IllegalArgumentException",
|
||||
errorMessage: "请求内容不正确",
|
||||
cause: "用户不存在"
|
||||
})
|
||||
}
|
||||
|
||||
const session = await Token.findOne({ token: accessToken })
|
||||
if (!session) {
|
||||
return await rep.code(401).send({
|
||||
error: "IllegalArgumentException",
|
||||
errorMessage: "无效会话",
|
||||
cause: "无效会话"
|
||||
})
|
||||
}
|
||||
|
||||
if (Date.now() > session.expireDate) {
|
||||
return await rep.code(401).send({
|
||||
error: "IllegalArgumentException",
|
||||
errorMessage: "无效会话",
|
||||
cause: "会话已过期"
|
||||
})
|
||||
}
|
||||
|
||||
joinServerRequest.set(session.uuid, {
|
||||
accessToken: accessToken,
|
||||
serverId: serverId,
|
||||
ip: req.headers['x-forwarded-for'] || req.ip
|
||||
})
|
||||
|
||||
await rep.code(204).send()
|
||||
}
|
||||
}
|
||||
|
||||
export const hasJoined = {
|
||||
method: 'GET',
|
||||
url: '/sessionserver/session/minecraft/hasJoined',
|
||||
schema: {
|
||||
summary: "查询是否已加入服务器",
|
||||
description: "详情请见 authlib-injector 文档。",
|
||||
tags: [ "Sessionserver" ],
|
||||
query: {
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"serverId": {
|
||||
"type": "string"
|
||||
},
|
||||
"ip": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
|
||||
response: {
|
||||
200: PlayerSeriliazationSchema,
|
||||
204: {
|
||||
type: "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
preHandler: getOverridePreHandler('/sessionserver/session/minecraft/hasJoined'),
|
||||
handler: getOverrideHandler('/sessionserver/session/minecraft/hasJoined') ?? async function (req, rep) {
|
||||
const { username, serverId, ip } = req.query
|
||||
|
||||
const player = await Player.findOne({ username })
|
||||
if (!player) {
|
||||
return await rep.code(400).send({
|
||||
error: "IllegalArgumentException",
|
||||
errorMessage: "请求内容不正确",
|
||||
cause: "用户不存在"
|
||||
})
|
||||
}
|
||||
|
||||
const request = joinServerRequest.get(player.uuid)
|
||||
this.log.info(request)
|
||||
|
||||
if(ip) {
|
||||
if(ip !== request.ip) {
|
||||
return await rep.code(401).send({
|
||||
error: "IllegalArgumentException",
|
||||
errorMessage: "无效会话",
|
||||
cause: "IP 不匹配"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if(serverId !== request.serverId) {
|
||||
return await rep.code(401).send({
|
||||
error: "IllegalArgumentException",
|
||||
errorMessage: "无效会话",
|
||||
cause: "服务器 ID 不匹配"
|
||||
})
|
||||
}
|
||||
|
||||
await rep.code(200).send(await getPlayerSerialization(player))
|
||||
}
|
||||
}
|
||||
|
||||
export const profile = {
|
||||
method: 'GET',
|
||||
url: '/sessionserver/session/minecraft/profile/:uuid',
|
||||
schema: {
|
||||
summary: "获取玩家 Profile",
|
||||
description: "详情请见 authlib-injector 文档。",
|
||||
tags: [ "Sessionserver" ],
|
||||
params: {
|
||||
"uuid": {
|
||||
"type": "string"
|
||||
},
|
||||
unsigned: {
|
||||
anyOf: [
|
||||
{ "type": "boolean" },
|
||||
{ type: 'null' }
|
||||
]
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: PlayerSeriliazationSchema,
|
||||
204: {
|
||||
type: "null"
|
||||
}
|
||||
}
|
||||
},
|
||||
preHandler: getOverridePreHandler('/sessionserver/session/minecraft/profile/:uuid'),
|
||||
handler: getOverrideHandler('/sessionserver/session/minecraft/profile/:uuid') ?? async function (req, rep) {
|
||||
const { uuid } = req.params
|
||||
const player = await Player.findOne({ uuid: toSymboUUID(uuid) })
|
||||
if (!player) {
|
||||
return await rep.code(204).send()
|
||||
}
|
||||
|
||||
await rep.code(200).send(await getPlayerSerialization(player))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const profiles = {
|
||||
method: 'POST',
|
||||
url: '/api/profiles/minecraft',
|
||||
schema: {
|
||||
summary: "获取玩家信息",
|
||||
description: "获取玩家信息。详情请见 authlib-injector 文档。",
|
||||
tags: [ "api" ],
|
||||
body: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string'
|
||||
},
|
||||
name: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
preHandler: getOverridePreHandler('/api/profiles/minecraft') ?? async function(req, rep) {
|
||||
if(req.body.length >= 3) {
|
||||
await rep.code(400).send({
|
||||
error: "IllegalArgumentException",
|
||||
errorMessage: "请求内容过长, 最大请求玩家数: 2",
|
||||
cause: "请求内容过长, 最大请求玩家数: 2"
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: getOverrideHandler('/api/profiles/minecraft') ?? async function (req, rep) {
|
||||
const { body } = req
|
||||
const profiles = await Player.find({ username: { $in: body } })
|
||||
return await rep.code(200).send(profiles.map(profile => ({
|
||||
id: uuidToNoSymboUUID(profile.uuid),
|
||||
name: profile.username
|
||||
})))
|
||||
}
|
||||
}
|
644
src/routes/web-api.js
Normal file
644
src/routes/web-api.js
Normal file
@ -0,0 +1,644 @@
|
||||
import { config, getOverrideHandler, getOverridePreHandler } from "../config.js"
|
||||
import { Player } from "../models/player.js"
|
||||
import { createHash } from "crypto"
|
||||
import { generateToken, uuid } from "../generator.js"
|
||||
import { Token } from "../models/token.js"
|
||||
import { s3Instance, server } from "../index.js"
|
||||
import { ImageSecurity } from "../secure.js"
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3"
|
||||
import crypto from 'crypto'
|
||||
|
||||
const defaultSkin = "https://textures.minecraft.net/texture/ddb8684e59f771666bde5f411fcb2e495c452f2ecabc31981bc132ac71bdd394"
|
||||
|
||||
const BASE_RESPONSE = {
|
||||
err: {
|
||||
type: "number",
|
||||
description: "错误类型, 1.048596代表无错误",
|
||||
example: 1.048596
|
||||
},
|
||||
msg: {
|
||||
type: "string",
|
||||
description: "错误信息,如果有错误则返回错误信息,否则返回空字符串 << 感谢 Copilot 的补全",
|
||||
example: "你号被ban了"
|
||||
}
|
||||
}
|
||||
|
||||
const identifiers = new Map()
|
||||
|
||||
async function identifierValidator(req, rep) {
|
||||
const identifier = req.headers['x-lsp-identifier']
|
||||
if(!identifier) {
|
||||
return await rep.code(200).send({
|
||||
err: 1.143688,
|
||||
msg: "请求格式不正确"
|
||||
})
|
||||
}
|
||||
|
||||
if(!identifiers.has(identifier)) {
|
||||
return await rep.code(200).send({
|
||||
err: 0.456914,
|
||||
msg: "用户不存在"
|
||||
})
|
||||
}
|
||||
|
||||
const {t, uuid} = identifiers.get(identifier)
|
||||
if(t < Date.now()) {
|
||||
return await rep.code(200).send({
|
||||
err: 1.143688,
|
||||
msg: "令牌超时"
|
||||
})
|
||||
}
|
||||
|
||||
identifiers.set(identifier, {
|
||||
t: Date.now() + 1000 * 60 * 60,
|
||||
uuid
|
||||
})
|
||||
|
||||
req.player = await Player.findOne({ uuid })
|
||||
req.t = t
|
||||
}
|
||||
|
||||
export const login = {
|
||||
method: 'POST',
|
||||
url: '/api/login',
|
||||
schema: {
|
||||
summary: "登录",
|
||||
description: `登录到 webapi,后续请求需要携带请求头 'X-LSP-Idenitifier': '<token>'`,
|
||||
tags: [ 'webapi' ],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: {
|
||||
type: 'string',
|
||||
description: '用户名'
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
description: '密码'
|
||||
},
|
||||
createToken: {
|
||||
type: 'boolean',
|
||||
description: '是否创建一个 accessToken'
|
||||
}
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...BASE_RESPONSE,
|
||||
extra: {
|
||||
type: 'object',
|
||||
description: '额外信息',
|
||||
properties: {
|
||||
identifier: {
|
||||
type: 'string',
|
||||
description: 'identifier,后面请求必须带 X-LSP-Idenitifier: <token> 请求头'
|
||||
},
|
||||
textures: {
|
||||
type: 'object',
|
||||
description: '用户皮肤和披风',
|
||||
properties: {
|
||||
skin: {
|
||||
anyOf: [
|
||||
{ type: 'string', description: '皮肤' },
|
||||
{ type: 'null' }
|
||||
]
|
||||
},
|
||||
cape: {
|
||||
anyOf: [
|
||||
{ type: 'string', description: '披风' },
|
||||
{ type: 'null' }
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
description: '用户名'
|
||||
},
|
||||
uuid: {
|
||||
type: 'string',
|
||||
description: '用户唯一标识'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
401: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
err: {
|
||||
type: 'number',
|
||||
description: '错误类型'
|
||||
},
|
||||
msg: {
|
||||
type: 'string',
|
||||
description: '错误内容,展示给用户看的'
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
},
|
||||
preHandler: getOverridePreHandler('/api/login'),
|
||||
handler: getOverrideHandler('/api/login') ?? async function(req, rep) {
|
||||
const { username, password, createToken } = req.body;
|
||||
const user = await Player.findOne({ email: username, password: createHash("sha256").update(password).digest('hex') });
|
||||
if (!user) {
|
||||
return rep.code(200).send({
|
||||
err: 1.143688,
|
||||
msg: "用户名或密码错误"
|
||||
});
|
||||
}
|
||||
|
||||
if(user.permissions.indexOf("login") === -1) {
|
||||
return await rep.code(200).send({
|
||||
err: 0.337187,
|
||||
msg: "泻药,宁滴账号已被封禁"
|
||||
});
|
||||
}
|
||||
|
||||
const [token, key] = generateToken(`webapi:${user.username}`)
|
||||
this.log.info(`/api/login > 为玩家 webapi:${user.username} 生成令牌: ${token} | 随机 key = ${key}`)
|
||||
|
||||
identifiers.set(token, {
|
||||
uuid: user.uuid,
|
||||
t: Date.now() + 1000 * 60 * 60 * 24 * 1,
|
||||
})
|
||||
|
||||
if(createToken) {
|
||||
new Token({
|
||||
uuid: user.uuid,
|
||||
token: token,
|
||||
clientToken: `${req.headers['x-forwarded-for'] || req.ip}:${token.substring(3, 8)}`,
|
||||
expireDate: Date.now() + 1000 * 60 * 60 * 24 * 15,
|
||||
deadDate: Date.now() + 1000 * 60 * 60 * 24 * 30,
|
||||
}).save()
|
||||
}
|
||||
|
||||
return await rep.code(200).send(JSON.stringify({
|
||||
err: 1.048596,
|
||||
msg: '',
|
||||
extra: {
|
||||
identifier: token,
|
||||
textures: user.textures,
|
||||
username: user.username,
|
||||
uuid: user.uuid,
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export const register = {
|
||||
method: 'POST',
|
||||
url: '/api/register',
|
||||
schema: {
|
||||
summary: "注册",
|
||||
description: `注册到 webapi,后续请求需要先登录获取identifier,然后携带请求头 'X-LSP-Idenitifier': '<token>',200正确返回 <<< Copilot自己补全的`,
|
||||
tags: [ 'webapi' ],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: {
|
||||
type: 'string',
|
||||
description: '用户名'
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
description: '密码'
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
description: '邮箱'
|
||||
},
|
||||
invitationCode: {
|
||||
type: 'string',
|
||||
description: 'invitationCode'
|
||||
},
|
||||
validationCode: {
|
||||
type: 'string',
|
||||
description: 'invitationCode'
|
||||
},
|
||||
textureMigrations: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'object',
|
||||
description: '纹理迁移',
|
||||
properties: {
|
||||
skin: {
|
||||
anyOf: [
|
||||
{ type: 'string', description: '皮肤' },
|
||||
{ type: 'null' }
|
||||
]
|
||||
},
|
||||
cape: {
|
||||
anyOf: [
|
||||
{ type: 'string', description: '披风' },
|
||||
{ type: 'null' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'null' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...BASE_RESPONSE
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
preHandler: getOverridePreHandler('/api/register'),
|
||||
handler: getOverrideHandler('/api/register') ?? async function(req, rep) {
|
||||
const { username, password, email, invitationCode, textureMigrations, validationCode } = req.body
|
||||
const user = await Player.findOne({ $or: [
|
||||
{ email: email }, { username: username }
|
||||
] })
|
||||
if (user) {
|
||||
return await rep.code(200).send({
|
||||
err: 1.143688,
|
||||
msg: "用户名已存在"
|
||||
})
|
||||
}
|
||||
|
||||
if(username == 0 || password == 0 || email == 0 || invitationCode == 0 || validationCode == 0) {
|
||||
return await rep.code(200).send({
|
||||
err: 1.143688,
|
||||
msg: "用户名/密码/邮箱/telegramId不能为空"
|
||||
})
|
||||
}
|
||||
|
||||
const textures = { }
|
||||
|
||||
if(textureMigrations) {
|
||||
textures.skin = textureMigrations.skin ?? defaultSkin
|
||||
|
||||
if(textureMigrations.cape != 0 && textureMigrations.cape) {
|
||||
textures.cape = textureMigrations.cape
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Examples:
|
||||
{
|
||||
p(latform): "name",
|
||||
n(ame): "name",
|
||||
t(o): "email",
|
||||
}
|
||||
|
||||
|
||||
v -> Signature
|
||||
|
||||
*/
|
||||
const raw = Buffer.from(invitationCode, 'base64').toString().split(';').filter(it => it.indexOf('=') >= 0)
|
||||
const fields = new Map()
|
||||
raw.forEach(kvPair => {
|
||||
const [k, v] = kvPair.split('=', 2)
|
||||
req.log.info(`k: ${k} v: ${v}`)
|
||||
|
||||
fields.set(k, v)
|
||||
})
|
||||
|
||||
if(!crypto.createVerify('rsa-sha1').update(Buffer.from(invitationCode)).verify(server.keys.publicKey, Buffer.from(validationCode, 'hex'))) {
|
||||
return await rep.code(200).send({
|
||||
err: 1.143688,
|
||||
msg: "邀请码验证失败!非法邀请码!"
|
||||
})
|
||||
}
|
||||
if(fields.get('p') !== email) {
|
||||
return await rep.code(200).send({
|
||||
err: 1.143688,
|
||||
msg: "邀请码验证失败!这邀请码不属于你!"
|
||||
})
|
||||
}
|
||||
|
||||
const newUser = new Player({
|
||||
username,
|
||||
password: createHash("sha256").update(password).digest('hex'),
|
||||
email,
|
||||
uuid: uuid('LSPlayer:' + email),
|
||||
textues,
|
||||
registerDate: Date.now(),
|
||||
permissions: ['login'],
|
||||
binding: {
|
||||
platform: fields.get('t'),
|
||||
username: fields.get('n'),
|
||||
verified: true,
|
||||
}
|
||||
});
|
||||
await newUser.save()
|
||||
|
||||
return await rep.code(200).send({
|
||||
err: 1.048596,
|
||||
msg: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const textures = {
|
||||
method: 'GET',
|
||||
url: '/api/textures',
|
||||
schema: {
|
||||
summary: "获取材质",
|
||||
description: `获取材质,200正确返回 <<< Copilot自己补全的`,
|
||||
tags: [ 'webapi' ],
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...BASE_RESPONSE,
|
||||
extra: {
|
||||
textures: {
|
||||
type: 'object',
|
||||
description: '纹理',
|
||||
properties: {
|
||||
skin: {
|
||||
type: 'string',
|
||||
description: '皮肤',
|
||||
example: 'https://assets.lama.icu/textures/skin/steve.png'
|
||||
},
|
||||
cape: {
|
||||
type: 'string',
|
||||
description: '披风',
|
||||
example: 'https://assets.lama.icu/textures/cape/steve.png'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
preHandler: getOverridePreHandler('/api/textures') ?? identifierValidator,
|
||||
handler: getOverrideHandler('/api/textures') ?? async function(req, rep) {
|
||||
return await rep.code(200).send({
|
||||
err: 1.048596,
|
||||
msg: '',
|
||||
extra: {
|
||||
textures: {
|
||||
skin: req.user.textures.skin,
|
||||
cape: req.user.textures.cape
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const uploadTexture = {
|
||||
method: 'PUT',
|
||||
url: '/api/textures/:type',
|
||||
schema: {
|
||||
summary: "上传材质",
|
||||
description: `上传材质,200正确返回 <<< Copilot自己补全的`,
|
||||
tags: [ 'webapi' ],
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
...BASE_RESPONSE
|
||||
}
|
||||
},
|
||||
400: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...BASE_RESPONSE
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
preHandler: getOverridePreHandler('/api/textures') ?? identifierValidator,
|
||||
handler: getOverrideHandler('/api/textures') ?? async function(req, rep) {
|
||||
const { type } = req.params
|
||||
|
||||
if(type !== 'skin' && type !== 'cape') {
|
||||
rep.code(200).send({
|
||||
err: 1.143688,
|
||||
msg: "请求格式不正确"
|
||||
})
|
||||
}
|
||||
|
||||
const [hash, texture] = await ImageSecurity.createImageHash(req.body, type === 'cape')
|
||||
req.log.info(`Hash: ${hash}`)
|
||||
|
||||
const buf = await new Promise((resolve, reject) => {
|
||||
const buff = []
|
||||
texture.on("data", (d) => buff.push(d))
|
||||
texture.on("end", () => resolve(Buffer.concat(buff)))
|
||||
texture.on("error", (e) => reject(e))
|
||||
})
|
||||
|
||||
|
||||
await s3Instance.send(new PutObjectCommand({
|
||||
Bucket: config.storage.bucket,
|
||||
Key: `${type}/${hash}`,
|
||||
Body: buf,
|
||||
ACL: 'public-read'
|
||||
}))
|
||||
|
||||
req.log.info(`玩家: ${req.player.username} 上传皮肤,哈希为: ${hash} 成功上传到S3 Key: ${type}/${hash}`)
|
||||
|
||||
const update = { }
|
||||
update[type] = `${type}/${hash}`
|
||||
req.log.info(JSON.stringify({
|
||||
'$set': {
|
||||
textures: update
|
||||
}
|
||||
}))
|
||||
await Player.updateOne({ username: req.player.username }, {
|
||||
'$set': {
|
||||
textures: update
|
||||
}
|
||||
})
|
||||
|
||||
await rep.code(200).send({
|
||||
err: 1.048596,
|
||||
msg: JSON.stringify(update)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const telegramBind = {
|
||||
method: 'GET',
|
||||
url: '/api/binding/:uuid',
|
||||
schema: {
|
||||
summary: "获取 Telegram 绑定",
|
||||
description: "获取玩家对应 Telegram 绑定,返回纯文本。",
|
||||
tags: [ "webapi" ],
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: {
|
||||
type: 'string'
|
||||
},
|
||||
platform: {
|
||||
type: 'string'
|
||||
},
|
||||
verified: {
|
||||
type: 'boolean'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
preHandler: getOverridePreHandler('/api/binding/:uuid'),
|
||||
handler: getOverrideHandler('/api/binding/:uuid') ?? async function (req, rep) {
|
||||
const { uuid } = req.params
|
||||
return await rep.code(200).send((await Player.findOne({ uuid }))?.binding ?? { username: '', platform: '', verified: false })
|
||||
}
|
||||
}
|
||||
|
||||
export const meta = {
|
||||
method: "GET",
|
||||
url: "/",
|
||||
schema: {
|
||||
summary: "获取 Meta 信息",
|
||||
description: "获取服务器元数据。详情请见 authlib-injector 文档。",
|
||||
tags: [ "api" ],
|
||||
response: {
|
||||
200: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"serverName": {
|
||||
"type": "string"
|
||||
},
|
||||
"implementationName": {
|
||||
"type": "string"
|
||||
},
|
||||
"implementationVersion": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"skinDomains": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"signaturePublickey": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
preHandler: getOverridePreHandler("/"),
|
||||
handler: getOverrideHandler("/") ?? async function(req, rep) {
|
||||
rep.code(200).send({
|
||||
meta: {
|
||||
serverName: config.server.serverName,
|
||||
implementationName: "lsp-yggdrasil",
|
||||
implementationVersion: "1.0",
|
||||
|
||||
},
|
||||
skinDomains: config.server.skinDomain,
|
||||
signaturePublickey: this.keys.publicKey
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const status = {
|
||||
method: "GET",
|
||||
url: "/status",
|
||||
schema: {
|
||||
summary: "获取服务器信息",
|
||||
description: "获取服务器基本信息",
|
||||
tags: [ "webapi" ],
|
||||
response: {
|
||||
200: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"public": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"api": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"yggdrasil": {
|
||||
"type": "string"
|
||||
},
|
||||
"webapi": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"server": {
|
||||
"type": "string"
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {
|
||||
"type": "string"
|
||||
},
|
||||
"os": {
|
||||
"type": "string"
|
||||
},
|
||||
"arch": {
|
||||
"type": "string"
|
||||
},
|
||||
"t": {
|
||||
"type": "number"
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
preHandler: getOverridePreHandler("/status"),
|
||||
handler: getOverrideHandler("/status") ?? async function(req, rep) {
|
||||
rep.code(200).send({
|
||||
public: this.keys.publicKey,
|
||||
version: "1.0",
|
||||
name: config.server.serverName,
|
||||
api: {
|
||||
yggdrasil: "authlib-injector",
|
||||
webapi: "standard-1.0"
|
||||
},
|
||||
server: "lsp-yggdrasil",
|
||||
env: {
|
||||
"node": process.version,
|
||||
"os": process.platform,
|
||||
"arch": process.arch,
|
||||
"t": Date.now(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const CORS_BYPASS = {
|
||||
method: "OPTIONS",
|
||||
url: "/*",
|
||||
schema: {
|
||||
summary: "跨域访问",
|
||||
description: "跨域访问",
|
||||
tags: [ "webapi" ],
|
||||
response: {
|
||||
200: {
|
||||
type: "null"
|
||||
},
|
||||
},
|
||||
},
|
||||
preHandler: getOverridePreHandler("/*"),
|
||||
handler: getOverrideHandler("/*") ?? function(req, rep) {
|
||||
rep.header("Access-Control-Allow-Origin", "*").code(200).send()
|
||||
}
|
||||
|
||||
}
|
89
src/secure.js
Normal file
89
src/secure.js
Normal file
@ -0,0 +1,89 @@
|
||||
import { PNG } from 'pngjs'
|
||||
import fs from 'fs'
|
||||
import { createHash, createPrivateKey, createSign } from 'crypto'
|
||||
import { server } from './index.js'
|
||||
|
||||
export const ImageSecurity = {
|
||||
createImageHash: async (stream, isCape) => {
|
||||
const png = await new Promise((resolve, reject) => {
|
||||
stream.pipe(new PNG()).on('metadata', function(metadata) {
|
||||
if(metadata.width * metadata.height > 40960) {
|
||||
reject('Image size exceeds 40960 pixels')
|
||||
}
|
||||
}).on('parsed', function(_) {
|
||||
resolve(this)
|
||||
})
|
||||
})
|
||||
|
||||
if (isCape) {
|
||||
if(png.width % 64 === 0 && png.height % 32 === 0) {
|
||||
return [createHashInternal(png), stream]
|
||||
} else if (png.width % 22 === 0 && png.height % 17 === 0) {
|
||||
const newPNG = new PNG({
|
||||
width: Math.ceil(png.width / 22),
|
||||
height: Math.ceil(png.height / 17),
|
||||
})
|
||||
|
||||
for (let y = 0; y < png.height; y++) {
|
||||
for (let x = 0; x < png.width; x++) {
|
||||
const idx = (png.width * y + x) << 2
|
||||
newPNG.data[idx] = png.data[idx]
|
||||
newPNG.data[idx + 1] = png.data[idx + 1]
|
||||
newPNG.data[idx + 2] = png.data[idx + 2]
|
||||
newPNG.data[idx + 3] = png.data[idx + 3]
|
||||
}
|
||||
}
|
||||
|
||||
for (let y = png.height; y < newPNG.height; y++) {
|
||||
for (let x = png.width; x < newPNG.width; x++) {
|
||||
const idx = (png.width * y + x) << 2
|
||||
newPNG.data[idx] = 0
|
||||
newPNG.data[idx + 1] = 0
|
||||
newPNG.data[idx + 2] = 0
|
||||
newPNG.data[idx + 3] = 0
|
||||
}
|
||||
}
|
||||
|
||||
return [createHashInternal(newPNG), newPNG.pack()]
|
||||
}
|
||||
} else {
|
||||
if(png.width % 64 === 0 && png.height % 32 === 0) {
|
||||
const newPNG = new PNG({
|
||||
width: png.width,
|
||||
height: png.height
|
||||
})
|
||||
|
||||
for (let y = 0; y < png.height; y++) {
|
||||
for (let x = 0; x < png.width; x++) {
|
||||
const idx = (png.width * y + x) << 2
|
||||
newPNG.data[idx] = png.data[idx]
|
||||
newPNG.data[idx + 1] = png.data[idx + 1]
|
||||
newPNG.data[idx + 2] = png.data[idx + 2]
|
||||
newPNG.data[idx + 3] = png.data[idx + 3]
|
||||
}
|
||||
}
|
||||
|
||||
return [createHashInternal(png), newPNG.pack()]
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid image size')
|
||||
},
|
||||
sign: (data) => {
|
||||
return createSign('RSA-SHA1').update(data).sign({ key: createPrivateKey(server.keys.privateKey) }, 'hex')
|
||||
}
|
||||
}
|
||||
|
||||
const createHashInternal = (png) => {
|
||||
const hash = createHash('sha256')
|
||||
for (let y = 0; y < png.height; y++) {
|
||||
for (let x = 0; x < png.width; x++) {
|
||||
const idx = (png.width * y + x) << 2
|
||||
hash.update(png.data[idx].toString(16))
|
||||
hash.update(png.data[idx + 1].toString(16))
|
||||
hash.update(png.data[idx + 2].toString(16))
|
||||
hash.update(png.data[idx + 3].toString(16))
|
||||
}
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
114
src/telegram/player-commands.js
Normal file
114
src/telegram/player-commands.js
Normal file
@ -0,0 +1,114 @@
|
||||
import { telegraf, server } from '../index.js'
|
||||
import { Player } from '../models/player.js'
|
||||
import crypto from 'crypto'
|
||||
|
||||
export const registerAllPlayerCommands = () => {
|
||||
adminCreateInvitation()
|
||||
adminBan()
|
||||
adminRevokeBan()
|
||||
|
||||
userCreateInvitation()
|
||||
}
|
||||
|
||||
const adminCreateInvitation = () => {
|
||||
telegraf.command('invite', async (ctx) => {
|
||||
const args = ctx.update.message.text.split(' ').slice(1)
|
||||
if(args.length < 2) {
|
||||
ctx.reply('Usage: /invite <username> <email> [platform]')
|
||||
return
|
||||
}
|
||||
|
||||
const player = await Player.findOne({ 'binding.platform': 'telegram', 'binding.username': ctx.message.from.username })
|
||||
|
||||
if(!player || player.permissions.indexOf('admin') === -1) {
|
||||
return ctx.reply('配钥匙吗?什么?你配?你配几把?')
|
||||
}
|
||||
|
||||
const [invitation, v] = makeInvitation(args[0], args[2], args[1] || 'telegram')
|
||||
ctx.replyWithMarkdownV2('邀请码:\n```' + invitation + '```\n\n验证码:\n```' + v + "```")
|
||||
})
|
||||
}
|
||||
|
||||
const adminBan = () => {
|
||||
telegraf.command('banplr', async (ctx) => {
|
||||
const player = await Player.findOne({ 'binding.platform': 'telegram', 'binding.username': ctx.message.from.username })
|
||||
|
||||
if(!player || player.permissions.indexOf('admin') === -1) {
|
||||
return ctx.reply('配钥匙吗?什么?你配?哦不你不配!')
|
||||
}
|
||||
|
||||
const args = ctx.update.message.text.split(' ').slice(1)
|
||||
if(args.length < 1) {
|
||||
ctx.reply('Usage: /banplr <yggdrasil-player-username>')
|
||||
return
|
||||
}
|
||||
|
||||
await Player.updateOne({ username: args[0] }, { $pull: { permissions: 'login' } })
|
||||
|
||||
ctx.reply("已枪毙玩家 " + args[0])
|
||||
})
|
||||
}
|
||||
|
||||
const adminRevokeBan = () => {
|
||||
telegraf.command('revokeban', async (ctx) => {
|
||||
const player = await Player.findOne({ 'binding.platform': 'telegram', 'binding.username': ctx.message.from.username })
|
||||
|
||||
if(!player || player.permissions.indexOf('admin') === -1) {
|
||||
return ctx.reply('配钥匙吗?什么?你配?你配几把?')
|
||||
}
|
||||
|
||||
const args = ctx.update.message.text.split(' ').slice(1)
|
||||
if(args.length < 1) {
|
||||
ctx.reply('Usage: /revokeban <yggdrasil-player-username>')
|
||||
return
|
||||
}
|
||||
|
||||
await Player.updateOne({ username: args[0], permissions: { $nin: [ 'login' ] } }, { $push: { permissions: 'login' } })
|
||||
|
||||
ctx.reply("已复活玩家 " + args[0])
|
||||
})
|
||||
}
|
||||
|
||||
const userCreateInvitation = () => {
|
||||
telegraf.command('inviteme', async (ctx) => {
|
||||
const args = ctx.update.message.text.split(' ').slice(1)
|
||||
if(args.length === 0) {
|
||||
ctx.reply('Usage: /inviteme <email>')
|
||||
return
|
||||
}
|
||||
|
||||
if(ctx.message.chat.id === -1001780498838) {
|
||||
if(await Player.findOne({ 'email': args[0] })) {
|
||||
return await ctx.reply('该邮箱已被注册')
|
||||
}
|
||||
|
||||
const [i, v] = makeInvitation(ctx.message.from.username, args[0], 'telegram')
|
||||
ctx.replyWithMarkdownV2('邀请码:\n```' + i + '```\n\n验证码将发送到私聊,如果您未私聊启动过bot则无法接收到消息,请 /start 后再次执行此指令。')
|
||||
try {
|
||||
await telegraf.telegram.sendMessage(ctx.message.from.id, '验证码(请不要发给任何人):\n```' + v + "```", { parse_mode: 'Markdown' })
|
||||
} catch (_) { }
|
||||
} else {
|
||||
ctx.reply('您不符合自我邀请最低要求,无法获取邀请码')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
{
|
||||
p(latform): "name",
|
||||
n(ame): "name",
|
||||
t(o): "email",
|
||||
}
|
||||
*/
|
||||
const templete = "p=$0;n=$1;t=$2"
|
||||
const makeInvitation = (username, platform, email) => {
|
||||
let i = templete.replace('$0', platform)
|
||||
.replace('$1', username)
|
||||
.replace('$2', email)
|
||||
i = i.padEnd(i.length + 3 - i.length % 3, ';')
|
||||
|
||||
const invitation = Buffer.from(i).toString('base64')
|
||||
|
||||
const v = crypto.createSign('RSA-SHA1').update(invitation).sign(server.keys.privateKey, 'hex')
|
||||
return [invitation, v]
|
||||
}
|
284
tests/routings/authenticate.test.js
Normal file
284
tests/routings/authenticate.test.js
Normal file
@ -0,0 +1,284 @@
|
||||
import { config } from '../../src/config.js'
|
||||
import { server, setup, shutdown } from '../../src/index.js'
|
||||
import { generateKeyPairSync } from 'crypto'
|
||||
import { existsSync, writeFileSync } from 'fs'
|
||||
|
||||
beforeAll(() => {
|
||||
if(!existsSync(config.signing.private)) {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
||||
modulusLength: 4096,
|
||||
publicKeyEncoding: {
|
||||
type: "pkcs1",
|
||||
format: "pem"
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: "pkcs1",
|
||||
format: "pem"
|
||||
}
|
||||
})
|
||||
writeFileSync('public.pem', publicKey)
|
||||
writeFileSync('private.key', privateKey)
|
||||
}
|
||||
|
||||
if(process.env['CI']) {
|
||||
config.database.url = 'mongodb://setup-database:27017/yggdrasil?readPreference=primary&appname=MongoDB%20Compass&directConnection=true&ssl=false'
|
||||
}
|
||||
return setup()
|
||||
})
|
||||
|
||||
const login = async () => {
|
||||
const { accessToken, clientToken, selectedProfile, user } = JSON.parse((await server.inject({
|
||||
method: 'POST',
|
||||
url: '/authserver/authenticate',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
payload: {
|
||||
username: 'i@lama.icu',
|
||||
password: '123456',
|
||||
clientToken: 'UNIT_TEST',
|
||||
requestUser: true,
|
||||
agent: {
|
||||
name: 'minecraft',
|
||||
version: 1
|
||||
}
|
||||
}
|
||||
})).body)
|
||||
|
||||
return { accessToken, clientToken, selectedProfile, user }
|
||||
}
|
||||
|
||||
test('/authserver/authenticate', async function() {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/authserver/authenticate',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
payload: {
|
||||
username: 'i@lama.icu',
|
||||
password: '123456',
|
||||
clientToken: 'UNIT_TEST',
|
||||
requestUser: true,
|
||||
agent: {
|
||||
name: 'minecraft',
|
||||
version: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
test('/authserver/refresh', async function() {
|
||||
const credentals = await login()
|
||||
const refresh1 = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/authserver/refresh',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
payload: {
|
||||
accessToken: credentals.accessToken,
|
||||
clientToken: credentals.clientToken,
|
||||
}
|
||||
})
|
||||
|
||||
const newToken = JSON.parse(refresh1.body).accessToken
|
||||
|
||||
expect(refresh1.statusCode).toBe(200)
|
||||
|
||||
const refresh2 = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/authserver/refresh',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
payload: {
|
||||
accessToken: credentals.accessToken,
|
||||
clientToken: credentals.clientToken,
|
||||
}
|
||||
})
|
||||
|
||||
expect(refresh2.statusCode).toBe(401)
|
||||
|
||||
const refresh3 = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/authserver/refresh',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
payload: {
|
||||
accessToken: credentals.accessToken,
|
||||
clientToken: Math.random() + "",
|
||||
}
|
||||
})
|
||||
|
||||
expect(refresh3.statusCode).toBe(401)
|
||||
|
||||
const refresh4 = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/authserver/refresh',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
payload: {
|
||||
accessToken: newToken,
|
||||
}
|
||||
})
|
||||
|
||||
expect(refresh4.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
test('/authserver/validate', async function() {
|
||||
const credentals = await login()
|
||||
const validate1 = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/authserver/validate',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
payload: {
|
||||
accessToken: credentals.accessToken,
|
||||
clientToken: credentals.clientToken,
|
||||
}
|
||||
})
|
||||
|
||||
expect(validate1.statusCode).toBe(204)
|
||||
|
||||
const validate2 = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/authserver/validate',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
payload: {
|
||||
accessToken: credentals.accessToken,
|
||||
clientToken: credentals.clientToken + "hjfidhsw",
|
||||
}
|
||||
})
|
||||
|
||||
expect(validate2.statusCode).toBe(401)
|
||||
|
||||
const validate3 = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/authserver/validate',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
payload: {
|
||||
accessToken: credentals.accessToken + "hjfidhsw",
|
||||
clientToken: credentals.clientToken,
|
||||
}
|
||||
})
|
||||
|
||||
expect(validate3.statusCode).toBe(401)
|
||||
|
||||
const validate4 = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/authserver/validate',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
payload: {
|
||||
accessToken: credentals.accessToken,
|
||||
}
|
||||
})
|
||||
|
||||
expect(validate4.statusCode).toBe(204)
|
||||
})
|
||||
|
||||
test('/authserver/invalidate', async function() {
|
||||
let credentals = await login()
|
||||
const invalidate1 = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/authserver/invalidate',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
payload: {
|
||||
accessToken: credentals.accessToken,
|
||||
clientToken: credentals.clientToken,
|
||||
}
|
||||
})
|
||||
|
||||
expect(invalidate1.statusCode).toBe(204)
|
||||
|
||||
const credentals2 = await login()
|
||||
const invalidate2 = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/authserver/validate',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
payload: {
|
||||
accessToken: credentals2.accessToken,
|
||||
clientToken: credentals2.clientToken + "hjfidhsw",
|
||||
}
|
||||
})
|
||||
|
||||
expect(invalidate2.statusCode).toBe(401)
|
||||
|
||||
const validate = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/authserver/validate',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
payload: {
|
||||
accessToken: credentals.accessToken,
|
||||
clientToken: credentals.clientToken,
|
||||
}
|
||||
})
|
||||
|
||||
expect(validate.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
test('/authserver/signout', async function() {
|
||||
const credentals1 = await login()
|
||||
const credentals2 = await login()
|
||||
const signout = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/authserver/signout',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
payload: {
|
||||
username: 'i@lama.icu',
|
||||
password: '123456',
|
||||
}
|
||||
})
|
||||
|
||||
expect(signout.statusCode).toBe(204)
|
||||
|
||||
const validate1 = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/authserver/validate',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
payload: {
|
||||
accessToken: credentals1.accessToken,
|
||||
clientToken: credentals1.clientToken,
|
||||
}
|
||||
})
|
||||
|
||||
const validate2 = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/authserver/validate',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
payload: {
|
||||
accessToken: credentals2.accessToken,
|
||||
clientToken: credentals2.clientToken,
|
||||
}
|
||||
})
|
||||
|
||||
expect(validate1.statusCode).toBe(401)
|
||||
expect(validate2.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
return shutdown()
|
||||
})
|
Reference in New Issue
Block a user