Archive commit

This commit is contained in:
Erik McClure
2019-08-28 21:15:02 -07:00
commit 735845746e
1435 changed files with 140724 additions and 0 deletions
+617
View File
@@ -0,0 +1,617 @@
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<AccountData>;
accounts: ModelSubscriber<Account>;
origins: ModelSubscriber<Origin>;
auths: ModelSubscriber<Auth>;
ponies: ModelSubscriber<Character>;
accountAuths: ModelSubscriber<string[]>;
accountPonies: ModelSubscriber<PonyIdDateName[]>;
accountOrigins: ModelSubscriber<OriginInfoBase[]>;
private liveEvents: LiveCollection<Event>;
private handleError = (error: Error) => {
console.error(error);
this.error = error.message;
return undefined;
}
private checkError = <T>(promise: Promise<T>) => promise.catch(this.handleError) as Promise<T | undefined>;
private running = true;
private initializedLive = false;
private socket: SocketService<ClientAdminActions, IAdminServerActions>;
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<ClientAdminActions, IAdminServerActions>(
{ ...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<Account>('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<Auth>('auths', this.socket, {
fix: account => {
account.updatedAt = new Date(account.updatedAt!);
account.lastUsed = account.lastUsed && new Date(account.lastUsed);
},
});
this.ponies = new ModelSubscriber<Character>('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<Origin>('origins', this.socket, {});
this.accountAuths = new ModelSubscriber<string[]>('accountAuths', this.socket, {}, []);
this.accountPonies = new ModelSubscriber<PonyIdDateName[]>('accountPonies', this.socket, {}, []);
this.accountOrigins = new ModelSubscriber<OriginInfoBase[]>('accountOrigins', this.socket, {}, []);
this.liveEvents = new LiveCollection<Event>('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<FindPoniesResult | undefined> {
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<Account>) {
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<Settings>) {
return this.server.updateSettings(settings)
.then(() => this.updateState());
}
updateGameServerSettings(serverId: string, settings: Partial<GameServerSettings>) {
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<string>) {
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 ? ' ⚠' : ''} | Pony 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],
};
}
+251
View File
@@ -0,0 +1,251 @@
import { Injectable } from '@angular/core';
import { Howl } from 'howler';
import { random, sample } from 'lodash';
import { Season, Holiday, MapType } from '../../common/interfaces';
import { getUrl } from '../../client/rev';
interface Track {
name: string;
src: string[];
howl?: Howl;
}
function getTracks(season: Season, holiday: Holiday, map: MapType) {
switch (map) {
case MapType.Island:
return [
'island',
'sunny-island',
];
case MapType.House:
return [
'happy-house',
'sweet-home',
];
case MapType.Cave:
return [
'cave-crystals',
'cave-secrets',
];
default:
return [
//'largo',
//'musicbox',
//'unrest',
'bossanova',
'clop',
'fivefour',
'hypnosis',
'scherzo',
'trills',
'waltzalt',
...(season === Season.Winter ? [
'trees-winter',
'reindeer-winter',
] : [
'trees',
'reindeer',
]),
'season',
'ambient',
'building',
'school',
'falling',
'tio',
'orchid',
...(season === Season.Winter ? [
'xmas-air',
'xmas-horns',
'xmas-presents',
] : []),
...(holiday === Holiday.Halloween ? [
'ghost',
'pumpkin',
] : []),
];
}
}
const FADE_TRACKS = true;
function fadeOut(track: Track, id: number, volume: number) {
const howl = track && track.howl;
if (howl) {
if (FADE_TRACKS) {
howl
.fade(volume, 0, 1000, id)
.once('fade', () => howl.pause(id).stop(id), id);
} else {
howl
.volume(0, id)
.pause(id)
.stop(id);
}
}
}
function fadeIn(track: Track, id: number, volume: number) {
if (track && track.howl) {
track.howl.fade(0, volume, 1000, id);
}
}
interface Instance {
id: number;
track: Track;
}
@Injectable({ providedIn: 'root' })
export class Audio {
private tracks: Track[] = [];
private volume = 0;
private loops = 0;
private playing = false;
private stopped: Instance[] = [];
private instance?: Instance;
get trackName() {
return this.instance && this.volume ? this.instance.track.name : '';
}
initTracks(season: Season, holiday: Holiday, map: MapType) {
const tracks = getTracks(season, holiday, map);
// Make new tracks more frequent
// const duplicateTracks = tracks.filter(t => t === 'ghost' || t === 'pumpkin');
// tracks.push(...duplicateTracks);
// tracks.push(...duplicateTracks);
this.tracks = tracks.map(name => ({ name, src: [getUrl(`music/${name}.webm`), getUrl(`music/${name}.mp3`)] }));
this.loops = 0;
}
setVolume(volume: number) {
this.volume = volume / 100;
if (this.playing) {
if (this.instance) {
this.setInstanceVolume(this.instance, this.volume);
} else if (this.volume) {
this.playRandomTrack();
}
}
}
play() {
try {
if (!this.playing) {
this.playing = true;
if (this.volume) {
if (this.instance) {
this.resumeInstance(this.instance);
} else {
this.playRandomTrack();
}
}
}
} catch (e) {
console.error(e);
}
}
playOrSwitchToRandomTrack() {
if (FADE_TRACKS) {
if (this.playing && this.volume) {
this.playRandomTrack();
} else {
this.play();
}
} else {
this.play();
}
}
stop() {
if (this.playing) {
this.playing = false;
this.stopInstance(this.instance);
}
}
forcePlay() {
if (!this.instance || !this.instance.track.howl!.playing(this.instance.id)) {
this.playRandomTrack();
}
}
touch() {
this.stopInstances();
this.setInstanceVolume(this.instance, this.volume);
}
private switchToTrack(track: Track) {
if (this.instance && this.instance.track === track) {
return false;
} else {
this.stopInstance(this.instance);
this.instance = this.playTrack(track);
return true;
}
}
playRandomTrack() {
while (!this.switchToTrack(sample(this.tracks)!))
;
this.loops = random(4, 7);
}
private playTrack(track: Track): Instance {
this.prepareTrack(track);
const id = track.howl!.play();
fadeIn(track, id, this.volume);
return { id, track };
}
private resumeInstance({ track, id }: Instance) {
track.howl!.play(id);
fadeIn(track, id, this.volume);
}
private stopInstance(instance: Instance | undefined) {
if (instance) {
this.stopped.push(instance);
}
this.stopInstances();
}
private stopInstances() {
this.stopped.forEach(({ track, id }) => fadeOut(track, id, this.volume));
this.stopped = this.stopped.filter(({ track, id }) => track.howl!.playing(id));
}
private setInstanceVolume(instance: Instance | undefined, volume: number) {
if (instance) {
const howl = instance.track.howl!;
howl.volume(volume, instance.id);
if (volume && !howl.playing(instance.id)) {
howl.play(instance.id);
} else if (!volume && howl.playing(instance.id)) {
howl.pause(instance.id);
}
}
}
private prepareTrack(track: Track) {
if (!track.howl) {
track.howl = new Howl({
src: track.src,
loop: true,
html5: true,
});
track.howl.on('end', id => this.onEnd(id));
}
}
private handlingOnEnd = 0;
private handlingOnEndAt = 0;
private onEnd(id: number) {
if (
this.instance && this.instance.id === id && --this.loops < 0 &&
(this.handlingOnEnd !== id || this.handlingOnEndAt < performance.now())
) {
this.handlingOnEnd = id;
this.handlingOnEndAt = performance.now() + 500;
if (this.volume && this.playing) {
this.playRandomTrack();
} else {
this.stopInstance(this.instance);
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Injectable } from '@angular/core';
import { Router, CanActivate } from '@angular/router';
import { Model } from './model';
@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate {
constructor(private router: Router, private model: Model) {
}
canActivate() {
return this.model.accountPromise
.then(account => {
if (account) {
return true;
} else {
this.router.navigate(['/']);
return false;
}
}) as any;
}
}
@@ -0,0 +1,47 @@
import { Injectable } from '@angular/core';
import { ClientErrorHandler, ClientOptions } from 'ag-sockets/dist/browser';
import { Person } from '../../common/rollbar';
@Injectable()
export class ErrorReporter {
disable() {
}
configureUser(_person: Person) {
}
configureData(_data: any) {
}
captureEvent(_data: any) {
}
reportError(error: any, data?: any) {
console.error(error, data);
}
createClientErrorHandler(socketOptions: ClientOptions): ClientErrorHandler {
const handleRecvError = (error: Error, data: string | Uint8Array) => {
if (error.message) {
let method: string | undefined;
if (data instanceof Uint8Array) {
const bytes: number[] = [];
const length = Math.min(data.length, 200);
for (let i = 0; i < length; i++) {
bytes.push(data[i]);
}
const trail = length < data.length ? '...' : '';
if (data.length > 0) {
const item = socketOptions.client[data[0]] as string | [string, any];
method = typeof item === 'string' ? item : item[0];
}
data = `<${bytes.toString()}${trail}>`;
}
this.reportError(error, { data, method });
}
};
return { handleRecvError };
}
}
@@ -0,0 +1,38 @@
import { Injectable, NgZone } from '@angular/core';
export interface FrameLoop {
init(): void;
destroy(): void;
}
@Injectable({
providedIn: 'root',
})
export class FrameService {
constructor(private zone: NgZone) {
}
create(frame: (delta: number) => void) {
const zone = this.zone;
let ref = 0;
let last = 0;
function tick(now: number) {
ref = requestAnimationFrame(tick);
frame((now - last) / 1000);
last = now;
}
return {
init() {
if (!ref) {
last = performance.now();
zone.runOutsideAngular(() => ref = requestAnimationFrame(tick));
}
},
destroy() {
cancelAnimationFrame(ref);
ref = 0;
}
};
}
}
+312
View File
@@ -0,0 +1,312 @@
import { Injectable, NgZone, ErrorHandler } from '@angular/core';
import { merge } from 'lodash';
import { createClientSocket, SocketService, ClientOptions, ClientErrorHandler } from 'ag-sockets/dist/browser';
import { ServerInfo, IServerActions, GameStatus, AccountData, LeaveReason } from '../../common/interfaces';
import { RequestError, findById, includes } from '../../common/utils';
import { OFFLINE_ERROR, PROTECTION_ERROR, BROWSER_NOT_SUPPORTED_ERROR, VERSION_ERROR } from '../../common/errors';
import { startGameLoop, GameLoop } from '../../client/gameLoop';
import { ClientActions } from '../../client/clientActions';
import { socketOptions } from '../../client/data';
import { PonyTownGame } from '../../client/game';
import { Model } from './model';
import { ErrorReporter } from './errorReporter';
import { meetsRequirement } from '../../common/accountUtils';
import { isLanguage, isFocused, sortServersForRussian } from '../../client/clientUtils';
import { StorageService } from './storageService';
export interface ClientSocketService extends SocketService<ClientActions, IServerActions> { }
function createSocket(
gameService: GameService, game: PonyTownGame, model: Model, zone: NgZone, options: ClientOptions,
token: string, errorHandler: ClientErrorHandler
): ClientSocketService {
const socket = createClientSocket<ClientActions, IServerActions>(options, token, errorHandler);
socket.client = new ClientActions(gameService, game, model, zone);
if (!socket.supportsBinary) {
throw new Error(BROWSER_NOT_SUPPORTED_ERROR);
}
return socket;
}
@Injectable({
providedIn: 'root',
})
export class GameService {
playing = false;
joining = false;
offline = false;
protectionError = false;
rateLimitError = false;
versionError = false;
version?: string;
server?: ServerInfo;
servers: ServerInfo[] = [];
error?: string;
leftMessage?: string;
private safelyLeft = false;
private gameLoop?: GameLoop;
private disconnectedTimeout?: any;
private initialized = false;
private update?: boolean;
private locked = false;
constructor(
private model: Model,
private game: PonyTownGame,
private zone: NgZone,
private errorHandler: ErrorHandler,
private errorReporter: ErrorReporter,
private storage: StorageService,
) {
this.pollStatus();
}
get selected() {
return this.game.selected;
}
get account() {
return this.model.account;
}
get canPlay(): boolean {
return !!this.model.pony &&
!!this.model.pony.name &&
!this.model.pending &&
!this.joining &&
!!this.server &&
!this.server.offline &&
!this.rateLimitError &&
!this.versionError &&
!this.locked;
}
get updateWarning(): boolean {
return !!this.update;
}
get filterSwearWords(): boolean {
return !!(this.server && this.server.filter)
|| !!(this.account && this.account.settings && this.account.settings.filterSwearWords);
}
get wasPlaying() {
return this.storage.getBoolean('playing');
}
join(ponyId: string) {
this.errorReporter.captureEvent({ name: 'Join' });
const server = this.server;
if (this.playing || this.joining || !server) {
return Promise.resolve();
}
if (typeof WebSocket === 'undefined' || typeof Float32Array === 'undefined') {
return Promise.reject(new Error(BROWSER_NOT_SUPPORTED_ERROR));
}
this.joining = true;
this.leftMessage = undefined;
this.safelyLeft = false;
return this.model.join(server.id, ponyId)
.then(({ token, alert }) => {
if (!this.joining) {
return false;
}
if (!token) {
this.model.accountAlert = alert;
this.joining = false;
return false;
}
return this.zone.runOutsideAngular(() => {
const options = { ...socketOptions(), path: server.path, host: server.host };
const errorHandler = this.errorReporter.createClientErrorHandler(options);
const socket = createSocket(this, this.game, this.model, this.zone, options, token, errorHandler);
if (this.gameLoop) {
this.gameLoop.cancel();
}
this.game.startup(socket, this.model.isMod);
this.gameLoop = startGameLoop(this.game, e => this.handleGameError(e));
return this.gameLoop.started
.then(() => {
this.errorReporter.captureEvent({ name: 'gameLoop.started' });
const socketConnected = this.pollUntilConnected(socket);
socket.connect();
return socketConnected;
})
.then(() => {
this.errorReporter.captureEvent({ name: 'socketConnected' });
return true;
})
.catch(e => {
this.errorReporter.captureEvent({ name: 'socket.disconnect()', error: e.message });
socket.disconnect();
throw e;
});
});
})
.then(joined => {
this.errorReporter.captureEvent({ name: joined ? 'Joined game' : 'Not joined game' });
})
.catch((e: RequestError) => {
this.errorReporter.captureEvent({ name: 'Failed to join game', error: e.message });
// if (e.status && e.status > 500 && e.status < 500) {
// this.rateLimitError = true;
// setTimeout(() => this.rateLimitError = false, 5000);
// }
this.zone.run(() => this.left('join.catch'));
throw e;
});
}
leave(reason: string) {
this.errorReporter.captureEvent({ name: 'Leave', reason });
this.game.leave();
this.left('leave');
}
joined() {
this.errorReporter.captureEvent({ name: 'Joined' });
this.storage.setBoolean('playing', true);
clearTimeout(this.disconnectedTimeout);
setTimeout(() => {
this.joining = false;
this.playing = true;
});
}
left(from: string, reason = LeaveReason.None) {
this.errorReporter.captureEvent({ name: 'Left', from, reason });
this.storage.setBoolean('playing', false);
this.safelyLeft = true;
if (reason === LeaveReason.Swearing) {
this.leftMessage = 'Kicked for swearing or inappropriate language';
this.locked = true;
} else {
this.leftMessage = undefined;
}
if (this.gameLoop) {
this.errorReporter.captureEvent({ name: 'gameLoop.cancel()' });
this.gameLoop.cancel();
this.gameLoop = undefined;
}
clearTimeout(this.disconnectedTimeout);
setTimeout(() => {
this.joining = false;
this.playing = false;
});
if (this.locked) {
setTimeout(() => {
this.locked = false;
}, 7000);
}
if (this.model.friends) {
for (const friend of this.model.friends) {
friend.online = false;
friend.entityId = 0;
}
}
this.game.release();
this.game.onLeft.next();
}
disconnected() {
this.errorReporter.captureEvent({ name: 'Disconnected' });
clearTimeout(this.disconnectedTimeout);
if (!this.safelyLeft) {
this.disconnectedTimeout = setTimeout(() => this.left('disconnected.timeout'), 10000);
}
}
private pollStatus() {
return this.getAndUpdateStatus(this.account)
.finally(() => {
setTimeout(() => this.pollStatus(), this.initialized ? 10000 : 500);
});
}
private getAndUpdateStatus(account: AccountData | undefined) {
if (this.joining || this.playing || !account || !isFocused()) {
return Promise.resolve();
} else {
return this.model.status(this.initialized)
.then(status => this.updateStatus(account, status))
.catch((e: RequestError) => {
DEVELOPMENT && console.error(e);
this.offline = e.message === OFFLINE_ERROR;
this.versionError = e.message === VERSION_ERROR;
this.protectionError = e.message === PROTECTION_ERROR;
});
}
}
private updateStatus(account: AccountData, status: GameStatus) {
this.initialized = true;
this.offline = false;
this.version = status.version;
this.update = status.update;
for (const server of status.servers) {
const existing = findById(this.servers, server.id);
if (existing) {
merge(existing, server);
} else if ('name' in server) {
const info = server as ServerInfo;
info.countryFlags = info.flag && /^[a-z]{2}( [a-z]{2})*$/.test(info.flag) ? info.flag.split(/ /g) : [];
if (info.name && account && meetsRequirement(account, info.require)) {
this.servers.push(info);
}
} else {
// got new server on the list
this.initialized = false;
}
}
for (let i = this.servers.length - 1; i >= 0; i--) {
if (!findById(status.servers, this.servers[i].id)) {
this.servers.splice(i, 1);
}
}
if (isLanguage('ru')) {
this.servers.sort(sortServersForRussian);
}
if (!this.server && account.settings.defaultServer) {
this.server = findById(this.servers, account.settings.defaultServer);
if (DEVELOPMENT && /join/.test(this.model.pony.name)) {
setTimeout(() => this.join(this.model.pony.id));
}
}
if (!includes(this.servers, this.server)) {
this.server = undefined;
}
}
private handleGameError(error: Error) {
this.errorReporter.captureEvent({ name: 'handleGameError', error: error.message });
this.error = error.message;
this.errorHandler.handleError(error);
this.leave('handleGameError');
}
private pollUntilConnected(socket: ClientSocketService) {
return new Promise<void>((resolve, reject) => {
const interval = setInterval(() => {
if (socket.isConnected) {
clearInterval(interval);
this.zone.run(resolve);
} else if (!this.joining) {
clearInterval(interval);
this.zone.run(() => reject(new Error('Cancelled (poll)')));
}
}, 10);
});
}
}
@@ -0,0 +1,41 @@
import { Injectable } from '@angular/core';
import { StorageService } from './storageService';
interface InstallEvent extends Event {
prompt(): void;
userChoice: Promise<'accepted' | 'dismissed'>;
}
@Injectable({
providedIn: 'root',
})
export class InstallService {
private installEvent?: InstallEvent;
constructor(private storage: StorageService) {
if (!this.storage.getBoolean('install-dismissed')) {
window.addEventListener('beforeinstallprompt', event => {
event.preventDefault();
this.installEvent = event as any;
});
}
}
get canInstall() {
return !!this.installEvent || (DEVELOPMENT && localStorage.getItem('install'));
}
install() {
if (!this.installEvent) {
return Promise.reject(new Error('Cannot install'));
}
this.installEvent.prompt();
return this.installEvent.userChoice
.finally(() => {
this.installEvent = undefined;
});
}
dismiss() {
this.installEvent = undefined;
this.storage.setBoolean('install-dismissed', true);
}
}
@@ -0,0 +1,44 @@
import { Injectable, NgZone } from '@angular/core';
import { removeItem } from '../../common/utils';
@Injectable({
providedIn: 'root',
})
export class IntervalUpdateService {
private interval: any;
private actions: (() => void)[] = [];
constructor(private zone: NgZone) {
}
subscribe(action: () => void) {
this.actions.push(action);
if (!this.interval) {
this.zone.runOutsideAngular(() => {
this.interval = setInterval(() => {
this.actions.forEach(a => a());
}, 1000 * 10);
});
}
return () => {
removeItem(this.actions, action);
if (this.actions.length === 0) {
clearInterval(this.interval);
this.interval = undefined;
}
};
}
toggle(action: () => void) {
let unsubscribe: (() => void) | undefined;
return (on: boolean) => {
if (on && !unsubscribe) {
unsubscribe = this.subscribe(action);
} else if (!on && unsubscribe) {
unsubscribe();
unsubscribe = undefined;
}
};
}
}
@@ -0,0 +1,145 @@
import { SocketService } from 'ag-sockets/dist/browser';
import { removeItem } from '../../common/utils';
import { Document, LiveResponse, IAdminServerActions, BaseValues } from '../../common/adminInterfaces';
import { ClientAdminActions } from '../../client/clientAdminActions';
export interface Options<T> {
// collection
beforeUpdate?: (updates: T[]) => void;
onUpdated?: (added: T[], all: T[]) => void;
onFinished?: () => void;
// item
decode: (fields: any[], base: BaseValues) => T;
onUpdate?: (oldItem: T, newItem: T) => void;
onDelete?: (item: T) => void;
deleteItems?: boolean;
ignore?: (item: T) => boolean;
}
export class LiveCollection<T extends Document> {
items: T[] = [];
finished = false;
private running = true;
private itemsMap = new Map<string, T>();
private liveTimeout: any;
constructor(
private name: 'events',
private rate: number,
private getKey: (item: T) => string,
private options: Options<T>,
private socket: SocketService<ClientAdminActions, IAdminServerActions>,
private timestamp = (new Date(0)).toISOString(),
private logError = (e: Error) => console.error(e.stack),
) {
}
get(key: string) {
return this.itemsMap.get(key);
}
push(item: T) {
this.items.push(item);
this.itemsMap.set(this.getKey(item), item);
return item;
}
remove(key: string) {
return this.server.removeItem(this.name, key)
.then(() => this.removeItem(key, true, true));
}
removeItem(key: string, deleted = false, removeFromList = false) {
const item = this.itemsMap.get(key);
if (item) {
if (removeFromList || this.options.deleteItems) {
removeItem(this.items, item);
this.itemsMap.delete(key);
} else if (deleted) {
item.deleted = true;
}
if (deleted && this.options.onDelete) {
this.options.onDelete(item);
}
}
}
assignAccount(id: string, account: string) {
return this.server.assignAccount(this.name, id, account);
}
live(): Promise<void> {
if (!this.running)
return Promise.resolve();
clearTimeout(this.liveTimeout);
return this.update()
.catch(this.logError)
.then(more => {
this.liveTimeout = setTimeout(() => this.live(), more ? 100 : this.rate);
});
}
stop() {
this.running = false;
}
read({ updates, deletes, base, more }: LiveResponse, liveFetch = true) {
const items = updates.map(i => this.options.decode(i, base));
if (liveFetch) {
const timestamp = items
.reduce((max, i) => max.getTime() < i.updatedAt.getTime() ? i.updatedAt : max, new Date(this.timestamp));
this.timestamp = timestamp.toISOString();
}
if (this.options.beforeUpdate) {
this.options.beforeUpdate(items);
}
const { added, all } = this.applyUpdates(items, liveFetch);
if (this.options.onUpdated && items.length) {
this.options.onUpdated(added, all);
}
deletes.forEach(key => this.removeItem(key, true));
if (liveFetch) {
const finished = this.finished || !more;
if (!this.finished && finished) {
this.finished = true;
if (this.options.onFinished) {
this.options.onFinished();
}
}
}
return more;
}
private get server() {
return this.socket.server;
}
private update() {
return this.socket.isConnected ? this.server.getAll(this.name, this.timestamp).then(r => this.read(r)) : Promise.resolve(false);
}
private applyUpdates(updates: T[], liveFetch: boolean) {
const added: T[] = [];
const all: T[] = [];
updates.forEach(update => {
const doc = this.get(this.getKey(update));
if (doc) {
if (this.options.onUpdate) {
this.options.onUpdate(doc, update);
} else {
Object.assign(doc, update);
}
all.push(doc);
} else if (!liveFetch || !this.options.ignore || !this.options.ignore(update)) {
this.push(update);
added.push(update);
all.push(update);
}
});
return { added, all };
}
}
+488
View File
@@ -0,0 +1,488 @@
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { merge } from 'lodash';
import { Subject } from 'rxjs';
import { HASH } from '../../generated/hash';
import {
AccountData, UpdateAccountData, AccountSettings, GameStatus, SocialSiteInfo, PonyObject, JoinResponse,
OAuthProvider, EntitiesEditorInfo, FriendData, PalettePonyInfo, HiddenPlayer
} from '../../common/interfaces';
import { createDefaultPony, syncLockedPonyInfo, mockPaletteManager } from '../../common/ponyInfo';
import { removeById, observableToPromise, delay, computeFriendsCRC } from '../../common/utils';
import { isMod, getSupporterInviteLimit, getCharacterLimit } from '../../common/accountUtils';
import {
NAME_ERROR, ACCESS_ERROR, CHARACTER_SAVING_ERROR, NOT_AUTHENTICATED_ERROR, OFFLINE_ERROR, PROTECTION_ERROR
} from '../../common/errors';
import { version, host } from '../../client/data';
import {
toSocialSiteInfo, cleanName, validatePonyName, isStandalone, attachDebugMethod
} from '../../client/clientUtils';
import { ErrorReporter } from './errorReporter';
import { randomString } from '../../common/stringUtils';
import { StorageService } from './storageService';
import { decompressPonyString, compressPonyString, decodePonyInfo } from '../../common/compressPony';
import { SECOND, PLAYER_DESC_MAX_LENGTH } from '../../common/constants';
import { canUseTag } from '../../common/tags';
export interface Friend extends FriendData {
entityId: number;
crc: number;
online: boolean;
ponyInfo: PalettePonyInfo | undefined;
actualName: string;
}
const LIMIT_ERROR = 'Request limit reached, please wait';
const noneSite: SocialSiteInfo = { id: '', name: 'none', url: '', icon: '', color: '#222' };
const modStatus = {
mod: false,
check: {} as any,
editor: {
names: [],
typeToName: [],
nameToTypes: [],
} as EntitiesEditorInfo,
};
function compareStrings(a: string | undefined, b: string | undefined) {
return (a || '').localeCompare(b || '');
}
function comparePonies(a: PonyObject, b: PonyObject) {
return compareStrings(a.name, b.name) || compareStrings(a.id, b.id);
}
function getDefaultPony(ponies: PonyObject[]) {
let result = ponies[0];
for (let i = 1; i < ponies.length; i++) {
if (compareStrings(result.lastUsed, ponies[i].lastUsed) < 0) {
result = ponies[i];
}
}
return result || createDefaultPonyObject();
}
export function createDefaultPonyObject(): PonyObject {
return {
id: '',
name: '',
info: '',
ponyInfo: createDefaultPony(),
};
}
export function getPonyTag(pony: PonyObject, account: AccountData | undefined) {
if (account) {
const tag = canUseTag(account, pony.tag || '') ? pony.tag : undefined;
return (!tag && account.supporter && !pony.hideSupport) ? `sup${account.supporter}` : tag;
} else {
return undefined;
}
}
const entityTypeToName = new Map<number, string>();
const entityNameToTypes = new Map<string, number[]>();
export function getEntityNames() {
return modStatus.editor.names;
}
export function getEntityTypesFromName(name: string) {
return entityNameToTypes.get(name);
}
export function getEntityNameFromType(type: number) {
return entityTypeToName.get(type);
}
export function compareFriends(a: Friend, b: Friend) {
return a.online !== b.online ? (a.online ? -1 : 1) : a.accountName.localeCompare(b.accountName);
}
@Injectable({ providedIn: 'root' })
export class Model {
loading = true;
loadingError?: string;
account?: AccountData;
ponies: PonyObject[] = [];
pending = false;
sites: SocialSiteInfo[] = [noneSite];
accountPromise!: Promise<AccountData | undefined>;
accountChanged = new Subject<void>();
protectionErrors = new Subject<void>();
authError?: string;
accountAlert?: string;
mergedAccount = false;
updating = false;
updatingTakesLongTime = false;
suffix = '';
friends: Friend[] | undefined = undefined;
private _pony: PonyObject = createDefaultPonyObject();
constructor(
private http: HttpClient,
private router: Router,
private storage: StorageService,
private errorReporter: ErrorReporter,
) {
this.initialize();
// handle completed sign-in
if (typeof window !== 'undefined') {
window.addEventListener('message', event => {
if (event.data && event.data.type === 'loaded-page') {
const path = event.data.path;
if (event.source && 'close' in event.source) {
event.source.close();
}
this.initialize();
this.accountPromise.then(() => router.navigateByUrl(path));
}
});
}
if (DEVELOPMENT) {
attachDebugMethod('ddos', () => this.protectionErrors.next());
attachDebugMethod('userModel', this);
}
}
private initialize() {
this.loading = true;
this.account = undefined;
this.loadingError = undefined;
this.accountAlert = undefined;
this.ponies = [];
this.friends = undefined;
this.sites = [noneSite];
this._pony = createDefaultPonyObject();
this.storage.setItem('bid', this.storage.getItem('bid') || randomString(20));
this.accountPromise = this.initializeAccount();
}
private initializeAccount(): Promise<AccountData | undefined> {
return this.getAccount()
.then(account => {
if (!account) {
throw new Error(ACCESS_ERROR);
}
if ('limit' in account) {
throw new Error(LIMIT_ERROR);
}
this.errorReporter.configureUser({ id: account.id, username: account.name });
try {
modStatus.mod = isMod(account);
modStatus.check = account.check;
modStatus.editor = account.editor || modStatus.editor;
} catch { }
if (modStatus.editor) {
modStatus.editor.typeToName.forEach(({ type, name }) => entityTypeToName.set(type, name));
modStatus.editor.nameToTypes.forEach(({ types, name }) => entityNameToTypes.set(name, types));
}
this.account = account;
this.sites = [noneSite, ...(account.sites || []).map(toSocialSiteInfo)];
this.ponies = account.ponies ? account.ponies.sort(comparePonies) : [];
this.friends = undefined;
this.selectPony(getDefaultPony(this.ponies));
this.storage.setItem('vid', account.id);
this.loading = false;
this.accountAlert = account.alert;
this.accountChanged.next();
this.fetchFriends();
return account;
})
.catch((e: Error) => {
if (e.message === ACCESS_ERROR) {
this.loading = false;
this.storage.setItem('vid', '---');
} else if (e.message === LIMIT_ERROR) {
this.loadingError = 'request-limit';
return delay(5000).then(() => this.initializeAccount());
} else if (e.message === OFFLINE_ERROR) {
this.loadingError = 'cannot-connect';
return delay(5000).then(() => this.initializeAccount());
} else if (e.message === PROTECTION_ERROR) {
this.loadingError = 'cloudflare-error';
this.protectionErrors.next();
// } else if (e.message === VERSION_ERROR) {
// this.updating = true;
} else {
setTimeout(() => this.loadingError = 'unexpected-error', 5 * SECOND);
console.error(e);
}
return undefined;
});
}
private fetchFriends() {
this.getFriends()
.then(friends => {
this.friends = friends.map(f => ({
...f,
online: false,
entityId: 0,
crc: 0,
ponyInfo: f.pony && decodePonyInfo(f.pony, mockPaletteManager) || undefined,
actualName: '',
})).sort(compareFriends);
})
.catch(e => {
DEVELOPMENT && console.error(e);
setTimeout(() => this.fetchFriends(), 5000);
});
}
get characterLimit() {
return this.account ? getCharacterLimit(this.account) : 0;
}
get supporterInviteLimit() {
return this.account ? getSupporterInviteLimit(this.account) : 0;
}
get isMod() {
return modStatus.mod;
}
get modCheck() {
return modStatus.check;
}
get editorInfo() {
return modStatus.editor;
}
get pony() {
return this._pony;
}
get supporter() {
return this.account && this.account.supporter || 0;
}
get missingBirthdate() {
return !!this.account && !this.account.birthdate;
}
computeFriendsCRC() {
return this.friends ? computeFriendsCRC(this.friends.map(f => f.accountId)) : 0;
}
parsePonyObject(pony: PonyObject): PonyObject {
try {
const ponyInfo = decompressPonyString(pony.info, true);
return { ponyInfo, ...pony };
} catch (e) {
this.errorReporter.reportError(e, { ponyInfo: pony.info });
this.errorReporter.reportError('Pony info reading error', { originalError: e.message, ponyInfo: pony.info });
throw new Error('Error while reading pony info');
}
}
selectPony(pony: PonyObject) {
const copy = this.parsePonyObject(pony);
copy.ponyInfo && syncLockedPonyInfo(copy.ponyInfo);
this._pony = copy;
}
// account
signIn(provider: OAuthProvider) {
this.authError = undefined;
this.openAuth(provider.url!);
}
connectSite(provider: OAuthProvider) {
this.authError = undefined;
this.openAuth(`${provider.url}/merge`);
}
signOut() {
this.authError = undefined;
return this.post<AccountData | undefined>('/auth/sign-out', {}, false)
.catch(e => console.error(e))
.then(() => this.initialize())
.then(() => this.router.navigate(['/']));
}
private openAuth(url: string) {
url = `${host.replace(/\/$/, '')}${url}`;
if (isStandalone()) {
window.open(url);
} else {
location.href = url;
}
}
getAccount() {
return this.post<AccountData | { limit: true; } | undefined>('/api1/account', {}, false);
}
getAccountCharacters() {
return this.post<PonyObject[]>('/api/account-characters', {});
}
updateAccount(account: Partial<UpdateAccountData>) {
return this.post<AccountData>('/api/account-update', { account })
.then(a => merge(this.account, a));
}
saveSettings(settings: AccountSettings) {
return this.post<AccountData>('/api/account-settings', { settings })
.then(a => merge(this.account, a));
}
removeSite(siteId: string) {
return this.post('/api/remove-site', { siteId })
.then(() => {
if (this.account && this.account.sites) {
removeById(this.account.sites, siteId);
}
});
}
unhidePlayer(hideId: string) {
return this.post('/api/remove-hide', { hideId });
}
verifyAccount() {
const verificationId = this.storage.getItem('vid');
const accountId = this.account && this.account.id || '---';
if (!this.loading && verificationId && accountId !== verificationId) {
this.initialize();
}
}
getHides(page: number) {
return this.post<HiddenPlayer[]>('/api/get-hides', { page });
}
getFriends() {
return this.post<FriendData[]>('/api/get-friends', {});
}
// ponies
savePony(pony: PonyObject, fast = false) {
return Promise.resolve()
.then(() => {
if (this.pending) {
throw new Error('Saving in progress');
}
pony.name = cleanName(pony.name);
pony.desc = pony.desc && pony.desc.substr(0, PLAYER_DESC_MAX_LENGTH) || '';
if (!validatePonyName(pony.name)) {
throw new Error(NAME_ERROR);
}
if (pony.ponyInfo) {
pony.info = compressPonyString(pony.ponyInfo);
}
const { id, name, desc, site, tag, info, hideSupport, respawnAtSpawn } = pony;
if (!fast) {
this.pending = true;
}
return this.post<PonyObject | undefined>('/api/pony/save', {
pony: { id, name, desc, site, tag, info, hideSupport, respawnAtSpawn }
});
})
.catch((e: Error) => {
if (e.message === CHARACTER_SAVING_ERROR) {
this.errorReporter.reportError(e, { pony });
}
throw e;
})
.then(newPony => {
if (!newPony) {
throw new Error('Failed to save pony');
}
if (pony.id) {
removeById(this.ponies, pony.id);
} else {
this.account!.characterCount++;
}
this.ponies.push(newPony);
this.ponies.sort(comparePonies);
if (this.pony === pony) {
this.selectPony(newPony);
}
return newPony;
})
.finally(() => this.pending = false);
}
removePony(pony: PonyObject) {
return this.post('/api/pony/remove', { id: pony.id })
.then(() => {
removeById(this.ponies, pony.id);
this.account!.characterCount--;
if (this.pony === pony) {
this.selectPony(getDefaultPony(this.ponies));
}
});
}
loadPonies() {
return this.getAccountCharacters()
.then(ponies => {
if (this.account) {
this.account.ponies = ponies || [];
this.ponies = this.account.ponies.sort(comparePonies);
}
});
}
sortPonies() {
this.ponies.sort(comparePonies);
}
// game
status(short: boolean): Promise<GameStatus> {
let age = 6;
if (this.account) {
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1;
if (this.account.birthyear) {
age = currentYear - this.account.birthyear;
} else if (this.account.birthdate) {
const [year, month] = this.account.birthdate.split('-');
const before = parseInt(month, 10) > currentMonth;
age = Math.max(0, currentYear - parseInt(year, 10) - (before ? 1 : 0));
}
}
const params = new HttpParams()
.set('short', short.toString())
.set('d', age.toString())
.set('t', (Date.now() % 0x10000).toString(16));
return observableToPromise(this.http.get<GameStatus>('/api2/game/status', { params }));
}
join(serverId: string, ponyId: string): Promise<JoinResponse> {
if (this.pending)
return Promise.reject(new Error('Joining in progress'));
if (!serverId)
return Promise.reject(new Error('Invalid server ID'));
if (!ponyId)
return Promise.reject(new Error('Invalid pony ID'));
this.pending = true;
const alert = !!this.accountAlert ? 'y' : '';
return this.post<JoinResponse>('/api/game/join', { version, ponyId, serverId, alert, url: location.href })
.finally(() => this.pending = false);
}
private post<T = void>(url: string, data: any, authenticate = true): Promise<T> {
if (authenticate) {
if (!this.account) {
return Promise.reject(new Error(NOT_AUTHENTICATED_ERROR));
}
const accountId = this.account.id + this.suffix;
const accountName = this.account.name + this.suffix;
data = { accountId, accountName, ...data };
}
const params = new HttpParams()
.set('t', (Date.now() % 0x10000).toString(16));
const headers = new HttpHeaders({ 'api-version': HASH, 'api-bid': this.storage.getItem('bid') || '-' });
return observableToPromise(this.http.post<T>(url, data, { params, headers }));
}
}
@@ -0,0 +1,107 @@
// import { Observable } from 'rxjs';
import { SocketService } from 'ag-sockets/dist/browser';
import { ClientAdminActions } from '../../client/clientAdminActions';
import { IAdminServerActions, ModelTypes } from '../../common/adminInterfaces';
import { removeItem } from '../../common/utils';
import { Subscription } from '../../common/interfaces';
import { MINUTE } from '../../common/constants';
type OnModel<T> = (item: T | undefined) => void;
interface ModelSubscriberConfig<T> {
fix?: (item: T) => void;
}
interface ModelSubscription<T> {
value: T | undefined;
timeout: any;
callbacks: OnModel<T>[];
}
const unsubscribeTimeout = 1 * MINUTE;
export class ModelSubscriber<T> {
private subscriptions = new Map<string, ModelSubscription<T>>();
// private observables = new Map<string, ModelSubscription<T>>();
constructor(
private type: ModelTypes,
private socket: SocketService<ClientAdminActions, IAdminServerActions>,
private config: ModelSubscriberConfig<T> = {},
private defaultValue: T | undefined = undefined,
) {
}
// for(id: string) {
// return this.createObservable(id);
// }
// private createObservable(id: string) {
// return new Observable<T | undefined>(observer => {
// this.socket.server.subscribe(this.model, id);
// return () => {
// this.socket.server.unsubscribe(this.model, id);
// };
// });
// }
get(id: string) {
const subscription = this.subscriptions.get(id);
return subscription && subscription.value;
}
subscribe(id: string, callback: OnModel<T>): Subscription {
const subscription = this.subscriptions.get(id);
if (subscription) {
if (subscription.timeout) {
clearTimeout(subscription.timeout);
subscription.timeout = 0;
}
subscription.callbacks.push(callback);
if (subscription.value !== undefined) {
callback(subscription.value);
}
} else {
this.socket.server.subscribe(this.type, id);
this.subscriptions.set(id, {
value: this.defaultValue,
timeout: 0,
callbacks: [callback],
});
}
return {
unsubscribe: () => this.unsubscribe(id, callback),
};
}
unsubscribe(id: string, callback: OnModel<T>) {
const subscription = this.subscriptions.get(id);
if (subscription) {
removeItem(subscription.callbacks, callback);
if (subscription.callbacks.length === 0) {
subscription.timeout = setTimeout(() => {
this.socket.server.unsubscribe(this.type, id);
this.subscriptions.delete(id);
}, unsubscribeTimeout);
}
}
}
update(id: string, update: T) {
const subscription = this.subscriptions.get(id);
if (update !== undefined && this.config.fix) {
this.config.fix(update);
}
if (subscription) {
subscription.value = update;
subscription.callbacks.forEach(c => c(update));
}
}
connected() {
this.subscriptions.forEach((_, id) => {
this.socket.server.subscribe(this.type, id);
});
}
}
@@ -0,0 +1,61 @@
import { ErrorHandler, Injectable, Injector, InjectionToken } from '@angular/core';
import * as Rollbar from 'rollbar';
import { version } from '../../client/data';
import { HASH } from '../../generated/hash';
import { ROLLBAR_ENV, ROLLBAR_TOKEN } from '../../generated/rollbarConfig';
import { rollbarCheckIgnore, isIgnoredError } from '../../common/rollbar';
const host = typeof location === 'undefined' ? '' : location.host;
const rollbarConfig = {
environment: ROLLBAR_ENV,
accessToken: ROLLBAR_TOKEN,
ignoredMessages: ['disconnected'],
hostWhiteList: [host],
captureUncaught: true,
captureUnhandleRejections: true,
// checkIgnore,
enabled: true,
payload: {
environment: ROLLBAR_ENV,
version: version, // NOTE: workaround for compilation issue
client: {
javascript: {
source_map_enabled: true,
guess_uncaught_frames: true,
code_version: HASH,
},
},
},
};
export const RollbarService = new InjectionToken<Rollbar>('rollbar');
export function rollbarFactory() {
if (DEVELOPMENT) {
return undefined;
} else {
const rollbar = Rollbar.init(rollbarConfig);
rollbar.configure({ checkIgnore: rollbarCheckIgnore });
return rollbar;
}
}
@Injectable()
export class RollbarErrorHandler extends ErrorHandler {
constructor(private injector: Injector) {
super();
}
handleError(error: any) {
super.handleError(error);
if (!DEVELOPMENT && rollbarConfig.accessToken) {
const rollbar = this.injector.get(RollbarService);
const err = error.originalError || error || {};
if (!isIgnoredError(err)) {
rollbar.error(err);
}
}
}
}
@@ -0,0 +1,39 @@
import { Injectable, Inject } from '@angular/core';
import * as Rollbar from 'rollbar';
import { Person, rollbarCheckIgnore, isIgnoredError } from '../../common/rollbar';
import { RollbarService } from './rollbarErrorHandler';
import { ErrorReporter } from './errorReporter';
@Injectable()
export class RollbarErrorReporter extends ErrorReporter {
constructor(@Inject(RollbarService) private rollbar?: Rollbar) {
super();
}
configureUser(person: Person) {
if (this.rollbar) {
this.rollbar.configure({ payload: { person }, checkIgnore: rollbarCheckIgnore });
}
}
configureData(data: any) {
if (this.rollbar) {
this.rollbar.configure({ payload: data, checkIgnore: rollbarCheckIgnore });
}
}
captureEvent(data: any) {
if (this.rollbar) {
this.rollbar.captureEvent(data, 'info');
}
}
reportError(error: any, data?: any) {
DEVELOPMENT && console.error(error, data);
if (this.rollbar && !isIgnoredError(error)) {
this.rollbar.error(error, data);
}
}
disable() {
if (this.rollbar) {
this.rollbar.configure({ enabled: false });
}
}
}
@@ -0,0 +1,43 @@
import { Injectable } from '@angular/core';
import { AccountSettings, BrowserSettings } from '../../common/interfaces';
import { StorageService } from './storageService';
import { Model } from './model';
@Injectable({ providedIn: 'root' })
export class SettingsService {
browser: BrowserSettings;
private save: (settings: AccountSettings) => boolean = () => false;
constructor(private storage: StorageService, private model: Model) {
this.browser = this.storage.getJSON('browser-settings', {});
}
get account(): AccountSettings {
return this.model.account ? this.model.account.settings : {};
}
set account(value) {
if (this.model.account) {
this.model.account.settings = value;
}
}
saving(save: (settings: AccountSettings) => boolean) {
this.save = save;
}
saveAccountSettings(settings: AccountSettings) {
if (this.model.account) {
this.model.account.settings = settings;
}
if (settings.filterWords) {
settings.filterWords = settings.filterWords.trim();
}
if (this.save(settings)) {
return Promise.resolve();
} else {
return this.model.saveSettings(settings);
}
}
saveBrowserSettings(settings?: BrowserSettings) {
this.browser = settings || this.browser;
this.storage.setJSON('browser-settings', this.browser);
}
}
@@ -0,0 +1,84 @@
import { Injectable } from '@angular/core';
/* istanbul ignore next */
@Injectable({ providedIn: 'root' })
export class StorageService {
private data?: Map<string, string> = undefined;
constructor() {
try {
if (typeof localStorage === 'undefined') {
this.data = new Map();
}
} catch {
this.data = new Map();
}
}
getItem(key: string) {
if (this.data) {
return this.data.get(key);
} else {
try {
const value = localStorage.getItem(key);
return value == null ? undefined : value;
} catch {
return undefined;
}
}
}
setItem(key: string, data: string) {
try {
localStorage.setItem(key, data);
this.data = undefined;
} catch {
if (!this.data) {
this.data = new Map();
}
this.data.set(key, data);
}
}
removeItem(key: string) {
if (this.data) {
this.data.delete(key);
} else {
try {
localStorage.removeItem(key);
} catch { }
}
}
clear() {
if (this.data) {
this.data.clear();
} else {
try {
localStorage.clear();
} catch { }
}
}
getJSON<T>(key: string, defaultValue: T): T {
try {
return JSON.parse(this.getItem(key) || '');
} catch {
return defaultValue;
}
}
setJSON(key: string, value: any) {
this.setItem(key, JSON.stringify(value));
}
getInt(key: string) {
return parseInt(this.getItem(key) || '0', 10) | 0;
}
setInt(key: string, value: number) {
this.setItem(key, value.toString(10));
}
getBoolean(key: string) {
return this.getItem(key) === 'true';
}
setBoolean(key: string, value: boolean) {
if (value) {
this.setItem(key, 'true');
} else {
this.removeItem(key);
}
}
}