Yggdrasil/services/userService.js
azures04 e8f58e63cd Refactor texture handling and update route structure
Moved sessionserver routes to correct directory and removed subdirectory logic from texture file lookup. Updated texture URLs to remove leading slashes and fixed endpoint concatenation. Added default textures for Alex and Steve.
2025-12-28 22:22:54 +01:00

539 lines
16 KiB
JavaScript

const fs = require("node:fs/promises")
const path = require("node:path")
const util = require("node:util")
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)
}
module.exports = {
banUser,
showCape,
hideCape,
unbanUser,
resetSkin,
isBlocked,
bulkLookup,
uploadSkin,
blockPlayer,
getNameUUIDs,
unblockPlayer,
getPrivileges,
getPlayerBans,
changeUsername,
getPreferences,
getBlockedUuids,
registerTexture,
getLegacyProfile,
addProfileAction,
getPlayerActions,
updatePrivileges,
getPlayerProperty,
addPlayerProperty,
updatePreferences,
getSettingsSchema,
getPlayerBanStatus,
removeProfileAction,
getPlayerProperties,
updatePlayerProperty,
deletePlayerProperty,
getPlayerCertificate,
savePlayerCertificate,
clearAllPlayerActions,
getPlayerNameChangeStatus,
getPlayerUsernamesHistory,
deleteExpiredCertificates,
fetchOrGenerateCertificate,
}