Initial project structure and core files

Add base project files including environment example, license, README, .gitignore, error classes, ESLint config, database modules, texture assets, repositories, routes, schemas, services, and server entry point. This establishes the foundational structure for a Yggdrasil-compatible REST API with modular error handling, database setup, and route organization.
This commit is contained in:
2026-01-05 04:42:39 +01:00
commit 587146d322
112 changed files with 8540 additions and 0 deletions

145
services/adminService.js Normal file
View File

@@ -0,0 +1,145 @@
const userRepository = require("../repositories/userRepository")
const adminRepository = require("../repositories/adminRepository")
const bcrypt = require("bcryptjs")
const { DefaultError } = require("../errors/errors")
const ADMIN_JWT_SECRET = process.env.ADMIN_JWT_SECRET || "udjJLGCOq7m3NmGpdVLJ@#"
async function registerAdmin(username, plainPassword, permissions = []) {
const hashedPassword = await bcrypt.hash(plainPassword, 10)
const result = await adminRepository.createAdmin(username, hashedPassword)
if (permissions.length > 0) {
for (const perm of permissions) {
await adminRepository.assignPermission(result.id, perm)
}
}
return { id: result.id, username, message: "Administrateur créé avec succès." }
}
async function checkAdminAccess(adminId, requiredPermission) {
if (!adminId || !requiredPermission) {
throw new DefaultError(400, "ID administrateur ou permission manquante.")
}
return await adminRepository.hasPermission(adminId, requiredPermission)
}
async function changeAdminPassword(adminId, newPlainPassword) {
if (!newPlainPassword || newPlainPassword.length < 6) {
throw new DefaultError(400, "Le mot de passe doit contenir au moins 6 caractères.")
}
const hashed = await bcrypt.hash(newPlainPassword, 10)
return await adminRepository.updateAdminPassword(adminId, hashed)
}
async function getAdminProfile(adminId) {
const admin = await adminRepository.getAdminById(adminId)
if (!admin) {
throw new DefaultError(404, "Administrateur introuvable.")
}
const permissions = await adminRepository.getAdminPermissions(adminId)
return {
id: admin.id,
username: admin.username,
createdAt: admin.createdAt,
permissions: permissions
}
}
async function grantPermission(adminId, permissionKey) {
return await adminRepository.assignPermission(adminId, permissionKey)
}
async function revokePermission(adminId, permissionKey) {
return await adminRepository.revokePermission(adminId, permissionKey)
}
async function loginAdmin(username, password) {
const admin = await adminRepository.getAdminByUsername(username)
if (!admin) {
throw new DefaultError(403, "Invalid credentials.")
}
const isMatch = await bcrypt.compare(password, admin.password)
if (!isMatch) {
throw new DefaultError(403, "Invalid credentials.")
}
const token = jwt.sign(
{ id: admin.id, username: admin.username, type: "admin" },
ADMIN_JWT_SECRET,
{ expiresIn: "8h" }
)
return { token }
}
function hasPermission(requiredPermission) {
return async (req, res, next) => {
try {
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw new DefaultError(401, "Authentification admin requise.")
}
const token = authHeader.split(" ")[1]
const decoded = jwt.verify(token, ADMIN_JWT_SECRET)
if (decoded.type !== "admin") {
throw new DefaultError(403, "Invalid token.")
}
const hasAccess = await adminService.checkAdminAccess(decoded.id, requiredPermission)
if (!hasAccess) {
throw new DefaultError(403, `Missing permission : ${requiredPermission}`)
}
req.admin = decoded
next()
} catch (err) {
if (err.name === "JsonWebTokenError") {
return next(new DefaultError(401, "Invalid session."))
}
next(err)
}
}
}
async function uploadCape(fileObject, alias = null) {
const buffer = await fs.readFile(fileObject.path)
const hash = crypto.createHash("sha256").update(buffer).digest("hex")
const existing = await userRepository.getTextureByHash(hash)
if (existing) throw new DefaultError(409, "Cette cape existe déjà.")
const textureUrl = `/texture/${hash}`
await userRepository.createTexture(crypto.randomUUID(), hash, 'CAPE', textureUrl, alias)
return { hash, url: textureUrl }
}
async function deleteGlobalCape(hash) {
const success = await userRepository.deleteTexture(hash)
if (!success) throw new DefaultError(404, "Cape introuvable.")
return { message: "Texture supprimée globalement." }
}
module.exports = {
loginAdmin,
uploadCape,
registerAdmin,
getAdminProfile,
grantPermission,
revokePermission,
checkAdminAccess,
changeAdminPassword,
hasPermission,
}

