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,