Introduces zod-based validation schemas for Minecraft and Mojang API endpoints. Refactors texture route to support hash-based file serving and removes the old static texture route. Updates database schema for player properties and adds an event to clean expired certificates. Improves ValidationError formatting, adjusts skin/cape URL construction, and adds SSRF protection for skin uploads.
539 lines
16 KiB
JavaScript
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,
|
|
} |