Yggdrasil/services/userService.js
2026-01-24 01:42:30 +01:00

611 lines
18 KiB
JavaScript

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 authService = require("./authService")
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 getSkins(uuid) {
try {
const rawSkins = await userRepository.getSkins(uuid)
return {
code: 200,
data: rawSkins.map(r => ({
id: r.textureUuid,
state: r.isSelected == 1 ? "ACTIVE" : "INACTIVE",
url: r.url,
variant: r.variant || "CLASSIC"
}))
}
} catch (error) {
throw error
}
}
async function getCapes(uuid) {
try {
const rawCapes = await userRepository.getCapes(uuid)
return {
code: 200,
data: rawCapes.map(r => ({
id: r.textureUuid,
state: r.isSelected == 1 ? "ACTIVE" : "INACTIVE",
url: r.url,
alias: r.alias || "LentiaCustomCape"
}))
}
} catch (error) {
throw error
}
}
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 targetDir = path.join(TEXTURES_DIR)
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,
getCapes,
showCape,
hideCape,
getSkins,
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,
}