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:
Gilles Lazures 2025-12-28 07:49:31 +01:00
parent 5dd1de1521
commit 1fe46a03fd
8 changed files with 252 additions and 19 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

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