Compare commits

..

91 Commits

Author SHA1 Message Date
e242d78864 Update oauth2Service.js 2026-01-24 22:22:33 +01:00
33d54e655a Create success.html 2026-01-24 21:48:14 +01:00
39a566a2f5 Update [name].js 2026-01-24 03:32:36 +01:00
66b3268f8e Update userService.js 2026-01-24 01:42:30 +01:00
9db4d62d78 Update [name].js 2026-01-24 01:37:08 +01:00
492f012519 Update active.js 2026-01-24 00:57:58 +01:00
c5ef3d8181 Update active.js 2026-01-24 00:46:58 +01:00
bf17261fdf Update active.js 2026-01-24 00:46:11 +01:00
bb7b2328ab Update server.js 2026-01-24 00:14:05 +01:00
b6ad724602 Update userRepository.js 2026-01-23 23:13:03 +01:00
dbcb436c9f Refactor skin selection logic and remove DB trigger
Removed the 'unique_active_skin' database trigger from setupDatabase and updated setSkin in userRepository to handle skin selection logic in application code. This change centralizes the logic for ensuring only one active skin per user, improving maintainability and error handling.
2026-01-23 23:07:10 +01:00
aea0b7b016 Delete skins.js 2026-01-23 22:58:02 +01:00
11f930c3d8 Update skins.js 2026-01-23 22:56:37 +01:00
b82c06165a Update skins.js 2026-01-23 22:54:54 +01:00
48ea9f708b Expand skin upload schema and simplify texture storage
Updated the skin upload schema to accept both JSON and multipart/form-data content types, and to allow requests without a URL. Simplified the texture storage path in userService.js by removing subdirectory partitioning based on hash.
2026-01-23 22:52:00 +01:00
3f64c2c897 Update userService.js 2026-01-23 21:49:47 +01:00
c1dbfc58aa Update userService.js 2026-01-23 21:48:45 +01:00
65f2f26325 Update userService.js 2026-01-23 21:42:18 +01:00
f9270c1f9c Update userService.js 2026-01-23 21:41:32 +01:00
6f3231aee7 Update userService.js 2026-01-23 21:40:06 +01:00
4a0fcf65f3 Update userRepository.js 2026-01-23 21:38:54 +01:00
cc8e581cbe Update userRepository.js 2026-01-23 21:38:21 +01:00
b4ae5fa4d9 Update index.js 2026-01-23 21:33:13 +01:00
7018e8c497 Update userRepository.js 2026-01-23 21:30:41 +01:00
8e0a1ab673 Update userRepository.js 2026-01-23 21:29:37 +01:00
e089957db7 Update userRepository.js 2026-01-23 21:28:54 +01:00
c8812c5153 Add getSkins and getCapes methods to user modules
Introduces getSkins and getCapes functions in both userRepository and userService to retrieve player skins and capes from the database. These methods return structured data for use in higher-level application logic.
2026-01-23 21:27:45 +01:00
66db52e7c8 Update server.js 2026-01-19 20:37:12 +01:00
21fd655a1f Update register.js 2026-01-19 20:35:58 +01:00
074a5dc04b Update register.js 2026-01-19 20:35:19 +01:00
6cf822e603 Update register.js 2026-01-19 20:34:51 +01:00
535c21b971 register 2026-01-19 20:34:07 +01:00
44a9ff12b1 register 2026-01-19 20:31:13 +01:00
f5df40f264 Update register.html 2026-01-19 20:28:56 +01:00
caa318f2c7 register 2026-01-19 20:26:58 +01:00
d6ac0fd8b4 r 2026-01-19 20:26:05 +01:00
6666025726 Update register.html 2026-01-19 20:24:30 +01:00
57aeb47ed1 Update register.html 2026-01-19 20:19:19 +01:00
4975f7e191 Add static registration page and static file serving
Added a new registration HTML page under data/static/register.html and introduced a static file serving route in routes/static.js. Minor adjustments were made to authRepository.js and userRepository.js to update module imports. This enables serving static assets and provides a registration UI.
2026-01-19 02:47:55 +01:00
c96e728228 fixed cape upload 2026-01-18 23:47:29 +01:00
308c3b5479 fixed cape upload 2026-01-18 23:34:40 +01:00
086468405a fixed cape upload 2026-01-18 23:32:32 +01:00
0b8ab9f194 Update databaseGlobals.js 2026-01-18 23:18:58 +01:00
01e0b94d35 Add composite primary key to player_capes and update messages
Added a composite primary key (playerUuid, assetHash) to the player_capes table for better data integrity. Updated userRepository.js to use English messages for cape assignment and removal operations.
2026-01-18 23:16:11 +01:00
0049ae8ec6 Update server.js 2026-01-18 23:10:31 +01:00
9469822ef9 Update [hash].js 2026-01-18 23:10:13 +01:00
99598f2b7a Update [hash].js 2026-01-18 23:06:40 +01:00
85ba96cb6f Update [hash].js 2026-01-18 23:05:59 +01:00
ac6eca3f31 Update [hash].js 2026-01-18 23:03:05 +01:00
53e58bdb30 Log header validation result in server.js
Added a console.log statement to output the result of header validation in the request handler. Also fixed a minor formatting issue in the cape texture schema file.
2026-01-18 23:00:59 +01:00
adddbadbf3 Update [hash].js 2026-01-18 22:58:43 +01:00
b15595ecb1 Update [hash].js 2026-01-18 22:54:03 +01:00
aacfca136c Update databaseGlobals.js 2026-01-18 21:52:01 +01:00
9ac904ba08 Update capes.js 2026-01-18 21:39:35 +01:00
fab7066ee3 Update [hash].js 2026-01-18 21:38:39 +01:00
86490ebd2d Update [hash].js 2026-01-18 21:35:55 +01:00
dd04b2014a Update server.js 2026-01-18 21:30:42 +01:00
83d46425b3 Update adminService.js 2026-01-18 21:26:40 +01:00
7eac539598 Update adminService.js 2026-01-18 21:25:40 +01:00
9a6b119d37 Update adminService.js 2026-01-18 21:24:35 +01:00
d8318f874f Update adminService.js 2026-01-18 21:23:56 +01:00
487bd08141 Update adminService.js 2026-01-18 21:21:42 +01:00
30a0ac3927 Update adminService.js 2026-01-18 21:19:32 +01:00
21cf0f49e6 Update server.js 2026-01-18 21:18:50 +01:00
a31be145cc Update adminService.js 2026-01-18 21:04:08 +01:00
0e0f176e50 Update login.js 2026-01-18 21:01:46 +01:00
86349bcf4f Add admin login and password change endpoints
Introduces POST /login and PATCH /password routes for admin authentication and password management. Adds corresponding schema validation for login and password change, enforces stricter password requirements, and updates adminService with JWT-based profile retrieval and improved token handling.
2026-01-18 19:38:24 +01:00
d590ecce6d Fixed '_' char in column name in admin repo 2026-01-18 19:04:24 +01:00
617e60cf75 Fix admin permissions table name and seed default permissions
Corrects the table name from 'apiAdministrators_permissions' to 'apiAdministratorsPermissions' in adminRepository.js for consistency with the database schema. Also seeds default permissions into 'apiAdministratorsPermissionsList' during database setup.
2026-01-18 18:55:22 +01:00
f1a482c58f Moved cosmectics schemas to valid location 2026-01-18 18:27:12 +01:00
69ad2c8f83 Update server.js 2026-01-18 18:05:23 +01:00
71627c7041 Centralize and standardize database error handling
Introduced a new handleDBError utility in modules/utils.js to centralize database error logging and throwing. Refactored all repositories to use this utility, replacing repetitive error handling and logger calls with a single function call for improved maintainability and consistency.
2026-01-18 18:04:10 +01:00
88f8ee57e1 Update userRepository.js 2026-01-18 15:45:22 +01:00
bfc98243d0 Update databaseGlobals.js 2026-01-18 15:42:24 +01:00
c6afafca2a Refactor cape selection logic and remove trigger
Removed the 'unique_active_cape' database trigger and updated the showCape function to manually ensure only one cape is selected per player. This change centralizes the selection logic in application code for better maintainability and error handling.
2026-01-18 15:40:00 +01:00
b6b7cf7fe0 Update sessionsService.js 2026-01-14 23:56:31 +01:00
ffa23e35b0 Update .env.example 2026-01-11 21:15:40 +01:00
c5b6f6c107 Add Discord OAuth2 account linking and login support
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.
2026-01-11 21:03:12 +01:00
5b81f57adb Rename deleteGlobalCape to deleteCape
Refactored the function and route handler from deleteGlobalCape to deleteCape for consistency and clarity in naming. Updated all references accordingly.
2026-01-05 05:07:53 +01:00
bfad2a39c1 Add player action logging for admin operations
Introduces a new addPlayerAction method in adminRepository and logPlayerAction in adminService to record admin actions on player accounts. Updates relevant admin routes to log actions such as bans, unbans, forced name changes, and skin resets. Also improves error messages in adminService for consistency and clarity.
2026-01-05 05:06:06 +01:00
439094013d Add admin API, permissions, and player management routes
Introduces admin database tables, repository, and service for managing administrators and permissions. Adds new admin routes for banning players, managing cosmetics (capes), changing player passwords and usernames, and handling player textures. Updates user and session services to support admin actions and permission checks. Adds related schema validation for new endpoints.
2026-01-05 04:44:56 +01:00
da8ab9d488 Refactor API and schema paths, fix key usage and profile data
Renamed 'mojangapi' directories to 'api' for both routes and schemas to standardize API structure. Updated serverService to use the correct public key (profilePropertyKeys) for server metadata. Fixed sessionsService to return full skin and cape data arrays instead of just the first element.
2025-12-30 13:41:19 +01:00
36a9a0b193 fix sessionService 2025-12-30 07:40:21 +01:00
9b36c85974 Add legacy skin and cape routes, improve error handling
Introduces legacy routes for Minecraft skins and capes to support older endpoints. Enhances error handling in sessionsRepository for missing skins/capes, adds getActiveSkin and getActiveCape to sessionsService, and improves error logging in server.js.
2025-12-29 22:02:52 +01:00
80fb6c6cd4 Fix route path and UUID formatting in session service
Changed route definitions in index.js from "" to "/" for correct routing. Updated sessionsService.js to add dashes to UUID before querying user data, ensuring proper UUID formatting.
2025-12-29 01:21:21 +01:00
947192d997 Update package.json 2025-12-28 23:28:03 +01:00
3cd42103e5 Add legacy authentication and session routes
Introduces legacy endpoints for login, joinserver, and checkserver, along with their input validation schemas. Updates sessionsService with joinLegacyServer to support legacy session handling. This enables compatibility with legacy clients requiring these authentication flows.
2025-12-28 23:19:38 +01:00
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
a3eb5ee70c Added authlib support 2025-12-28 22:10:14 +01:00
a7cf6ad5a1 Update logger.js 2025-12-28 21:46:02 +01:00
064703878c Update .env.example 2025-12-28 21:43:40 +01:00
77 changed files with 2011 additions and 282 deletions

View File