289
services/authService.js Normal file
View File

@@ -0,0 +1,289 @@
const jwt = require("jsonwebtoken")
const utils = require("../modules/utils")
const bcrypt = require("bcryptjs")
const crypto = require("node:crypto")
const userRepository = require("../repositories/userRepository")
const authRepository = require("../repositories/authRepository")
const certsManager = require("../modules/certificatesManager")
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 }) {
const availability = await checkUsernameAvailability(username)
if (availability.status === "DUPLICATE") {
throw new DefaultError(409, "Username taken.", "ForbiddenOperationException")
}
if (availability.status === "NOT_ALLOWED") {
throw new DefaultError(400, availability.message || "Username invalid.", "InvalidUsernameException")
}
if (email) {
try {
await authRepository.getUser(email)
throw new DefaultError(409, "E-Mail taken.", "ForbiddenOperationException")
} catch (error) {
if (error.code !== 404) throw error
}
}
const userRegistration = await authRepository.register(email || "", username, password)
const { uuid } = userRegistration
const resolvedCountry = registrationCountry || await utils.getRegistrationCountryFromIp(clientIp)
await userRepository.addPropertyToPlayer("registrationCountry", resolvedCountry || "UNKNOWN", uuid)
await userRepository.addPropertyToPlayer("userPreferredLanguage", preferredLanguage || "fr-FR", uuid)
return { code: 200, message: "User created successfully", uuid }
}
async function authenticate({ identifier, password, clientToken, requireUser }) {
let userResult
try {
userResult = await authRepository.getUser(identifier, true)
} catch (error) {
if (error.code === 404) {
throw new DefaultError(403, "Invalid credentials. Invalid username or password.", "ForbiddenOperationException")
}
throw error
}
const passwordValidationProcess = await bcrypt.compare(password, userResult.user.password)
if (!passwordValidationProcess) {
throw new DefaultError(403, "Invalid credentials. Invalid username or password.", "ForbiddenOperationException")
}
delete userResult.user.password
const $clientToken = clientToken || crypto.randomUUID()
const accessToken = jwt.sign({
uuid: userResult.user.uuid,
username: userResult.user.username,
clientToken: $clientToken,
}, keys.authenticationKeys.private, {
subject: userResult.user.uuid,
issuer: "LentiaYggdrasil",
expiresIn: "1d",
algorithm: "RS256"
})
const clientSessionProcess = await authRepository.insertClientSession(accessToken, $clientToken, userResult.user.uuid)
const userObject = {
clientToken: clientSessionProcess.clientToken,
accessToken: clientSessionProcess.accessToken,
availableProfiles: [{
name: userResult.user.username,
id: userResult.user.uuid,
}],
selectedProfile: {
name: userResult.user.username,
id: userResult.user.uuid,
}
}
if (requireUser) {
try {
const propertiesRequest = await authRepository.getPlayerProperties(userResult.user.uuid)
userObject.user = {
username: userResult.user.username,
properties: propertiesRequest.properties
}
} catch (error) {
if (error.code !== 404) throw error
userObject.user = {
username: userResult.user.username,
properties: []
}
}
}
return {
code: 200,
response: userObject
}
}
async function refreshToken({ previousAccessToken, clientToken, requireUser }) {
let sessionCheck
try {
sessionCheck = await authRepository.getClientSession(previousAccessToken, clientToken)
} catch (error) {
throw new DefaultError(403, "Invalid token or session expired.", "ForbiddenOperationException")
}
const uuid = sessionCheck.session.uuid
const userResult = await authRepository.getUser(uuid, true)
delete userResult.user.password
await authRepository.invalidateClientSession(previousAccessToken, clientToken)
const $clientToken = clientToken || crypto.randomUUID()
const newAccessToken = jwt.sign({
uuid: userResult.user.uuid,
username: userResult.user.username,
clientToken: $clientToken,
}, keys.authenticationKeys.private, {
subject: userResult.user.uuid,
issuer: "LentiaYggdrasil",
expiresIn: "1d",
algorithm: "RS256"
})
const clientSessionProcess = await authRepository.insertClientSession(newAccessToken, $clientToken, userResult.user.uuid)
const userObject = {
clientToken: clientSessionProcess.clientToken,
accessToken: clientSessionProcess.accessToken,
selectedProfile: {
name: userResult.user.username,
id: userResult.user.uuid,
}
}
if (requireUser) {
try {
const propertiesRequest = await authRepository.getPlayerProperties(userResult.user.uuid)
userObject.user = {
username: userResult.user.username,
properties: propertiesRequest.properties
}
} catch (error) {
if (error.code !== 404) throw error
userObject.user = {
username: userResult.user.username,
properties: []
}
}
}
return {
code: 200,
response: userObject
}
}
async function validate({ accessToken, clientToken }) {
if (clientToken !== undefined) {
await authRepository.validateClientSession(accessToken, clientToken)
} else {
await authRepository.validateClientSessionWithoutClientToken(accessToken)
}
return { code: 204 }
}
async function invalidate({ accessToken, clientToken }) {
await authRepository.invalidateClientSession(accessToken, clientToken)
return { code: 204 }
}
async function signout({ uuid }) {
await authRepository.revokeAccessTokens(uuid)
return { code: 204 }
}
async function verifyAccessToken({ accessToken }) {
try {
if (!accessToken) {
throw new DefaultError(400, "Token is missing.")
}
const decoded = jwt.verify(accessToken, keys.authenticationKeys.public, {
algorithms: ["RS256"],
issuer: "LentiaYggdrasil"
})
const clientToken = decoded.clientToken
if (!clientToken) {
throw new DefaultError(403, "Token format invalid (missing clientToken).")
}
try {
await authRepository.validateClientSession(accessToken, clientToken)
} catch (error) {
throw new DefaultError(401, "Session has been revoked or invalidated.")
}
return {
code: 200,
user: {
uuid: decoded.sub,
username: decoded.username
},
session: {
clientToken
}
}
} catch (error) {
if (error instanceof DefaultError) throw error
if (error.name === "TokenExpiredError") {
throw new DefaultError(401, "Token has expired.")
}
throw new DefaultError(500, "Internal Verification Error", error.toString())
}
}
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,
invalidate,
registerUser,
authenticate,
refreshToken,
verifyAccessToken,
checkUsernameAvailability
}

