644 lines
20 KiB
JavaScript
644 lines
20 KiB
JavaScript
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 textues = { }
|
||
|
||
if(textureMigrations) {
|
||
textues.skin = textureMigrations.skin ?? defaultSkin
|
||
|
||
if(textureMigrations.cape != 0 && textureMigrations.cape) {
|
||
textues.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('t') !== 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('p'),
|
||
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()
|
||
}
|
||
|
||
} |