@@ -1,5 +1,34 @@
WEB_PORT=3000
IS_PROD=TRUE
DATABASE_HOST=host
DATABASE_USER=username
DATABASE_PASSWORD=Password
#Config
IS_PROD=FALSE
WEB_PORT=8877
SUPPORT_REGISTER=TRUE
API_ADMIN_SECRET="oJs8XtVbgY485HTvFNrM@#"
#MariaDB
DATABASE_HOST="azures.fr"
DATABASE_USER="azures04"
DATABASE_PASSWORD="0YkeEkLLjBb@#"
DATABASE_NAME="modun"
#Mojang API
SUPPORT_UUID_TO_NAME_HISTORY=TRUE
#Authlib-Injector
SUPPORT_AUTHLIB_INJECTOR=TRUE
SUPPORT_LEGACY_SKIN_API=TRUE #[legacy_skin_api]
SUPPORT_MOJANG_FALLBACK=FALSE #[no_mojang_namespace]
SUPPORT_MOJANG_TELEMETRY_BLOCKER=TRUE #[enable_mojang_anti_features]
SUPPORT_PROFILE_KEY=TRUE #[enable_profile_key]
SUPPORT_ONLY_DEFAULT_USERNAME=true #[username_check]
REGISTER_ENDPOINT="/register"
HOMEPAGE_URL=
SERVER_NAME="Yggdrasil"
#Skin API
TEXTURES_ENDPOINTS=https://yggdrasil.azures.fr/textures
#Discord OAuth2
DISCORD_CLIENT_ID="DISCORD_CLIENT_ID"
DISCORD_CLIENT_SECRET="SUPER_SECRET"
DISCORD_ASSOCIATION_REDIRECT_URL="REMOTE_URL"
DISCORD_LOGIN_REDIRECT_URL="LOCAL_URL_FOR_LAUNCHERS"

1
.gitignore vendored
View File

@@ -134,3 +134,4 @@ dist
logs
tests
data/keys
tests

6
data/static/iziToast.min.js vendored Normal file

File diff suppressed because one or more lines are too long

43
data/static/register.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="
default-src 'self';
script-src 'self' https://cdnjs.cloudflare.com 'unsafe-inline';
style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com;
font-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com;
connect-src 'self' https://yggdrasil.azures.fr;
">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/hung1001/font-awesome-pro@4cac1a6/css/all.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/izitoast/1.4.0/css/iziToast.min.css">
<title>Registration</title>
</head>
<body>
<h1>
<i class="fad fa-shield-alt"></i>
Lentia Yggdrasil
</h1>
<h3>Page d'inscription</h3>
<hr>
<div>
<input type="email" placeholder="Adresse mail" id="email">
<input type="text" placeholder="Nom d'utilisateur" id="username">
<input type="password" placeholder="Mot de passe" id="password">
<button id="register">
Créer mon compte !
</button>
</div>
<script src="./iziToast.min.js"></script>
<script src="./register.js"></script>
<style>
div > input {
width: 50%;
}
</style>
</body>
</html>

29
data/static/register.js Normal file
View File

@@ -0,0 +1,29 @@
const button = document.querySelector("#register")
async function register(email, username, password) {
const response = await fetch("https://yggdrasil.azures.fr/register", {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify({
email,
username,
password
})
})
const json = await response.json()
if (json.code != 200) {
return iziToast.error({ title: json.error || "Erreur", message: json.message || "Erreur inconnue" })
} else {
console.log(json)
return iziToast.success({ title: "Succès", message: json.message })
}
}
button.addEventListener("click", () => {
const email = document.querySelector("#email").value
const username = document.querySelector("#username").value
const password = document.querySelector("#password").value
register(email, username, password)
})

25
data/static/success.html Normal file
View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/hung1001/font-awesome-pro@4cac1a6/css/all.css">
<title>Registration</title>
</head>
<body>
<h1>
<i class="fad fa-shield-alt"></i>
Lentia Yggdrasil
</h1>
<h3>Authentification réussie</h3>
<hr>
<div>
<p>
Vous pouvez dès à présent fermer cette page et retourner au launcher
</p>
</div>
</body>
</html>

