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