Compare commits

..

28 Commits

Author SHA1 Message Date
815d22d50c 修复 typo 引发的巨大bug
Some checks failed
continuous-integration/drone/push Build is failing
2022-07-14 15:08:24 +08:00
9fb90bef0e 修复 Bugs
Some checks failed
continuous-integration/drone/push Build is failing
2022-07-14 04:06:35 +08:00
5f52abba97 优化指令
Some checks failed
continuous-integration/drone/push Build is failing
2022-07-05 01:48:48 +08:00
904b906b63 优化项目结构,修改注册流程,添加管理员指令
Some checks failed
continuous-integration/drone/push Build is failing
2022-07-05 01:35:51 +08:00
32f7577a07 添加 S3支持 完成皮肤上传和安全验证
Some checks failed
continuous-integration/drone/push Build is failing
2022-07-02 20:35:20 +08:00
e143d3aa6c 修复好多 BUG,添加web-api部分功能
All checks were successful
continuous-integration/drone/push Build is passing
2022-05-30 02:12:54 +08:00
877e1e4587 添加 API 文档
All checks were successful
continuous-integration/drone/push Build is passing
2022-05-18 15:47:15 +08:00
db72ad3694 添加本地测试和CI测试同时支持
All checks were successful
continuous-integration/drone/push Build is passing
2022-05-16 13:25:50 +08:00
f23a607148 修正 mongoose 调用,添加更多输出提示
Some checks failed
continuous-integration/drone/push Build is failing
2022-05-16 13:19:28 +08:00
4d448d876e 添加TGBot前端玩家注册
Some checks failed
continuous-integration/drone/push Build is failing
2022-05-15 00:29:52 +08:00
5f5918e830 修正typo
All checks were successful
continuous-integration/drone/push Build is passing
2022-05-14 14:37:45 +08:00
373ce3d003 测试脚本添加签名密钥生成
Some checks failed
continuous-integration/drone/push Build is failing
2022-05-14 14:36:07 +08:00
1a3e37f39a 更新CI测试中mongodb的URL
Some checks failed
continuous-integration/drone/push Build is failing
2022-05-14 14:30:26 +08:00
98cee5bd95 添加 CI 测试平台,mongodb版本切换到5
Some checks failed
continuous-integration/drone/push Build is failing
2022-05-14 14:22:39 +08:00
d27773cbf2 CI切换到mongodb4
Some checks reported errors
continuous-integration/drone/push Build was killed
2022-05-14 14:13:49 +08:00
ffe187417c 添加 /api 接口,优化config中的注释,更新README.MD
Some checks failed
continuous-integration/drone/push Build is failing
2022-05-14 13:56:31 +08:00
c8891848f1 bug修复
Some checks failed
continuous-integration/drone/push Build is failing
2022-05-13 15:28:13 +08:00
c94563f5c8 添加logo
Some checks failed
continuous-integration/drone/push Build is failing
2022-05-13 14:49:44 +08:00
b5111370a3 完成皮肤数据的RSA签名和皮肤安全检查
Some checks failed
continuous-integration/drone/push Build is failing
2022-05-13 10:16:32 +08:00
324d8b3b54 添加 meta接口 status接口 完善 config和gitignore
Some checks failed
continuous-integration/drone/push Build is failing
2022-05-11 10:26:07 +08:00
95b30485ea 修复bug
Some checks failed
continuous-integration/drone/push Build is failing
2022-05-11 08:58:06 +08:00
4aedaea646 添加构建failed说明
Some checks failed
continuous-integration/drone/push Build is failing
2022-05-09 22:12:11 +08:00
6575af4665 实现 sessionserver
Some checks failed
continuous-integration/drone/push Build is failing
2022-05-09 22:09:08 +08:00
5ca196e5d2 延长测试timeout时间
Some checks failed
continuous-integration/drone/push Build is failing
2022-05-08 21:35:17 +08:00
24342022dd 添加单元测试
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone Build is failing
2022-05-05 15:36:10 +08:00
bd8b866f07 优化项目结构,优化构建脚本,优化README.MD
All checks were successful
continuous-integration/drone/push Build is passing
2022-05-04 21:04:44 +08:00
99d304899d 添加更多 README.MD 信息
All checks were successful
continuous-integration/drone/push Build is passing
2022-05-04 16:59:08 +08:00
d8782c6b1f 添加 authserver 剩余 API 的实现。 2022-05-04 16:48:09 +08:00
20 changed files with 4324 additions and 967 deletions

View File

@ -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
View File

@ -1,3 +1,8 @@
yarn-error.log
node_modules
production
production
**/*.key
**/*.pem
# You should NEVER EVER UPLOAD THIS FILE
launch.ps1

View File

@ -1,10 +1,76 @@
# 老色批世界树((
<div align='center'>
<img src='logo.png'/>
<h1>LSP Yggdrasil</h1>
</div>
老色批世界树 —— 一个高性能麻将、奥苏力不-印寨克托接口的实现。使用fastify来把处理速度加速到老色批的速度
[![996.icu](https://img.shields.io/badge/link-996.icu-red.svg)](https://996.icu)
[![LICENSE](https://img.shields.io/badge/license-Anti%20996-blue.svg)](https://github.com/996icu/996.ICU/blob/master/LICENSE)
[![Build Status](https://ci.186526.xyz/api/badges/Lama3L9R/lsp-yggdrasil/status.svg)](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` —— 单元测试

View File

@ -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一样快的世界树服务器了")
})

View File

@ -4,6 +4,8 @@
*/
export default {
testTimeout: 15 * 1000,
// All imported modules in your tests should be mocked automatically
// automock: false,

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -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"
}
}

View File

@ -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]
}

View File

@ -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
})
}
}

View File

@ -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()
}
})()

View File

@ -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),
}
}

View File

@ -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),
}
]
}
}

View File

@ -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
})
}))

View File

@ -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
View 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
View 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
View 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')
}

View 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]
}

View 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()
})

3022
yarn.lock

File diff suppressed because it is too large Load Diff