From 5dd1de15216f74006269aec37fc1467aa8a6cad1 Mon Sep 17 00:00:00 2001 From: azures04 Date: Sun, 28 Dec 2025 07:15:24 +0100 Subject: [PATCH] Add Minecraft services API routes and user service Introduces new routes under /minecraftservices and /mojangapi for profile, skin, cape, blocklist, privileges, and certificate management. Adds a comprehensive userService module to handle user-related operations, and extends userRepository with methods for username changes, skin/cape management, blocking, and profile lookups. Refactors username availability logic into authService, updates error handling, and improves logger and utility functions. Also updates route handlers to use consistent return statements and enhances route registration logging. --- errors/ServiceError.js | 17 +- errors/errors.js | 2 + modules/logger.js | 3 +- modules/utils.js | 11 +- repositories/authRepository.js | 45 +- repositories/userRepository.js | 340 ++++++++++++++ routes/authserver/authenticate.js | 4 +- routes/authserver/invalidate.js | 2 +- routes/authserver/refresh.js | 4 +- routes/authserver/signout.js | 3 +- routes/authserver/validate.js | 2 +- .../minecraft/profile/capes/active.js | 26 ++ .../minecraft/profile/index.js | 18 + .../minecraft/profile/lookup/bulk/byname.js | 10 + .../profile/lookup/name/[username].js | 27 ++ .../minecraft/profile/name/[name].js | 44 ++ .../minecraft/profile/namechange.js | 12 + .../minecraft/profile/skins/active.js | 12 + routes/minecraftservices/player/attributes.js | 34 ++ .../minecraftservices/player/certificates.js | 12 + routes/minecraftservices/privacy/blocklist.js | 39 ++ routes/minecraftservices/privileges.js | 34 ++ routes/minecraftservices/productvoucher.js | 2 +- routes/minecraftservices/publickeys.js | 2 +- .../minecraft/profile/lookup/bulk/byname.js | 10 + .../profile/lookup/name/[username].js | 28 ++ routes/mojangapi/profiles/minecraft.js | 10 + .../mojangapi/user/profiles/[uuid]/names.js | 15 + .../users/profiles/minecraft/[username].js | 27 ++ server.js | 6 +- services/authService.js | 61 ++- services/userService.js | 435 ++++++++++++++++++ 32 files changed, 1235 insertions(+), 62 deletions(-) create mode 100644 routes/minecraftservices/minecraft/profile/capes/active.js create mode 100644 routes/minecraftservices/minecraft/profile/index.js create mode 100644 routes/minecraftservices/minecraft/profile/lookup/bulk/byname.js create mode 100644 routes/minecraftservices/minecraft/profile/lookup/name/[username].js create mode 100644 routes/minecraftservices/minecraft/profile/name/[name].js create mode 100644 routes/minecraftservices/minecraft/profile/namechange.js create mode 100644 routes/minecraftservices/minecraft/profile/skins/active.js create mode 100644 routes/minecraftservices/player/attributes.js create mode 100644 routes/minecraftservices/player/certificates.js create mode 100644 routes/minecraftservices/privacy/blocklist.js create mode 100644 routes/minecraftservices/privileges.js create mode 100644 routes/mojangapi/minecraft/profile/lookup/bulk/byname.js create mode 100644 routes/mojangapi/profile/lookup/name/[username].js create mode 100644 routes/mojangapi/profiles/minecraft.js create mode 100644 routes/mojangapi/user/profiles/[uuid]/names.js create mode 100644 routes/mojangapi/users/profiles/minecraft/[username].js create mode 100644 services/userService.js diff --git a/errors/ServiceError.js b/errors/ServiceError.js index 27661e0..3df8e15 100644 --- a/errors/ServiceError.js +++ b/errors/ServiceError.js @@ -1,10 +1,12 @@ -class AccountsAPIError extends Error { - constructor(code, path, error, errorMessage) { +class ServiceError extends Error { + constructor(code, path, error, errorMessage, details = null) { super(errorMessage || error || "Accounts API Error") this.code = code this.path = path this.errorType = error this.errorMessage = errorMessage + this.developerMessage = errorMessage + this.details = details this.isOperational = true Error.captureStackTrace(this, this.constructor) @@ -19,14 +21,23 @@ class AccountsAPIError extends Error { if (this.errorType && this.errorType.trim() !== "") { response.error = this.errorType + response.errorType = this.errorType + } + + if (this.details) { + response.details = this.details } if (this.errorMessage && this.errorMessage.trim() !== "") { response.errorMessage = this.errorMessage } + if (this.developerMessage && this.developerMessage.trim() !== "") { + response.developerMessage = this.developerMessage + } + return response } } -module.exports = AccountsAPIError \ No newline at end of file +module.exports = ServiceError \ No newline at end of file diff --git a/errors/errors.js b/errors/errors.js index b86d7b7..f5339ad 100644 --- a/errors/errors.js +++ b/errors/errors.js @@ -1,4 +1,5 @@ const DefaultError = require("./DefaultError") +const ServiceError = require("./ServiceError") const SessionError = require("./SessionError") const YggdrasilError = require("./YggdrasilError") const ValidationError = require("./ValidationError") @@ -6,6 +7,7 @@ const ValidationError = require("./ValidationError") module.exports = { DefaultError, SessionError, + ServiceError, YggdrasilError, ValidationError } \ No newline at end of file diff --git a/modules/logger.js b/modules/logger.js index 47651d8..1eae838 100644 --- a/modules/logger.js +++ b/modules/logger.js @@ -1,5 +1,6 @@ const fs = require("node:fs") const path = require("node:path") +const utils = require("./utils") require("colors") require("dotenv").config({ quiet: true @@ -42,7 +43,7 @@ function write($stream, level, color, content, extraLabels = []) { function createLogger(root) { // eslint-disable-next-line no-useless-escape - const fileName = (/false/).test(process.env.IS_PROD.toLowerCase()) ? new Date().toLocaleString("fr-FR", { timeZone: "UTC" }).replace(/[\/:]/g, "-").replace(/ /g, "_") : "DEV-LOG" + const fileName = utils.isTrueFromDotEnv("IS_PROD") ? new Date().toLocaleString("fr-FR", { timeZone: "UTC" }).replace(/[\/:]/g, "-").replace(/ /g, "_") : "DEV-LOG" const logsDir = path.join(root, "logs") diff --git a/modules/utils.js b/modules/utils.js index 08d34ba..5845615 100644 --- a/modules/utils.js +++ b/modules/utils.js @@ -1,5 +1,3 @@ -const path = require("node:path") -const logger = require("./logger") const crypto = require("node:crypto") const certificatesManager = require("./certificatesManager") @@ -53,8 +51,13 @@ function addDashesToUUID(uuid) { ) } +function isTrueFromDotEnv(key) { + return (process.env[key] || "").trim().toLowerCase() === "true" +} + module.exports = { - getRegistrationCountryFromIp, + signProfileData, addDashesToUUID, - signProfileData + isTrueFromDotEnv, + getRegistrationCountryFromIp, } \ No newline at end of file diff --git a/repositories/authRepository.js b/repositories/authRepository.js index e301eb0..5cd423a 100644 --- a/repositories/authRepository.js +++ b/repositories/authRepository.js @@ -2,7 +2,6 @@ const logger = require("../modules/logger") const bcrypt = require("bcryptjs") const database = require("../modules/database") const { DefaultError } = require("../errors/errors") -const usernameRegex = /^[a-zA-Z0-9_]{3,16}$/ async function getUser(identifier, requirePassword = false) { try { @@ -28,19 +27,14 @@ async function getUser(identifier, requirePassword = false) { async function register(email, username, password) { try { - const availability = await checkUsernameAvailability(username) - if (availability.allowed) { - const sql = `INSERT INTO players (email, username, password, uuid) VALUES (?, ?, ?, ?)` - const uuid = crypto.randomUUID() - const hashedPassword = await bcrypt.hash(password, 10) - const result = await database.query(sql, [email, username, hashedPassword, uuid]) - if (result.affectedRows > 0) { - return { code: 200, email, username, uuid } - } else { - throw new DefaultError(500, "Please contact an administrator.", "InternalServerError") - } + const sql = `INSERT INTO players (email, username, password, uuid) VALUES (?, ?, ?, ?)` + const uuid = crypto.randomUUID() + const hashedPassword = await bcrypt.hash(password, 10) + const result = await database.query(sql, [email, username, hashedPassword, uuid]) + if (result.affectedRows > 0) { + return { code: 200, email, username, uuid } } else { - throw new DefaultError(415, "Illegal Server Character", availability.message || "INVALID_USERNAME_FORMAT") + throw new DefaultError(500, "Please contact an administrator.", "InternalServerError") } } catch (error) { if (error instanceof DefaultError) throw error @@ -51,30 +45,6 @@ async function register(email, username, password) { } } -async function checkUsernameAvailability(username) { - if (!usernameRegex.test(username)) { - return { code: 200, allowed: false, message: "Invalid format (3-16 alphanumeric chars)." } - } - - const blocklist = await getUsernamesRules() - const normalizedUsername = username.toLowerCase() - - for (const entry of blocklist) { - if (entry.type === "literal") { - if (normalizedUsername === entry.value) { - return { code: 200, allowed: false, message: "This username is reserved." } - } - } - else if (entry.type === "regex") { - if (entry.pattern.test(username)) { - return { code: 200, allowed: false, message: "This username contains forbidden patterns." } - } - } - } - - return { code: 200, allowed: true } -} - async function insertClientSession(accessToken, clientToken, uuid) { try { const sql = `INSERT INTO clientSessions (accessToken, clientToken, uuid) VALUES (?, ?, ?)` @@ -228,6 +198,7 @@ module.exports = { getUser, register, getClientSession, + getUsernamesRules, revokeAccessTokens, insertClientSession, getPlayerProperties, diff --git a/repositories/userRepository.js b/repositories/userRepository.js index c7bbd34..34d8a82 100644 --- a/repositories/userRepository.js +++ b/repositories/userRepository.js @@ -295,18 +295,358 @@ async function getPlayerBans(uuid) { } } +async function changeUsername(uuid, newName) { + try { + const sql = "UPDATE players SET username = ? WHERE uuid = ?" + const result = await database.query(sql, [newName, uuid]) + if (result.affectedRows > 0) { + return { code: 200, message: "Username changed successfully" } + } else { + throw new DefaultError(404, "User not found") + } + } catch (error) { + if (error instanceof DefaultError) throw error + if (error.code === "ER_DUP_ENTRY" || error.errno === 1062) { + throw new DefaultError(409, "Username already taken", "ForbiddenOperationException") + } + logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"]) + throw new DefaultError(500, "Internal Server Error", error.toString()) + } +} + +async function resetSkin(uuid, hash, variant) { + try { + const insertSql = ` + INSERT IGNORE INTO playersSkins (playerUuid, assetHash, variant, isSelected) + VALUES (?, ?, ?, 0) + ` + await database.query(insertSql, [uuid, hash, variant]) + const updateSql = ` + UPDATE playersSkins + SET isSelected = (assetHash = ?) + WHERE playerUuid = ? + ` + await database.query(updateSql, [hash, uuid]) + return { code: 200 } + } catch (error) { + logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function hideCape(uuid) { + try { + const sql = "UPDATE playersCapes SET isSelected = 0 WHERE playerUuid = ?" + await database.query(sql, [uuid]) + return { code: 200 } + } catch (error) { + logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"]) + 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 showCape(uuid, hash) { + try { + const sql = ` + UPDATE playersCapes + SET isSelected = (assetHash = ?) + WHERE playerUuid = ? + ` + const result = await database.query(sql, [hash, uuid]) + return { code: 200, changed: result.affectedRows > 0 } + } catch (error) { + logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function checkCapeOwnership(uuid, hash) { + try { + const sql = "SELECT 1 FROM playersCapes WHERE playerUuid = ? AND assetHash = ?" + const rows = await database.query(sql, [uuid, hash]) + return rows.length > 0 + } catch (error) { + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function getPlayerMeta(uuid) { + try { + const sql = `SELECT createdAt, nameChangeAllowed FROM players WHERE uuid = ?` + const rows = await database.query(sql, [uuid]) + return rows[0] + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function getLastNameChange(uuid) { + try { + const sql = ` + SELECT changedAt + FROM uuidToNameHistory + WHERE uuid = ? AND changedAt IS NOT NULL + ORDER BY changedAt DESC + LIMIT 1 + ` + const rows = await database.query(sql, [uuid]) + return rows[0] + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function getPlayerCertificate(uuid) { + try { + const sql = "SELECT * FROM playerCertificates WHERE uuid = ?" + const rows = await database.query(sql, [uuid]) + return rows[0] + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function savePlayerCertificate(uuid, privateKey, publicKey, signatureV2, expiresAt, refreshedAfter) { + try { + const sql = ` + REPLACE INTO playerCertificates + (uuid, privateKey, publicKey, publicKeySignatureV2, expiresAt, refreshedAfter) + VALUES (?, ?, ?, ?, ?, ?) + ` + const result = await database.query(sql, [uuid, privateKey, publicKey, signatureV2, expiresAt, refreshedAfter]) + return result.affectedRows > 0 + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function deleteExpiredCertificates(isoDate) { + try { + const sql = "DELETE FROM playerCertificates WHERE expiresAt < ?" + const result = await database.query(sql, [isoDate]) + return result.affectedRows + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function addProfileAction(uuid, actionCode) { + try { + const cleanUuid = uuid.replace(/-/g, "") + const sql = "INSERT IGNORE INTO playerProfileActions (uuid, action) VALUES (?, ?)" + const result = await database.query(sql, [cleanUuid, actionCode]) + return result.affectedRows > 0 + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function removeProfileAction(uuid, actionCode) { + try { + const cleanUuid = uuid.replace(/-/g, "") + const sql = "DELETE FROM playerProfileActions WHERE uuid = ? AND action = ?" + const result = await database.query(sql, [cleanUuid, actionCode]) + return result.affectedRows + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function getPlayerActions(uuid) { + try { + const cleanUuid = uuid.replace(/-/g, "") + const sql = "SELECT action FROM playerProfileActions WHERE uuid = ?" + const rows = await database.query(sql, [cleanUuid]) + return rows.map(r => r.action) + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function clearAllPlayerActions(uuid) { + try { + const cleanUuid = uuid.replace(/-/g, "") + const sql = "DELETE FROM playerProfileActions WHERE uuid = ?" + const result = await database.query(sql, [cleanUuid]) + return result.affectedRows + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function blockPlayer(blockerUuid, blockedUuid) { + try { + const sql = `INSERT IGNORE INTO playersBlockslist (blockerUuid, blockedUuid) VALUES (?, ?)` + const result = await database.query(sql, [blockerUuid, blockedUuid]) + return result.affectedRows > 0 + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function unblockPlayer(blockerUuid, blockedUuid) { + try { + const sql = `DELETE FROM playersBlockslist WHERE blockerUuid = ? AND blockedUuid = ?` + const result = await database.query(sql, [blockerUuid, blockedUuid]) + return result.affectedRows > 0 + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function getBlockedUuids(blockerUuid) { + try { + const sql = `SELECT blockedUuid FROM playersBlockslist WHERE blockerUuid = ?` + const rows = await database.query(sql, [blockerUuid]) + return rows.map(r => r.blockedUuid) + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function isBlocked(blockerUuid, targetUuid) { + try { + const sql = `SELECT 1 FROM playersBlockslist WHERE blockerUuid = ? AND blockedUuid = ? LIMIT 1` + const rows = await database.query(sql, [blockerUuid, targetUuid]) + return rows.length > 0 + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function getUsersByNames(usernames) { + try { + if (!usernames || usernames.length === 0) return [] + const uniqueNames = [...new Set(usernames)] + + const placeholders = uniqueNames.map(() => "?").join(", ") + const sql = `SELECT uuid, username FROM players WHERE username IN (${placeholders})` + + const rows = await database.query(sql, uniqueNames) + return rows + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function getUuidAndUsername(username) { + try { + const sql = "SELECT uuid, username FROM players WHERE username = ?" + const rows = await database.query(sql, [username]) + + return rows[0] || null + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function getProfileByUsername(username) { + try { + const sql = "SELECT uuid, username FROM players WHERE username = ?" + const rows = await database.query(sql, [username]) + return rows[0] || null + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function getProfileByHistory(username, isoDate) { + try { + const sql = ` + SELECT uuid, username + FROM uuidToNameHistory + WHERE username = ? + AND (changedAt <= ? OR changedAt IS NULL) + ORDER BY changedAt DESC + LIMIT 1 + ` + const rows = await database.query(sql, [username, isoDate]) + return rows[0] || null + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + +async function getNameHistory(uuid) { + try { + const sql = ` + SELECT username, changedAt + FROM uuidToNameHistory + WHERE uuid = ? + ORDER BY changedAt ASC + ` + const rows = await database.query(sql, [uuid]) + return rows + } catch (error) { + logger.log("Database Error: " + error.toString(), ["MariaDB", "red"]) + throw new DefaultError(500, "Internal Server Error", "Database Error") + } +} + module.exports = { banUser, + showCape, + hideCape, + resetSkin, + isBlocked, unbanUser, + blockPlayer, getPlayerBans, + getPlayerMeta, + unblockPlayer, + changeUsername, + getNameHistory, + getBlockedUuids, + getUsersByNames, + getPlayerActions, + getTextureByUuid, + addProfileAction, + getLastNameChange, getPlayerProperty, + getUuidAndUsername, + checkCapeOwnership, + getProfileByHistory, getPlayerPrivileges, getPlayerProperties, + removeProfileAction, addPropertyToPlayer, + getProfileByUsername, getPlayerPreferences, + getPlayerCertificate, + clearAllPlayerActions, + savePlayerCertificate, updatePlayerPrivileges, deletePropertyToPlayer, updatePropertyToPlayer, getPlayerSettingsSchema, updatePlayerPreferences, + deleteExpiredCertificates, } \ No newline at end of file diff --git a/routes/authserver/authenticate.js b/routes/authserver/authenticate.js index ccf5ba0..56ee8ed 100644 --- a/routes/authserver/authenticate.js +++ b/routes/authserver/authenticate.js @@ -11,7 +11,7 @@ const limiter = rateLimit({ standardHeaders: true, legacyHeaders: false, handler: (req, res) => { - res.status(429).json({ + return res.status(429).json({ error: "TooManyRequestsException", errorMessage: "Too many login attempts, please try again later." }) @@ -29,7 +29,7 @@ router.post("/", limiter, async (req, res) => { }) logger.log(`User authenticated: ${username}`, ["AUTH", "green"]) - res.status(200).json(result.response) + return res.status(200).json(result.response) } catch (err) { if (err instanceof DefaultError) { throw new YggdrasilError( err.code, err.error || "ForbiddenOperationException", err.message, "Invalid credentials") diff --git a/routes/authserver/invalidate.js b/routes/authserver/invalidate.js index 5aafcc9..d6db19a 100644 --- a/routes/authserver/invalidate.js +++ b/routes/authserver/invalidate.js @@ -8,7 +8,7 @@ router.post("/", async (req, res) => { const { accessToken, clientToken } = req.body try { await authService.invalidate({ accessToken, clientToken }) - res.sendStatus(204) + return res.sendStatus(204) } catch (err) { if (err instanceof DefaultError) { throw new YggdrasilError(err.code, err.error || "ForbiddenOperationException", err.message, "Invalid token.") diff --git a/routes/authserver/refresh.js b/routes/authserver/refresh.js index 1fb8b64..7b234dc 100644 --- a/routes/authserver/refresh.js +++ b/routes/authserver/refresh.js @@ -16,9 +16,7 @@ router.post("/", async (req, res) => { const profileName = result.response.selectedProfile ? result.response.selectedProfile.name : "Unknown" logger.log(`Session refreshed for: ${profileName}`, ["AUTH", "green"]) - - res.status(200).json(result.response) - + return res.status(200).json(result.response) } catch (err) { if (err instanceof DefaultError) { throw new YggdrasilError(err.code, err.error || "ForbiddenOperationException", err.message, "Invalid token.") diff --git a/routes/authserver/signout.js b/routes/authserver/signout.js index 8fa5720..80c9090 100644 --- a/routes/authserver/signout.js +++ b/routes/authserver/signout.js @@ -17,8 +17,7 @@ router.post("/", async (req, res) => { await authService.signout({ uuid: userUuid }) logger.log(`User signed out globally: ${username}`, ["AUTH", "green"]) - res.sendStatus(204) - + return res.sendStatus(204) } catch (err) { if (err instanceof DefaultError) { throw new YggdrasilError(err.code === 403 ? 403 : 500, err.error || "ForbiddenOperationException", err.message || "Invalid credentials.", "Invalid credentials.") diff --git a/routes/authserver/validate.js b/routes/authserver/validate.js index 6642ef1..db6e72c 100644 --- a/routes/authserver/validate.js +++ b/routes/authserver/validate.js @@ -8,7 +8,7 @@ router.post("/", async (req, res) => { const { accessToken, clientToken } = req.body try { await authService.validate({ accessToken, clientToken }) - res.sendStatus(204) + return res.sendStatus(204) } catch (err) { if (err instanceof DefaultError) { throw new YggdrasilError(err.code, err.error || "ForbiddenOperationException", err.message, "Invalid token.") diff --git a/routes/minecraftservices/minecraft/profile/capes/active.js b/routes/minecraftservices/minecraft/profile/capes/active.js new file mode 100644 index 0000000..1f16188 --- /dev/null +++ b/routes/minecraftservices/minecraft/profile/capes/active.js @@ -0,0 +1,26 @@ +const express = require("express") +const router = express.Router() +const userService = require("../../../../../services/userService") +const authService = require("../../../../../services/authService") + +router.delete("/", async (req, res) => { + const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization }) + await userService.hideCape(player.user.uuid) + return res.status(200).send() +}) + +router.put("/", async (req, res) => { + const player = await authService.verifyAccessToken(req.headers.authorization) + + await userService.showCape(player.user.uuid, req.body.capeId) + const [skinsResult, capesResult] = await Promise.all([userService.getSkins(player.user.uuid), userService.getCapes(player.user.uuid)]) + + return res.status(200).json({ + id: player.user.uuid.replace(/-/g, ""), + name: player.user.username, + skins: skinsResult.data || [], + capes: capesResult.data || [] + }) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/minecraftservices/minecraft/profile/index.js b/routes/minecraftservices/minecraft/profile/index.js new file mode 100644 index 0000000..89914f3 --- /dev/null +++ b/routes/minecraftservices/minecraft/profile/index.js @@ -0,0 +1,18 @@ +const express = require("express") +const router = express.Router() +const userService = require("../../../../services/userService") +const authService = require("../../../../services/authService") + +router.get("/", async (req, res) => { + const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization.replace("Bearer ", "") }) + const [skinsResult, capesResult] = await Promise.all([userService.getSkins(player.user.uuid), userService.getCapes(player.user.uuid)]) + + return res.status(200).json({ + id: player.uuid.replace(/-/g, ""), + name: player.user.username, + skins: skinsResult.data || [], + capes: capesResult.data || [] + }) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/minecraftservices/minecraft/profile/lookup/bulk/byname.js b/routes/minecraftservices/minecraft/profile/lookup/bulk/byname.js new file mode 100644 index 0000000..b194acd --- /dev/null +++ b/routes/minecraftservices/minecraft/profile/lookup/bulk/byname.js @@ -0,0 +1,10 @@ +const express = require("express") +const userService = require("../../../../../../services/userService") +const router = express.Router() + +router.post("/", async (req, res) => { + const profiles = await userService.bulkLookup(req.body) + return res.status(200).json(profiles) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/minecraftservices/minecraft/profile/lookup/name/[username].js b/routes/minecraftservices/minecraft/profile/lookup/name/[username].js new file mode 100644 index 0000000..2367069 --- /dev/null +++ b/routes/minecraftservices/minecraft/profile/lookup/name/[username].js @@ -0,0 +1,27 @@ +const express = require("express") +const utils = require("../../../../../../modules/utils") +const userService = require("../../../../../../services/userService") +const authService = require("../../../../../../services/authService") +const { ServiceError } = require("../../../../../../errors/errors") +const router = express.Router({ mergeParams: true }) + +router.get("", async (req, res) => { + const profile = await userService.getLegacyProfile(req.params.username) + const isUsernameOK = await authService.checkUsernameAvailability(newName) + const at = req.query.at + if (at != undefined && utils.isTrueFromDotEnv("SUPPORT_UUID_TO_NAME_HISTORY")) { + const history = await userService.getNameUUIDs(parseInt(at)) + return res.status(history.code).json(history.data) + } else { + throw new ServiceError(400, req.originalUrl, "IllegalArgumentException", "Invalid timestamp.") + } + if (isUsernameOK.status != "AVAILABLE") { + throw new ServiceError(400, req.originalUrl, "CONSTRAINT_VIOLATION", "Invalid username.") + } + if (!profile) { + return res.status(204).send() + } + return res.status(200).json(profile) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/minecraftservices/minecraft/profile/name/[name].js b/routes/minecraftservices/minecraft/profile/name/[name].js new file mode 100644 index 0000000..effc594 --- /dev/null +++ b/routes/minecraftservices/minecraft/profile/name/[name].js @@ -0,0 +1,44 @@ +const express = require("express") +const authService = require("../../../../../services/authService") +const { DefaultError, ServiceError } = require("../../../../../errors/errors") +const router = express.Router({ mergeParams: true }) + +router.get("/available", async (req, res) => { + try { + await authService.verifyAccessToken({ accessToken: req.headers.authorization.replace("Bearer", "").trim() }) + const isAvailable = await authService.checkUsernameAvailability(req.params.name) + return res.status(200).json({ status: isAvailable.status }) + } catch (error) { + if (error instanceof DefaultError) { + throw new ServiceError(error.code, req.originalUrl, null, null, null) + } + throw error + } +}) + +router.put("/", async (req, res) => { + try { + const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization.replace("Bearer", "").trim() }) + const newName = req.params.name + + await userService.changeUsername(player.uuid, newName) + + const skinsResult = await userService.getSkins({ uuid: player.uuid }) + const capesResult = await userService.getCapes({ uuid: player.uuid }) + + return res.status(200).json({ + id: player.uuid.replace(/-/g, ""), + name: newName, + skins: skinsResult.data || [], + capes: capesResult.data || [] + }) + + } catch (err) { + const mcStatus = err.code === 409 ? "DUPLICATE" : (err.code === 400 || err.code === 403) ? "NOT_ALLOWED" : null + const finalCode = (mcStatus === "DUPLICATE") ? 403 : (err.code || 500) + const errorType = mcStatus ? "FORBIDDEN" : (err.error || "Internal Server Error") + throw new ServiceError(finalCode, req.originalUrl, errorType, err.message, mcStatus ? { status: mcStatus } : null) + } +}) + +module.exports = router \ No newline at end of file diff --git a/routes/minecraftservices/minecraft/profile/namechange.js b/routes/minecraftservices/minecraft/profile/namechange.js new file mode 100644 index 0000000..e50c196 --- /dev/null +++ b/routes/minecraftservices/minecraft/profile/namechange.js @@ -0,0 +1,12 @@ +const express = require("express") +const router = express.Router() +const userService = require("../../../../services/userService") +const authService = require("../../../../services/authService") + +router.put("/", async (req, res) => { + const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization.replace("Bearer ", "") }) + const nameChangeInformation = await userService.getPlayerNameChangeStatus(player.user.uuid) + return res.status(nameChangeInformation.code).json(nameChangeInformation.data) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/minecraftservices/minecraft/profile/skins/active.js b/routes/minecraftservices/minecraft/profile/skins/active.js new file mode 100644 index 0000000..9ab08e6 --- /dev/null +++ b/routes/minecraftservices/minecraft/profile/skins/active.js @@ -0,0 +1,12 @@ +const express = require("express") +const router = express.Router() +const userService = require("../../../../../services/userService") +const authService = require("../../../../../services/authService") + +router.delete("/", async (req, res) => { + const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization }) + await userService.resetSkin(player.user.uuid) + return res.status(200).send() +}) + +module.exports = router \ No newline at end of file diff --git a/routes/minecraftservices/player/attributes.js b/routes/minecraftservices/player/attributes.js new file mode 100644 index 0000000..e69088c --- /dev/null +++ b/routes/minecraftservices/player/attributes.js @@ -0,0 +1,34 @@ +const express = require("express") +const userService = require("../../../services/userService") +const authService = require("../../../services/authService") +const router = express.Router() + +router.get("", async (req, res) => { + const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization.replace("Bearer", "").trim() }) + + const [preferencesResult, privilegesResult, banStatus] = await Promise.all([userService.getPreferences(player.user.uuid), userService.getPrivileges(player.user.uuid), userService.getPlayerBanStatus(player.user.uuid)]) + return res.status(200).json({ + privileges: privilegesResult.data, + ...preferencesResult.data, + banStatus: { + bannedScopes: banStatus.isBanned ? { MULTIPLAYER: banStatus.activeBan } : {} + } + }) +}) + +router.post("", async (req, res) => { + const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization.replace("Bearer", "").trim() }) + + await userService.updatePreferences(player.user.uuid, req.body) + + const [preferencesResult, privilegesResult, banStatus] = await Promise.all([userService.getPreferences(player.user.uuid), userService.getPrivileges(player.user.uuid), userService.getPlayerBanStatus(player.user.uuid)]) + return res.status(200).json({ + privileges: privilegesResult.data, + ...preferencesResult.data, + banStatus: { + bannedScopes: banStatus.isBanned ? { MULTIPLAYER: banStatus.activeBan } : {} + } + }) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/minecraftservices/player/certificates.js b/routes/minecraftservices/player/certificates.js new file mode 100644 index 0000000..a0e607c --- /dev/null +++ b/routes/minecraftservices/player/certificates.js @@ -0,0 +1,12 @@ +const express = require("express") +const userService = require("../../../services/userService") +const authService = require("../../../services/authService") +const router = express.Router() + +router.post("", async (req, res) => { + const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization.replace("Bearer", "").trim() }) + const certificates = await userService.fetchOrGenerateCertificate(player.user.uuid) + return res.status(200).json(certificates.data) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/minecraftservices/privacy/blocklist.js b/routes/minecraftservices/privacy/blocklist.js new file mode 100644 index 0000000..20895f5 --- /dev/null +++ b/routes/minecraftservices/privacy/blocklist.js @@ -0,0 +1,39 @@ +const express = require("express") +const router = express.Router() +const utils = require("../../../modules/utils") // Pour addDashesToUUID +const authService = require("../../../services/authService") +const userService = require("../../../services/userService") + +router.get("/", async (req, res, next) => { + const user = await authService.verifyUserFromHeader(req.headers.authorization) + const result = await userService.getBlockedUuids(user.uuid) + return res.status(200).json({ + blockedProfiles: result.data || [] + }) +}) + +router.put("/:uuid", async (req, res, next) => { + const user = await authService.verifyUserFromHeader(req.headers.authorization) + const targetUuid = utils.addDashesToUUID(req.params.uuid) + + await userService.blockPlayer(user.uuid, targetUuid) + + const result = await userService.getBlockedUuids(user.uuid) + return res.status(200).json({ + blockedProfiles: result.data || [] + }) +}) + +router.delete("/:uuid", async (req, res, next) => { + const user = await authService.verifyUserFromHeader(req.headers.authorization) + const targetUuid = utils.addDashesToUUID(req.params.uuid) + + await userService.unblockPlayer(user.uuid, targetUuid) + + const result = await userService.getBlockedUuids(user.uuid) + return res.status(200).json({ + blockedProfiles: result.data || [] + }) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/minecraftservices/privileges.js b/routes/minecraftservices/privileges.js new file mode 100644 index 0000000..3d82079 --- /dev/null +++ b/routes/minecraftservices/privileges.js @@ -0,0 +1,34 @@ +const express = require("express") +const userService = require("../../services/userService") +const authService = require("../../services/authService") +const router = express.Router() + +router.get("", async (req, res) => { + const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization.replace("Bearer", "").trim() }) + + const [preferencesResult, privilegesResult, banStatus] = await Promise.all([userService.getPreferences(player.user.uuid), userService.getPrivileges(player.user.uuid), userService.getPlayerBanStatus(player.user.uuid)]) + return res.status(200).json({ + privileges: privilegesResult.data, + ...preferencesResult.data, + banStatus: { + bannedScopes: banStatus.isBanned ? { MULTIPLAYER: banStatus.activeBan } : {} + } + }) +}) + +router.post("", async (req, res) => { + const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization.replace("Bearer", "").trim() }) + + await userService.updatePreferences(player.user.uuid, req.body) + + const [preferencesResult, privilegesResult, banStatus] = await Promise.all([userService.getPreferences(player.user.uuid), userService.getPrivileges(player.user.uuid), userService.getPlayerBanStatus(player.user.uuid)]) + return res.status(200).json({ + privileges: privilegesResult.data, + ...preferencesResult.data, + banStatus: { + bannedScopes: banStatus.isBanned ? { MULTIPLAYER: banStatus.activeBan } : {} + } + }) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/minecraftservices/productvoucher.js b/routes/minecraftservices/productvoucher.js index f4f48aa..7bf08e4 100644 --- a/routes/minecraftservices/productvoucher.js +++ b/routes/minecraftservices/productvoucher.js @@ -2,7 +2,7 @@ const express = require("express") const router = express.Router() router.get("/giftcode", (req, res) => { - res.status(404).json({ + return res.status(404).json({ path: "/productvoucher/giftcode", errorType: "NOT_FOUND", error: "NOT_FOUND", diff --git a/routes/minecraftservices/publickeys.js b/routes/minecraftservices/publickeys.js index 8465151..9c1b44e 100644 --- a/routes/minecraftservices/publickeys.js +++ b/routes/minecraftservices/publickeys.js @@ -12,7 +12,7 @@ router.get("", (req, res) => { } ] } - res.status(200).json(publicKeys) + return res.status(200).json(publicKeys) }) module.exports = router \ No newline at end of file diff --git a/routes/mojangapi/minecraft/profile/lookup/bulk/byname.js b/routes/mojangapi/minecraft/profile/lookup/bulk/byname.js new file mode 100644 index 0000000..b194acd --- /dev/null +++ b/routes/mojangapi/minecraft/profile/lookup/bulk/byname.js @@ -0,0 +1,10 @@ +const express = require("express") +const userService = require("../../../../../../services/userService") +const router = express.Router() + +router.post("/", async (req, res) => { + const profiles = await userService.bulkLookup(req.body) + return res.status(200).json(profiles) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/mojangapi/profile/lookup/name/[username].js b/routes/mojangapi/profile/lookup/name/[username].js new file mode 100644 index 0000000..50f8c41 --- /dev/null +++ b/routes/mojangapi/profile/lookup/name/[username].js @@ -0,0 +1,28 @@ +const express = require("express") +const utils = require("../../../../../modules/utils") +const userService = require("../../../../../services/userService") +const authService = require("../../../../../services/authService") +const { ServiceError } = require("../../../../../errors/errors") +const router = express.Router({ mergeParams: true }) + + +router.get("", async (req, res) => { + const profile = await userService.getLegacyProfile(req.params.username) + const isUsernameOK = await authService.checkUsernameAvailability(newName) + const at = req.query.at + if (at != undefined && utils.isTrueFromDotEnv("SUPPORT_UUID_TO_NAME_HISTORY")) { + const history = await userService.getNameUUIDs(parseInt(at)) + return res.status(history.code).json(history.data) + } else { + throw new ServiceError(400, req.originalUrl, "IllegalArgumentException", "Invalid timestamp.") + } + if (isUsernameOK.status != "AVAILABLE") { + throw new ServiceError(400, req.originalUrl, "CONSTRAINT_VIOLATION", "Invalid username.") + } + if (!profile) { + return res.status(204).send() + } + return res.status(200).json(profile) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/mojangapi/profiles/minecraft.js b/routes/mojangapi/profiles/minecraft.js new file mode 100644 index 0000000..971fece --- /dev/null +++ b/routes/mojangapi/profiles/minecraft.js @@ -0,0 +1,10 @@ +const express = require("express") +const userService = require("../../../services/userService") +const router = express.Router() + +router.post("/", async (req, res) => { + const profiles = await userService.bulkLookup(req.body) + return res.status(200).json(profiles) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/mojangapi/user/profiles/[uuid]/names.js b/routes/mojangapi/user/profiles/[uuid]/names.js new file mode 100644 index 0000000..6dd32c0 --- /dev/null +++ b/routes/mojangapi/user/profiles/[uuid]/names.js @@ -0,0 +1,15 @@ +const express = require("express") +const utils = require("../../../../../modules/utils") +const userService = require("../../../../../services/userService") +const { ServiceError } = require("../../../../../errors/errors") +const router = express.Router({ mergeParams: true }) + +router.get("/", async (req, res) => { + if (!utils.isTrueFromDotEnv("SUPPORT_UUID_TO_NAME_HISTORY")) { + throw new ServiceError(404, req.originalUrl, "Not found", null, null) + } + const history = await userService.getPlayerUsernamesHistory(req.params.uuid) + return res.status(200).json(history) +}) + +module.exports = router \ No newline at end of file diff --git a/routes/mojangapi/users/profiles/minecraft/[username].js b/routes/mojangapi/users/profiles/minecraft/[username].js new file mode 100644 index 0000000..fa38356 --- /dev/null +++ b/routes/mojangapi/users/profiles/minecraft/[username].js @@ -0,0 +1,27 @@ +const express = require("express") +const utils = require("../../../../../modules/utils") +const userService = require("../../../../../services/userService") +const authService = require("../../../../../services/authService") +const { ServiceError } = require("../../../../../errors/errors") +const router = express.Router({ mergeParams: true }) + +router.get("", async (req, res) => { + const profile = await userService.getLegacyProfile(req.params.username) + const isUsernameOK = await authService.checkUsernameAvailability(newName) + const at = req.query.at + if (at != undefined && utils.isTrueFromDotEnv("SUPPORT_UUID_TO_NAME_HISTORY")) { + const history = await userService.getNameUUIDs(parseInt(at)) + return res.status(history.code).json(history.data) + } else { + throw new ServiceError(400, req.originalUrl, "IllegalArgumentException", "Invalid timestamp.") + } + if (isUsernameOK.status != "AVAILABLE") { + throw new ServiceError(400, req.originalUrl, "CONSTRAINT_VIOLATION", "Invalid username.") + } + if (!profile) { + return res.status(204).send() + } + return res.status(200).json(profile) +}) + +module.exports = router \ No newline at end of file diff --git a/server.js b/server.js index 4242b64..16424e9 100644 --- a/server.js +++ b/server.js @@ -128,8 +128,10 @@ for (const route of routes) { for (const layer of router.stack) { if (layer.route && layer.route.methods) { const method = Object.keys(layer.route.methods).join(", ").toUpperCase() - const subPath = routePath === "/" ? "" : routePath - logger.log(`${method.cyan} ${subPath.cyan.bold} route registered`, ["WEB", "yellow"]) + const innerPath = layer.route.path === "/" ? "" : layer.route.path + const mountPrefix = routePath === "/" ? "" : routePath + const fullDisplayPath = mountPrefix + innerPath + logger.log(`${method.cyan} ${fullDisplayPath.cyan.bold} route registered`, ["WEB", "yellow"]) } } } diff --git a/services/authService.js b/services/authService.js index 65f9bbf..7ecfc6f 100644 --- a/services/authService.js +++ b/services/authService.js @@ -9,13 +9,15 @@ const { DefaultError } = require("../errors/errors") const keys = certsManager.getKeys() const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/ +const usernameRegex = /^[a-zA-Z0-9_]{3,16}$/ async function registerUser({ username, password, email, registrationCountry, preferredLanguage, clientIp }) { - try { - await authRepository.getUser(username) + const availability = await checkUsernameAvailability(username) + if (availability.status === "DUPLICATE") { throw new DefaultError(409, "Username taken.", "ForbiddenOperationException") - } catch (error) { - if (error.code !== 404) throw error + } + if (availability.status === "NOT_ALLOWED") { + throw new DefaultError(400, availability.message || "Username invalid.", "InvalidUsernameException") } if (email) { @@ -225,6 +227,56 @@ async function verifyAccessToken({ accessToken }) { } } +async function checkUsernameAvailability(username) { + if (!usernameRegex.test(username)) { + return { + code: 200, + status: "NOT_ALLOWED", + message: "Invalid characters or length." + } + } + + try { + await authRepository.getUser(username) + return { + code: 200, + status: "DUPLICATE", + message: "Username is already in use." + } + } catch (error) { + if (error.code !== 404) throw error + } + + const blocklist = await authRepository.getUsernamesRules() + const normalizedUsername = username.toLowerCase() + + for (const entry of blocklist) { + if (entry.type === "literal") { + if (normalizedUsername === entry.value) { + return { + code: 200, + status: "NOT_ALLOWED", + message: "Username is reserved." + } + } + } + else if (entry.type === "regex") { + if (entry.pattern.test(username)) { + return { + code: 200, + status: "NOT_ALLOWED", + message: "Username contains forbidden words." + } + } + } + } + + return { + code: 200, + status: "AVAILABLE" + } +} + module.exports = { signout, validate, @@ -233,4 +285,5 @@ module.exports = { authenticate, refreshToken, verifyAccessToken, + checkUsernameAvailability } \ No newline at end of file diff --git a/services/userService.js b/services/userService.js new file mode 100644 index 0000000..f219cea --- /dev/null +++ b/services/userService.js @@ -0,0 +1,435 @@ +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") + +async function getPlayerProperties(uuid) { + try { + const result = await userRepository.getPlayerProperties(uuid) + return result + } catch (error) { + if (error.code === 404) { + return { code: 200, properties: [] } + } + throw error + } +} + +async function getPlayerProperty(uuid, key) { + return await userRepository.getPlayerProperty(key, uuid) +} + +async function addPlayerProperty(uuid, key, value) { + return await userRepository.addPropertyToPlayer(key, value, uuid) +} + +async function updatePlayerProperty(uuid, key, value) { + return await userRepository.updatePropertyToPlayer(key, value, uuid) +} + +async function deletePlayerProperty(uuid, key) { + return await userRepository.deletePropertyToPlayer(key, uuid) +} + +async function getSettingsSchema() { + const schema = await userRepository.getPlayerSettingsSchema() + return { + code: 200, + schema + } +} + +async function getPreferences(uuid) { + return await userRepository.getPlayerPreferences(uuid) +} + +async function updatePreferences(uuid, updates) { + return await userRepository.updatePlayerPreferences(uuid, updates) +} + +async function getPrivileges(uuid) { + return await userRepository.getPlayerPrivileges(uuid) +} + +async function updatePrivileges(uuid, updates) { + return await userRepository.updatePlayerPrivileges(uuid, updates) +} + +async function banUser(uuid, { reasonKey, reasonMessage, expires }) { + if (!reasonKey) { + throw new DefaultError(400, "A reason key is required to ban a user.") + } + + return await userRepository.banUser(uuid, { + reasonKey, + reasonMessage: reasonMessage || "Banned by operator", + expires + }) +} + +async function unbanUser(uuid) { + return await userRepository.unbanUser(uuid) +} + +async function getPlayerBans(uuid) { + const result = await userRepository.getPlayerBans(uuid) + if (result.code === 204) { + return { code: 200, bans: [] } + } + return result +} + +async function changeUsername(uuid, newName) { + const availability = await authService.checkUsernameAvailability(newName) + if (availability.status === "DUPLICATE") { + throw new DefaultError(409, "Username already taken.", "ForbiddenOperationException") + } + if (availability.status === "NOT_ALLOWED") { + throw new DefaultError(400, availability.message || "Invalid username format.", "InvalidUsernameException") + } + + return await userRepository.changeUsername(uuid, newName) +} + +async function resetSkin(playerUuid) { + const isSteve = Math.random() < 0.5 + const targetHash = isSteve ? STEVE_HASH : ALEX_HASH + const variant = isSteve ? "CLASSIC" : "SLIM" + await userRepository.resetSkin(playerUuid, targetHash, variant) + return { + code: 200, + message: "Skin reset successfully", + model: variant + } +} + +async function hideCape(playerUuid) { + await userRepository.hideCape(playerUuid) + return { + code: 200, + message: "Cape hidden" + } +} + +async function showCape(playerUuid, textureUuid) { + const texture = await userRepository.getTextureByUuid(textureUuid) + if (!texture) { + throw new DefaultError(404, "Cape texture not found in server assets.", "TextureNotFoundException") + } + const ownsCape = await userRepository.checkCapeOwnership(playerUuid, texture.hash) + if (!ownsCape) { + throw new DefaultError(403, "You do not own this cape.", "ForbiddenOperationException") + } + await userRepository.showCape(playerUuid, texture.hash) + + return { + code: 200, + message: "Cape showed" + } +} + +async function getPlayerNameChangeStatus(uuid) { + const player = await userRepository.getPlayerMeta(uuid) + if (!player) { + throw new DefaultError(404, "User not found") + } + const history = await userRepository.getLastNameChange(uuid) + const response = { + changedAt: history ? history.changedAt : player.createdAt, + createdAt: player.createdAt, + nameChangeAllowed: !!player.nameChangeAllowed + } + + return { code: 200, data: response } +} + +async function getPlayerCertificate(uuid) { + const cert = await userRepository.getPlayerCertificate(uuid) + if (cert) { + return { code: 200, data: cert } + } + throw new DefaultError(404, "Certificate not found") +} + +async function savePlayerCertificate(uuid, keys) { + const success = await userRepository.savePlayerCertificate( + uuid, + keys.privateKey, + keys.publicKey, + keys.signatureV2, + keys.expiresAt, + keys.refreshedAfter + ) + + if (success) { + return { code: 200, message: "Certificate saved" } + } + throw new DefaultError(500, "Failed to save certificate") +} + +async function deleteExpiredCertificates(isoDate) { + const count = await userRepository.deleteExpiredCertificates(isoDate) + return { code: 200, deletedCount: count } +} + +async function addProfileAction(uuid, actionCode) { + const added = await userRepository.addProfileAction(uuid, actionCode) + return { + code: 200, + success: true, + added: added + } +} + +async function removeProfileAction(uuid, actionCode) { + const count = await userRepository.removeProfileAction(uuid, actionCode) + return { + code: 200, + deletedCount: count + } +} + +async function getPlayerActions(uuid) { + const actions = await userRepository.getPlayerActions(uuid) + return { + code: 200, + actions: actions + } +} + +async function clearAllPlayerActions(uuid) { + const count = await userRepository.clearAllPlayerActions(uuid) + return { + code: 200, + deletedCount: count + } +} + +async function blockPlayer(blockerUuid, blockedUuid) { + if (blockerUuid === blockedUuid) { + throw new DefaultError(400, "You cannot block yourself.") + } + const changed = await userRepository.blockPlayer(blockerUuid, blockedUuid) + return { code: 200, changed: changed } +} + +async function unblockPlayer(blockerUuid, blockedUuid) { + const changed = await userRepository.unblockPlayer(blockerUuid, blockedUuid) + return { code: 200, changed: changed } +} + +async function getBlockedUuids(blockerUuid) { + const list = await userRepository.getBlockedUuids(blockerUuid) + return { code: 200, data: list } +} + +async function isBlocked(blockerUuid, targetUuid) { + const status = await userRepository.isBlocked(blockerUuid, targetUuid) + return { code: 200, isBlocked: status } +} + +async function getPlayerBanStatus(uuid) { + const result = await userRepository.getPlayerBans(uuid) + if (!result || result.code !== 200 || !result.bans || result.bans.length === 0) { + return { isBanned: false, activeBan: null } + } + + const now = new Date() + const activeBan = result.bans.find(b => !b.expires || new Date(b.expires) > now) + + if (!activeBan) { + return { isBanned: false, activeBan: null } + } + + return { + isBanned: true, + activeBan: { + banId: activeBan.banId, + expires: activeBan.expires, + reason: activeBan.reason, + reasonMessage: activeBan.reasonMessage + } + } +} + +async function fetchOrGenerateCertificate(uuid) { + try { + const cached = await userRepository.getPlayerCertificate(uuid) + if (cached) { + const expiresAtDate = new Date(cached.expiresAt) + if (expiresAtDate > new Date(Date.now() + 60000)) { + return { + code: 200, + data: { + keyPair: { + privateKey: cached.privateKey, + publicKey: cached.publicKey + }, + publicKeySignature: cached.publicKeySignatureV2, + publicKeySignatureV2: cached.publicKeySignatureV2, + expiresAt: cached.expiresAt, + refreshedAfter: cached.refreshedAfter + } + } + } + } + } catch (error) { + if (error.code !== 404 && error.code !== 500) { + logger.warn(`Error fetching cache for ${uuid}:` + error.message, ["Certificate", "yellow"]) + } + } + + const { privateKey, publicKey } = await generateKeyPairAsync("rsa", { + modulusLength: 4096, + publicKeyEncoding: { type: "pkcs1", format: "pem" }, + privateKeyEncoding: { type: "pkcs1", format: "pem" } + }) + + const now = new Date() + const expiresAt = new Date(now.getTime() + 48 * 60 * 60 * 1000).toISOString() + const refreshedAfter = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString() + const keys = certsManager.getKeys() + const serverPrivateKey = keys.playerCertificateKeys.private + + const signer = crypto.createSign("SHA256") + signer.update(uuid) + signer.update(publicKey) + signer.update(expiresAt) + + const signatureV2 = signer.sign(serverPrivateKey, "base64") + await userRepository.savePlayerCertificate(uuid, privateKey, publicKey, signatureV2, expiresAt, refreshedAfter) + + return { + code: 200, + data: { + keyPair: { + privateKey: privateKey, + publicKey: publicKey + }, + publicKeySignature: signatureV2, + publicKeySignatureV2: signatureV2, + expiresAt: expiresAt, + refreshedAfter: refreshedAfter + } + } +} + +async function bulkLookup(usernames) { + if (!Array.isArray(usernames)) { + throw new DefaultError(400, "Invalid payload. Array of strings expected.") + } + + if (usernames.length > 10) { + throw new DefaultError(400, "Too many usernames provided (max 10).") + } + + if (usernames.length === 0) { + return [] + } + + const users = await userRepository.getUsersByNames(usernames) + + return users.map(u => ({ + id: u.uuid.replace(/-/g, ""), + name: u.username + })) +} + +async function getLegacyProfile(username) { + const user = await userRepository.getUuidAndUsername(username) + + if (!user) { + return null + } + + return { + id: user.uuid.replace(/-/g, ""), + name: user.username + } +} + +async function getNameUUIDs(username, dateInput) { + let profile + + if (!dateInput || dateInput == 0) { + profile = await userRepository.getProfileByUsername(username) + } else { + const targetDate = new Date(Number(dateInput)).toISOString() + profile = await userRepository.getProfileByHistory(username, targetDate) + } + + if (!profile) { + throw new DefaultError(404, "Couldn't find any profile with that name") + } + + return { + code: 200, + data: { + id: profile.uuid.replace(/-/g, ""), + name: profile.username + } + } +} + +async function getPlayerUsernamesHistory(uuid) { + const dashedUuid = utils.addDashesToUUID(uuid) + const history = await userRepository.getNameHistory(dashedUuid) + if (!history || history.length === 0) { + throw new DefaultError(404, "User not found") + } + + return history.map(entry => { + const cleanEntry = { + name: entry.username + } + + if (entry.changedAt) { + const dateObj = new Date(entry.changedAt) + if (!isNaN(dateObj.getTime())) { + cleanEntry.changedToAt = dateObj.getTime() + } + } + return cleanEntry + }) +} + +module.exports = { + banUser, + showCape, + hideCape, + unbanUser, + resetSkin, + isBlocked, + bulkLookup, + blockPlayer, + getNameUUIDs, + unblockPlayer, + getPrivileges, + getPlayerBans, + changeUsername, + getPreferences, + getBlockedUuids, + getLegacyProfile, + addProfileAction, + getPlayerActions, + updatePrivileges, + getPlayerProperty, + addPlayerProperty, + updatePreferences, + getSettingsSchema, + getPlayerBanStatus, + removeProfileAction, + getPlayerProperties, + updatePlayerProperty, + deletePlayerProperty, + getPlayerCertificate, + savePlayerCertificate, + clearAllPlayerActions, + getPlayerNameChangeStatus, + getPlayerUsernamesHistory, + deleteExpiredCertificates, + fetchOrGenerateCertificate, +} \ No newline at end of file