monitor/core/modules/AdminStore/index.js
2025-04-16 22:30:27 +07:00

683 lines
24 KiB
JavaScript

const modulename = 'AdminStore';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import { cloneDeep } from 'lodash-es';
import { nanoid } from 'nanoid';
import { txHostConfig } from '@core/globalData';
import CfxProvider from './providers/CitizenFX.js';
import { createHash } from 'node:crypto';
import consoleFactory from '@lib/console.js';
import fatalError from '@lib/fatalError.js';
import { chalkInversePad } from '@lib/misc.js';
const console = consoleFactory(modulename);
//NOTE: The way I'm doing versioning right now is horrible but for now it's the best I can do
//NOTE: I do not need to version every admin, just the file itself
const ADMIN_SCHEMA_VERSION = 1;
//Helpers
const migrateProviderIdentifiers = (providerName, providerData) => {
if (providerName === 'citizenfx') {
// data may be empty, or nameid may be invalid
try {
const res = /\/user\/(\d{1,8})/.exec(providerData.data.nameid);
providerData.identifier = `fivem:${res[1]}`;
} catch (error) {
providerData.identifier = 'fivem:00000000';
}
} else if (providerName === 'discord') {
providerData.identifier = `discord:${providerData.id}`;
}
};
/**
* Module responsible for storing, retrieving and validating admins data.
*/
export default class AdminStore {
constructor() {
this.adminsFile = txHostConfig.dataSubPath('admins.json');
this.adminsFileHash = null;
this.admins = null;
this.refreshRoutine = null;
//Not alphabetical order, but that's fine
//FIXME: move to a separate file
//TODO: maybe put in @shared so the frontend's UnauthorizedPage can use it
//TODO: when migrating the admins page to react, definitely put this in @shared so the front rendering doesn't depend on the backend response - lessons learned from the settings page.
//FIXME: if not using enums, definitely use so other type of type safety
//FIXME: maybe rename all_permissions to `administrator` (just like discord) or `super_admin` and rename the `Admins` page to `Users`. This fits better with how people use txAdmin as "mods" are not really admins
this.registeredPermissions = {
'all_permissions': 'All Permissions',
'manage.admins': 'Manage Admins', //will enable the "set admin" button in the player modal
'settings.view': 'Settings: View (no tokens)',
'settings.write': 'Settings: Change',
'console.view': 'Console: View',
'console.write': 'Console: Write',
'control.server': 'Start/Stop Server + Scheduler', //FIXME: horrible name
'announcement': 'Send Announcements',
'commands.resources': 'Start/Stop Resources',
'server.cfg.editor': 'Read/Write server.cfg', //FIXME: rename to server.cfg_editor
'txadmin.log.view': 'View System Logs', //FIXME: rename to system.log.view
'server.log.view': 'View Server Logs',
'menu.vehicle': 'Spawn / Fix Vehicles',
'menu.clear_area': 'Reset world area',
'menu.viewids': 'View Player IDs in-game', //be able to see the ID of the players
'players.direct_message': 'Direct Message',
'players.whitelist': 'Whitelist',
'players.warn': 'Warn',
'players.kick': 'Kick',
'players.ban': 'Ban',
'players.freeze': 'Freeze Players',
'players.heal': 'Heal', //self, everyone, and the "heal" button in player modal
'players.playermode': 'NoClip / God Mode', //self playermode, and also the player spectate option
'players.spectate': 'Spectate', //self playermode, and also the player spectate option
'players.teleport': 'Teleport', //self teleport, and the bring/go to on player modal
'players.troll': 'Troll Actions', //all the troll options in the player modal
};
//FIXME: pode remover, hardcode na cron function
this.hardConfigs = {
refreshInterval: 15e3,
};
//Load providers
//FIXME: pode virar um top-level singleton , não precisa estar na classe
try {
this.providers = {
discord: false,
citizenfx: new CfxProvider(),
};
} catch (error) {
throw new Error(`Failed to load providers with error: ${error.message}`);
}
//Check if admins file exists
let adminFileExists;
try {
fs.statSync(this.adminsFile, fs.constants.F_OK);
adminFileExists = true;
} catch (error) {
if (error.code === 'ENOENT') {
adminFileExists = false;
} else {
throw new Error(`Failed to check presence of admin file with error: ${error.message}`);
}
}
//Printing PIN or starting loop
if (!adminFileExists) {
if (!txHostConfig.defaults.account) {
this.addMasterPin = (Math.random() * 10000).toFixed().padStart(4, '0');
this.admins = false;
} else {
const { username, fivemId, password } = txHostConfig.defaults.account;
this.createAdminsFile(
username,
fivemId ? `fivem:${fivemId}` : undefined,
undefined,
password,
password ? false : undefined,
);
console.ok(`Created master account ${chalkInversePad(username)} with credentials provided by ${txHostConfig.sourceName}.`);
}
} else {
this.loadAdminsFile();
this.setupRefreshRoutine();
}
}
/**
* sets the admins file refresh routine
*/
setupRefreshRoutine() {
this.refreshRoutine = setInterval(() => {
this.checkAdminsFile();
}, this.hardConfigs.refreshInterval);
}
/**
* Creates a admins.json file based on the first account
* @param {string} username
* @param {string|undefined} fivemId with the fivem: prefix
* @param {string|undefined} discordId with the discord: prefix
* @param {string|undefined} password backup password
* @param {boolean|undefined} isPlainTextPassword
* @returns {(boolean)} true or throws an error
*/
createAdminsFile(username, fivemId, discordId, password, isPlainTextPassword) {
//Sanity check
if (this.admins !== false && this.admins !== null) throw new Error('Admins file already exists.');
if (typeof username !== 'string' || username.length < 3) throw new Error('Invalid username parameter.');
//Handling password
let password_hash, password_temporary;
if(password){
password_hash = isPlainTextPassword ? GetPasswordHash(password) : password;
// password_temporary = false; //undefined will do the same
} else {
const veryRandomString = `${username}-password-not-meant-to-be-used-${nanoid()}`;
password_hash = GetPasswordHash(veryRandomString);
password_temporary = true;
}
//Handling third party providers
const providers = {};
if (fivemId) {
providers.citizenfx = {
id: username,
identifier: fivemId,
data: {},
};
}
if (discordId) {
providers.discord = {
id: discordId,
identifier: `discord:${discordId}`,
data: {},
};
}
//Creating new admin
const newAdmin = {
$schema: ADMIN_SCHEMA_VERSION,
name: username,
master: true,
password_hash,
password_temporary,
providers,
permissions: [],
};
this.admins = [newAdmin];
this.addMasterPin = undefined;
//Saving admin file
try {
const jsonData = JSON.stringify(this.admins);
this.adminsFileHash = createHash('sha1').update(jsonData).digest('hex');
fs.writeFileSync(this.adminsFile, jsonData, { encoding: 'utf8', flag: 'wx' });
this.setupRefreshRoutine();
return newAdmin;
} catch (error) {
let message = `Failed to create '${this.adminsFile}' with error: ${error.message}`;
console.verbose.error(message);
throw new Error(message);
}
}
/**
* Returns a list of admins and permissions
*/
getAdminsList() {
if (this.admins == false) return [];
return this.admins.map((user) => {
return {
name: user.name,
master: user.master,
providers: Object.keys(user.providers),
permissions: user.permissions,
};
});
}
/**
* Returns the raw array of admins, except for the hash
*/
getRawAdminsList() {
if (this.admins === false) return [];
return cloneDeep(this.admins);
}
/**
* Returns all data from an admin by provider user id (ex discord id), or false
* @param {string} uid
*/
getAdminByProviderUID(uid) {
if (this.admins == false) return false;
let id = uid.trim().toLowerCase();
if (!id.length) return false;
let admin = this.admins.find((user) => {
return Object.keys(user.providers).find((provider) => {
return (id === user.providers[provider].id.toLowerCase());
});
});
return (admin) ? cloneDeep(admin) : false;
}
/**
* Returns an array with all identifiers of the admins (fivem/discord)
*/
getAdminsIdentifiers() {
if (this.admins === false) return [];
const ids = [];
for (const admin of this.admins) {
admin.providers.citizenfx && ids.push(admin.providers.citizenfx.identifier);
admin.providers.discord && ids.push(admin.providers.discord.identifier);
}
return ids;
}
/**
* Returns all data from an admin by their name, or false
* @param {string} uname
*/
getAdminByName(uname) {
if (!this.admins) return false;
const username = uname.trim().toLowerCase();
if (!username.length) return false;
const admin = this.admins.find((user) => {
return (username === user.name.toLowerCase());
});
return (admin) ? cloneDeep(admin) : false;
}
/**
* Returns all data from an admin by game identifier, or false
* @param {string[]} identifiers
*/
getAdminByIdentifiers(identifiers) {
if (!this.admins) return false;
identifiers = identifiers
.map((i) => i.trim().toLowerCase())
.filter((i) => i.length);
if (!identifiers.length) return false;
const admin = this.admins.find((user) =>
identifiers.find((identifier) =>
Object.keys(user.providers).find((provider) =>
(identifier === user.providers[provider].identifier.toLowerCase()))));
return (admin) ? cloneDeep(admin) : false;
}
/**
* Returns a list with all registered permissions
*/
getPermissionsList() {
return cloneDeep(this.registeredPermissions);
}
/**
* Writes to storage the admins file
*/
async writeAdminsFile() {
const jsonData = JSON.stringify(this.admins, null, 2);
this.adminsFileHash = createHash('sha1').update(jsonData).digest('hex');
await fsp.writeFile(this.adminsFile, jsonData, 'utf8');
return true;
}
/**
* Writes to storage the admins file
*/
async checkAdminsFile() {
const restore = async () => {
try {
await this.writeAdminsFile();
console.ok('Restored admins.json file.');
} catch (error) {
console.error(`Failed to restore admins.json file: ${error.message}`);
console.verbose.dir(error);
}
};
try {
const jsonData = await fsp.readFile(this.adminsFile, 'utf8');
const inboundHash = createHash('sha1').update(jsonData).digest('hex');
if (this.adminsFileHash !== inboundHash) {
console.warn('The admins.json file was modified or deleted by an external source, txAdmin will try to restore it.');
restore();
}
} catch (error) {
console.error(`Cannot check admins file integrity: ${error.message}`);
}
}
/**
* Add a new admin to the admins file
* NOTE: I'm fully aware this coud be optimized. Leaving this way to improve readability and error verbosity
* @param {string} name
* @param {object|undefined} citizenfxData or false
* @param {object|undefined} discordData or false
* @param {string} password
* @param {array} permissions
*/
async addAdmin(name, citizenfxData, discordData, password, permissions) {
if (this.admins == false) throw new Error('Admins not set');
//Check if username is already taken
if (this.getAdminByName(name)) throw new Error('Username already taken');
//Preparing admin
const admin = {
$schema: ADMIN_SCHEMA_VERSION,
name,
master: false,
password_hash: GetPasswordHash(password),
password_temporary: true,
providers: {},
permissions,
};
//Check if provider uid already taken and inserting into admin object
if (citizenfxData) {
const existingCitizenFX = this.getAdminByProviderUID(citizenfxData.id);
if (existingCitizenFX) throw new Error('CitizenFX ID already taken');
admin.providers.citizenfx = {
id: citizenfxData.id,
identifier: citizenfxData.identifier,
data: {},
};
}
if (discordData) {
const existingDiscord = this.getAdminByProviderUID(discordData.id);
if (existingDiscord) throw new Error('Discord ID already taken');
admin.providers.discord = {
id: discordData.id,
identifier: discordData.identifier,
data: {},
};
}
//Saving admin file
this.admins.push(admin);
this.refreshOnlineAdmins().catch((e) => { });
try {
return await this.writeAdminsFile();
} catch (error) {
throw new Error(`Failed to save admins.json with error: ${error.message}`);
}
}
/**
* Edit admin and save to the admins file
* @param {string} name
* @param {string|null} password
* @param {object|false} [citizenfxData] or false
* @param {object|false} [discordData] or false
* @param {string[]} [permissions]
*/
async editAdmin(name, password, citizenfxData, discordData, permissions) {
if (this.admins == false) throw new Error('Admins not set');
//Find admin index
let username = name.toLowerCase();
let adminIndex = this.admins.findIndex((user) => {
return (username === user.name.toLowerCase());
});
if (adminIndex == -1) throw new Error('Admin not found');
//Editing admin
if (password !== null) {
this.admins[adminIndex].password_hash = GetPasswordHash(password);
delete this.admins[adminIndex].password_temporary;
}
if (typeof citizenfxData !== 'undefined') {
if (!citizenfxData) {
delete this.admins[adminIndex].providers.citizenfx;
} else {
this.admins[adminIndex].providers.citizenfx = {
id: citizenfxData.id,
identifier: citizenfxData.identifier,
data: {},
};
}
}
if (typeof discordData !== 'undefined') {
if (!discordData) {
delete this.admins[adminIndex].providers.discord;
} else {
this.admins[adminIndex].providers.discord = {
id: discordData.id,
identifier: discordData.identifier,
data: {},
};
}
}
if (typeof permissions !== 'undefined') this.admins[adminIndex].permissions = permissions;
//Prevent race condition, will allow the session to be updated before refreshing socket.io
//sessions which will cause reauth and closing of the temp password modal on first access
setTimeout(() => {
this.refreshOnlineAdmins().catch((e) => { });
}, 250);
//Saving admin file
try {
await this.writeAdminsFile();
return (password !== null) ? this.admins[adminIndex].password_hash : true;
} catch (error) {
throw new Error(`Failed to save admins.json with error: ${error.message}`);
}
}
/**
* Delete admin and save to the admins file
* @param {string} name
*/
async deleteAdmin(name) {
if (this.admins == false) throw new Error('Admins not set');
//Delete admin
let username = name.toLowerCase();
let found = false;
this.admins = this.admins.filter((user) => {
if (username !== user.name.toLowerCase()) {
return true;
} else {
found = true;
return false;
}
});
if (!found) throw new Error('Admin not found');
//Saving admin file
this.refreshOnlineAdmins().catch((e) => { });
try {
return await this.writeAdminsFile();
} catch (error) {
throw new Error(`Failed to save admins.json with error: ${error.message}`);
}
}
/**
* Loads the admins.json file into the admins list
* NOTE: The verbosity here is driving me insane.
* But still seems not to be enough for people that don't read the README.
*/
async loadAdminsFile() {
let raw = null;
let jsonData = null;
let hasMigration = false;
const callError = (reason) => {
let details;
if (reason === 'cannot read file') {
details = ['This means the file doesn\'t exist or txAdmin doesn\'t have permission to read it.'];
} else {
details = [
'This likely means the file got somehow corrupted.',
'You can try restoring it or you can delete it and let txAdmin create a new one.',
];
}
fatalError.AdminStore(0, [
['Unable to load admins.json', reason],
...details,
['Admin File Path', this.adminsFile],
]);
};
try {
raw = await fsp.readFile(this.adminsFile, 'utf8');
this.adminsFileHash = createHash('sha1').update(raw).digest('hex');
} catch (error) {
return callError('cannot read file');
}
if (!raw.length) {
return callError('empty file');
}
try {
jsonData = JSON.parse(raw);
} catch (error) {
return callError('json parse error');
}
if (!Array.isArray(jsonData)) {
return callError('not an array');
}
if (!jsonData.length) {
return callError('no admins');
}
const structureIntegrityTest = jsonData.some((x) => {
if (typeof x.name !== 'string' || x.name.length < 3) return true;
if (typeof x.master !== 'boolean') return true;
if (typeof x.password_hash !== 'string' || !x.password_hash.startsWith('$2')) return true;
if (typeof x.providers !== 'object') return true;
const providersTest = Object.keys(x.providers).some((y) => {
if (!Object.keys(this.providers).includes(y)) return true;
if (typeof x.providers[y].id !== 'string' || x.providers[y].id.length < 3) return true;
if (typeof x.providers[y].data !== 'object') return true;
if (typeof x.providers[y].identifier === 'string') {
if (x.providers[y].identifier.length < 3) return true;
} else {
migrateProviderIdentifiers(y, x.providers[y]);
hasMigration = true;
}
});
if (providersTest) return true;
if (!Array.isArray(x.permissions)) return true;
return false;
});
if (structureIntegrityTest) {
return callError('invalid data in the admins file');
}
const masters = jsonData.filter((x) => x.master);
if (masters.length !== 1) {
return callError('must have exactly 1 master account');
}
//Migrate admin stuff
jsonData.forEach((admin) => {
//Migration (tx v7.3.0)
if (admin.$schema === undefined) {
//adding schema version
admin.$schema = ADMIN_SCHEMA_VERSION;
hasMigration = true;
//separate DM and Announcement permissions
if (admin.permissions.includes('players.message')) {
hasMigration = true;
admin.permissions = admin.permissions.filter((perm) => perm !== 'players.message');
admin.permissions.push('players.direct_message');
admin.permissions.push('announcement');
}
//Adding the new permission, except if they have no permissions or all of them
if (admin.permissions.length && !admin.permissions.includes('all_permissions')) {
admin.permissions.push('server.log.view');
}
}
});
this.admins = jsonData;
if (hasMigration) {
try {
await this.writeAdminsFile();
console.ok('The admins.json file was migrated to a new version.');
} catch (error) {
console.error(`Failed to migrate admins.json with error: ${error.message}`);
}
}
return true;
}
/**
* Notify game server about admin changes
*/
async refreshOnlineAdmins() {
//Refresh auth of all admins connected to socket.io
txCore.webServer.webSocket.reCheckAdminAuths().catch((e) => { });
try {
//Getting all admin identifiers
const adminIDs = this.admins.reduce((ids, adm) => {
const adminIDs = Object.keys(adm.providers).map((pName) => adm.providers[pName].identifier);
return ids.concat(adminIDs);
}, []);
//Finding online admins
const playerList = txCore.fxPlayerlist.getPlayerList();
const onlineIDs = playerList.filter((p) => {
return p.ids.some((i) => adminIDs.includes(i));
}).map((p) => p.netid);
txCore.fxRunner.sendEvent('adminsUpdated', onlineIDs);
} catch (error) {
console.verbose.error('Failed to refreshOnlineAdmins() with error:');
console.verbose.dir(error);
}
}
/**
* Returns a random token to be used as CSRF Token.
*/
genCsrfToken() {
return nanoid();
}
/**
* Checks if there are admins configured or not.
* Optionally, prints the master PIN on the console.
*/
hasAdmins(printPin = false) {
if (Array.isArray(this.admins) && this.admins.length) {
return true;
} else {
if (printPin) {
console.warn('Use this PIN to add a new master account: ' + chalkInversePad(this.addMasterPin));
}
return false;
}
}
/**
* Returns the public name to display for that particular purpose
* TODO: maybe use enums for the purpose
*/
getAdminPublicName(name, purpose) {
if (!name || !purpose) throw new Error('Invalid parameters');
const replacer = txConfig.general.serverName ?? 'txAdmin';
if (purpose === 'punishment') {
return txConfig.gameFeatures.hideAdminInPunishments ? replacer : name;
} else if (purpose === 'message') {
return txConfig.gameFeatures.hideAdminInMessages ? replacer : name;
} else {
throw new Error(`Invalid purpose: ${purpose}`);
}
}
};