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 getPlayerPropertyByValue(key, value) { return await userRepository.getPlayerPropertyByValue(key, value) } 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, getPlayerPropertyByValue, getPlayerNameChangeStatus, getPlayerUsernamesHistory, deleteExpiredCertificates, fetchOrGenerateCertificate, }