BIN
data/textures/alex.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
data/textures/steve.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -3,7 +3,7 @@ const globals = require("globals")
module.exports = [
{
ignores: ["node_modules", "logs", "coverage", ".env", "*.log"],
ignores: ["node_modules", "data", "logs", "coverage", ".env", "*.log"],
},
js.configs.recommended,
{

View File

@@ -45,7 +45,7 @@ async function setupDatabase() {
name VARCHAR(256) NOT NULL,
value VARCHAR(512) NOT NULL,
uuid VARCHAR(36) NOT NULL,
UNIQUE KEY unique_property (uuid, name),
UNIQUE KEY uniqueProperty (uuid, name),
FOREIGN KEY (uuid) REFERENCES players(uuid) ON DELETE CASCADE
)
`)
@@ -142,7 +142,7 @@ async function setupDatabase() {
await conn.query(`
CREATE TABLE IF NOT EXISTS banReasons (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
reason_key VARCHAR(512) UNIQUE NOT NULL
reasonKey VARCHAR(512) UNIQUE NOT NULL
)
`)
logger.log(`${"banReasons".bold} table ready`, ["MariaDB", "yellow"])
@@ -224,13 +224,13 @@ async function setupDatabase() {
await conn.query(`
CREATE TABLE IF NOT EXISTS playersCapes (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
playerUuid VARCHAR(36) NOT NULL,
assetHash VARCHAR(64) NOT NULL,
isSelected TINYINT(1) DEFAULT 0,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (playerUuid, assetHash),
FOREIGN KEY (playerUuid) REFERENCES players(uuid) ON DELETE CASCADE,
FOREIGN KEY (assetHash) REFERENCES textures(hash)
FOREIGN KEY (assetHash) REFERENCES textures(hash) ON DELETE CASCADE
)
`)
logger.log(`${"playersCapes".bold} table ready`, ["MariaDB", "yellow"])
@@ -242,36 +242,8 @@ async function setupDatabase() {
logger.log(`defaults skins (steve, alex) ready`, ["MariaDB", "yellow"])
await conn.query(`DROP TRIGGER IF EXISTS unique_active_skin`)
await conn.query(`
CREATE TRIGGER unique_active_skin
AFTER UPDATE ON playersSkins
FOR EACH ROW
BEGIN
IF NEW.isSelected = 1 THEN
UPDATE playersSkins
SET isSelected = 0
WHERE playerUuid = NEW.playerUuid
AND assetHash != NEW.assetHash;
END IF;
END;
`)
logger.log(`${"unique_active_skin".bold} trigger ready`, ["MariaDB", "yellow"])
await conn.query(`DROP TRIGGER IF EXISTS unique_active_cape`)
await conn.query(`
CREATE TRIGGER unique_active_cape
AFTER UPDATE ON playersCapes
FOR EACH ROW
BEGIN
IF NEW.isSelected = 1 THEN
UPDATE playersCapes
SET isSelected = 0
WHERE playerUuid = NEW.playerUuid
AND id != NEW.id;
END IF;
END;
`)
logger.log(`${"unique_active_cape".bold} trigger ready`, ["MariaDB", "yellow"])
await conn.query(`DROP TRIGGER IF EXISTS auto_assign_random_default_skin`)
await conn.query(`
@@ -335,8 +307,20 @@ async function setupDatabase() {
`)
logger.log(`${"serverSessions".bold} table ready`, ["MariaDB", "yellow"])
try {
await conn.query(`SET GLOBAL event_scheduler = ON;`)
logger.log("MariaDB Event Scheduler enabled.", ["MariaDB", "yellow"])
logger.log("MySQL Event Scheduler enabled.", ["MySQL", "yellow"])
await conn.query(`
CREATE EVENT IF NOT EXISTS clean_expired_certificates
ON SCHEDULE EVERY 1 HOUR
DO
DELETE FROM playerCertificates WHERE expiresAt < NOW();
`)
logger.log(`${"clean_expired_certificates".bold} event ready`, ["MySQL", "yellow"])
} catch (e) {
logger.log("Warning: Could not enable Event Scheduler (permission issue?). Skipping event creation.", ["MySQL", "red"])
}
await conn.query(`
CREATE EVENT IF NOT EXISTS clean_expired_certificates
@@ -346,6 +330,47 @@ async function setupDatabase() {
`)
logger.log(`${"clean_expired_certificates".bold} event ready`, ["MariaDB", "yellow"])
await conn.query(`
CREATE TABLE IF NOT EXISTS apiAdministrators (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(255) UNIQUE NOT NULL,
password TEXT NOT NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
logger.log(`${"apiAdministrators".bold} table ready`, ["MariaDB", "yellow"])
await conn.query(`
CREATE TABLE IF NOT EXISTS apiAdministratorsPermissionsList (
permissionKey VARCHAR(64) PRIMARY KEY
)
`)
logger.log(`${"apiAdministratorsPermissionsList".bold} table ready`, ["MariaDB", "yellow"])
await conn.query(`INSERT IGNORE INTO apiAdministratorsPermissionsList(permissionKey) VALUES ("RESET_PLAYER_SKIN"), ("GRANT_PLAYER_CAPE"), ("REMOVE_PLAYER_CAPE"), ("CHANGE_PLAYER_USERNAME"), ("CHANGE_PLAYER_PASSWORD"), ("UPLOAD_CAPE"), ("DELETE_CAPES"), ("PLAYER_BAN"), ("PLAYER_BAN_HISTORY"), ("PLAYER_BAN_STATUS"), ("PLAYER_ACTIONS_LIST"), ("PLAYER_UNBAN"), ("REGISTER_USER")`)
logger.log(`${"apiAdministratorsPermissionsList".bold} permissions ready`, ["MariaDB", "yellow"])
await conn.query(`
CREATE TABLE IF NOT EXISTS apiAdministratorsPermissions (
administratorId INTEGER NOT NULL,
permissionKey VARCHAR(64) NOT NULL,
PRIMARY KEY (administratorId, permissionKey),
FOREIGN KEY (administratorId) REFERENCES apiAdministrators(id) ON DELETE CASCADE,
FOREIGN KEY (permissionkey) REFERENCES apiAdministratorsPermissionsList(permissionKey) ON DELETE CASCADE
)
`)
logger.log(`${"apiAdministratorsPermissions".bold} table ready`, ["MariaDB", "yellow"])
await conn.query(`
CREATE TABLE IF NOT EXISTS oaauth2LinkAttempts (
OAuth2LinkId VARCHAR(255) NOT NULL,
playerUuid VARCHAR(255) NOT NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (playerUuid) REFERENCES players(uuid) ON DELETE CASCADE
)
`)
logger.log(`${"oaauth2LinkAttempts".bold} table ready`, ["MariaDB", "yellow"])
logger.log("MariaDB database successfully initialised!", ["MariaDB", "yellow"])
} catch (err) {

View File

@@ -1,12 +1,13 @@
const fs = require("node:fs")
const path = require("node:path")
const utils = require("./utils")
require("colors")
require("dotenv").config({
quiet: true
})
process.setMaxListeners(15)
function isTrueFromDotEnv(key) {
return (process.env[key] || "").trim().toLowerCase() === "true"
}
function cleanup($stream) {
if (!$stream.destroyed) {
@@ -43,7 +44,7 @@ function write($stream, level, color, content, extraLabels = []) {
function createLogger(root) {
// eslint-disable-next-line no-useless-escape
const fileName = utils.isTrueFromDotEnv("IS_PROD") ? new Date().toLocaleString("fr-FR", { timeZone: "UTC" }).replace(/[\/:]/g, "-").replace(/ /g, "_") : "DEV-LOG"
const fileName = isTrueFromDotEnv("IS_PROD") ? new Date().toLocaleString("fr-FR", { timeZone: "UTC" }).replace(/[\/:]/g, "-").replace(/ /g, "_") : "DEV-LOG"
const logsDir = path.join(root, "logs")

View File

@@ -1,4 +1,6 @@
const crypto = require("node:crypto")
const logger = require("../modules/logger")
const { DefaultError } = require("../errors/errors")
const certificatesManager = require("./certificatesManager")
async function getRegistrationCountryFromIp(ipAddress) {
@@ -55,7 +57,22 @@ function isTrueFromDotEnv(key) {
return (process.env[key] || "").trim().toLowerCase() === "true"
}
function getUrlParam(url, param) {
const urlParams = new URLSearchParams(url)
return urlParams.get(param)
}
function handleDBError(error, errorMessage = "Internal Server Error", code = 500) {
if (error instanceof DefaultError) {
throw error
}
logger.log(errorMessage.bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(code, errorMessage, "InternalServerError")
}
module.exports = {
getUrlParam,
handleDBError,
signProfileData,
addDashesToUUID,
isTrueFromDotEnv,

238
package-lock.json generated
View File

@@ -1,16 +1,18 @@
{
"name": "base-rest-api",
"name": "yggdrasil",
"version": "0.0.1-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "base-rest-api",
"name": "yggdrasil",
"version": "0.0.1-alpha",
"license": "AGPL-3.0-only",
"dependencies": {
"@mgalacyber/discord-oauth2": "^1.9.6",
"bcryptjs": "^3.0.3",
"colors": "^1.4.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
@@ -240,6 +242,36 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@mgalacyber/discord-oauth2": {
"version": "1.9.6",
"resolved": "https://registry.npmjs.org/@mgalacyber/discord-oauth2/-/discord-oauth2-1.9.6.tgz",
"integrity": "sha512-sSzi5aikr+32WnVvkfDpSu0SDtBh0anwvWRB0WfsjUrd4/WqfXPXTu0G2kqQOmcvXEQzws1/8wFbDymklUELJw==",
"license": "Apache-2.0",
"dependencies": {
"@mgalacyber/package-notifier": "^1.0.1",
"undici": "^6.16.0"
}
},
"node_modules/@mgalacyber/package-notifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@mgalacyber/package-notifier/-/package-notifier-1.0.1.tgz",
"integrity": "sha512-s/QgExSx4RRbVV73hMbBBZvkHjQ6WzOAc7tYJ/82JHMEabwbiQYoIdRJAH6y3MmAKo11+wKFCovHaM6oCPnpYA==",
"license": "Apache-2.0",
"dependencies": {
"@mgalacyber/termbox": "^1.0.0",
"axios": "^1.6.7",
"string-width": "^2.1.1"
}
},
"node_modules/@mgalacyber/termbox": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mgalacyber/termbox/-/termbox-1.0.0.tgz",
"integrity": "sha512-0tQL5lJw9NW1cND5loLgj5hrvJmIIY2vBWVsXsIz4pQEZR3KasMUKBcOxAKclMaoy19Y5sgqge018M67AgqkhA==",
"license": "Apache-2.0",
"dependencies": {
"string-width": "^2.1.1"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -322,6 +354,15 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz",
"integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -365,6 +406,23 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -607,6 +665,18 @@
"node": ">=0.1.90"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -660,6 +730,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -721,6 +810,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -819,6 +917,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -1181,6 +1294,63 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1320,6 +1490,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -1524,6 +1709,15 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
"integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -2157,6 +2351,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@@ -2498,6 +2698,31 @@
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
"integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
"license": "MIT",
"dependencies": {
"is-fullwidth-code-point": "^2.0.0",
"strip-ansi": "^4.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/strip-ansi": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
"integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -2596,6 +2821,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",

View File

@@ -1,15 +1,15 @@
{
"name": "base-rest-api",
"name": "yggdrasil",
"version": "0.0.1-alpha",
"description": "",
"repository": {
"type": "git",
"url": "https://gitea.azures.fr/azures04/Base-REST-API"
"url": "https://gitea.azures.fr/azures04/Yggdrasil"
},
"license": "AGPL-3.0-only",
"author": {
"name": "azures04",
"url": "https://gitea.azures.fr/azures04/Base-REST-API",
"url": "https://gitea.azures.fr/azures04/Yggdrasil",
"email": "gilleslazure04@gmail.com"
},
"type": "commonjs",
@@ -20,11 +20,13 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"homepage": "https://gitea.azures.fr/azures04/Base-REST-API",
"readme": "https://gitea.azures.fr/azures04/Base-REST-API/src/branch/main/README.md",
"homepage": "https://gitea.azures.fr/azures04/Yggdrasil",
"readme": "https://gitea.azures.fr/azures04/Yggdrasil/src/branch/main/README.md",
"dependencies": {
"@mgalacyber/discord-oauth2": "^1.9.6",
"bcryptjs": "^3.0.3",
"colors": "^1.4.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",

View File

@@ -0,0 +1,139 @@
const utils = require("../modules/utils")
const database = require("../modules/database")
const { DefaultError } = require("../errors/errors")
async function getAdminById(id) {
try {
const sql = "SELECT id, username, createdAt FROM apiAdministrators WHERE id = ?"
const rows = await database.query(sql, [id])
return rows[0] || null
} catch (error) {
return utils.handleDBError(error)
}
}
async function createAdmin(username, hashedPassword) {
try {
const sql = "INSERT INTO apiAdministrators (username, password) VALUES (?, ?)"
const result = await database.query(sql, [username, hashedPassword])
if (result.affectedRows > 0) {
return { code: 200, id: result.insertId, username }
} else {
throw new DefaultError(500, "Failed to create administrator.")
}
} catch (error) {
if (error.code === "ER_DUP_ENTRY") {
throw new DefaultError(409, "Administrator username already exists.")
}
return utils.handleDBError(error)
}
}
async function hasPermission(adminId, permissionKey) {
try {
const sql = `
SELECT COUNT(*) as count
FROM apiAdministratorsPermissions
WHERE administratorId = ? AND permissionKey = ?
`
const rows = await database.query(sql, [adminId, permissionKey])
return rows[0].count === 1
} catch (error) {
return utils.handleDBError(error)
}
}
async function assignPermission(adminId, permissionKey) {
try {
const sql = "INSERT INTO apiAdministratorsPermissions (administratorId, permissionKey) VALUES (?, ?)"
const result = await database.query(sql, [adminId, permissionKey])
return result.affectedRows > 0
} catch (error) {
if (error.code === "ER_DUP_ENTRY") return true
return utils.handleDBError(error)
}
}
async function revokePermission(adminId, permissionKey) {
try {
const sql = "DELETE FROM apiAdministratorsPermissions WHERE administratorId = ? AND permissionKey = ?"
const result = await database.query(sql, [adminId, permissionKey])
return result.affectedRows > 0
} catch (error) {
return utils.handleDBError(error)
}
}
async function getAdminPermissions(adminId) {
try {
const sql = `
SELECT permissionKey
FROM apiAdministratorsPermissions
WHERE administratorId = ?
`
const rows = await database.query(sql, [adminId])
return rows.map(r => r.permissionKey)
} catch (error) {
return utils.handleDBError(error)
}
}
async function updateAdminPassword(adminId, newHashedPassword) {
try {
const sql = "UPDATE apiAdministrators SET password = ? WHERE id = ?"
const result = await database.query(sql, [newHashedPassword, adminId])
if (result.affectedRows > 0) {
return {
code: 200,
message: "Password updated successfully."
}
} else {
throw new DefaultError(404, "Administrator not found.")
}
} catch (error) {
return utils.handleDBError(error)
}
}
async function getAdminByUsername(username) {
try {
const sql = "SELECT id, username, password, createdAt FROM apiAdministrators WHERE username = ?"
const rows = await database.query(sql, [username])
return rows[0] || null
} catch (error) {
return utils.handleDBError(error)
}
}
async function addPlayerAction(playerUuid, actionCode) {
try {
const cleanUuid = playerUuid.replace(/-/g, "")
const sql = "INSERT IGNORE INTO playerProfileActions (uuid, action) VALUES (?, ?)"
const result = await database.query(sql, [cleanUuid, actionCode])
return {
code: 200,
success: result.affectedRows > 0,
message: result.affectedRows > 0 ? "Action taken." : "Action already taken."
}
} catch (error) {
return utils.handleDBError(error)
}
}
module.exports = {
createAdmin,
getAdminById,
hasPermission,
addPlayerAction,
assignPermission,
revokePermission,
getAdminByUsername,
getAdminPermissions,
updateAdminPassword
}

View File

@@ -1,6 +1,6 @@
const logger = require("../modules/logger")
const bcrypt = require("bcryptjs")
const database = require("../modules/database")
const utils = require("../modules/utils")
const { DefaultError } = require("../errors/errors")
async function getUser(identifier, requirePassword = false) {
@@ -19,9 +19,7 @@ async function getUser(identifier, requirePassword = false) {
return { code: 200, user }
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Internal Server Error", "Please contact an administrator.")
return utils.handleDBError(error)
}
}
@@ -37,11 +35,7 @@ async function register(email, username, password) {
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
}
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
console.log(error)
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
@@ -56,7 +50,7 @@ async function insertClientSession(accessToken, clientToken, uuid) {
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
}
} catch (error) {
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
return utils.handleDBError(error)
}
}
@@ -70,9 +64,7 @@ async function getPlayerProperties(uuid) {
}
return { code: 200, properties: properties.map(property => { return { name: property.name, value: property.value } }) }
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
@@ -91,9 +83,7 @@ async function getClientSession(accessToken, clientToken) {
throw new DefaultError(404, "Client session not found")
}
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
@@ -112,9 +102,7 @@ async function validateClientSession(accessToken, clientToken) {
throw new DefaultError(404, "Client session not found for this accessToken/clientToken combination.")
}
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
@@ -133,9 +121,7 @@ async function validateClientSessionWithoutClientToken(accessToken) {
throw new DefaultError(404, "Client session not found for this accessToken.")
}
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
@@ -153,9 +139,7 @@ async function invalidateClientSession(accessToken, clientToken) {
throw new DefaultError(404, "Client session not found for this accessToken/clientToken combination.")
}
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
@@ -173,9 +157,7 @@ async function revokeAccessTokens(uuid) {
throw new DefaultError(404, "No access token found for this user.")
}
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}

View File

@@ -0,0 +1,51 @@
const utils = require("../modules/utils")
const database = require("../modules/database")
async function createLinkAttempt(OAuth2LinkId, playerUuid) {
try {
const sql = `
INSERT INTO oaauth2LinkAttempts (OAuth2LinkId, playerUuid)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE playerUuid = VALUES(playerUuid), createdAt = NOW()
`
const result = await database.query(sql, [OAuth2LinkId, playerUuid])
return result.affectedRows > 0
} catch (error) {
return utils.handleDBError(error)
}
}
async function popLinkAttempt(OAuth2LinkId) {
try {
const selectSql = "SELECT playerUuid FROM oaauth2LinkAttempts WHERE OAuth2LinkId = ?"
const rows = await database.query(selectSql, [OAuth2LinkId])
if (rows.length === 0) return null
const playerUuid = rows[0].playerUuid
const deleteSql = "DELETE FROM oaauth2LinkAttempts WHERE OAuth2LinkId = ?"
await database.query(deleteSql, [OAuth2LinkId])
return playerUuid
} catch (error) {
return utils.handleDBError(error)
}
}
async function unlinkProviderAccount(provider, playerUuid) {
try {
const sql = `DELETE FROM playersProperties WHERE name = '${provider}Id' AND uuid = ?`
const result = await database.query(sql, [playerUuid])
return result.affectedRows > 0
} catch (error) {
return utils.handleDBError(error)
}
}
module.exports = {
popLinkAttempt,
createLinkAttempt,
unlinkProviderAccount
}

View File

@@ -1,4 +1,4 @@
const logger = require("../modules/logger")
const utils = require("../modules/utils")
const database = require("../modules/database")
const { DefaultError } = require("../errors/errors")
@@ -15,9 +15,7 @@ async function insertLegacyClientSessions(sessionId, uuid) {
throw new DefaultError(500, "Internal Server Error", "Unknown DB Error")
}
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Internal Server Error", "Please contact an administrator.")
return utils.handleDBError(error)
}
}
@@ -39,8 +37,7 @@ async function validateLegacyClientSession(sessionId, uuid) {
}
}
} catch (error) {
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Internal Server Error", "Please contact an administrator.")
return utils.handleDBError(error)
}
}
@@ -54,8 +51,7 @@ async function getBlockedServers() {
blockedServers: blockedServers.map(bannedServer => ({ sha1: bannedServer.hashedIp }))
}
} catch (error) {
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Internal Server Error", "Please contact an administrator.")
return utils.handleDBError(error)
}
}
@@ -69,11 +65,12 @@ async function getActiveSkin(uuid) {
`
const rows = await database.query(sql, [uuid])
const skin = rows[0]
if (!skin) {
throw new DefaultError(404, "Not found", "Not found")
}
return { code: 200, data: skin || null }
} catch (error) {
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Internal Server Error", "Please contact an administrator.")
return utils.handleDBError(error)
}
}
@@ -87,11 +84,12 @@ async function getActiveCape(uuid) {
`
const rows = await database.query(sql, [uuid])
const cape = rows[0]
if (!cape) {
throw new DefaultError(404, "Not found", "Not found")
}
return { code: 200, data: cape || null }
} catch (error) {
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Internal Server Error", "Please contact an administrator.")
return utils.handleDBError(error)
}
}
@@ -105,8 +103,7 @@ async function getProfileActionsList(uuid) {
return { code: 200, data: actions }
} catch (error) {
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Internal Server Error", "Please contact an administrator.")
return utils.handleDBError(error)
}
}
@@ -125,8 +122,7 @@ async function saveServerSession(uuid, accessToken, serverId, ip) {
return { code: 200, success: result.affectedRows > 0 }
} catch (error) {
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Internal Server Error", "Please contact an administrator.")
return utils.handleDBError(error)
}
}
@@ -136,6 +132,7 @@ async function getServerSession(uuid, serverId) {
SELECT ip
FROM serverSessions
WHERE uuid = ? AND serverId = ?
AND createdAt > (NOW() - INTERVAL 30 SECOND)
`
const rows = await database.query(sql, [uuid, serverId])
const session = rows[0]
@@ -146,8 +143,7 @@ async function getServerSession(uuid, serverId) {
return { code: 200, valid: true, ip: session.ip }
} catch (error) {
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Internal Server Error", "Please contact an administrator.")
return utils.handleDBError(error)
}
}

View File

@@ -1,8 +1,38 @@
const utils = require("../modules/utils")
const crypto = require("node:crypto")
const logger = require("../modules/logger")
const database = require("../modules/database")
const { DefaultError } = require("../errors/errors")
async function getSkins(uuid) {
try {
const sql = `
SELECT t.uuid as textureUuid, t.hash, t.url, ps.variant, ps.isSelected
FROM playersSkins ps
JOIN textures t ON ps.assetHash = t.hash
WHERE ps.playerUuid = ?
`
const rows = await database.query(sql, [uuid])
return rows
} catch (error) {
return utils.handleDBError(error)
}
}
async function getCapes(uuid) {
try {
const sql = `
SELECT t.uuid as textureUuid, t.hash, t.url, t.alias, pc.isSelected
FROM playersCapes pc
JOIN textures t ON pc.assetHash = t.hash
WHERE pc.playerUuid = ?
`
const rows = await database.query(sql, [uuid])
return rows
} catch (error) {
return utils.handleDBError(error)
}
}
async function addPropertyToPlayer(key, value, uuid) {
try {
const sql = `
@@ -18,8 +48,7 @@ async function addPropertyToPlayer(key, value, uuid) {
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
}
} catch (error) {
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
@@ -34,9 +63,7 @@ async function deletePropertyToPlayer(key, uuid) {
throw new DefaultError(500, "Property not found for this user/key combination.")
}
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
@@ -50,9 +77,7 @@ async function updatePropertyToPlayer(key, value, uuid) {
throw new DefaultError(404, "Property not found for this user/key combination")
}
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
@@ -68,9 +93,7 @@ async function getPlayerProperties(uuid) {
properties: rows.map(property => ({ name: property.name, value: property.value }))
}
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
@@ -84,9 +107,30 @@ async function getPlayerProperty(key, uuid) {
}
return { code: 200, property }
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
async function getPlayerPropertyByValue(key, value) {
try {
const sql = `SELECT * FROM playersProperties WHERE name = ? AND value = ?`
const rows = await database.query(sql, [key, value])
const property = rows[0]
if (!property) {
throw new DefaultError(404, "No property found with this value for the specified key")
}
return {
code: 200,
property: {
name: property.name,
value: property.value,
uuid: property.uuid
}
}
} catch (error) {
return utils.handleDBError(error)
}
}
@@ -101,9 +145,8 @@ async function getPlayerSettingsSchema() {
RAW_SCHEMA_CACHE.privileges = privilegesRows.map(c => c.Field).filter(n => n !== "uuid")
RAW_SCHEMA_CACHE.preferences = preferencesRows.map(c => c.Field).filter(n => n !== "uuid")
return RAW_SCHEMA_CACHE
} catch (err) {
logger.log("Database Schema Error: " + err.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Schema Error")
} catch (error) {
return utils.handleDBError(error)
}
}
@@ -127,9 +170,7 @@ async function updatePlayerPreferences(uuid, updates) {
throw new DefaultError(404, "Player preferences not found or no changes made.")
}
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
@@ -149,9 +190,7 @@ async function getPlayerPreferences(uuid) {
throw new DefaultError(404, "Preferences not found for this UUID.")
}
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
@@ -174,9 +213,7 @@ async function getPlayerPrivileges(uuid) {
throw new DefaultError(404, "Privileges not found for this UUID.")
}
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
@@ -200,9 +237,7 @@ async function updatePlayerPrivileges(uuid, updates) {
throw new DefaultError(404, "Player privileges not found or no changes made.")
}
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
@@ -213,11 +248,11 @@ async function banUser(uuid, { reasonKey, reasonMessage, expires = null }) {
}
let reasonId
const reasonRows = await database.query("SELECT id FROM banReasons WHERE reason_key = ?", [reasonKey])
const reasonRows = await database.query("SELECT id FROM banReasons WHERE reasonKey = ?", [reasonKey])
if (reasonRows.length > 0) {
reasonId = reasonRows[0].id
} else {
const insertReason = await database.query("INSERT INTO banReasons (reason_key) VALUES (?)", [reasonKey])
const insertReason = await database.query("INSERT INTO banReasons (reasonKey) VALUES (?)", [reasonKey])
reasonId = insertReason.insertId
}
@@ -243,8 +278,7 @@ async function banUser(uuid, { reasonKey, reasonMessage, expires = null }) {
if (error.code === "ER_NO_REFERENCED_ROW_2" || error.toString().includes("foreign key constraint")) {
throw new DefaultError(404, "User not found (cannot ban a ghost).")
}
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Internal Server Error", error.toString())
return utils.handleDBError(error)
}
}
@@ -266,9 +300,7 @@ async function unbanUser(uuid) {
}
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
@@ -279,7 +311,7 @@ async function getPlayerBans(uuid) {
b.banId,
b.expires,
b.reasonMessage,
r.reason_key as reason
r.reasonKey as reason
FROM bans b
JOIN banReasons r ON b.reason = r.id
WHERE b.uuid = ?
@@ -293,9 +325,7 @@ async function getPlayerBans(uuid) {
return { code: 204 }
}
} catch (error) {
if (error instanceof DefaultError) throw error
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Please contact an administrator.", "InternalServerError")
return utils.handleDBError(error)
}
}
@@ -313,8 +343,7 @@ async function changeUsername(uuid, newName) {
if (error.code === "ER_DUP_ENTRY" || error.errno === 1062) {
throw new DefaultError(409, "Username already taken", "ForbiddenOperationException")
}
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Internal Server Error", error.toString())
return utils.handleDBError(error)
}
}
@@ -330,8 +359,7 @@ async function createTexture(uuid, hash, type, url, alias) {
if (error.code === 'ER_DUP_ENTRY') {
return false
}
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -342,8 +370,7 @@ async function getTextureByUuid(textureUuid) {
const rows = await database.query(sql, [textureUuid])
return rows[0] || null
} catch (error) {
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -353,8 +380,7 @@ async function getTextureByHash(hash) {
const rows = await database.query(sql, [hash])
return rows[0]
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -373,8 +399,7 @@ async function resetSkin(uuid, hash, variant) {
await database.query(updateSql, [hash, uuid])
return { code: 200 }
} catch (error) {
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -384,23 +409,27 @@ async function hideCape(uuid) {
await database.query(sql, [uuid])
return { code: 200 }
} catch (error) {
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
async function showCape(uuid, hash) {
try {
const sql = `
UPDATE playersCapes
SET isSelected = (assetHash = ?)
WHERE playerUuid = ?
`
const result = await database.query(sql, [hash, uuid])
return { code: 200, changed: result.affectedRows > 0 }
await database.query(
"UPDATE playersCapes SET isSelected = 0 WHERE playerUuid = ?",
[uuid]
)
const result = await database.query(
"UPDATE playersCapes SET isSelected = 1 WHERE playerUuid = ? AND assetHash = ?",
[uuid, hash]
)
const affectedRows = Array.isArray(result) ? result[0].affectedRows : result.affectedRows
return { code: 200, changed: affectedRows > 0 }
} catch (error) {
logger.log("Internal Server Error".bold + " : " + error.toString(), ["MariaDB", "yellow"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -410,7 +439,7 @@ async function checkCapeOwnership(uuid, hash) {
const rows = await database.query(sql, [uuid, hash])
return rows.length > 0
} catch (error) {
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -420,8 +449,7 @@ async function getPlayerMeta(uuid) {
const rows = await database.query(sql, [uuid])
return rows[0]
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -437,8 +465,7 @@ async function getLastNameChange(uuid) {
const rows = await database.query(sql, [uuid])
return rows[0]
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -448,8 +475,7 @@ async function getPlayerCertificate(uuid) {
const rows = await database.query(sql, [uuid])
return rows[0]
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -463,8 +489,7 @@ async function savePlayerCertificate(uuid, privateKey, publicKey, signatureV2, e
const result = await database.query(sql, [uuid, privateKey, publicKey, signatureV2, expiresAt, refreshedAfter])
return result.affectedRows > 0
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -474,8 +499,7 @@ async function deleteExpiredCertificates(isoDate) {
const result = await database.query(sql, [isoDate])
return result.affectedRows
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -486,8 +510,7 @@ async function addProfileAction(uuid, actionCode) {
const result = await database.query(sql, [cleanUuid, actionCode])
return result.affectedRows > 0
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -498,8 +521,7 @@ async function removeProfileAction(uuid, actionCode) {
const result = await database.query(sql, [cleanUuid, actionCode])
return result.affectedRows
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -510,8 +532,7 @@ async function getPlayerActions(uuid) {
const rows = await database.query(sql, [cleanUuid])
return rows.map(r => r.action)
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -522,8 +543,7 @@ async function clearAllPlayerActions(uuid) {
const result = await database.query(sql, [cleanUuid])
return result.affectedRows
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -533,8 +553,7 @@ async function blockPlayer(blockerUuid, blockedUuid) {
const result = await database.query(sql, [blockerUuid, blockedUuid])
return result.affectedRows > 0
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -544,8 +563,7 @@ async function unblockPlayer(blockerUuid, blockedUuid) {
const result = await database.query(sql, [blockerUuid, blockedUuid])
return result.affectedRows > 0
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -555,8 +573,7 @@ async function getBlockedUuids(blockerUuid) {
const rows = await database.query(sql, [blockerUuid])
return rows.map(r => r.blockedUuid)
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -566,8 +583,7 @@ async function isBlocked(blockerUuid, targetUuid) {
const rows = await database.query(sql, [blockerUuid, targetUuid])
return rows.length > 0
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -582,8 +598,7 @@ async function getUsersByNames(usernames) {
const rows = await database.query(sql, uniqueNames)
return rows
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -594,8 +609,7 @@ async function getUuidAndUsername(username) {
return rows[0] || null
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -605,8 +619,7 @@ async function getProfileByUsername(username) {
const rows = await database.query(sql, [username])
return rows[0] || null
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -623,8 +636,7 @@ async function getProfileByHistory(username, isoDate) {
const rows = await database.query(sql, [username, isoDate])
return rows[0] || null
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
@@ -639,44 +651,110 @@ async function getNameHistory(uuid) {
const rows = await database.query(sql, [uuid])
return rows
} catch (error) {
logger.log("Database Error: " + error.toString(), ["MariaDB", "red"])
throw new DefaultError(500, "Internal Server Error", "Database Error")
return utils.handleDBError(error)
}
}
async function setSkin(uuid, hash, variant) {
const insertSql = `
try {
const resetSql = `UPDATE playersSkins SET isSelected = 0 WHERE playerUuid = ?`
await database.query(resetSql, [uuid])
const upsertSql = `
INSERT INTO playersSkins (playerUuid, assetHash, variant, isSelected)
VALUES (?, ?, ?, 1)
ON DUPLICATE KEY UPDATE isSelected = 1, variant = ?
ON DUPLICATE KEY UPDATE isSelected = 1, variant = VALUES(variant)
`
await database.query(insertSql, [uuid, hash, variant, variant])
const updateSql = `
UPDATE playersSkins
SET isSelected = 0
WHERE playerUuid = ? AND assetHash != ?
`
await database.query(updateSql, [uuid, hash])
await database.query(upsertSql, [uuid, hash, variant.toUpperCase()])
return true
} catch (error) {
return utils.handleDBError(error)
}
}
async function updatePassword(uuid, hashedPassword) {
try {
const sql = "UPDATE players SET password = ? WHERE uuid = ?"
const result = await database.query(sql, [hashedPassword, uuid])
if (result.affectedRows > 0) {
return { code: 200, message: "Password updated successfully" }
} else {
throw new DefaultError(404, "User not found")
}
} catch (error) {
return utils.handleDBError(error)
}
}
async function addCapeToPlayer(uuid, hash) {
try {
const sql = `
INSERT INTO playersCapes (playerUuid, assetHash, isSelected)
VALUES (?, ?, 0)
`
const result = await database.query(sql, [uuid, hash])
if (result.affectedRows > 0) {
return { code: 200, message: "Cape granted to the player." }
}
throw new DefaultError(500, "Error when assigning the cape.")
} catch (error) {
if (error.code === "ER_DUP_ENTRY") {
throw new DefaultError(409, "The player already possesses this cloak.")
}
return utils.handleDBError(error)
}
}
async function removeCapeFromPlayer(uuid, hash) {
try {
const sql = "DELETE FROM playersCapes WHERE playerUuid = ? AND assetHash = ?"
const result = await database.query(sql, [uuid, hash])
if (result.affectedRows > 0) {
return { code: 200, message: "Cape removed from player." }
} else {
throw new DefaultError(404, "The player does not own this cloak.")
}
} catch (error) {
return utils.handleDBError(error)
}
}
async function deleteTexture(hash) {
try {
const sql = "DELETE FROM textures WHERE hash = ?"
const result = await database.query(sql, [hash])
return result.affectedRows > 0
} catch (error) {
return utils.handleDBError(error)
}
}
module.exports = {
setSkin,
banUser,
getSkins,
getCapes,
showCape,
hideCape,
resetSkin,
isBlocked,
unbanUser,
blockPlayer,
deleteTexture,
createTexture,
getPlayerBans,
getPlayerMeta,
unblockPlayer,
changeUsername,
getNameHistory,
updatePassword,
getBlockedUuids,
getUsersByNames,
addCapeToPlayer,
getPlayerActions,
getTextureByUuid,
getTextureByHash,
@@ -690,6 +768,7 @@ module.exports = {
getPlayerProperties,
removeProfileAction,
addPropertyToPlayer,
removeCapeFromPlayer,
getProfileByUsername,
getPlayerPreferences,
getPlayerCertificate,
@@ -700,5 +779,6 @@ module.exports = {
updatePropertyToPlayer,
getPlayerSettingsSchema,
updatePlayerPreferences,
getPlayerPropertyByValue,
deleteExpiredCertificates,
}

38
routes/admin/ban/index.js Normal file
View File

@@ -0,0 +1,38 @@
const express = require("express")
const router = express.Router()
const userService = require("../../../services/userService")
const adminService = require("../../../services/adminService")
router.get("/:uuid", adminService.hasPermission("PLAYER_BAN_STATUS"), async (req, res) => {
const { uuid } = req.params
const banStatus = await userService.getPlayerBanStatus(uuid)
return res.status(200).json(banStatus)
})
router.get("/:uuid/actions", adminService.hasPermission("PLAYER_ACTIONS_LIST"), async (req, res) => {
const { uuid } = req.params
const playerActions = await userService.getPlayerActions(uuid)
return res.status(200).json(playerActions)
})
router.get("/:uuid/history", adminService.hasPermission("PLAYER_BAN_HISTORY"), async (req, res) => {
const { uuid } = req.params
const banHistory = await userService.getPlayerBans(uuid)
return res.status(200).json(banHistory)
})
router.put("/:uuid", adminService.hasPermission("PLAYER_BAN"), async (req, res) => {
const { reasonKey, reasonMessage, expires } = req.body
const ban = await userService.banUser(uuid, { reasonKey, reasonMessage, expires })
await adminService.logPlayerAction("BAN")
return res.status(200).json(ban)
})
router.delete("/:uuid", adminService.hasPermission("PLAYER_UNBAN"), async (req, res) => {
const { uuid } = req.params
const ban = await userService.unbanUser(uuid)
await adminService.logPlayerAction("UNBAN")
return res.status(200).json(ban)
})
module.exports = router

View File

@@ -0,0 +1,19 @@
const express = require("express")
const path = require("node:path")
const multer = require("multer")
const router = express.Router()
const adminService = require("../../../services/adminService")
const upload = multer({ dest: path.join(process.cwd(), "data/temp/") })
router.post("/upload", adminService.hasPermission("UPLOAD_CAPE"), upload.single("file"), async (req, res) => {
const result = await adminService.uploadCape(req.file, req.body.alias)
res.status(201).json(result)
})
router.delete("/:hash", adminService.hasPermission("DELETE_CAPES"), async (req, res) => {
const result = await adminService.deleteCape(req.params.hash)
res.status(200).json(result)
})
module.exports = router

21
routes/admin/index.js Normal file
View File

@@ -0,0 +1,21 @@
const express = require("express")
const router = express.Router()
const adminService = require("../../services/adminService")
router.post("/login", async (req, res) => {
const { username, password } = req.body
const result = await adminService.loginAdmin(username, password)
return res.status(200).json(result)
})
router.patch("/password", async (req, res) => {
const token = req.headers.authorization.replace("Bearer ", "")
const profile = await adminService.getAdminProfileByToken(token)
const { newPassword } = req.body
const result = await adminService.changeAdminPassword(profile.id, newPassword)
return res.status(200).json(result)
})
module.exports = router

View File

@@ -0,0 +1,12 @@
const express = require("express")
const router = express.Router()
const userService = require("../../../services/userService")
const adminService = require("../../../services/adminService")
router.patch("/:uuid", adminService.hasPermission("CHANGE_PLAYER_PASSWORD"), async (req, res) => {
const { newPassword } = req.body
const result = await userService.changePassword(req.params.uuid, newPassword)
return res.status(200).json(result)
})
module.exports = router

View File

@@ -0,0 +1,24 @@
const express = require("express")
const router = express.Router()
const userService = require("../../../services/userService")
const adminService = require("../../../services/adminService")
router.delete("/skin/:uuid", adminService.hasPermission("RESET_PLAYER_SKIN"), async (req, res) => {
const result = await userService.resetSkin(req.params.uuid)
await adminService.logPlayerAction("USING_BANNED_SKIN")
return res.status(200).json(result)
})
router.put("/cape/:uuid/:hash", adminService.hasPermission("GRANT_PLAYER_CAPE"), async (req, res) => {
const { uuid, hash } = req.params
const result = await userService.grantCape(uuid, hash)
return res.status(200).json(result)
})
router.delete("/cape/:uuid/:hash", adminService.hasPermission("REMOVE_PLAYER_CAPE"), async (req, res) => {
const { uuid, hash } = req.params
const result = await userService.removeCape(uuid, hash)
return res.status(200).json(result)
})
module.exports = router

View File

@@ -0,0 +1,13 @@
const express = require("express")
const router = express.Router()
const userService = require("../../../services/userService")
const adminService = require("../../../services/adminService")
router.patch("/:uuid", adminService.hasPermission("CHANGE_PLAYER_USERNAME"), async (req, res) => {
const { newUsername } = req.body
const result = await userService.changeUsername(req.params.uuid, newUsername)
await adminService.logPlayerAction("FORCED_NAME_CHANGE")
return res.status(200).json(result)
})
module.exports = router

View File

@@ -0,0 +1,15 @@
const express = require("express")
const router = express.Router()
const oauth2Service = require("../../../services/oauth2Service")
router.get("/login", async (req, res) => {
const redirectObject = await oauth2Service.generateLoginDiscordURL()
return res.status(200).redirect(redirectObject.url)
})
router.get("/login/callback", async (req, res) => {
const result = await oauth2Service.handleLoginCallback("discord", req.query.code, req.query.requestUser)
return res.status(result.code).json(result.response)
})
module.exports = router

View File

@@ -1,6 +1,6 @@
const express = require("express")
const router = express.Router()
const { YggdrasilError } = require("../../errors/errors")
const { YggdrasilError, DefaultError } = require("../../errors/errors")
const rateLimit = require("express-rate-limit")
const authService = require("../../services/authService")
const logger = require("../../modules/logger")

17
routes/index.js Normal file
View File

@@ -0,0 +1,17 @@
const express = require("express")
const router = express.Router()
const utils = require("../modules/utils")
const serverService = require("../services/serverService")
if (utils.isTrueFromDotEnv("SUPPORT_AUTHLIB_INJECTOR")) {
router.get("/", (req, res) => {
const hostname = req.hostname
const metadata = serverService.getServerMetadata(hostname)
res.header("X-Authlib-Injector-Date", new Date().toISOString())
return res.status(200).json(metadata)
})
} else {
router.get("/", (req, res, next) => next())
}
module.exports = router

View File

@@ -0,0 +1,10 @@
const express = require("express")
const router = express.Router({ mergeParams: true })
const sessionsService = require("../../../services/sessionsService")
router.get("", async (req, res) => {
const cape = await sessionsService.getActiveCape({ username: req.params.username.replace(".png", "") })
return res.redirect(`/textures${cape.data.url}`)
})
module.exports = router

View File

@@ -0,0 +1,10 @@
const express = require("express")
const router = express.Router({ mergeParams: true })
const sessionsService = require("../../../services/sessionsService")
router.get("", async (req, res) => {
const cape = await sessionsService.getActiveSkin({ username: req.params.username.replace(".png", "") })
return res.redirect(`/textures${cape.data.url}`)
})
module.exports = router

View File

@@ -0,0 +1,24 @@
const express = require("express")
const router = express.Router()
const sessionsService = require("../../services/sessionsService")
router.get("/", async (req, res) => {
const { user, serverId } = req.query
try {
const result = await sessionsService.hasJoinedServer({
username: user,
serverId,
ip: null
})
if (result.code === 200) {
return res.send("YES")
} else {
return res.send("NO")
}
} catch (err) {
return res.send("NO")
}
})
module.exports = router

View File

@@ -0,0 +1,10 @@
const express = require("express")
const router = express.Router({ mergeParams: true })
const sessionsService = require("../../../services/sessionsService")
router.get("", async (req, res) => {
const cape = await sessionsService.getActiveCape({ username: req.params.username.replace(".png", "") })
return res.redirect(`/textures${cape.data.url}`)
})
module.exports = router

View File

@@ -0,0 +1,26 @@
const express = require("express")
const router = express.Router()
const sessionsService = require("../../services/sessionsService")
const logger = require("../../modules/logger")
router.get("/", async (req, res) => {
const { user, sessionId, serverId } = req.query
const clientIp = req.ip || req.connection.remoteAddress
try {
await sessionsService.joinLegacyServer({
name: user,
sessionId,
serverId,
ip: clientIp
})
logger.log(`Legacy Join: ${user} -> ${serverId}`, ["AUTH", "green"])
return res.send("OK")
} catch (err) {
return res.send("Bad login")
}
})
module.exports = router

36
routes/legacy/login.js Normal file
View File

@@ -0,0 +1,36 @@
const express = require("express")
const router = express.Router()
const crypto = require("crypto")
const authService = require("../../services/authService")
const sessionsService = require("../../services/sessionsService")
const logger = require("../../modules/logger")
router.all("/", async (req, res) => {
const { user, password } = { ...req.query, ...req.body }
try {
const result = await authService.authenticate({
identifier: user,
password,
clientToken: "",
requireUser: false
})
const profile = result.response.selectedProfile
const sessionId = crypto.randomBytes(16).toString("hex")
await sessionsService.registerLegacySession({
uuid: profile.id,
sessionId
})
logger.log(`Legacy Login: ${user}`, ["AUTH", "green"])
const timestamp = Date.now()
return res.send(`${timestamp}:deprecated:${profile.name}:${sessionId}:${profile.id}`)
} catch (err) {
return res.send("Bad login")
}
})
module.exports = router

View File

@@ -0,0 +1,10 @@
const express = require("express")
const router = express.Router({ mergeParams: true })
const sessionsService = require("../../../services/sessionsService")
router.get("", async (req, res) => {
const cape = await sessionsService.getActiveSkin({ username: req.params.username.replace(".png", "") })
return res.redirect(`/textures${cape.data.url}`)
})
module.exports = router

26
routes/link/discord.js Normal file
View File

@@ -0,0 +1,26 @@
const express = require("express")
const router = express.Router()
const authService = require("../../services/authService")
const oauth2Service = require("../../services/oauth2Service")
router.get("/redirect", async (req, res) => {
const accessToken = req.headers.authorization.replace("Bearer ", "")
const player = await authService.verifyAccessToken({ accessToken })
const redirectObject = await oauth2Service.generateAssociationDiscordURL(player.user.uuid)
return res.json({ url: redirectObject.url })
})
router.get("/link", async (req, res) => {
const { code, state } = req.query
const result = await oauth2Service.handleAssociationCallback("discord", code, state)
return res.status(200).json(result)
})
router.delete("/link", async (req, res) => {
const accessToken = req.headers.authorization.replace("Bearer ", "")
const player = await authService.verifyAccessToken({ accessToken })
const result = await oauth2Service.unlinkAccount("discord", player.user.uuid)
return res.status(result.code).json(result)
})
module.exports = router

View File

@@ -6,11 +6,19 @@ const authService = require("../../../../../services/authService")
router.delete("/", async (req, res) => {
const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization.replace("Bearer", "").trim() })
await userService.hideCape(player.user.uuid)
return res.status(200).send()
const [skinsResult, capesResult] = await Promise.all([userService.getSkins(player.user.uuid), userService.getCapes(player.user.uuid)])
return res.status(200).json({
id: player.user.uuid.replace(/-/g, ""),
name: player.user.username,
skins: skinsResult.data || [],
capes: capesResult.data || []
})
})
router.put("/", async (req, res) => {
const player = await authService.verifyAccessToken(req.headers.authorization)
const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization.replace("Bearer", "").trim() })
await userService.showCape(player.user.uuid, req.body.capeId)
const [skinsResult, capesResult] = await Promise.all([userService.getSkins(player.user.uuid), userService.getCapes(player.user.uuid)])

View File

@@ -8,7 +8,7 @@ router.get("/", async (req, res) => {
const [skinsResult, capesResult] = await Promise.all([userService.getSkins(player.user.uuid), userService.getCapes(player.user.uuid)])
return res.status(200).json({
id: player.uuid.replace(/-/g, ""),
id: player.user.uuid.replace(/-/g, ""),
name: player.user.username,
skins: skinsResult.data || [],
capes: capesResult.data || []

View File

@@ -1,5 +1,6 @@
const express = require("express")
const authService = require("../../../../../services/authService")
const userService = require("../../../../../services/userService")
const { DefaultError, ServiceError } = require("../../../../../errors/errors")
const router = express.Router({ mergeParams: true })
@@ -21,13 +22,13 @@ router.put("/", async (req, res) => {
const player = await authService.verifyAccessToken({ accessToken: req.headers.authorization.replace("Bearer", "").trim() })
const newName = req.params.name
await userService.changeUsername(player.uuid, newName)
await userService.changeUsername(player.user.uuid, newName)
const skinsResult = await userService.getSkins({ uuid: player.uuid })
const capesResult = await userService.getCapes({ uuid: player.uuid })
const skinsResult = await userService.getSkins({ uuid: player.user.uuid })
const capesResult = await userService.getCapes({ uuid: player.user.uuid })
return res.status(200).json({
id: player.uuid.replace(/-/g, ""),
id: player.user.uuid.replace(/-/g, ""),
name: newName,
skins: skinsResult.data || [],
capes: capesResult.data || []

View File

@@ -1,8 +1,28 @@
const express = require("express")
const router = express.Router()
const utils = require("../modules/utils")
const logger = require("../modules/logger")
const authService = require("../services/authService")
const adminService = require("../services/adminService")
if (!utils.isTrueFromDotEnv("SUPPORT_REGISTER")) {
router.post("/", adminService.hasPermission("REGISTER_USER"), async (req, res) => {
const { username, password, email, registrationCountry, preferredLanguage } = req.body
const clientIp = req.headers["x-forwarded-for"] || req.connection.remoteAddress
const result = await authService.registerUser({
username,
password,
email,
registrationCountry,
preferredLanguage,
clientIp
})
logger.log(`New user registered: ${username}`, ["Web", "yellow", "AUTH", "green"])
return res.status(200).json(result)
})
} else {
router.post("/", async (req, res) => {
const { username, password, email, registrationCountry, preferredLanguage } = req.body
const clientIp = req.headers["x-forwarded-for"] || req.connection.remoteAddress
@@ -19,5 +39,6 @@ router.post("/", async (req, res) => {
logger.log(`New user registered: ${username}`, ["Web", "yellow", "AUTH", "green"])
return res.status(200).json(result)
})
}
module.exports = router

7
routes/static.js Normal file
View File

@@ -0,0 +1,7 @@
const path = require("node:path")
const expres = require("express")
const router = expres.Router()
router.use(expres.static(path.join(process.cwd(), "data", "static")))
module.exports = router

View File

@@ -9,12 +9,7 @@ const TEXTURES_DIR = path.join(process.cwd(), "data", "textures")
router.get("/", async (req, res, next) => {
try {
const hash = req.params.hash
if (!/^[a-f0-9]{64}$/i.test(hash)) {
throw new DefaultError(404, "Texture not found")
}
const subDir = hash.substring(0, 2)
const filePath = path.join(TEXTURES_DIR, subDir, hash)
const filePath = path.join(TEXTURES_DIR, hash)
if (!fs.existsSync(filePath)) {
throw new DefaultError(404, "Texture not found")
}

View File

@@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
GET: {
headers: z.object({
"content-type": z.string().regex(/application\/json/i),
"authorization": z.string().startsWith("Bearer ")
}),
query: z.object({
uuid: z.string().uuid()
})
}
}

View File

@@ -0,0 +1,13 @@
const z = require("zod")
module.exports = {
GET: {
headers: z.object({
"content-type": z.string().regex(/application\/json/i),
"authorization": z.string().startsWith("Bearer ")
}),
query: z.object({
uuid: z.string().uuid()
})
}
}

View File

@@ -0,0 +1,38 @@
const z = require("zod")
const uuidSchema = z.object({
uuid: z.string().uuid()
})
module.exports = {
GET: {
headers: z.object({
"content-type": z.string().regex(/application\/json/i),
"authorization": z.string().startsWith("Bearer ")
}),
query: uuidSchema
},
PUT: {
headers: z.object({
"content-type": z.string().regex(/application\/json/i),
"authorization": z.string().startsWith("Bearer ")
}),
body: z.object({
reasonKey: z.string().min(1),
reasonMessage: z.string().optional(),
expires: z.number().int().positive().optional()
}),
error: {
code: 400,
error: "CONSTRAINT_VIOLATION",
errorMessage: "Invalid ban format"
}
},
DELETE: {
headers: z.object({
"content-type": z.string().regex(/application\/json/i),
"authorization": z.string().startsWith("Bearer ")
}),
query: uuidSchema
}
}

View File

@@ -0,0 +1,9 @@
const z = require("zod")
module.exports = {
DELETE: {
headers: z.object({
"authorization": z.string().startsWith("Bearer ")
})
}
}

17
schemas/admin/login.js Normal file
View File

@@ -0,0 +1,17 @@
const z = require("zod")
module.exports = {
POST: {
headers: z.object({
"content-type": z.string().regex(/application\/json/i),
}),
body: z.object({
username: z.string()
.min(1),
password: z.string()
.min(8, { message: "The password must be at least 8 characters long." })
.regex(/[A-Z]/, { message: "The password must contain a capital letter." })
.regex(/[0-9]/, { message: "The password must contain a number." })
})
}
}

16
schemas/admin/password.js Normal file
View File

@@ -0,0 +1,16 @@
const z = require("zod")
module.exports = {
PATCH: {
headers: z.object({
"content-type": z.string().regex(/application\/json/i),
"authorization": z.string().startsWith("Bearer ")
}),
body: z.object({
newPassword: z.string()
.min(8, { message: "The password must be at least 8 characters long." })
.regex(/[A-Z]/, { message: "The password must contain a capital letter." })
.regex(/[0-9]/, { message: "The password must contain a number." })
})
}
}

View File

@@ -0,0 +1,16 @@
const z = require("zod")
module.exports = {
PATCH: {
headers: z.object({
"content-type": z.string().regex(/application\/json/i),
"authorization": z.string().startsWith("Bearer ")
}),
body: z.object({
newPassword: z.string()
.min(8, { message: "The password must be at least 8 characters long." })
.regex(/[A-Z]/, { message: "The password must contain a capital letter." })
.regex(/[0-9]/, { message: "The password must contain a number." }),
})
}
}

View File

@@ -0,0 +1,14 @@
const z = require("zod")
module.exports = {
PUT: {
headers: z.object({
"authorization": z.string().startsWith("Bearer ")
})
},
DELETE: {
headers: z.object({
"authorization": z.string().startsWith("Bearer ")
})
}
}

View File

@@ -0,0 +1,11 @@
const z = require("zod")
module.exports = {
GET: {
query: z.object({
user: z.string().min(1),
sessionId: z.string().min(1),
serverId: z.string().min(1)
})
}
}

View File

@@ -0,0 +1,10 @@
const z = require("zod")
module.exports = {
GET: {
query: z.object({
user: z.string().min(1),
serverId: z.string().min(1)
})
}
}

16
schemas/legacy/login.js Normal file
View File

@@ -0,0 +1,16 @@
const z = require("zod")
const loginShape = {
user: z.string().min(1, { message: "Username required" }),
password: z.string().min(1, { message: "Password required" }),
version: z.union([z.string(), z.number()]).optional()
}
module.exports = {
POST: {
body: z.object(loginShape),
},
GET: {
query: z.object(loginShape)
}
}

View File

@@ -0,0 +1,24 @@
const z = require("zod")
module.exports = {
GET: {
query: z.object({
code: z.string({ required_error: "Authorisation code required" }),
state: z.string({ required_error: "The state parameter is required." })
}),
error: {
code: 400,
message: "Invalid Discord callback settings"
}
},
DELETE: {
headers: z.object({
authorization: z.string({ required_error: "The authentication token is required." })
.regex(/^Bearer\s.+/, { message: "Invalid Authorization header format (Bearer token expected)" })
}),
error: {
code: 401,
message: "Authentication required for disassociation"
}
}
}

View File

@@ -0,0 +1,14 @@
const z = require("zod")
module.exports = {
GET: {
headers: z.object({
authorization: z.string({ required_error: "The authentication token is required." })
.regex(/^Bearer\s.+/, { message: "Invalid Authorization header format (Bearer token expected)" })
}),
error: {
code: 401,
message: "Authentication required to generate the link URL"
}
}
}

View File

@@ -1,24 +0,0 @@
const z = require("zod")
module.exports = {
POST: {
headers: z.object({
"content-type": z.string()
.regex(/application\/json/i, { message: "Content-Type must be application/json" }),
"authorization": z.string().min(1, { message: "Authorization header is required." })
}),
body: z.object({
variant: z.enum(["classic", "slim"], {
errorMap: () => ({ message: "Variant must be 'classic' or 'slim'." })
}),
url: z.string()
.url({ message: "Invalid URL format." })
.max(2048, { message: "URL is too long." })
}),
error: {
code: 400,
message: "Invalid skin URL or variant.",
error: "IllegalArgumentException"
}
}
}

View File

@@ -21,11 +21,25 @@ databaseGlobals.setupDatabase()
certificates.setupKeys()
app.use(hpp())
app.use(helmet())
app.use(helmet({
crossOriginResourcePolicy: { policy: "cross-origin" },
crossOriginEmbedderPolicy: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdnjs.cloudflare.com", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com"],
fontSrc: ["'self'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com"],
connectSrc: ["'self'", "https://yggdrasil.azures.fr"],
imgSrc: ["'self'", "data:"],
},
}
}))
app.use(cors({ origin: "*" }))
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
// app.use(cookieParser())
app.set("trust proxy", true)
@@ -142,14 +156,10 @@ for (const route of routes) {
}
}
app.all(/.*/, (req, res, next) => {
next(new DefaultError(404, `Can't find ${req.originalUrl} on this server!`, null, "NotFound"))
})
app.use((err, req, res, next) => {
const statusCode = err.statusCode || err.code || 500
logger.log(req.originalUrl)
logger.error(`Error occured on: ${req.originalUrl.bold}`, ["API", "red"])
logger.error(err.message, ["API", "red"])
if (typeof err.serialize === "function") {

172
services/adminService.js Normal file
View File

@@ -0,0 +1,172 @@
const fs = require("node:fs").promises
const path = require("node:path")
const jwt = require("jsonwebtoken")
const crypto = require("node:crypto")
const bcrypt = require("bcryptjs")
const userRepository = require("../repositories/userRepository")
const adminRepository = require("../repositories/adminRepository")
const { DefaultError } = require("../errors/errors")
const ADMIN_JWT_SECRET = process.env.ADMIN_JWT_SECRET || "udjJLGCOq7m3NmGpdVLJ@#"
async function registerAdmin(username, plainPassword, permissions = []) {
const hashedPassword = await bcrypt.hash(plainPassword, 10)
const result = await adminRepository.createAdmin(username, hashedPassword)
if (permissions.length > 0) {
for (const perm of permissions) {
await adminRepository.assignPermission(result.id, perm)
}
}
return { id: result.id, username, message: "Administrator successfully created." }
}
async function checkAdminAccess(adminId, requiredPermission) {
if (typeof adminId != "number" || !requiredPermission) {
throw new DefaultError(400, "Administrator ID or permission missing.")
}
return await adminRepository.hasPermission(adminId, requiredPermission)
}
async function changeAdminPassword(adminId, newPlainPassword) {
if (!newPlainPassword || newPlainPassword.length < 8) {
throw new DefaultError(400, "The password must contain at least 8 characters.")
}
const hashed = await bcrypt.hash(newPlainPassword, 10)
return await adminRepository.updateAdminPassword(adminId, hashed)
}
async function getAdminProfile(adminId) {
const admin = await adminRepository.getAdminById(adminId)
if (!admin) {
throw new DefaultError(404, "Administrator not found.")
}
const permissions = await adminRepository.getAdminPermissions(adminId)
return {
id: admin.id,
username: admin.username,
createdAt: admin.createdAt,
permissions: permissions
}
}
async function getAdminProfileByToken(accessToken) {
try {
const decoded = jwt.verify(accessToken, { complete: true, json: true })
return getAdminProfile(decoded.sub)
} catch (error) {
throw error
}
}
async function grantPermission(adminId, permissionKey) {
return await adminRepository.assignPermission(adminId, permissionKey)
}
async function revokePermission(adminId, permissionKey) {
return await adminRepository.revokePermission(adminId, permissionKey)
}
async function loginAdmin(username, password) {
const admin = await adminRepository.getAdminByUsername(username)
if (!admin) {
throw new DefaultError(403, "Invalid credentials.")
}
const isMatch = await bcrypt.compare(password, admin.password)
if (!isMatch) {
throw new DefaultError(403, "Invalid credentials.")
}
const token = jwt.sign(
{ id: admin.id, username: admin.username, type: "admin" },
ADMIN_JWT_SECRET,
{ expiresIn: "8h", subject: admin.id.toString(), issuer: "Yggdrasil" }
)
return { token }
}
function hasPermission(requiredPermission) {
return async (req, res, next) => {
try {
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw new DefaultError(401, "Admin auth required.")
}
const token = authHeader.split(" ")[1]
const decoded = jwt.verify(token, ADMIN_JWT_SECRET)
if (decoded.type !== "admin") {
throw new DefaultError(403, "Invalid token.")
}
const hasAccess = await checkAdminAccess(decoded.id, requiredPermission)
if (!hasAccess) {
throw new DefaultError(403, `Missing permission : ${requiredPermission}`)
}
req.admin = decoded
next()
} catch (err) {
if (err.name === "JsonWebTokenError") {
return next(new DefaultError(401, "Invalid session."))
}
next(err)
}
}
}
async function uploadCape(fileObject, alias = null) {
const buffer = await fs.readFile(fileObject.path)
const hash = crypto.createHash("sha256").update(buffer).digest("hex")
const existing = await userRepository.getTextureByHash(hash)
if (existing) {
await fs.unlink(fileObject.path)
throw new DefaultError(409, "Cape already existing.")
}
const finalPath = path.join(process.cwd(), "data/textures", `${hash}`)
await fs.rename(fileObject.path, finalPath)
const textureUrl = `/texture/${hash}`
await userRepository.createTexture(crypto.randomUUID(), hash, "CAPE", textureUrl, alias)
return { hash, url: textureUrl }
}
async function deleteCape(hash) {
const success = await userRepository.deleteTexture(hash)
if (!success) throw new DefaultError(404, "Cape not found.")
return { message: "Texture removed." }
}
async function logPlayerAction(playerUuid, actionCode) {
return await adminRepository.addPlayerAction(playerUuid, actionCode)
}
module.exports = {
loginAdmin,
uploadCape,
deleteCape,
registerAdmin,
hasPermission,
getAdminProfile,
grantPermission,
logPlayerAction,
revokePermission,
checkAdminAccess,
changeAdminPassword,
getAdminProfileByToken
}

View File

@@ -56,11 +56,12 @@ async function authenticate({ identifier, password, clientToken, requireUser })
delete userResult.user.password
const $clientToken = uuidRegex.test(clientToken) ? clientToken : crypto.randomUUID()
const $clientToken = clientToken || crypto.randomUUID()
const accessToken = jwt.sign({
uuid: userResult.user.uuid,
username: userResult.user.username,
clientToken: $clientToken,
type: "Minecraft"
}, keys.authenticationKeys.private, {
subject: userResult.user.uuid,
issuer: "LentiaYggdrasil",
@@ -105,6 +106,70 @@ async function authenticate({ identifier, password, clientToken, requireUser })
}
}
async function authenticateWithoutPassword({ identifier, requireUser }) {
let userResult
try {
userResult = await authRepository.getUser(identifier, true)
} catch (error) {
if (error.code === 404) {
throw new DefaultError(403, "Invalid credentials. Invalid username or password.", "ForbiddenOperationException")
}
throw error
}
delete userResult.user.password
const $clientToken = crypto.randomUUID()
const accessToken = jwt.sign({
uuid: userResult.user.uuid,
username: userResult.user.username,
clientToken: $clientToken,
type: "Minecraft_OAuth2"
}, keys.authenticationKeys.private, {
subject: userResult.user.uuid,
issuer: "LentiaYggdrasil",
expiresIn: "1d",
algorithm: "RS256"
})
const clientSessionProcess = await authRepository.insertClientSession(accessToken, $clientToken, userResult.user.uuid)
const userObject = {
clientToken: clientSessionProcess.clientToken,
accessToken: clientSessionProcess.accessToken,
availableProfiles: [{
name: userResult.user.username,
id: userResult.user.uuid,
}],
selectedProfile: {
name: userResult.user.username,
id: userResult.user.uuid,
}
}
if (requireUser) {
try {
const propertiesRequest = await authRepository.getPlayerProperties(userResult.user.uuid)
userObject.user = {
username: userResult.user.username,
properties: propertiesRequest.properties
}
} catch (error) {
if (error.code !== 404) throw error
userObject.user = {
username: userResult.user.username,
properties: []
}
}
}
return {
code: 200,
response: userObject
}
}
async function refreshToken({ previousAccessToken, clientToken, requireUser }) {
let sessionCheck
try {
@@ -120,11 +185,12 @@ async function refreshToken({ previousAccessToken, clientToken, requireUser }) {
await authRepository.invalidateClientSession(previousAccessToken, clientToken)
const $clientToken = uuidRegex.test(clientToken) ? clientToken : crypto.randomUUID()
const $clientToken = clientToken || crypto.randomUUID()
const newAccessToken = jwt.sign({
uuid: userResult.user.uuid,
username: userResult.user.username,
clientToken: $clientToken,
type: "Minecraft"
}, keys.authenticationKeys.private, {
subject: userResult.user.uuid,
issuer: "LentiaYggdrasil",
@@ -281,9 +347,10 @@ module.exports = {
signout,
validate,
invalidate,
registerUser,
authenticate,
refreshToken,
registerUser,
verifyAccessToken,
checkUsernameAvailability
checkUsernameAvailability,
authenticateWithoutPassword,
}

134
services/oauth2Service.js Normal file
View File

@@ -0,0 +1,134 @@
const { DiscordOAuth2 } = require("@mgalacyber/discord-oauth2")
const oauth2Repository = require("../repositories/oauth2Repository")
const userService = require("./userService")
const authService = require("./authService")
const { StateTypes, Scopes, PromptTypes, ResponseCodeTypes } = require("@mgalacyber/discord-oauth2")
const { DefaultError, YggdrasilError } = require("../errors/errors")
const oauth2_association = new DiscordOAuth2({
clientId: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
redirectUri: process.env.DISCORD_ASSOCIATION_REDIRECT_URL
})
const oauth2_login = new DiscordOAuth2({
clientId: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
redirectUri: process.env.DISCORD_LOGIN_REDIRECT_URL
})
async function generateAssociationDiscordURL(playerUuid) {
const redirectObject = await oauth2_association.GenerateOAuth2Url({
state: StateTypes.UserAuth,
scope: [
Scopes.Identify
],
prompt: PromptTypes.Consent,
responseCode: ResponseCodeTypes.Code,
})
await oauth2Repository.createLinkAttempt(redirectObject.state, playerUuid)
return redirectObject
}
async function handleAssociationCallback(provider, code, state) {
const playerUuid = await oauth2Repository.popLinkAttempt(state)
if (!playerUuid) {
throw new DefaultError(400, "Invalid or expired session state.", "InvalidStateError")
}
let isProviderAlreadyLinked = false
try {
await userService.getPlayerProperty(playerUuid, `${provider}Id`)
isProviderAlreadyLinked = true
} catch (error) {
if (error.code !== 404) throw error
}
if (isProviderAlreadyLinked) {
throw new DefaultError(409, `Account from ${provider} already linked to that player`, "AlreadyLinkedException")
}
try {
const tokenResponse = await oauth2_association.GetAccessToken(code)
const userProfile = await oauth2_association.UserDataSchema.GetUserProfile(tokenResponse.accessToken)
if (!userProfile || !userProfile.id) {
throw new DefaultError(500, `Failed to retrieve ${provider} profile.`)
}
await userService.addPlayerProperty(playerUuid, `${provider}Id`, userProfile.id)
return {
code: 200,
message: "Account linked successfully",
provider: {
id: userProfile.id,
username: userProfile.username
}
}
} catch (error) {
if (error instanceof DefaultError) throw error
throw new DefaultError(500, `${provider} authentication failed: + ${error.message}`)
}
}
async function unlinkAccount(provider, playerUuid) {
try {
const property = await userService.getPlayerProperty(playerUuid, `${provider}Id`).catch(() => null)
if (!property) {
throw new DefaultError(404, `No ${provider} account linked to this player.`, "NotLinkedError")
}
const success = await oauth2Repository.unlinkProviderAccount(provider, playerUuid)
if (!success) {
throw new DefaultError(500, "Failed to unlink the account. Please try again.")
}
return {
code: 200,
message: `${provider} account successfully unlinked.`
}
} catch (error) {
if (error instanceof DefaultError) throw error;
throw new DefaultError(500, "An error occurred during unlinking: " + error.message)
}
}
async function generateLoginDiscordURL() {
const redirectObject = await oauth2_login.GenerateOAuth2Url({
state: StateTypes.UserAuth,
scope: [
Scopes.Identify
],
prompt: PromptTypes.Consent,
responseCode: ResponseCodeTypes.Code,
})
return redirectObject
}
async function handleLoginCallback(provider, code, requestUser) {
try {
const tokenResponse = await oauth2_login.GetAccessToken(code)
const userProfile = await oauth2_login.UserDataSchema.GetUserProfile(tokenResponse.accessToken)
if (!userProfile || !userProfile.id) {
throw new DefaultError(500, `Failed to retrieve ${provider} profile.`)
}
const propertyObject = await userService.getPlayerPropertyByValue(`${provider}Id`, userProfile.id)
return await authService.authenticateWithoutPassword({ identifier: propertyObject.property.uuid, requireUser: requestUser || true })
} catch (error) {
if (error.code == 404) {
throw new YggdrasilError(404, "NotLinkedError", `No ${provider} account linked to any player.`)
}
if (error instanceof DefaultError) throw error
throw new DefaultError(500, `${provider} authentication failed: + ${error.message}`)
}
}
module.exports = {
unlinkAccount,
handleLoginCallback,
generateLoginDiscordURL,
handleAssociationCallback,
generateAssociationDiscordURL
}

39
services/serverService.js Normal file
View File

@@ -0,0 +1,39 @@
const certs = require("../modules/certificatesManager")
const utils = require("../modules/utils")
const package = require("../package.json")
function getServerMetadata(hostname) {
const keys = certs.getKeys()
const publicKeyPEM = keys.profilePropertyKeys.public
const serverMeta = {
meta: {
serverName: process.env.SERVER_NAME || "Yggdrasil Server",
implementationName: package.name,
implementationVersion: package.version,
"feature.legacy_skin_api": utils.isTrueFromDotEnv("SUPPORT_LEGACY_SKIN_API"),
"feature.no_mojang_namespace": utils.isTrueFromDotEnv("SUPPORT_MOJANG_FALLBACK"),
"feature.enable_mojang_anti_features": utils.isTrueFromDotEnv("SUPPORT_MOJANG_TELEMETRY_BLOCKER"),
"feature.enable_profile_key": utils.isTrueFromDotEnv("SUPPORT_PROFILE_KEY"),
"feature.username_check": utils.isTrueFromDotEnv("SUPPORT_ONLY_DEFAULT_USERNAME"),
links: {
homepage: process.env.HOMEPAGE_URL || `http://${hostname}`,
}
},
skinDomains: [
hostname,
`.${hostname}`
],
signaturePublickey: publicKeyPEM
}
if (utils.isTrueFromDotEnv("SUPPORT_REGISTER")) {
serverMeta.meta.links.register = process.env.REGISTER_ENDPOINT || `http://${hostname}/register`
}
return serverMeta
}
module.exports = {
getServerMetadata
}

View File

@@ -44,9 +44,9 @@ async function getBlockedServers() {
}
async function getProfile({ uuid, unsigned = false }) {
let userResult
let userResult, $uuid = utils.addDashesToUUID(uuid)
try {
userResult = await authRepository.getUser(uuid, false)
userResult = await authRepository.getUser($uuid, false)
} catch (error) {
if (error.code === 404) {
return { code: 204, message: "User not found" }
@@ -73,12 +73,12 @@ async function getProfile({ uuid, unsigned = false }) {
const hasValidCape = !!activeCape
const skinNode = hasValidSkin ? {
url: (process.env.TEXTURES_ENDPOINTS || `http://localhost:${process.env.WEB_PORT}/textures/`) + activeSkin.url,
url: (process.env.TEXTURES_ENDPOINTS || `http://localhost:${process.env.WEB_PORT}/textures`) + activeSkin.url,
metadata: activeSkin.variant === "SLIM" ? { model: "slim" } : undefined
} : undefined
const capeNode = hasValidCape ? {
url: (process.env.TEXTURES_ENDPOINTS || `http://localhost:${process.env.WEB_PORT}/textures/`) + activeCape.url
url: (process.env.TEXTURES_ENDPOINTS || `http://localhost:${process.env.WEB_PORT}/textures`) + activeCape.url
} : undefined
const texturesObject = {
@@ -122,6 +122,7 @@ async function joinServer({ accessToken, selectedProfile, clientToken, serverId,
} catch (error) {
throw new DefaultError(403, "Invalid access token", "ForbiddenOperationException")
}
await sessionRepository.saveServerSession(selectedProfile, accessToken, serverId, ip)
return { code: 204 }
}
@@ -152,10 +153,54 @@ async function hasJoinedServer({ username, serverId, ip }) {
})
}
async function joinLegacyServer({ name, sessionId, serverId }) {
try {
await validateLegacySession({ name, sessionId })
} catch (error) {
throw new DefaultError(403, "Bad login", "ForbiddenOperationException")
}
const userResult = await authRepository.getUser(name)
const uuid = userResult.user.uuid
await sessionRepository.saveServerSession(uuid, sessionId, serverId, "0.0.0.0")
return { code: 200, message: "OK" }
}
async function getActiveSkin({ username }) {
try {
const dbUser = await authRepository.getUser(username)
const activeSkin = await sessionRepository.getActiveSkin(dbUser.user.uuid)
return activeSkin
} catch (error) {
if (!(error instanceof DefaultError)) {
throw new DefaultError(400, "Bad Request", error.toString())
}
throw error
}
}
async function getActiveCape({ username }) {
try {
const dbUser = await authRepository.getUser(username)
const activeCape = await sessionRepository.getActiveCape(dbUser.user.uuid)
return activeCape
} catch (error) {
if (!(error instanceof DefaultError)) {
throw new DefaultError(400, "Bad Request", error.toString())
}
throw error
}
}
module.exports = {
getProfile,
joinServer,
getActiveCape,
getActiveSkin,
hasJoinedServer,
joinLegacyServer,
getBlockedServers,
registerLegacySession,
validateLegacySession,

View File

@@ -1,9 +1,11 @@
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")
@@ -12,6 +14,42 @@ 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)
@@ -28,6 +66,10 @@ 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)
}
@@ -451,15 +493,14 @@ async function uploadSkin(uuid, fileObject, variant) {
const existingTexture = await userRepository.getTextureByHash(hash)
if (!existingTexture) {
const subDir = hash.substring(0, 2)
const targetDir = path.join(TEXTURES_DIR, subDir)
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}`
const textureUrl = `texture/${hash}`
await userRepository.createTexture(newTextureUuid, hash, 'SKIN', textureUrl, null)
}
@@ -498,15 +539,43 @@ async function uploadSkinFromUrl(uuid, url, variant) {
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,
@@ -514,6 +583,7 @@ module.exports = {
getPlayerBans,
changeUsername,
getPreferences,
changePassword,
getBlockedUuids,
registerTexture,
getLegacyProfile,
@@ -523,6 +593,7 @@ module.exports = {
getPlayerProperty,
addPlayerProperty,
updatePreferences,
uploadSkinFromUrl,
getSettingsSchema,
getPlayerBanStatus,
removeProfileAction,
@@ -532,6 +603,7 @@ module.exports = {
getPlayerCertificate,
savePlayerCertificate,
clearAllPlayerActions,
getPlayerPropertyByValue,
getPlayerNameChangeStatus,
getPlayerUsernamesHistory,
deleteExpiredCertificates,