Add skin upload and texture management endpoints
Introduces endpoints and logic for uploading Minecraft skins via file or URL, storing textures, and managing player skins. Adds new repository and service methods for texture registration and retrieval, updates authorization handling, and uses process.cwd() for data paths. Also includes static serving of textures and rate limiting for skin uploads.
This commit is contained in:
parent
5dd1de1521
commit
1fe46a03fd
@ -1,7 +1,7 @@
|
||||
const fs = require("node:fs")
|
||||
const path = require("node:path")
|
||||
const crypto = require("node:crypto")
|
||||
const keysRoot = path.join(__dirname, "..", "data", "keys")
|
||||
const keysRoot = path.join(process.cwd(), "data", "keys")
|
||||
const keysList = ["authenticationKeys", "profilePropertyKeys", "playerCertificateKeys"]
|
||||
|
||||
function generateKeysPair() {
|
||||
|
||||
@ -86,6 +86,6 @@ function stripColors(string) {
|
||||
return string.replace(/\x1B\[[0-9;]*[mK]/g, "")
|
||||
}
|
||||
|
||||
const logger = createLogger(path.join(__dirname, ".."))
|
||||
const logger = createLogger(process.cwd())
|
||||
|
||||
module.exports = logger
|
||||
@ -314,6 +314,46 @@ async function changeUsername(uuid, newName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createTexture(uuid, hash, type, url, alias) {
|
||||
try {
|
||||
const sql = `
|
||||
INSERT INTO textures (uuid, hash, type, url, alias)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
await database.query(sql, [uuid, hash, type, url, alias])
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_DUP_ENTRY') {
|
||||
return false
|
||||
}
|
||||
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
|
||||
throw new DefaultError(500, "Internal Server Error", "Database Error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function getTextureByUuid(textureUuid) {
|
||||
try {
|
||||
const sql = "SELECT hash FROM textures WHERE uuid = ?"
|
||||
const rows = await database.query(sql, [textureUuid])
|
||||
return rows[0] || null
|
||||
} catch (error) {
|
||||
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
|
||||
throw new DefaultError(500, "Internal Server Error", "Database Error")
|
||||
}
|
||||
}
|
||||
|
||||
async function getTextureByHash(hash) {
|
||||
try {
|
||||
const sql = "SELECT uuid FROM textures WHERE hash = ?"
|
||||
const rows = await database.query(sql, [hash])
|
||||
return rows[0]
|
||||
} catch (error) {
|
||||
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
|
||||
throw new DefaultError(500, "Internal Server Error", "Database Error")
|
||||
}
|
||||
}
|
||||
|
||||
async function resetSkin(uuid, hash, variant) {
|
||||
try {
|
||||
const insertSql = `
|
||||
@ -345,17 +385,6 @@ async function hideCape(uuid) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getTextureByUuid(textureUuid) {
|
||||
try {
|
||||
const sql = "SELECT hash FROM textures WHERE uuid = ?"
|
||||
const rows = await database.query(sql, [textureUuid])
|
||||
return rows[0] || null
|
||||
} catch (error) {
|
||||
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
|
||||
throw new DefaultError(500, "Internal Server Error", "Database Error")
|
||||
}
|
||||
}
|
||||
|
||||
async function showCape(uuid, hash) {
|
||||
try {
|
||||
const sql = `
|
||||
@ -611,7 +640,24 @@ async function getNameHistory(uuid) {
|
||||
}
|
||||
}
|
||||
|
||||
async function setSkin(uuid, hash, variant) {
|
||||
const insertSql = `
|
||||
INSERT INTO playersSkins (playerUuid, assetHash, variant, isSelected)
|
||||
VALUES (?, ?, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE isSelected = 1, variant = ?
|
||||
`
|
||||
await database.query(insertSql, [uuid, hash, variant, variant])
|
||||
const updateSql = `
|
||||
UPDATE playersSkins
|
||||
SET isSelected = 0
|
||||
WHERE playerUuid = ? AND assetHash != ?
|
||||
`
|
||||
await database.query(updateSql, [uuid, hash])
|
||||
return true
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setSkin,
|
||||
banUser,
|
||||
showCape,
|
||||
hideCape,
|
||||
@ -619,6 +665,7 @@ module.exports = {
|
||||
isBlocked,
|
||||
unbanUser,
|
||||
blockPlayer,
|
||||
createTexture,
|
||||
getPlayerBans,
|
||||
getPlayerMeta,
|
||||
unblockPlayer,
|
||||
@ -628,6 +675,7 @@ module.exports = {
|
||||
getUsersByNames,
|
||||
getPlayerActions,
|
||||
getTextureByUuid,
|
||||
getTextureByHash,
|
||||
addProfileAction,
|
||||
getLastNameChange,
|
||||
getPlayerProperty,
|
||||
|
||||
@ -4,7 +4,7 @@ const userService = require("../../../../../services/userService")
|
||||
const authService = require("../../../../../services/authService")
|
||||
|
||||
router.delete("/", async (req, res) => {
|
||||
const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization })
|
||||
const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization.replace("Bearer", "").trim() })
|
||||
await userService.hideCape(player.user.uuid)
|
||||
return res.status(200).send()
|
||||
})
|
||||
|
||||
@ -4,7 +4,7 @@ const userService = require("../../../../../services/userService")
|
||||
const authService = require("../../../../../services/authService")
|
||||
|
||||
router.delete("/", async (req, res) => {
|
||||
const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization })
|
||||
const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization.replace("Bearer", "").trim() })
|
||||
await userService.resetSkin(player.user.uuid)
|
||||
return res.status(200).send()
|
||||
})
|
||||
|
||||
71
routes/minecraftservices/minecraft/profile/skins/index.js
Normal file
71
routes/minecraftservices/minecraft/profile/skins/index.js
Normal file
@ -0,0 +1,71 @@
|
||||
const express = require("express")
|
||||
const router = express.Router()
|
||||
const multer = require("multer")
|
||||
const rateLimit = require("express-rate-limit")
|
||||
const userService = require("../../../../../services/userService")
|
||||
const authService = require("../../../../../services/authService")
|
||||
const { DefaultError } = require("../../../../../errors/errors")
|
||||
|
||||
const TEMP_DIR = path.join(process.cwd(), "data", "temp")
|
||||
|
||||
if (!fs.existsSync(TEMP_DIR)) {
|
||||
fs.mkdirSync(TEMP_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
dest: TEMP_DIR,
|
||||
limits: { fileSize: 2 * 1024 * 1024 }
|
||||
})
|
||||
|
||||
const uploadLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
return req.headers.authorization || req.ip
|
||||
},
|
||||
handler: (req, res, next, options) => {
|
||||
throw new DefaultError(429, "Too many requests. Please try again later.")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
router.post("/", uploadLimiter, async (req, res, next) => {
|
||||
if (req.is('application/json')) {
|
||||
try {
|
||||
const token = req.headers.authorization.replace("Bearer ", "").trim()
|
||||
const player = await authService.verifyAccessToken({ accessToken: token })
|
||||
|
||||
await userService.uploadSkinFromUrl(player.user.uuid, req.body.url, req.body.variant)
|
||||
|
||||
return res.status(200).send()
|
||||
} catch (err) {
|
||||
return next(err)
|
||||
}
|
||||
}
|
||||
|
||||
else {
|
||||
upload.single("file")(req, res, async (err) => {
|
||||
if (err) return next(err)
|
||||
try {
|
||||
if (!req.headers.authorization) {
|
||||
if (req.file) await fs.promises.unlink(req.file.path).catch(() => {})
|
||||
throw new DefaultError(401, "Missing Authorization Header")
|
||||
}
|
||||
|
||||
const token = req.headers.authorization.replace("Bearer ", "").trim()
|
||||
const player = await authService.verifyAccessToken({ accessToken: token })
|
||||
|
||||
await userService.uploadSkin(player.user.uuid, req.file, req.body.variant)
|
||||
|
||||
return res.status(200).send()
|
||||
} catch (error) {
|
||||
if (req.file) await fs.promises.unlink(req.file.path).catch(() => {})
|
||||
return next(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
13
routes/textures/texture.js
Normal file
13
routes/textures/texture.js
Normal file
@ -0,0 +1,13 @@
|
||||
const express = require("express")
|
||||
const router = express.Router()
|
||||
const path = require("node:path")
|
||||
const fs = require("node:fs")
|
||||
|
||||
const TEXTURES_DIR = path.join(process.cwd(), "data", "textures")
|
||||
if (!fs.existsSync(TEXTURES_DIR)) {
|
||||
fs.mkdirSync(TEXTURES_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
router.use(express.static(TEXTURES_DIR))
|
||||
|
||||
module.exports = router
|
||||
@ -1,10 +1,14 @@
|
||||
const fs = require("node:fs/promises")
|
||||
const util = require("node:util")
|
||||
const logger = require("../modules/logger")
|
||||
const crypto = require("node:crypto")
|
||||
const certsManager = require("../modules/certificatesManager")
|
||||
const userRepository = require("../repositories/userRepository")
|
||||
const { DefaultError } = require("../errors/errors")
|
||||
const crypto = require("node:crypto")
|
||||
const util = require("node:util")
|
||||
const generateKeyPairAsync = util.promisify(crypto.generateKeyPair)
|
||||
const certsManager = require("../modules/certificatesManager")
|
||||
const logger = require("../modules/logger")
|
||||
|
||||
const TEMP_DIR = path.join(process.cwd(), "data", "temp")
|
||||
const TEXTURES_DIR = path.join(process.cwd(), "data", "textures")
|
||||
|
||||
async function getPlayerProperties(uuid) {
|
||||
try {
|
||||
@ -396,6 +400,101 @@ async function getPlayerUsernamesHistory(uuid) {
|
||||
})
|
||||
}
|
||||
|
||||
async function registerTexture(hash, type, url, alias = null) {
|
||||
const existingTexture = await userRepository.getTextureByHash(hash)
|
||||
|
||||
if (existingTexture) {
|
||||
return {
|
||||
code: 200,
|
||||
textureUuid: existingTexture.uuid,
|
||||
isNew: false
|
||||
}
|
||||
}
|
||||
|
||||
const newUuid = crypto.randomUUID()
|
||||
await userRepository.createTexture(newUuid, hash, type, url, alias)
|
||||
|
||||
return {
|
||||
code: 201,
|
||||
textureUuid: newUuid,
|
||||
isNew: true
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadSkin(uuid, fileObject, variant) {
|
||||
if (!fileObject || !fileObject.path) {
|
||||
throw new DefaultError(400, "No skin file provided.")
|
||||
}
|
||||
|
||||
const tempPath = fileObject.path
|
||||
let buffer
|
||||
|
||||
try {
|
||||
buffer = await fs.readFile(tempPath)
|
||||
if (buffer.length < 8 || !buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))) {
|
||||
throw new DefaultError(400, "Invalid file format. Only PNG is allowed.")
|
||||
}
|
||||
|
||||
try {
|
||||
const width = buffer.readUInt32BE(16)
|
||||
const height = buffer.readUInt32BE(20)
|
||||
if (width !== 64 || (height !== 64 && height !== 32)) {
|
||||
throw new DefaultError(400, `Invalid skin dimensions. Got ${width}x${height}, expected 64x64 or 64x32.`)
|
||||
}
|
||||
} catch (e) {
|
||||
throw new DefaultError(400, "Could not read image dimensions.")
|
||||
}
|
||||
|
||||
const hash = crypto.createHash("sha256").update(buffer).digest("hex")
|
||||
const existingTexture = await userRepository.getTextureByHash(hash)
|
||||
|
||||
if (!existingTexture) {
|
||||
const subDir = hash.substring(0, 2)
|
||||
const targetDir = path.join(TEXTURES_DIR, subDir)
|
||||
const targetPath = path.join(targetDir, hash)
|
||||
|
||||
await fs.mkdir(targetDir, { recursive: true })
|
||||
await fs.writeFile(targetPath, buffer)
|
||||
|
||||
const newTextureUuid = crypto.randomUUID()
|
||||
const textureUrl = `/texture/${hash}`
|
||||
await userRepository.createTexture(newTextureUuid, hash, 'SKIN', textureUrl, null)
|
||||
}
|
||||
|
||||
const validVariant = (variant === "slim") ? "slim" : "classic"
|
||||
await userRepository.setSkin(uuid, hash, validVariant)
|
||||
|
||||
return { code: 200, message: "Skin uploaded successfully" }
|
||||
|
||||
} catch (error) {
|
||||
throw error
|
||||
} finally {
|
||||
await fs.unlink(tempPath).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadSkinFromUrl(uuid, url, variant) {
|
||||
if (!url) throw new DefaultError(400, "Missing 'url' parameter.")
|
||||
|
||||
let buffer
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error("Fetch failed")
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
buffer = Buffer.from(arrayBuffer)
|
||||
} catch (err) {
|
||||
throw new DefaultError(400, "Could not download skin from the provided URL.")
|
||||
}
|
||||
|
||||
const tempFileName = crypto.randomBytes(16).toString("hex")
|
||||
const tempPath = path.join(TEMP_DIR, tempFileName)
|
||||
|
||||
await fs.mkdir(TEMP_DIR, { recursive: true })
|
||||
await fs.writeFile(tempPath, buffer)
|
||||
|
||||
return await uploadSkin(uuid, { path: tempPath }, variant)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
banUser,
|
||||
showCape,
|
||||
@ -404,6 +503,7 @@ module.exports = {
|
||||
resetSkin,
|
||||
isBlocked,
|
||||
bulkLookup,
|
||||
uploadSkin,
|
||||
blockPlayer,
|
||||
getNameUUIDs,
|
||||
unblockPlayer,
|
||||
@ -412,6 +512,7 @@ module.exports = {
|
||||
changeUsername,
|
||||
getPreferences,
|
||||
getBlockedUuids,
|
||||
registerTexture,
|
||||
getLegacyProfile,
|
||||
addProfileAction,
|
||||
getPlayerActions,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user