39
services/serverService.js Normal file
View File

@@ -0,0 +1,39 @@
const certs = require("../modules/certificatesManager")
const utils = require("../modules/utils")
const package = require("../package.json")
function getServerMetadata(hostname) {
const keys = certs.getKeys()
const publicKeyPEM = keys.profilePropertyKeys.public
const serverMeta = {
meta: {
serverName: process.env.SERVER_NAME || "Yggdrasil Server",
implementationName: package.name,
implementationVersion: package.version,
"feature.legacy_skin_api": utils.isTrueFromDotEnv("SUPPORT_LEGACY_SKIN_API"),
"feature.no_mojang_namespace": utils.isTrueFromDotEnv("SUPPORT_MOJANG_FALLBACK"),
"feature.enable_mojang_anti_features": utils.isTrueFromDotEnv("SUPPORT_MOJANG_TELEMETRY_BLOCKER"),
"feature.enable_profile_key": utils.isTrueFromDotEnv("SUPPORT_PROFILE_KEY"),
"feature.username_check": utils.isTrueFromDotEnv("SUPPORT_ONLY_DEFAULT_USERNAME"),
links: {
homepage: process.env.HOMEPAGE_URL || `http://${hostname}`,
}
},
skinDomains: [
hostname,
`.${hostname}`
],
signaturePublickey: publicKeyPEM
}
if (utils.isTrueFromDotEnv("SUPPORT_REGISTER")) {
serverMeta.meta.links.register = process.env.REGISTER_ENDPOINT || `http://${hostname}/register`
}
return serverMeta
}
module.exports = {
getServerMetadata
}

