Spaces:
Running
Running
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; | |
} | |
} | |