Pokemon_server / server /user-groups.ts
Jofthomas's picture
Jofthomas HF staff
Upload 4781 files
5c2ed06 verified
import { FS } from '../lib/fs';
import type { RoomSection } from './chat-commands/room-settings';
import { toID } from '../sim/dex-data';
export type GroupSymbol = '~' | '#' | '★' | '*' | '@' | '%' | '☆' | '§' | '+' | '^' | ' ' | '‽' | '!';
export type EffectiveGroupSymbol = GroupSymbol | 'whitelist';
export type AuthLevel = EffectiveGroupSymbol | 'unlocked' | 'trusted' | 'autoconfirmed';
export const PLAYER_SYMBOL: GroupSymbol = '\u2606';
export const HOST_SYMBOL: GroupSymbol = '\u2605';
export const ROOM_PERMISSIONS = [
'addhtml', 'announce', 'ban', 'bypassafktimer', 'declare', 'editprivacy', 'editroom', 'exportinputlog', 'game', 'gamemanagement', 'gamemoderation', 'joinbattle', 'kick', 'minigame', 'modchat', 'modlog', 'mute', 'nooverride', 'receiveauthmessages', 'roombot', 'roomdriver', 'roommod', 'roomowner', 'roomvoice', 'roomprizewinner', 'show', 'showmedia', 'timer', 'tournaments', 'warn',
] as const;
export const GLOBAL_PERMISSIONS = [
// administrative
'bypassall', 'console', 'disableladder', 'lockdown', 'potd',
// other
'addhtml', 'alts', 'altsself', 'autotimer', 'globalban', 'bypassblocks', 'bypassafktimer', 'forcepromote', 'forcerename', 'forcewin', 'gdeclare', 'hiderank', 'ignorelimits', 'importinputlog', 'ip', 'ipself', 'lock', 'makeroom', 'modlog', 'rangeban', 'promote',
] as const;
export type RoomPermission = typeof ROOM_PERMISSIONS[number];
export type GlobalPermission = typeof GLOBAL_PERMISSIONS[number];
export type GroupInfo = {
symbol: GroupSymbol,
id: ID,
name: string,
rank: number,
inherit?: GroupSymbol,
jurisdiction?: string,
globalonly?: boolean,
roomonly?: boolean,
battleonly?: boolean,
root?: boolean,
globalGroupInPersonalRoom?: GroupSymbol,
} & {
[P in RoomPermission | GlobalPermission]?: string | boolean;
};
/**
* Auth table - a Map for which users are in which groups.
*
* Notice that auth.get will return the default group symbol if the
* user isn't in a group.
*/
export abstract class Auth extends Map<ID, GroupSymbol | ''> {
/**
* Will return the default group symbol if the user isn't in a group.
*
* Passing a User will read `user.group`, which is relevant for unregistered
* users with temporary global auth.
*/
get(user: ID | User) {
if (typeof user !== 'string') return user.tempGroup;
return super.get(user) || Auth.defaultSymbol();
}
isStaff(userid: ID) {
if (this.has(userid)) {
const rank = this.get(userid);
// At one point bots used to be ranked above drivers, so this checks
// driver rank to make sure this function works on servers that
// did not reorder the ranks.
return Auth.atLeast(rank, '*') || Auth.atLeast(rank, '%');
} else {
return false;
}
}
atLeast(user: User, group: AuthLevel) {
if (user.hasSysopAccess()) return true;
if (group === 'trusted' || group === 'autoconfirmed') {
if (user.trusted && group === 'trusted') return true;
if (user.autoconfirmed && !user.locked && group === 'autoconfirmed') return true;
group = Config.groupsranking[1];
}
if (user.locked || user.semilocked) return false;
if (group === 'unlocked') return true;
if (group === 'whitelist' && this.has(user.id)) {
return true;
}
if (!Config.groups[group]) return false;
if (this.get(user.id) === ' ' && group !== ' ') return false;
return Auth.atLeast(this.get(user.id), group);
}
static defaultSymbol() {
return Config.groupsranking[0] as GroupSymbol;
}
static getGroup(symbol: EffectiveGroupSymbol): GroupInfo;
static getGroup<T>(symbol: EffectiveGroupSymbol, fallback: T): GroupInfo | T;
static getGroup(symbol: EffectiveGroupSymbol, fallback?: AnyObject) {
if (Config.groups[symbol]) return Config.groups[symbol];
if (fallback !== undefined) return fallback;
// unidentified groups are treated as voice
return {
...(Config.groups['+'] || {}),
symbol,
id: 'voice',
name: symbol,
};
}
getEffectiveSymbol(user: User): EffectiveGroupSymbol {
const group = this.get(user);
if (this.has(user.id) && group === Auth.defaultSymbol()) {
return 'whitelist';
}
return group;
}
static hasPermission(
user: User,
permission: string,
target: User | EffectiveGroupSymbol | ID | null,
room?: BasicRoom | null,
cmd?: string,
cmdToken?: string,
): boolean {
if (user.hasSysopAccess()) return true;
const auth: Auth = room ? room.auth : Users.globalAuth;
const symbol = auth.getEffectiveSymbol(user);
let targetSymbol: EffectiveGroupSymbol | null;
if (!target) {
targetSymbol = null;
} else if (typeof target === 'string' && !toID(target)) { // empty ID -> target is a group symbol
targetSymbol = target as EffectiveGroupSymbol;
} else {
targetSymbol = auth.get(target as User | ID);
}
if (!targetSymbol || ['whitelist', 'trusted', 'autoconfirmed'].includes(targetSymbol)) {
targetSymbol = Auth.defaultSymbol();
}
let group = Auth.getGroup(symbol);
if (group['root']) return true;
// Global drivers who are SLs should get room mod powers too
if (
room?.settings.section &&
room.settings.section === Users.globalAuth.sectionLeaders.get(user.id) &&
// But dont override ranks above moderator such as room owner
(Auth.getGroup('@').rank > group.rank)
) {
group = Auth.getGroup('@');
}
let jurisdiction = group[permission as GlobalPermission | RoomPermission];
if (jurisdiction === true && permission !== 'jurisdiction') {
jurisdiction = group['jurisdiction'] || true;
}
const roomPermissions = room ? room.settings.permissions : null;
if (roomPermissions) {
let foundSpecificPermission = false;
if (cmd) {
if (!cmdToken) cmdToken = `/`;
const namespace = cmd.slice(0, cmd.indexOf(' '));
if (roomPermissions[`${cmdToken}${cmd}`]) {
// this checks sub commands and command objects, but it checks to see if a sub-command
// overrides (should a perm for the command object exist) first
if (!auth.atLeast(user, roomPermissions[`${cmdToken}${cmd}`])) return false;
jurisdiction = 'u';
foundSpecificPermission = true;
} else if (roomPermissions[`${cmdToken}${namespace}`]) {
// if it's for one command object
if (!auth.atLeast(user, roomPermissions[`${cmdToken}${namespace}`])) return false;
jurisdiction = 'u';
foundSpecificPermission = true;
}
if (foundSpecificPermission && targetSymbol === Users.Auth.defaultSymbol()) {
// if /permissions has granted unranked users permission to use the command,
// grant jurisdiction over unranked (since unranked users don't have jurisdiction over unranked)
// see https://github.com/smogon/pokemon-showdown/pull/9534#issuecomment-1565719315
(jurisdiction as string) += Users.Auth.defaultSymbol();
}
}
if (!foundSpecificPermission && roomPermissions[permission]) {
if (!auth.atLeast(user, roomPermissions[permission])) return false;
jurisdiction = 'u';
}
}
return Auth.hasJurisdiction(symbol, jurisdiction, targetSymbol as GroupSymbol);
}
static atLeast(symbol: EffectiveGroupSymbol, symbol2: EffectiveGroupSymbol) {
return Auth.getGroup(symbol).rank >= Auth.getGroup(symbol2).rank;
}
static supportedRoomPermissions(room: Room | null = null) {
const commands = [];
for (const handler of Chat.allCommands()) {
if (!handler.hasRoomPermissions && !handler.broadcastable) continue;
// if it's only broadcast permissions, not use permissions, use the broadcast symbol
const cmdPrefix = handler.hasRoomPermissions ? "/" : "!";
commands.push(`${cmdPrefix}${handler.fullCmd}`);
if (handler.aliases.length) {
for (const alias of handler.aliases) {
// kind of a hack but this is the only good way i could think of to
// overwrite the alias without making assumptions about the string
commands.push(`${cmdPrefix}${handler.fullCmd.replace(handler.cmd, alias)}`);
}
}
}
return [
...ROOM_PERMISSIONS,
...commands,
];
}
static hasJurisdiction(
symbol: EffectiveGroupSymbol,
jurisdiction?: string | boolean,
targetSymbol?: GroupSymbol | null
) {
if (!targetSymbol) {
return !!jurisdiction;
}
if (typeof jurisdiction !== 'string') {
return !!jurisdiction;
}
if (jurisdiction.includes(targetSymbol)) {
return true;
}
if (jurisdiction.includes('a')) {
return true;
}
if (jurisdiction.includes('u') && Auth.getGroup(symbol).rank > Auth.getGroup(targetSymbol).rank) {
return true;
}
return false;
}
static listJurisdiction(user: User, permission: GlobalPermission | RoomPermission) {
const symbols = Object.keys(Config.groups) as GroupSymbol[];
return symbols.filter(targetSymbol => Auth.hasPermission(user, permission, targetSymbol));
}
static isValidSymbol(symbol: string): symbol is GroupSymbol {
if (symbol.length !== 1) return false;
return !/[A-Za-z0-9|,]/.test(symbol);
}
static isAuthLevel(level: string): level is AuthLevel {
if (Config.groupsranking.includes(level as EffectiveGroupSymbol)) return true;
return ['‽', '!', 'unlocked', 'trusted', 'autoconfirmed', 'whitelist'].includes(level);
}
static ROOM_PERMISSIONS = ROOM_PERMISSIONS;
static GLOBAL_PERMISSIONS = GLOBAL_PERMISSIONS;
}
export class RoomAuth extends Auth {
room: BasicRoom;
constructor(room: BasicRoom) {
super();
this.room = room;
}
get(userOrID: ID | User): GroupSymbol {
const id = typeof userOrID === 'string' ? userOrID : userOrID.id;
const parentAuth: Auth | null = this.room.parent ? this.room.parent.auth :
this.room.settings.isPrivate !== true ? Users.globalAuth : null;
const parentGroup = parentAuth ? parentAuth.get(userOrID) : Auth.defaultSymbol();
if (this.has(id)) {
// authority is whichever is higher between roomauth and global auth
const roomGroup = this.getDirect(id);
let group = Config.greatergroupscache[`${roomGroup}${parentGroup}`];
if (!group) {
// unrecognized groups always trump higher global rank
const roomRank = Auth.getGroup(roomGroup, { rank: Infinity }).rank;
const globalRank = Auth.getGroup(parentGroup).rank;
if (roomGroup === Users.PLAYER_SYMBOL || roomGroup === Users.HOST_SYMBOL || roomGroup === '#') {
// Player, Host, and Room Owner always trump higher global rank
group = roomGroup;
} else {
group = (roomRank > globalRank ? roomGroup : parentGroup);
}
Config.greatergroupscache[`${roomGroup}${parentGroup}`] = group;
}
return group;
}
return parentGroup;
}
getEffectiveSymbol(user: User) {
const symbol = super.getEffectiveSymbol(user);
if (!this.room.persist && symbol === user.tempGroup) {
const replaceGroup = Auth.getGroup(symbol).globalGroupInPersonalRoom;
if (replaceGroup) return replaceGroup;
}
// this is a bit of a hardcode, yeah, but admins need to have admin commands in prooms w/o the symbol
// and we want that to include sysops.
// Plus, using user.can is cleaner than Users.globalAuth.get(user) === admin and it accounts for more things.
// (and no this won't recurse or anything since user.can() with no room doesn't call this)
if (this.room.settings.isPrivate === true && user.can('makeroom')) {
// not hardcoding ~ here since globalAuth.get should return ~ in basically all cases
// except sysops, and there's an override for them anyways so it doesn't matter
return Users.globalAuth.get(user);
}
return symbol;
}
/** gets the room group without inheriting */
getDirect(id: ID): GroupSymbol {
return super.get(id);
}
save() {
// construct auth object
const auth = Object.create(null);
for (const [userid, groupSymbol] of this) {
auth[userid] = groupSymbol;
}
(this.room.settings as any).auth = auth;
this.room.saveSettings();
}
load() {
for (const userid in this.room.settings.auth) {
super.set(userid as ID, this.room.settings.auth[userid]);
}
}
set(id: ID, symbol: GroupSymbol) {
if (symbol === 'whitelist' as GroupSymbol) {
symbol = Auth.defaultSymbol();
}
super.set(id, symbol);
this.room.settings.auth[id] = symbol;
this.room.saveSettings();
const user = Users.get(id);
if (user) this.room.onUpdateIdentity(user);
return this;
}
delete(id: ID) {
if (!this.has(id)) return false;
super.delete(id);
delete this.room.settings.auth[id];
this.room.saveSettings();
return true;
}
}
export class GlobalAuth extends Auth {
usernames = new Map<ID, string>();
sectionLeaders = new Map<ID, RoomSection>();
constructor() {
super();
this.load();
}
save() {
FS('config/usergroups.csv').writeUpdate(() => {
let buffer = '';
for (const [userid, groupSymbol] of this) {
buffer += `${this.usernames.get(userid) || userid},${groupSymbol},${this.sectionLeaders.get(userid) || ''}\n`;
}
return buffer;
});
}
load() {
const data = FS('config/usergroups.csv').readIfExistsSync();
for (const row of data.split("\n")) {
if (!row) continue;
const [name, symbol, sectionid] = row.split(",");
const id = toID(name);
if (!id) {
Monitor.warn('Dropping malformed usergroups line (missing ID):');
Monitor.warn(row);
continue;
}
this.usernames.set(id, name);
if (sectionid) this.sectionLeaders.set(id, sectionid as RoomSection);
// handle glitched entries where a user has two entries in usergroups.csv due to bugs
const newSymbol = symbol.charAt(0) as GroupSymbol;
// Yes, we HAVE to ensure that it exists in the super. super.get here returns either the group symbol,
// or the default symbol if it cannot find a symbol in the map.
// the default symbol is truthy, and the symbol for trusted user is ` `
// meaning that the preexisting && atLeast would return true, which would skip the row and nuke all trusted users
// on a fresh load (aka, a restart).
const preexistingSymbol = super.has(id) ? super.get(id) : null;
// take a user's highest rank in usergroups.csv
if (preexistingSymbol && Auth.atLeast(preexistingSymbol, newSymbol)) continue;
super.set(id, newSymbol);
}
}
set(id: ID, group: GroupSymbol, username?: string) {
if (!username) username = id;
const user = Users.get(id, true);
if (user) {
user.tempGroup = group;
user.updateIdentity();
username = user.name;
Rooms.global.checkAutojoin(user);
}
this.usernames.set(id, username);
super.set(id, group);
void this.save();
return this;
}
delete(id: ID) {
if (!super.has(id)) return false;
super.delete(id);
const user = Users.get(id);
if (user) {
user.tempGroup = ' ';
}
this.usernames.delete(id);
this.save();
return true;
}
setSection(id: ID, sectionid: RoomSection, username?: string) {
if (!username) username = id;
const user = Users.get(id);
if (user) {
user.updateIdentity();
username = user.name;
Rooms.global.checkAutojoin(user);
}
if (!super.has(id)) this.set(id, ' ', username);
this.sectionLeaders.set(id, sectionid);
void this.save();
return this;
}
deleteSection(id: ID) {
if (!this.sectionLeaders.has(id)) return false;
this.sectionLeaders.delete(id);
if (super.get(id) === ' ') {
return this.delete(id);
}
const user = Users.get(id);
if (user) {
user.updateIdentity();
Rooms.global.checkAutojoin(user);
}
this.save();
return true;
}
}