212
services/sessionsService.js Normal file
View File

@@ -0,0 +1,212 @@
const utils = require("../modules/utils")
const authRepository = require("../repositories/authRepository")
const sessionRepository = require("../repositories/sessionsRepository")
const { DefaultError } = require("../errors/errors")
async function registerLegacySession({ uuid, sessionId }) {
try {
await sessionRepository.insertLegacyClientSessions(sessionId, uuid)
return { code: 200 }
} catch (error) {
if (error instanceof DefaultError) throw error
throw new DefaultError(500, "Internal Server Error", error.toString())
}
}
async function validateLegacySession({ name, sessionId }) {
let userResult
try {
userResult = await authRepository.getUser(name)
} catch (error) {
if (error.code === 404) {
throw error
}
throw error
}
try {
await sessionRepository.validateLegacyClientSession(sessionId, userResult.user.uuid)
return { code: 200 }
} catch (error) {
if (error.code === 404) {
throw new DefaultError(403, "Invalid session.", "ForbiddenOperationException")
}
throw error
}
}
async function getBlockedServers() {
try {
return await sessionRepository.getBlockedServers()
} catch (error) {
throw new DefaultError(500, "Unable to fetch blocked servers.", error.toString())
}
}
async function getProfile({ uuid, unsigned = false }) {
let userResult, $uuid = utils.addDashesToUUID(uuid)
try {
userResult = await authRepository.getUser($uuid, false)
} catch (error) {
if (error.code === 404) {
return { code: 204, message: "User not found" }
}
throw error
}
const dbUser = userResult.user
const username = dbUser.username
const cleanUuid = dbUser.uuid.replace(/-/g, "")
const [skinResult, capeResult, actionsResult] = await Promise.all([
sessionRepository.getActiveSkin(dbUser.uuid).catch(() => ({ data: null })),
sessionRepository.getActiveCape(dbUser.uuid).catch(() => ({ data: null })),
sessionRepository.getProfileActionsList(dbUser.uuid).catch(() => ({ data: [] }))
])
const activeSkin = skinResult.data
const activeCape = capeResult.data
const profileActions = actionsResult.data || []
const isSkinBanned = profileActions.includes("USING_BANNED_SKIN")
const hasValidSkin = activeSkin && !isSkinBanned
const hasValidCape = !!activeCape
const skinNode = hasValidSkin ? {
url: (process.env.TEXTURES_ENDPOINTS || `http://localhost:${process.env.WEB_PORT}/textures`) + activeSkin.url,
metadata: activeSkin.variant === "SLIM" ? { model: "slim" } : undefined
} : undefined
const capeNode = hasValidCape ? {
url: (process.env.TEXTURES_ENDPOINTS || `http://localhost:${process.env.WEB_PORT}/textures`) + activeCape.url
} : undefined
const texturesObject = {
...(skinNode && { SKIN: skinNode }),
...(capeNode && { CAPE: capeNode })
}
const texturePayload = {
timestamp: Date.now(),
profileId: cleanUuid,
profileName: username,
signatureRequired: !unsigned,
textures: texturesObject
}
const payloadJson = JSON.stringify(texturePayload)
const base64Value = Buffer.from(payloadJson).toString("base64")
const signature = unsigned ? null : utils.signProfileData(base64Value)
const propertyNode = {
name: "textures",
value: base64Value,
...(signature && { signature: signature })
}
return {
code: 200,
data: {
id: cleanUuid,
name: username,
properties: [propertyNode],
profileActions: profileActions
}
}
}
async function joinServer({ accessToken, selectedProfile, clientToken, serverId, ip }) {
try {
await authRepository.validateClientSession(accessToken, clientToken)
} catch (error) {
throw new DefaultError(403, "Invalid access token", "ForbiddenOperationException")
}
const existingSession = await sessionRepository.getServerSessionByUuid(selectedProfile)
if (existingSession && existingSession.serverId !== serverId) {
throw new DefaultError(403, "Already logged in on another server.", "ForbiddenOperationException")
}
await sessionRepository.saveServerSession(selectedProfile, accessToken, serverId, ip)
return { code: 204 }
}
async function hasJoinedServer({ username, serverId, ip }) {
let userResult
try {
userResult = await authRepository.getUser(username, false)
} catch (error) {
if (error.code === 404) return { code: 204, message: "User not found" }
throw error
}
const { uuid } = userResult.user
const joinCheck = await sessionRepository.getServerSession(uuid, serverId)
if (joinCheck.code !== 200 || !joinCheck.valid) {
return { code: 204, message: "Join verification failed" }
}
if (ip && ip.trim() !== "" && joinCheck.ip !== ip) {
return { code: 204, message: "Invalid IP address" }
}
return await getProfile({
uuid: uuid,
unsigned: false
})
}
async function joinLegacyServer({ name, sessionId, serverId }) {
try {
await validateLegacySession({ name, sessionId })
} catch (error) {
throw new DefaultError(403, "Bad login", "ForbiddenOperationException")
}
const userResult = await authRepository.getUser(name)
const uuid = userResult.user.uuid
await sessionRepository.saveServerSession(uuid, sessionId, serverId, "0.0.0.0")
return { code: 200, message: "OK" }
}
async function getActiveSkin({ username }) {
try {
const dbUser = await authRepository.getUser(username)
const activeSkin = await sessionRepository.getActiveSkin(dbUser.user.uuid)
return activeSkin
} catch (error) {
if (!(error instanceof DefaultError)) {
throw new DefaultError(400, "Bad Request", error.toString())
}
throw error
}
}
async function getActiveCape({ username }) {
try {
const dbUser = await authRepository.getUser(username)
const activeCape = await sessionRepository.getActiveCape(dbUser.user.uuid)
return activeCape
} catch (error) {
if (!(error instanceof DefaultError)) {
throw new DefaultError(400, "Bad Request", error.toString())
}
throw error
}
}
module.exports = {
getProfile,
joinServer,
getActiveCape,
getActiveSkin,
hasJoinedServer,
joinLegacyServer,
getBlockedServers,
registerLegacySession,
validateLegacySession,
}

