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.
This commit is contained in:
Gilles Lazures 2025-12-28 07:15:24 +01:00
parent 228345c859
commit 5dd1de1521
32 changed files with 1235 additions and 62 deletions

View File

@ -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
module.exports = ServiceError

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.")

View File

@ -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.")

View File

@ -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.")

View File

@ -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.")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ router.get("", (req, res) => {
}
]
}
res.status(200).json(publicKeys)
return res.status(200).json(publicKeys)
})
module.exports = router

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"])
}
}
}

View File

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

435
services/userService.js Normal file
View File

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