From 1fe46a03fddbcb32787245835bc2b9d62eb61506 Mon Sep 17 00:00:00 2001 From: azures04 Date: Sun, 28 Dec 2025 07:49:31 +0100 Subject: [PATCH] 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. --- modules/certificatesManager.js | 2 +- modules/logger.js | 2 +- repositories/userRepository.js | 70 +++++++++-- .../minecraft/profile/capes/active.js | 2 +- .../minecraft/profile/skins/active.js | 2 +- .../minecraft/profile/skins/index.js | 71 ++++++++++++ routes/textures/texture.js | 13 +++ services/userService.js | 109 +++++++++++++++++- 8 files changed, 252 insertions(+), 19 deletions(-) create mode 100644 routes/minecraftservices/minecraft/profile/skins/index.js create mode 100644 routes/textures/texture.js diff --git a/modules/certificatesManager.js b/modules/certificatesManager.js index 5dff5ec..df3365e 100644 --- a/modules/certificatesManager.js +++ b/modules/certificatesManager.js @@ -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() { diff --git a/modules/logger.js b/modules/logger.js index 1eae838..b8d86ea 100644 --- a/modules/logger.js +++ b/modules/logger.js @@ -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 \ No newline at end of file diff --git a/repositories/userRepository.js b/repositories/userRepository.js index 34d8a82..97aafdc 100644 --- a/repositories/userRepository.js +++ b/repositories/userRepository.js @@ -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, diff --git a/routes/minecraftservices/minecraft/profile/capes/active.js b/routes/minecraftservices/minecraft/profile/capes/active.js index 1f16188..d99b869 100644 --- a/routes/minecraftservices/minecraft/profile/capes/active.js +++ b/routes/minecraftservices/minecraft/profile/capes/active.js @@ -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() }) diff --git a/routes/minecraftservices/minecraft/profile/skins/active.js b/routes/minecraftservices/minecraft/profile/skins/active.js index 9ab08e6..6cd257c 100644 --- a/routes/minecraftservices/minecraft/profile/skins/active.js +++ b/routes/minecraftservices/minecraft/profile/skins/active.js @@ -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() }) diff --git a/routes/minecraftservices/minecraft/profile/skins/index.js b/routes/minecraftservices/minecraft/profile/skins/index.js new file mode 100644 index 0000000..1bd03bc --- /dev/null +++ b/routes/minecraftservices/minecraft/profile/skins/index.js @@ -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 \ No newline at end of file diff --git a/routes/textures/texture.js b/routes/textures/texture.js new file mode 100644 index 0000000..060973c --- /dev/null +++ b/routes/textures/texture.js @@ -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 \ No newline at end of file diff --git a/services/userService.js b/services/userService.js index f219cea..c9da2e9 100644 --- a/services/userService.js +++ b/services/userService.js @@ -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,