568
services/userService.js Normal file
View File

@@ -0,0 +1,568 @@
const fs = require("node:fs/promises")
const path = require("node:path")
const util = require("node:util")
const bcrypt = require("bcryptjs")
const logger = require("../modules/logger")
const crypto = require("node:crypto")
const ssrfcheck = require("ssrfcheck")
const certsManager = require("../modules/certificatesManager")
const userRepository = require("../repositories/userRepository")
const { DefaultError } = require("../errors/errors")
const generateKeyPairAsync = util.promisify(crypto.generateKeyPair)
const TEMP_DIR = path.join(process.cwd(), "data", "temp")
const TEXTURES_DIR = path.join(process.cwd(), "data", "textures")
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
})
}
async function registerTexture(hash, type, url, alias = null) {
const existingTexture = await userRepository.getTextureByHash(hash)
if (existingTexture) {
return {
code: 200,
textureUuid: existingTexture.uuid,
isNew: false
}
}
const newUuid = crypto.randomUUID()
await userRepository.createTexture(newUuid, hash, type, url, alias)
return {
code: 201,
textureUuid: newUuid,
isNew: true
}
}
async function uploadSkin(uuid, fileObject, variant) {
if (!fileObject || !fileObject.path) {
throw new DefaultError(400, "No skin file provided.")
}
const tempPath = fileObject.path
let buffer
try {
buffer = await fs.readFile(tempPath)
if (buffer.length < 8 || !buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))) {
throw new DefaultError(400, "Invalid file format. Only PNG is allowed.")
}
try {
const width = buffer.readUInt32BE(16)
const height = buffer.readUInt32BE(20)
if (width !== 64 || (height !== 64 && height !== 32)) {
throw new DefaultError(400, `Invalid skin dimensions. Got ${width}x${height}, expected 64x64 or 64x32.`)
}
} catch (e) {
throw new DefaultError(400, "Could not read image dimensions.")
}
const hash = crypto.createHash("sha256").update(buffer).digest("hex")
const existingTexture = await userRepository.getTextureByHash(hash)
if (!existingTexture) {
const subDir = hash.substring(0, 2)
const targetDir = path.join(TEXTURES_DIR, subDir)
const targetPath = path.join(targetDir, hash)
await fs.mkdir(targetDir, { recursive: true })
await fs.writeFile(targetPath, buffer)
const newTextureUuid = crypto.randomUUID()
const textureUrl = `texture/${hash}`
await userRepository.createTexture(newTextureUuid, hash, 'SKIN', textureUrl, null)
}
const validVariant = (variant === "slim") ? "slim" : "classic"
await userRepository.setSkin(uuid, hash, validVariant)
return { code: 200, message: "Skin uploaded successfully" }
} catch (error) {
throw error
} finally {
await fs.unlink(tempPath).catch(() => {})
}
}
async function uploadSkinFromUrl(uuid, url, variant) {
if (!url) throw new DefaultError(400, "Missing 'url' parameter.")
if (ssrfcheck.isSSRFSafeURL(url)) throw new DefaultError(400, "Bad request", null)
let buffer
try {
const response = await fetch(url)
if (!response.ok) throw new Error("Fetch failed")
const arrayBuffer = await response.arrayBuffer()
buffer = Buffer.from(arrayBuffer)
} catch (err) {
throw new DefaultError(400, "Could not download skin from the provided URL.")
}
const tempFileName = crypto.randomBytes(16).toString("hex")
const tempPath = path.join(TEMP_DIR, tempFileName)
await fs.mkdir(TEMP_DIR, { recursive: true })
await fs.writeFile(tempPath, buffer)
return await uploadSkin(uuid, { path: tempPath }, variant)
}
async function changePassword(uuid, newPlainPassword) {
if (!newPlainPassword || newPlainPassword.length < 6) {
throw new DefaultError(400, "Password is too short. Minimum 6 characters.")
}
const salt = await bcrypt.genSalt(10)
const hashedPassword = await bcrypt.hash(newPlainPassword, salt)
return await userRepository.updatePassword(uuid, hashedPassword)
}
async function grantCape(uuid, hash) {
const texture = await userRepository.getTextureByHash(hash)
if (!texture) {
throw new DefaultError(404, "Texture de cape introuvable dans la base globale.")
}
return await userRepository.addCapeToPlayer(uuid, hash)
}
async function removeCape(uuid, hash) {
return await userRepository.removeCapeFromPlayer(uuid, hash)
}
module.exports = {
banUser,
showCape,
hideCape,
unbanUser,
resetSkin,
isBlocked,
grantCape,
bulkLookup,
uploadSkin,
removeCape,
blockPlayer,
getNameUUIDs,
unblockPlayer,
getPrivileges,
getPlayerBans,
changeUsername,
getPreferences,
changePassword,
getBlockedUuids,
registerTexture,
getLegacyProfile,
addProfileAction,
getPlayerActions,
updatePrivileges,
getPlayerProperty,
addPlayerProperty,
updatePreferences,
uploadSkinFromUrl,
getSettingsSchema,
getPlayerBanStatus,
removeProfileAction,
getPlayerProperties,
updatePlayerProperty,
deletePlayerProperty,
getPlayerCertificate,
savePlayerCertificate,
clearAllPlayerActions,
getPlayerNameChangeStatus,
getPlayerUsernamesHistory,
deleteExpiredCertificates,
fetchOrGenerateCertificate,
}