import { NgZone, Injectable } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; import { merge, remove, repeat, noop } from 'lodash'; import { SocketService, createClientSocket } from 'ag-sockets/dist/browser'; import { fromNow } from '../../common/utils'; import { DAY, MINUTE } from '../../common/constants'; import { AccountData, AccountCounters } from '../../common/interfaces'; import { Account, Auth, Character, Origin, OriginInfo, Event, AdminState, UpdateOrigin, AccountFlags, IAdminServerActions, FindPonyQuery, AuthUpdate, ItemCounts, SupporterFlags, GameServerSettings, Settings, AccountState, MergeAccountData, FindAccountQuery, ClearOrignsOptions, OriginInfoBase, PonyIdDateName, Stats, BaseValues, } from '../../common/adminInterfaces'; import { ClientAdminActions } from '../../client/clientAdminActions'; import { LiveCollection } from './liveCollection'; import { socketOptions, token } from '../../client/data'; import { getUrl } from '../../client/rev'; import { formatChat, formatEventDesc, getId, banMessage, parsePonies } from '../../common/adminUtils'; import { StorageService } from './storageService'; import { decompressPonyString } from '../../common/compressPony'; import { ModelSubscriber } from './modelSubscriber'; interface FindPoniesResult { items: string[]; totalCount: number; } const notification = window.Notification; function shouldNotify(e: Event) { return !/^(Spam|Suspicious message|Invalid account|Invite limit reached|Timed out for (swearing|spamming))$/i .test(e.message); } @Injectable({ providedIn: 'root' }) export class AdminModel { account?: AccountData; counts: ItemCounts = { accounts: 0, characters: 0, auths: 0, origins: 0, }; initialized = false; state: AdminState = { status: { diskSpace: '', memoryUsage: '', certificateExpiration: '', lastPatreonUpdate: '', }, loginServers: [ { updating: false, dead: false, }, ], gameServers: [], }; updated?: (list: string, added: boolean) => void; // TODO: remove this duplicateEntries?: string[]; error?: string; log = (..._: any[]) => { }; get loading(): boolean { return !this.account; } accountPromise!: Promise; accounts: ModelSubscriber; origins: ModelSubscriber; auths: ModelSubscriber; ponies: ModelSubscriber; accountAuths: ModelSubscriber; accountPonies: ModelSubscriber; accountOrigins: ModelSubscriber; private liveEvents: LiveCollection; private handleError = (error: Error) => { console.error(error); this.error = error.message; return undefined; } private checkError = (promise: Promise) => promise.catch(this.handleError) as Promise; private running = true; private initializedLive = false; private socket: SocketService; private resolveAccount!: (account: AccountData) => void; private initAccountPromise() { this.accountPromise = new Promise(resolve => { this.resolveAccount = resolve; }); } constructor(private sanitizer: DomSanitizer, private storage: StorageService, zone: NgZone) { this.initAccountPromise(); this.socket = createClientSocket( { ...socketOptions() }, token, undefined, zone.run.bind(zone)); (window as any).model = this; if (this.socket) { this.socket.client = new ClientAdminActions(this); this.socket.connect(); } this.accounts = new ModelSubscriber('accounts', this.socket, { fix: account => { account.createdAt = new Date(account.createdAt!); account.updatedAt = new Date(account.updatedAt!); account.lastVisit = account.lastVisit && new Date(account.lastVisit); if (account.alert) { account.alert.expires = new Date(account.alert.expires); } }, }); this.auths = new ModelSubscriber('auths', this.socket, { fix: account => { account.updatedAt = new Date(account.updatedAt!); account.lastUsed = account.lastUsed && new Date(account.lastUsed); }, }); this.ponies = new ModelSubscriber('ponies', this.socket, { fix: character => { character.createdAt = new Date(character.createdAt!); character.updatedAt = new Date(character.updatedAt!); character.lastUsed = character.lastUsed && new Date(character.lastUsed); }, }); this.origins = new ModelSubscriber('origins', this.socket, {}); this.accountAuths = new ModelSubscriber('accountAuths', this.socket, {}, []); this.accountPonies = new ModelSubscriber('accountPonies', this.socket, {}, []); this.accountOrigins = new ModelSubscriber('accountOrigins', this.socket, {}, []); this.liveEvents = new LiveCollection('events', 1000, getId, { decode: decodeEvent, onUpdated: (added, all) => { if (all.length) { this.log(`events ${all.length}`); } all.forEach(e => { e.descHTML = this.sanitizer.bypassSecurityTrustHtml(formatEventDesc(e.desc)); }); this.callUpdated('events', !!added); this.updateTitle(); if (this.notifications) { added.filter(shouldNotify).forEach(e => this.notify(e.message, e.desc)); all.filter(e => e.count === 10).forEach(e => this.notify(e.message, e.desc)); } }, onDelete: () => this.updateTitle(), }, this.socket); if (!this.socket) { this.initialize(true); } } get server() { return this.socket.server; } get notifications() { return this.storage.getItem('admin-notifications') === 'true'; } get connected() { return this.socket.isConnected; } get events() { return this.liveEvents.items; } get loaded() { return this.liveEvents.finished; } initialize(live: boolean) { if (this.initializedLive) return; notification.requestPermission(); this.initializedLive = true; this.server.getSignedAccount() .then(account => { this.account = account; this.updateState(); this.resolveAccount(account); if (live) { setTimeout(() => this.liveEvents.live(), 100); setInterval(() => this.checkDuplicateEntries(), 60 * MINUTE); setInterval(() => { if (this.connected) { this.getCounts().then(counts => this.counts = counts || this.counts); } }, 5 * 1000); } }); } connectedToSocket() { this.accounts.connected(); this.origins.connected(); this.auths.connected(); this.ponies.connected(); this.accountAuths.connected(); this.accountPonies.connected(); this.accountOrigins.connected(); this.updateTitle(); } checkDuplicateEntries(force = false) { return this.server.getDuplicateEntries(force) .then(entries => this.duplicateEntries = entries || []) .catch(noop); } stop() { this.running = false; this.liveEvents.stop(); } toggleNotifications() { this.storage.setItem('admin-notifications', this.notifications ? 'false' : 'true'); } // other getCounts() { return this.checkError(this.server.getCounts()); } getRequestStats() { return this.checkError(this.server.getRequestStats()); } getOtherStats() { return this.checkError(this.server.getOtherStats()); } // auths getAuth(id: string) { return this.checkError(this.server.getAuth(id)); } getAuthsForAccount(accountId: string) { return this.checkError(this.server.getAuthsForAccount(accountId)); } removeAuth(id: string) { return this.checkError(this.server.removeAuth(id)); } assignAuth(authId: string, accountId: string) { return this.checkError(this.server.assignAuth(authId, accountId)); } updateAuth(id: string, update: AuthUpdate) { return this.checkError(this.server.updateAuth(id, update)); } setAuthPledged(id: string, pledged: number) { return this.checkError(this.server.updateAuth(id, { pledged })); } // ponies getPoniesCreators(accountId: string) { return this.checkError(this.server.getPoniesCreators(accountId)); } getPoniesForAccount(accountId: string) { return this.checkError(this.server.getPoniesForAccount(accountId)); } getPonyInfo(pony: Character) { return this.server.getPonyInfo(pony._id) .then(data => { if (data) { pony.info = data.info; pony.ponyInfo = decompressPonyString(data.info, false); pony.lastUsed = data.lastUsed ? new Date(data.lastUsed) : undefined; pony.creator = data.creator; } }); } removePony(id: string) { return this.checkError(this.server.removePony(id)); } assignPony(ponyId: string, accountId: string) { return this.checkError(this.server.assignPony(ponyId, accountId)); } findPonies(query: FindPonyQuery, page: number, skipTotalCount: boolean): Promise { return this.checkError(this.server.findPonies(query, page, skipTotalCount)); } removePoniesAboveLimit(account: string) { return this.checkError(this.server.removePoniesAboveLimit(account)); } removeAllPonies(account: string) { return this.checkError(this.server.removeAllPonies(account)); } createPony(accountId: string, name: string, info: string) { return this.checkError(this.server.createPony(accountId, name, info)); } restorePonies(accountId: string, chatlog: string, onlyIds?: string[]) { const ponies = parsePonies(chatlog, onlyIds); return Promise.all(ponies.map(({ name, info }) => this.createPony(accountId, name, info))); } // origins updateOrigin(origin: UpdateOrigin) { return this.checkError(this.server.updateOrigin(origin)); } removeOriginsForAccount(accountId: string, ips: string[]) { return this.checkError(this.server.removeOriginsForAccount(accountId, ips)); } clearOriginsForAccount(accountId: string, options: ClearOrignsOptions) { return this.clearOriginsForAccounts([accountId], options); } clearOriginsForAccounts(accounts: string[], options: ClearOrignsOptions) { return this.checkError(this.server.clearOriginsForAccounts(accounts, options)); } clearOrigins(count: number, andHigher: boolean, options: ClearOrignsOptions) { return this.checkError(this.server.clearOrigins(count, andHigher, options)); } addOriginToAccount(accountId: string, origin: OriginInfo) { return this.checkError(this.server.addOriginToAccount(accountId, origin)); } getOriginStats() { return this.checkError(this.server.getOriginStats()); } // accounts getAccount(id: string) { return this.checkError(this.server.getAccount(id)); } getDetailsForAccount(account: Account) { return this.checkError(this.server.getDetailsForAccount(account._id)); } findAccounts(query: FindAccountQuery) { return this.checkError(this.server.findAccounts(query)); } createAccount(name = '') { return this.checkError(this.server.createAccount(name) .then(id => (console.log(`created account: [${id}]`), id))); } getAccountStatus(accountId: string) { return this.checkError(this.server.getAccountStatus(accountId)); } getAccountAround(accountId: string) { return this.checkError(this.server.getAccountAround(accountId)); } getAccountHidden(accountId: string) { return this.checkError(this.server.getAccountHidden(accountId)); } getAccountFriends(accountId: string) { return this.checkError(this.server.getAccountFriends(accountId)); } getAllDuplicatesQuickInfo(accountId: string) { return this.checkError(this.server.getAllDuplicatesQuickInfo(accountId)); } getAllDuplicates(accountId: string) { return this.checkError(this.server.getAllDuplicates(accountId)); } getAccountsByEmails(emails: string[]) { return this.checkError(this.server.getAccountsByEmails(emails)); } getAccountsByOrigin(ip: string) { return this.checkError(this.server.getAccountsByOrigin(ip)); } removeAccount(accountId: string) { return this.checkError(this.server.removeAccount(accountId)); } setAlert(accountId: string, message: string, expiresIn: number) { return this.checkError(this.server.setAlert(accountId, message, expiresIn)); } setName(accountId: string, name: string) { return this.checkError(this.server.setName(accountId, name)); } setAge(accountId: string, age: number) { return this.checkError(this.server.setAge(accountId, age)); } setRole(accountId: string, role: string, set: boolean) { return this.checkError(this.server.setRole(accountId, role, set)); } setNote(accountId: string, note: string) { const account = this.accounts.get(accountId); if (account && account.note !== note) { account.note = note; account.noteUpdated = new Date(); } return this.checkError(this.server.updateAccount(accountId, { note })); } setAccountFlags(accountId: string, flags: AccountFlags) { return this.checkError(this.server.updateAccount(accountId, { flags })); } setSupporterFlags(accountId: string, supporter: SupporterFlags) { return this.checkError(this.server.updateAccount(accountId, { supporter })); } setAccountBanField(accountId: string, field: string, value: number) { return this.checkError(this.server.updateAccount(accountId, { [field]: value }, banMessage(field, value))); } setAccountTimeout(accountId: string, timeout: number) { return this.checkError(this.server.timeoutAccount(accountId, timeout)); } setAccountCounter(accountId: string, name: keyof AccountCounters, value: number) { return this.checkError(this.server.updateAccountCounter(accountId, name, value)); } updateAccount(accountId: string, update: Partial) { return this.checkError(this.server.updateAccount(accountId, update)); } mergeAccounts(accountId: string, withId: string) { return this.checkError(this.server.mergeAccounts(accountId, withId)); } unmergeAccounts(accountId: string, mergeId: string | undefined, split: MergeAccountData, keep: MergeAccountData) { return this.checkError(this.server.unmergeAccounts(accountId, mergeId, split, keep)); } addEmail(accountId: string, email: string) { return this.checkError(this.server.addEmail(accountId, email)); } removeEmail(accountId: string, email: string) { return this.checkError(this.server.removeEmail(accountId, email)); } removeIgnore(accountId: string, ignore: string) { return this.checkError(this.server.removeIgnore(accountId, ignore)); } addIgnores(accountId: string, ignores: string[]) { return this.checkError(this.server.addIgnores(accountId, ignores)); } removeFriend(accountId: string, friendId: string) { return this.checkError(this.server.removeFriend(accountId, friendId)); } addFriend(accountId: string, friendId: string) { return this.checkError(this.server.addFriend(accountId, friendId)); } setAccountState(accountId: string, state: AccountState) { return this.checkError(this.server.setAccountState(accountId, state)); } getIgnoresAndIgnoredBy(accountId: string) { return this.checkError(this.server.getIgnoresAndIgnoredBy(accountId)); } clearSessions(accountId: string) { return this.checkError(this.server.clearSessions(accountId)); } // events removeEvent(eventId: string) { // return this.checkError(this.server.removeEvent(eventId)) return this.liveEvents.remove(eventId) .then(() => this.callUpdated('events', false)) .then(() => this.updateTitle()) .catch(this.handleError); } cleanupDeletedEvents() { if (remove(this.events, e => e.deleted).length) { this.callUpdated('events', false); this.updateTitle(); } } // state updateSettings(settings: Partial) { return this.server.updateSettings(settings) .then(() => this.updateState()); } updateGameServerSettings(serverId: string, settings: Partial) { return this.server.updateGameServerSettings(serverId, settings) .then(() => this.updateState()); } report(accountId: string) { return this.checkError(this.server.report(accountId)); } action(action: string, accountId: string) { return this.checkError(this.server.action(action, accountId)); } kick(accountId: string) { return this.checkError(this.server.kick(accountId)); } kickAll(serverId: string) { return this.server.kickAll(serverId) .then(() => this.updateState()); } getChat(search: string, date?: string, caseInsensitive = false) { date = date || (new Date()).toISOString(); return search ? this.server.getChat(search, date, caseInsensitive) : Promise.resolve(''); } getChatForAccounts(accountIds: string[], date?: string) { date = date || (new Date()).toISOString(); return accountIds.length ? this.server.getChatForAccounts(accountIds, date) : Promise.resolve(''); } searchFormattedChat(search: string, date?: string) { return this.formatChat(this.getChat(search, date, true)); } accountsFormattedChat(accountIds: string[], date?: string) { return this.formatChat(this.getChatForAccounts(accountIds, date)); } private formatChat(promise: Promise) { return this.checkError(promise) .then(chat => chat === undefined ? 'ERROR' : chat) .then(raw => ({ raw, html: formatChat(raw) })); } fetchServerStats(serverId: string) { return this.checkError(this.server.fetchServerStats(serverId)); } fetchServerStatsTable(serverId: string, stats: Stats) { return this.checkError(this.server.fetchServerStatsTable(serverId, stats)); } notifyUpdate(server: string) { return this.server.notifyUpdate(server) .then(() => this.updateState()) .catch(this.handleError); } shutdownServers(server: string) { return this.server.shutdownServers(server) .then(() => this.updateState()) .catch(this.handleError); } resetUpdating(server: string) { return this.server.resetUpdating(server) .then(() => this.updateState()) .catch(this.handleError); } resetSupporter(accountId: string) { return this.server.resetSupporter(accountId) .catch(this.handleError); } getLastPatreonData() { return this.server.getLastPatreonData() .catch(this.handleError); } updatePastSupporters() { return this.server.updatePastSupporters() .catch(this.handleError); } // other getTimings(server: string) { return this.server.getTimings(server) .catch(this.handleError); } teleportTo(accountId: string) { return this.server.teleportTo(accountId) .catch(this.handleError); } // helpers get isLowDiskSpace() { return parseInt(this.state.status.diskSpace || '0', 10) > 95; } get isLowMemory() { return parseInt(this.state.status.memoryUsage || '0', 10) > 90; } get isOldCertificate() { const date = this.state.status.certificateExpiration; return date && (new Date(date)).getTime() < fromNow(7 * DAY).getTime(); } get isOldPatreon() { const date = this.state.status.lastPatreonUpdate; return date && (new Date(date)).getTime() < fromNow(-21 * MINUTE).getTime(); } private requestState() { return this.socket.isConnected ? this.server.getState().then(s => this.readState(s)) : Promise.resolve(); } private readState(state: AdminState) { merge(this.state, state); this.initialized = true; this.updateTitle(); } private updateStateTimeout: any; private updateState(): void { if (!this.running) return; clearTimeout(this.updateStateTimeout); this.requestState() .catch((e: Error) => console.error(e.stack)) .then(() => { this.updateStateTimeout = setTimeout(() => this.updateState(), 1000); }); } private callUpdated(list: string, added: boolean) { if (this.updated) { this.updated(list, added); } } updateTitle() { const ponies = this.state.gameServers.reduce((sum, s) => sum + s.online, 0); const count = this.events.reduce((sum, e) => sum + (e.deleted ? 0 : 1), 0); const inred = this.events.reduce((sum, e) => sum + ((!e.deleted && e.count > 9) ? 1 : 0), 0); const flag = this.isLowDiskSpace || this.isLowMemory || this.isOldCertificate || this.isOldPatreon; document.title = `${ponies} | ${count}${repeat('!', inred)}${flag ? ' 🚩' : ''}${!this.connected ? ' ⚠' : ''} | Your Town`; } private notify(title: string, body: string) { if (this.notifications && notification.permission === 'granted') { const n = new notification(title, { body: body || '', icon: getUrl('images/logo-120.png'), }); n.onclick = () => { window.focus(); n.close(); }; n.onshow = () => { setTimeout(() => n.close(), 4000); }; } } } function decodeDate(value: number | undefined, base: string | undefined): Date { if (value == null || base == null) { return new Date(0); } else { const d = new Date(base); d.setTime(d.getTime() + value); return d; } } export function decodeEvent(values: any[], base: BaseValues): Event { return { _id: values[0], updatedAt: decodeDate(values[1], base.updatedAt!), createdAt: decodeDate(values[2], base.createdAt!), type: values[3], server: values[4], message: values[5], desc: values[6], count: values[7] | 0, origin: values[8], account: values[9], pony: values[10], }; }