Introduces Discord OAuth2 integration for account association and login, including new routes for linking, unlinking, and authenticating via Discord. Adds supporting services, repositories, and schema validation for the OAuth2 flow. Refactors database schema and queries for consistency, and updates dependencies to include required OAuth2 libraries.
573 lines
17 KiB
JavaScript
573 lines
17 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 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,
|
|
} |