mirror of
https://github.com/Terncode/pixel.horse.git
synced 2026-09-24 21:55:52 +02:00
Archive commit
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { CounterService } from './counter';
|
||||
import { IClient } from '../serverInterfaces';
|
||||
import { isMutedOrShadowed, isIgnored } from '../playerUtils';
|
||||
|
||||
export const enum LimiterResult {
|
||||
Yes = 0,
|
||||
SameAccount = 1,
|
||||
MutedOrShadowed = 2,
|
||||
Ignored = 3,
|
||||
LimitReached = 4,
|
||||
TargetOffline = 5,
|
||||
}
|
||||
|
||||
export class ActionLimiter {
|
||||
private counters: CounterService<void>;
|
||||
constructor(clearTimeout: number, private countLimit: number) {
|
||||
this.counters = new CounterService<void>(clearTimeout);
|
||||
this.counters.start();
|
||||
}
|
||||
canExecute(requester: IClient, target: IClient): LimiterResult {
|
||||
if (requester === target || requester.accountId === target.accountId)
|
||||
return LimiterResult.SameAccount;
|
||||
|
||||
if (target.offline)
|
||||
return LimiterResult.TargetOffline;
|
||||
|
||||
if (isMutedOrShadowed(requester))
|
||||
return LimiterResult.MutedOrShadowed;
|
||||
|
||||
if (isIgnored(requester, target) || isIgnored(target, requester))
|
||||
return LimiterResult.Ignored;
|
||||
|
||||
if (this.counters.get(requester.accountId).count >= this.countLimit)
|
||||
return LimiterResult.LimitReached;
|
||||
|
||||
return LimiterResult.Yes;
|
||||
}
|
||||
count(requester: IClient) {
|
||||
return this.counters.add(requester.accountId).count;
|
||||
}
|
||||
dispose() {
|
||||
this.counters.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import { sort } from 'timsort';
|
||||
import { remove } from 'lodash';
|
||||
import { Subject } from 'rxjs';
|
||||
import * as db from '../db';
|
||||
import { LiveList } from './liveList';
|
||||
import { removeItem, includes, toInt, fromNow } from '../../common/utils';
|
||||
import {
|
||||
Account, Auth, Origin, OriginRef, OriginInfo, Character, ListListener, OriginInfoBase, Event, eventFields, PonyIdDateName
|
||||
} from '../../common/adminInterfaces';
|
||||
import {
|
||||
addToMap, removeFromMap, emailName, getIdsFromNote, compareAccounts, compareOriginRefs, compareByName,
|
||||
compareAuths, createIdStore, getPotentialDuplicates, createPotentialDuplicatesFilter
|
||||
} from '../../common/adminUtils';
|
||||
import { logger, logPerformance } from '../logger';
|
||||
import { ObservableList } from './observableList';
|
||||
import { HOUR } from '../../common/constants';
|
||||
import { getLoginServer } from '../internal';
|
||||
|
||||
function addAuthToAccount(account: Account, auth: Auth, log: string) {
|
||||
const existingAuth = account.auths!.find(a => a._id === auth._id);
|
||||
|
||||
if (existingAuth) { // TODO: remove
|
||||
console.log('duplicate auth', auth._id, 'to', account._id, log);
|
||||
} else {
|
||||
account.authsList!.pushOrdered(auth, compareAuths);
|
||||
}
|
||||
}
|
||||
|
||||
function pushUnique<T>(list: T[], item: T) {
|
||||
if (list.indexOf(item) === -1) {
|
||||
list.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
function removeAuthFromAccount(account: Account, auth: Auth) {
|
||||
return account.authsList!.remove(auth);
|
||||
}
|
||||
|
||||
function addPonyToAccount(account: Account, pony: Character) {
|
||||
if (account.poniesList) {
|
||||
account.poniesList.pushOrdered(pony, compareByName);
|
||||
}
|
||||
}
|
||||
|
||||
function removePonyFromAccount(account: Account, pony: Character) {
|
||||
if (account.poniesList) {
|
||||
return account.poniesList.remove(pony);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getTotalPledged(auths: Auth[] | undefined) {
|
||||
return Math.floor((auths || []).reduce((sum, a) => sum + toInt(a.pledged), 0) / 100);
|
||||
}
|
||||
|
||||
export class AdminService {
|
||||
readonly accounts: LiveList<Account>;
|
||||
readonly origins: LiveList<Origin>;
|
||||
readonly auths: LiveList<Auth>;
|
||||
readonly ponies: LiveList<Character>;
|
||||
readonly events: LiveList<Event>;
|
||||
readonly accountDeleted = new Subject<Account>();
|
||||
private emailMap = new Map<string, Account[]>();
|
||||
private noteRefMap = new Map<string, Account[]>();
|
||||
private browserIdMap = new Map<string, Account[]>();
|
||||
private unassignedAuths: Auth[] = [];
|
||||
private unassignedPonies: Character[] = [];
|
||||
constructor() {
|
||||
const accountId = createIdStore();
|
||||
|
||||
this.accounts = new LiveList<Account>(db.Account, {
|
||||
fields: [
|
||||
'_id', 'updatedAt', 'createdAt', 'lastVisit', 'name', 'birthdate', 'origins', 'ignores', 'emails', 'note',
|
||||
'counters', 'mute', 'shadow', 'ban', 'flags', 'roles', 'characterCount', 'patreon', 'supporter',
|
||||
'supporterDeclinedSince', 'lastBrowserId', 'noteUpdated', 'alert', 'birthyear'
|
||||
],
|
||||
clean: ({
|
||||
_id, createdAt, updatedAt, lastVisit, name, birthdate, origins, ignoresCount, emails, note, counters, mute,
|
||||
shadow, ban, flags, roles, characterCount, patreon, supporter, supporterDeclinedSince, auths, noteUpdated,
|
||||
alert, birthyear,
|
||||
}) =>
|
||||
({
|
||||
_id, createdAt, updatedAt, lastVisit, name, birthdate, origins, ignoresCount: toInt(ignoresCount),
|
||||
emails, note, counters, mute, shadow, ban, flags: toInt(flags), roles, birthyear,
|
||||
characterCount: toInt(characterCount), patreon: toInt(patreon), supporter: toInt(supporter),
|
||||
supporterDeclinedSince, totalPledged: getTotalPledged(auths), noteUpdated, alert,
|
||||
}),
|
||||
fix: account => {
|
||||
account._id = accountId(account._id);
|
||||
account.nameLower = account.name.toLowerCase();
|
||||
account.ignoresCount = account.ignores ? account.ignores.length : 0;
|
||||
account.ignores = undefined;
|
||||
account.origins = (account.origins || []).map(o => ({ ip: o.ip, country: o.country, last: o.last }));
|
||||
},
|
||||
onAdd: account => {
|
||||
account.auths = [];
|
||||
// account.ponies = [];
|
||||
account.originsRefs = [];
|
||||
account.authsList = new ObservableList(account.auths!, a => a._id);
|
||||
|
||||
if (account.lastBrowserId) {
|
||||
this.addBrowserIdToMap(account.lastBrowserId, account);
|
||||
}
|
||||
|
||||
if (account.emails) {
|
||||
for (const e of account.emails) {
|
||||
this.addEmailToMap(e, account);
|
||||
}
|
||||
}
|
||||
|
||||
this.addNoteRefsToMap(account.note, account);
|
||||
this.updateOriginRefs(account);
|
||||
this.accountsForPotentialDuplicatesCheck.push(account);
|
||||
},
|
||||
onUpdate: (oldAccount, newAccount) => {
|
||||
if (oldAccount.emails) {
|
||||
for (const e of oldAccount.emails) {
|
||||
if (!includes(newAccount.emails, e)) {
|
||||
this.removeEmailFromMap(e, oldAccount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newAccount.emails) {
|
||||
for (const e of newAccount.emails) {
|
||||
if (!includes(oldAccount.emails, e)) {
|
||||
this.addEmailToMap(e, oldAccount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (oldAccount.note !== newAccount.note) {
|
||||
this.removeNoteRefsFromMap(oldAccount.note, oldAccount);
|
||||
this.addNoteRefsToMap(newAccount.note, oldAccount);
|
||||
}
|
||||
|
||||
if (oldAccount.lastBrowserId !== newAccount.lastBrowserId) {
|
||||
oldAccount.lastBrowserId && this.removeBrowserIdFromMap(oldAccount.lastBrowserId, oldAccount);
|
||||
newAccount.lastBrowserId && this.addBrowserIdToMap(newAccount.lastBrowserId, oldAccount);
|
||||
}
|
||||
|
||||
Object.assign(oldAccount, newAccount);
|
||||
|
||||
if (newAccount.birthyear === undefined) {
|
||||
oldAccount.birthyear = undefined;
|
||||
}
|
||||
|
||||
if (newAccount.alert === undefined) {
|
||||
oldAccount.alert = undefined;
|
||||
}
|
||||
|
||||
if (newAccount.patreon === undefined) {
|
||||
oldAccount.patreon = undefined;
|
||||
}
|
||||
|
||||
if (newAccount.supporter === undefined) {
|
||||
oldAccount.supporter = undefined;
|
||||
}
|
||||
|
||||
this.updateOriginRefs(oldAccount);
|
||||
this.accountsForPotentialDuplicatesCheck.push(oldAccount);
|
||||
},
|
||||
onAddedOrUpdated: () => {
|
||||
this.assignItems(this.unassignedAuths, (account, auth) => addAuthToAccount(account, auth, 'onAddedOrUpdated'));
|
||||
this.assignItems(this.unassignedPonies, addPonyToAccount);
|
||||
},
|
||||
onDelete: account => {
|
||||
account.origins = [];
|
||||
this.updateOriginRefs(account);
|
||||
|
||||
if (account.emails) {
|
||||
for (const email of account.emails) {
|
||||
this.removeEmailFromMap(email, account);
|
||||
}
|
||||
}
|
||||
|
||||
account.lastBrowserId && this.removeBrowserIdFromMap(account.lastBrowserId, account);
|
||||
this.removeNoteRefsFromMap(account.note, account);
|
||||
this.accountDeleted.next(account);
|
||||
},
|
||||
onFinished: () => {
|
||||
sort(this.accounts.items, compareAccounts);
|
||||
this.auths.start();
|
||||
},
|
||||
});
|
||||
|
||||
this.origins = new LiveList<Origin>(db.Origin, {
|
||||
fields: ['_id', 'updatedAt', 'ip', 'country', 'mute', 'shadow', 'ban'],
|
||||
clean: ({ _id, updatedAt, ip, country, mute, shadow, ban, accounts }) =>
|
||||
({ _id, updatedAt, ip, country, mute, shadow, ban, accountsCount: accounts ? accounts.length : 0 }),
|
||||
onAdd: origin => {
|
||||
origin.accounts = [];
|
||||
},
|
||||
onSubscribeToMissing: ip => ({ ip, country: '??' }) as any,
|
||||
}, origin => origin.ip);
|
||||
|
||||
this.auths = new LiveList<Auth>(db.Auth, {
|
||||
fields: ['_id', 'updatedAt', 'account', 'provider', 'name', 'url', 'disabled', 'banned', 'pledged', 'lastUsed'],
|
||||
clean: ({ _id, updatedAt, account, provider, name, url, disabled, banned, pledged, lastUsed }) =>
|
||||
({ _id, updatedAt, account, provider, name, url, disabled, banned, pledged, lastUsed }),
|
||||
fix: auth => {
|
||||
if (auth.account) {
|
||||
auth.account = accountId(auth.account.toString());
|
||||
}
|
||||
},
|
||||
onAdd: auth => {
|
||||
this.assignAccount(auth, this.unassignedAuths, account => addAuthToAccount(account, auth, 'onAdd'));
|
||||
},
|
||||
onUpdate: this.createUpdater<Auth>({
|
||||
remove: (account, auth) => removeAuthFromAccount(account, auth) || removeItem(this.unassignedAuths, auth),
|
||||
add: (account, auth) =>
|
||||
account ? addAuthToAccount(account, auth, 'onUpdate') : pushUnique(this.unassignedAuths, auth),
|
||||
}),
|
||||
onDelete: auth => {
|
||||
removeItem(this.unassignedAuths, auth);
|
||||
this.accounts.for(auth.account, account => removeAuthFromAccount(account, auth));
|
||||
},
|
||||
onFinished: () => {
|
||||
logger.info('Admin service loaded');
|
||||
},
|
||||
});
|
||||
|
||||
this.ponies = new LiveList<Character>(db.Character, {
|
||||
fields: ['_id', 'createdAt', 'updatedAt', 'lastUsed', 'account', 'name', 'flags'],
|
||||
noStore: true,
|
||||
clean: ({ _id, createdAt, updatedAt, account, name, flags, lastUsed }) =>
|
||||
({ _id, createdAt, updatedAt, account, name, flags, lastUsed }),
|
||||
fix: pony => {
|
||||
if (pony.account) {
|
||||
pony.account = accountId(pony.account.toString());
|
||||
}
|
||||
},
|
||||
ignore: pony => {
|
||||
const account = this.accounts.get(pony.account);
|
||||
return account === undefined || account.ponies === undefined;
|
||||
},
|
||||
onAdd: pony => {
|
||||
this.assignAccount(pony, this.unassignedPonies, account => addPonyToAccount(account, pony));
|
||||
},
|
||||
onUpdate: this.createUpdater<Character>({
|
||||
remove: (account, pony) => removePonyFromAccount(account, pony) || removeItem(this.unassignedPonies, pony),
|
||||
add: (account, pony) => account ? addPonyToAccount(account, pony) : pushUnique(this.unassignedPonies, pony),
|
||||
}),
|
||||
onDelete: pony => {
|
||||
removeItem(this.unassignedPonies, pony);
|
||||
this.accounts.for(pony.account, account => removePonyFromAccount(account, pony));
|
||||
},
|
||||
// afterAssign: (from, to) => Promise.all([updateCharacterCount(from), updateCharacterCount(to)]),
|
||||
});
|
||||
|
||||
this.events = new LiveList<Event>(db.Event, {
|
||||
fields: eventFields,
|
||||
clean: ({ _id, createdAt, updatedAt, message, desc, account, pony, origin }) =>
|
||||
({ _id, createdAt, updatedAt, message, desc, account, pony, origin }),
|
||||
});
|
||||
|
||||
setTimeout(() => this.events.start(), 100);
|
||||
setTimeout(() => this.ponies.start(), 200);
|
||||
setTimeout(() => this.origins.start(), 300);
|
||||
setTimeout(() => this.accounts.start(), 400);
|
||||
}
|
||||
get loaded() {
|
||||
return this.accounts.loaded && this.origins.loaded && this.auths.loaded;
|
||||
}
|
||||
removedItem(type: 'events' | 'ponies' | 'accounts' | 'auths' | 'origins', id: string) {
|
||||
if (type === 'accounts') {
|
||||
this.accounts.removed(id);
|
||||
} else if (type === 'origins') {
|
||||
this.origins.removed(id);
|
||||
} else if (type === 'auths') {
|
||||
this.auths.removed(id);
|
||||
} else if (type === 'ponies') {
|
||||
this.ponies.removed(id);
|
||||
} else {
|
||||
console.warn(`Unhandled removedItem for type: ${type}`);
|
||||
}
|
||||
}
|
||||
getAccountsByNoteRef(accountId: string) {
|
||||
return this.noteRefMap.get(accountId) || [];
|
||||
}
|
||||
getAccountsByEmailName(emailName: string) {
|
||||
return this.emailMap.get(emailName) || [];
|
||||
}
|
||||
getAccountsByBrowserId(browserId: string) {
|
||||
return this.browserIdMap.get(browserId);
|
||||
}
|
||||
removeOriginsFromAccount(accountId: string, ips?: string[]) {
|
||||
const account = this.accounts.get(accountId);
|
||||
|
||||
if (account) {
|
||||
if (ips) {
|
||||
if (remove(account.origins, o => includes(ips, o.ip)).length) {
|
||||
this.updateOriginRefs(account);
|
||||
}
|
||||
} else if (account.origins.length) {
|
||||
account.origins = [];
|
||||
this.updateOriginRefs(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
subscribeToAccountAuths(accountId: string, listener: ListListener<string>) {
|
||||
const account = this.accounts.get(accountId);
|
||||
|
||||
if (account) {
|
||||
return account.authsList!.subscribe(listener);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
subscribeToAccountOrigins(accountId: string, listener: ListListener<OriginInfoBase>) {
|
||||
const account = this.accounts.get(accountId);
|
||||
|
||||
if (account) {
|
||||
if (!account.originsList) {
|
||||
account.originsList = new ObservableList(
|
||||
account.originsRefs!, ({ origin, last }) => ({ ip: origin.ip, country: origin.country, last }));
|
||||
}
|
||||
|
||||
return account.originsList.subscribe(listener);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
subscribeToAccountPonies(accountId: string, listener: ListListener<PonyIdDateName>) {
|
||||
const account = this.accounts.get(accountId);
|
||||
|
||||
if (account) {
|
||||
if (!account.ponies) {
|
||||
account.ponies = this.ponies.items.filter(p => p.account === account._id);
|
||||
this.ponies.fetch({ account: account._id });
|
||||
}
|
||||
|
||||
if (!account.poniesList) {
|
||||
account.poniesList = new ObservableList(
|
||||
account.ponies!, p => ({ id: p._id, name: p.name, date: p.lastUsed ? p.lastUsed.getTime() : 0 }));
|
||||
}
|
||||
|
||||
return account.poniesList.subscribe(listener);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
cleanupOriginsList(accountId: string) {
|
||||
const account = this.accounts.get(accountId);
|
||||
|
||||
if (account && account.originsList && !account.originsList.hasSubscribers()) {
|
||||
account.originsList = undefined;
|
||||
}
|
||||
}
|
||||
cleanupPoniesList(accountId: string) {
|
||||
const account = this.accounts.get(accountId);
|
||||
|
||||
if (account && account.ponies && account.poniesList && !account.poniesList.hasSubscribers()) {
|
||||
const ponies = account.ponies;
|
||||
account.ponies = undefined;
|
||||
account.poniesList = undefined;
|
||||
|
||||
for (const pony of ponies) {
|
||||
this.cleanupPony(pony._id);
|
||||
}
|
||||
}
|
||||
}
|
||||
cleanupPony(ponyId: string) {
|
||||
const pony = this.ponies.get(ponyId);
|
||||
|
||||
if (pony && !this.ponies.hasSubscriptions(ponyId)) {
|
||||
const account = this.accounts.get(pony.account);
|
||||
|
||||
if (!account || !account.ponies) {
|
||||
this.ponies.discard(ponyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
private duplicateFilter = createPotentialDuplicatesFilter(id => this.browserIdMap.get(id));
|
||||
private accountsForPotentialDuplicatesCheck: Account[] = [];
|
||||
async mergePotentialDuplicates() {
|
||||
const start = Date.now();
|
||||
const duplicateFilter = this.duplicateFilter;
|
||||
|
||||
while (this.accountsForPotentialDuplicatesCheck.length) {
|
||||
const popedAccount = this.accountsForPotentialDuplicatesCheck.pop()!;
|
||||
const account = this.getAccount(popedAccount._id);
|
||||
|
||||
if (account && duplicateFilter(account)) {
|
||||
const threshold = fromNow(-1 * HOUR).getTime();
|
||||
const duplicates = getPotentialDuplicates(account, id => this.getAccountsByBrowserId(id))
|
||||
.filter(a => a.createdAt && a.createdAt.getTime() < threshold);
|
||||
|
||||
if (duplicates.length) {
|
||||
const server = getLoginServer('login');
|
||||
const duplicate = duplicates[0];
|
||||
const accountIsOlder = account.lastVisit && duplicate.lastVisit
|
||||
&& account.lastVisit.getTime() < duplicate.lastVisit.getTime();
|
||||
const accountId = accountIsOlder ? duplicate._id : account._id;
|
||||
const withId = accountIsOlder ? account._id : duplicate._id;
|
||||
logPerformance(`mergePotentialDuplicates (${Date.now() - start}ms) [yes]`);
|
||||
await server.api.mergeAccounts(accountId, withId, `by server`, false, true);
|
||||
return accountId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.accountsForPotentialDuplicatesCheck = [];
|
||||
|
||||
logPerformance(`mergePotentialDuplicates (${Date.now() - start}ms) [no]`);
|
||||
return undefined;
|
||||
}
|
||||
// helpers
|
||||
private addEmailToMap(email: string, account: Account) {
|
||||
addToMap(this.emailMap, emailName(email), account);
|
||||
}
|
||||
private removeEmailFromMap(email: string, account: Account) {
|
||||
removeFromMap(this.emailMap, emailName(email), account);
|
||||
}
|
||||
private addNoteRefsToMap(note: string, account: Account) {
|
||||
for (let id of getIdsFromNote(note)) {
|
||||
if (id !== account._id) {
|
||||
addToMap(this.noteRefMap, id, account);
|
||||
}
|
||||
}
|
||||
}
|
||||
private removeNoteRefsFromMap(note: string, account: Account) {
|
||||
for (let id of getIdsFromNote(note)) {
|
||||
if (id !== account._id) {
|
||||
removeFromMap(this.noteRefMap, id, account);
|
||||
}
|
||||
}
|
||||
}
|
||||
private addBrowserIdToMap(browserId: string, account: Account) {
|
||||
addToMap(this.browserIdMap, browserId, account);
|
||||
}
|
||||
private removeBrowserIdFromMap(browserId: string, account: Account) {
|
||||
removeFromMap(this.browserIdMap, browserId, account);
|
||||
}
|
||||
private getAccount(id: string | undefined) {
|
||||
return id ? this.accounts.get(id) : undefined;
|
||||
}
|
||||
private getOrCreateOrigin({ ip, country }: OriginInfo): Origin {
|
||||
return this.origins.get(ip)
|
||||
|| this.origins.add({ _id: '', ip, country, accounts: [], updatedAt: new Date(0), createdAt: new Date(0) });
|
||||
}
|
||||
private assignAccount<T extends { account?: string; }>(item: T, unassigned: T[], action: (account: Account) => void) {
|
||||
const account = this.getAccount(item.account);
|
||||
|
||||
if (account) {
|
||||
action(account);
|
||||
} else {
|
||||
pushUnique(unassigned, item);
|
||||
}
|
||||
}
|
||||
private updateOriginRefs(a: Account) {
|
||||
if (a.originsRefs) {
|
||||
for (const o of a.originsRefs) {
|
||||
removeById(o.origin.accounts!, a._id);
|
||||
}
|
||||
}
|
||||
|
||||
const oldOriginRefs = a.originsRefs;
|
||||
a.originsRefs = a.origins.map(o => <OriginRef>{ origin: this.getOrCreateOrigin(o), last: o.last });
|
||||
|
||||
sort(a.originsRefs, compareOriginRefs);
|
||||
|
||||
for (const o of a.originsRefs) {
|
||||
if (o.origin.accounts && !includes(o.origin.accounts, a)) {
|
||||
o.origin.accounts.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
if (oldOriginRefs) {
|
||||
for (const o of oldOriginRefs) {
|
||||
if (!o.origin._id && o.origin.accounts!.length === 0) {
|
||||
this.origins.removed(o.origin.ip);
|
||||
} else {
|
||||
this.origins.trigger(o.origin.ip, o.origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (a.originsList) {
|
||||
a.originsList.replace(a.originsRefs);
|
||||
}
|
||||
}
|
||||
private assignItems<T extends { account?: string; }>(unassigned: T[], push: (account: Account, item: T) => void) {
|
||||
remove(unassigned, item => {
|
||||
const account = item.account && this.getAccount(item.account);
|
||||
|
||||
if (account) {
|
||||
push(account, item);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
private createUpdater<T extends { account?: string; }>(
|
||||
{ add, remove }: {
|
||||
remove: (account: Account, item: T) => void;
|
||||
add: (account: Account | undefined, item: T) => void;
|
||||
}
|
||||
) {
|
||||
return (oldItem: T, newItem: T) => {
|
||||
const oldAccountId = oldItem.account;
|
||||
const newAccountId = newItem.account;
|
||||
|
||||
Object.assign(oldItem, newItem);
|
||||
|
||||
if (oldAccountId !== newAccountId) {
|
||||
const oldAccount = this.getAccount(oldAccountId);
|
||||
const newAccount = this.getAccount(newAccountId);
|
||||
|
||||
if (oldAccount) {
|
||||
remove(oldAccount, oldItem);
|
||||
}
|
||||
|
||||
add(newAccount, oldItem);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function findIndexById<U, T extends { _id: U }>(items: T[], id: U): number {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i]._id === id) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function removeById<U, T extends { _id: U }>(items: T[], id: U): T | undefined {
|
||||
const index = findIndexById(items, id);
|
||||
|
||||
if (index !== -1) {
|
||||
const item = items[index];
|
||||
items.splice(index, 1);
|
||||
return item;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
interface Counter<T> {
|
||||
date: number;
|
||||
count: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
const zeroCounter: Counter<any> = { date: 0, count: 0, items: [] };
|
||||
|
||||
export class CounterService<T> {
|
||||
private counters = new Map<string, Counter<T>>();
|
||||
private interval: any;
|
||||
constructor(private clearTimeout: number) {
|
||||
}
|
||||
get(id: string): Counter<T> {
|
||||
return this.counters.get(id) || zeroCounter;
|
||||
}
|
||||
add(id: string, item?: T, count = 1) {
|
||||
const counter = this.counters.get(id) || { date: 0, count: 0, items: [] };
|
||||
counter.date = Date.now();
|
||||
counter.count += count;
|
||||
|
||||
if (item) {
|
||||
counter.items.push(item);
|
||||
}
|
||||
|
||||
this.counters.set(id, counter);
|
||||
return counter;
|
||||
}
|
||||
remove(id: string) {
|
||||
this.counters.delete(id);
|
||||
}
|
||||
cleanup() {
|
||||
const threshold = Date.now() - this.clearTimeout;
|
||||
const remove: string[] = [];
|
||||
|
||||
this.counters.forEach((value, key) => {
|
||||
if (value.date < threshold) {
|
||||
remove.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
remove.forEach(id => this.remove(id));
|
||||
}
|
||||
start() {
|
||||
this.interval = this.interval || setInterval(() => this.cleanup(), this.clearTimeout / 10);
|
||||
}
|
||||
stop() {
|
||||
clearInterval(this.interval);
|
||||
this.interval = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { NotificationFlags, FriendStatusFlags, FriendStatusData } from '../../common/interfaces';
|
||||
import { HOUR, FRIENDS_LIMIT } from '../../common/constants';
|
||||
import { AccountFlags } from '../../common/adminInterfaces';
|
||||
import { hasFlag } from '../../common/utils';
|
||||
import { IClient } from '../serverInterfaces';
|
||||
import { addFriend, removeFriend } from '../accountUtils';
|
||||
import { saySystem } from '../chat';
|
||||
import { logger } from '../logger';
|
||||
import { NotificationService } from './notification';
|
||||
import { ActionLimiter, LimiterResult } from './actionLimiter';
|
||||
import { updateEntityPlayerState } from '../playerUtils';
|
||||
import { getEntityName } from '../entityUtils';
|
||||
|
||||
export const PENDING_LIMIT = 2;
|
||||
export const REJECTED_LIMIT = 5;
|
||||
export const REJECTED_TIMEOUT = 2 * HOUR;
|
||||
|
||||
export function isFriend(client: IClient, friend: IClient) {
|
||||
return client.friends.has(friend.accountId);
|
||||
}
|
||||
|
||||
export function isOnlineFriend(client: IClient, friend: IClient) {
|
||||
return client.friends.has(friend.accountId) && !friend.accountSettings.hidden;
|
||||
}
|
||||
|
||||
export function toFriendOnline(client: IClient): FriendStatusData {
|
||||
return {
|
||||
accountId: client.accountId,
|
||||
accountName: client.accountName,
|
||||
status: FriendStatusFlags.Online,
|
||||
entityId: client.pony.id,
|
||||
crc: client.pony.crc,
|
||||
name: client.pony.name,
|
||||
nameBad: client.pony.nameBad,
|
||||
info: client.pony.infoSafe,
|
||||
};
|
||||
}
|
||||
|
||||
export function toFriendOffline(client: IClient): FriendStatusData {
|
||||
return {
|
||||
accountId: client.accountId,
|
||||
accountName: client.accountName,
|
||||
status: FriendStatusFlags.None,
|
||||
entityId: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function toFriendRemove(client: IClient): FriendStatusData {
|
||||
return {
|
||||
accountId: client.accountId,
|
||||
status: FriendStatusFlags.Remove,
|
||||
};
|
||||
}
|
||||
|
||||
export function toFriend(client: IClient): FriendStatusData {
|
||||
if (client.isConnected) {
|
||||
return toFriendOnline(client);
|
||||
} else {
|
||||
return toFriendOffline(client);
|
||||
}
|
||||
}
|
||||
|
||||
export class FriendsService {
|
||||
private limiter = new ActionLimiter(REJECTED_TIMEOUT, REJECTED_LIMIT);
|
||||
private pending = new Map<string, Set<string>>();
|
||||
constructor(
|
||||
private notificationService: NotificationService,
|
||||
private reportInviteLimit: (client: IClient) => void
|
||||
) {
|
||||
}
|
||||
dispose() {
|
||||
this.limiter.dispose();
|
||||
}
|
||||
clientDisconnected(client: IClient) {
|
||||
for (const key of Array.from(this.pending.keys())) {
|
||||
const pending = this.pending.get(key)!;
|
||||
pending.delete(client.accountId);
|
||||
|
||||
if (!pending.size) {
|
||||
this.pending.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
remove(client: IClient, friend: IClient) {
|
||||
removeFriend(client.accountId, friend.accountId).catch(e => logger.error(e));
|
||||
client.friends.delete(friend.accountId);
|
||||
client.friendsCRC = undefined;
|
||||
friend.friends.delete(client.accountId);
|
||||
friend.friendsCRC = undefined;
|
||||
client.reporter.systemLog(`Removed friend [${friend.accountId}]`);
|
||||
client.updateFriends([{ accountId: friend.accountId, status: FriendStatusFlags.Remove }], false);
|
||||
friend.updateFriends([{ accountId: client.accountId, status: FriendStatusFlags.Remove }], false);
|
||||
updateEntityPlayerState(client, friend.pony);
|
||||
updateEntityPlayerState(friend, client.pony);
|
||||
}
|
||||
removeByAccountId(client: IClient, friendAccountId: string) {
|
||||
removeFriend(client.accountId, friendAccountId).catch(e => logger.error(e));
|
||||
client.friends.delete(friendAccountId);
|
||||
client.friendsCRC = undefined;
|
||||
client.reporter.systemLog(`Removed friend [${friendAccountId}]`);
|
||||
client.updateFriends([{ accountId: friendAccountId, status: FriendStatusFlags.Remove }], false);
|
||||
}
|
||||
add(client: IClient, target: IClient) {
|
||||
const can = this.limiter.canExecute(client, target);
|
||||
|
||||
if (can === LimiterResult.LimitReached) {
|
||||
return saySystem(client, 'Reached request rejection limit');
|
||||
} else if (can !== LimiterResult.Yes) {
|
||||
return saySystem(client, 'Cannot send request');
|
||||
}
|
||||
|
||||
const pending = this.pending.get(client.accountId) || new Set();
|
||||
|
||||
if (pending.has(target.accountId))
|
||||
return saySystem(client, 'Already sent request');
|
||||
|
||||
if (isFriend(client, target))
|
||||
return saySystem(client, 'Already on friends list');
|
||||
|
||||
if (client.friends.size >= FRIENDS_LIMIT)
|
||||
return saySystem(client, 'Your friend list is full');
|
||||
|
||||
if (target.friends.size >= FRIENDS_LIMIT)
|
||||
return saySystem(client, 'Target player friend list is full');
|
||||
|
||||
if (hasFlag(client.account.flags, AccountFlags.BlockFriendRequests))
|
||||
return saySystem(client, 'Cannot send request');
|
||||
|
||||
if (target.accountSettings.ignoreFriendInvites)
|
||||
return saySystem(client, 'Cannot send request');
|
||||
|
||||
if (pending.size >= PENDING_LIMIT)
|
||||
return saySystem(client, 'Too many pending requests');
|
||||
|
||||
const notificationId = this.addInviteNotification(client, target);
|
||||
|
||||
if (!notificationId) {
|
||||
return saySystem(client, 'Cannot send request');
|
||||
}
|
||||
|
||||
pending.add(target.accountId);
|
||||
this.pending.set(client.accountId, pending);
|
||||
client.reporter.systemLog(`Friend request [${target.accountId}]`);
|
||||
}
|
||||
private acceptInvitation(client: IClient, friend: IClient, notificationId: number) {
|
||||
client.reporter.systemLog(`Friend request accepted by [${friend.accountId}]`);
|
||||
saySystem(client, `Friend request accepted by ${getEntityName(friend.pony, client)}`);
|
||||
|
||||
addFriend(client.accountId, friend.accountId)
|
||||
.catch(e => {
|
||||
if (e.message !== `Friend request already exists`) {
|
||||
logger.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
client.friends.add(friend.accountId);
|
||||
client.friendsCRC = undefined;
|
||||
friend.friends.add(client.accountId);
|
||||
friend.friendsCRC = undefined;
|
||||
this.removePending(client, friend);
|
||||
this.notificationService.removeNotification(friend, notificationId);
|
||||
client.updateFriends([toFriend(friend)], false);
|
||||
friend.updateFriends([toFriend(client)], false);
|
||||
updateEntityPlayerState(client, friend.pony);
|
||||
updateEntityPlayerState(friend, client.pony);
|
||||
}
|
||||
private rejectInvitation(client: IClient, friend: IClient, notificationId: number) {
|
||||
client.reporter.systemLog(`Friend request rejected by [${friend.accountId}]`);
|
||||
saySystem(client, `Friend request rejected by ${getEntityName(friend.pony, client)}`);
|
||||
this.removePending(client, friend);
|
||||
this.notificationService.removeNotification(friend, notificationId);
|
||||
this.countReject(client);
|
||||
}
|
||||
private removePending(client: IClient, friend: IClient) {
|
||||
const pending = this.pending.get(client.accountId);
|
||||
|
||||
if (pending) {
|
||||
pending.delete(friend.accountId);
|
||||
|
||||
if (pending.size === 0) {
|
||||
this.pending.delete(client.accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
private countReject(invitedBy: IClient) {
|
||||
const count = this.limiter.count(invitedBy);
|
||||
|
||||
if (count >= REJECTED_LIMIT) {
|
||||
this.reportInviteLimit(invitedBy);
|
||||
}
|
||||
}
|
||||
private addInviteNotification(client: IClient, friend: IClient) {
|
||||
const notificationId = this.notificationService.addNotification(friend, {
|
||||
id: 0,
|
||||
sender: client,
|
||||
name: client.pony.name || '',
|
||||
entityId: client.pony.id,
|
||||
message: `<div class="text-friends"><b>Friend request</b></div><b>#NAME#</b> wants to add you to their friends`,
|
||||
flags: NotificationFlags.Accept | NotificationFlags.Reject | NotificationFlags.Ignore |
|
||||
(client.pony.nameBad ? NotificationFlags.NameBad : 0),
|
||||
accept: () => this.acceptInvitation(client, friend, notificationId),
|
||||
reject: () => this.rejectInvitation(client, friend, notificationId),
|
||||
});
|
||||
|
||||
return notificationId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import * as fs from 'fs';
|
||||
import { Subject } from 'rxjs';
|
||||
import { HIDE_LIMIT, MINUTE } from '../../common/constants';
|
||||
import { NotificationFlags } from '../../common/interfaces';
|
||||
import { IClient, ServerNotification } from '../serverInterfaces';
|
||||
import { systemMessage, logger } from '../logger';
|
||||
import { NotificationService } from './notification';
|
||||
import { includes } from '../../common/utils';
|
||||
import { isFriend } from './friends';
|
||||
import { pathTo } from '../paths';
|
||||
import { addHide } from '../db';
|
||||
import { HidingStats } from '../../common/adminInterfaces';
|
||||
import { getEntityName } from '../entityUtils';
|
||||
import { saySystem } from '../chat';
|
||||
|
||||
interface Hide {
|
||||
by: string;
|
||||
who: string;
|
||||
}
|
||||
|
||||
const hidePlayerLimit = 'Cannot hide any more players.';
|
||||
const cannotHidePlayerInParty = 'Cannot hide players from your party.';
|
||||
const cannotHideFriends = 'Cannot hide friends.';
|
||||
const unhideAllLimit = 'Cannot unhide hidden players, try again later.';
|
||||
const unhideAllLimitNote = 'You can only do this once per hour.';
|
||||
|
||||
function clientInfo({ accountId, account, characterName }: IClient) {
|
||||
return `${characterName} (${account.name}) [${accountId}]`;
|
||||
}
|
||||
|
||||
function simpleNotification(message: string, note?: string): ServerNotification {
|
||||
return { id: 0, name: '', message, note, flags: NotificationFlags.Ok };
|
||||
}
|
||||
|
||||
export function hidingDataPath(serverId: string) {
|
||||
return pathTo('settings', `hiding-${serverId}.json`);
|
||||
}
|
||||
|
||||
export async function saveHidingData(hiding: HidingService, serverId: string) {
|
||||
if (!TESTS) {
|
||||
try {
|
||||
const data = hiding.serialize();
|
||||
await fs.writeFileAsync(hidingDataPath(serverId), data, 'utf8');
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function pollHidingDataSave(hiding: HidingService, serverId: string) {
|
||||
setInterval(() => saveHidingData(hiding, serverId), 10 * MINUTE);
|
||||
}
|
||||
|
||||
export class HidingService {
|
||||
changes = new Subject<Hide>();
|
||||
unhidesAll = new Subject<string>();
|
||||
private hides = new Map<string, Map<string, number>>();
|
||||
private unhides = new Map<string, number>();
|
||||
private interval: any;
|
||||
constructor(
|
||||
private clearUnhides: number,
|
||||
private notifications: NotificationService,
|
||||
private findClient: (accountId: string) => IClient | undefined,
|
||||
private log: (message: string) => void,
|
||||
) {
|
||||
}
|
||||
serialize() {
|
||||
const hides: any = {};
|
||||
const unhides: any = {};
|
||||
|
||||
this.hides.forEach((hidesMap, by) => {
|
||||
const list: any = {};
|
||||
hidesMap.forEach((value, key) => list[key] = value);
|
||||
hides[by] = list;
|
||||
});
|
||||
|
||||
this.unhides.forEach((value, key) => unhides[key] = value);
|
||||
|
||||
return JSON.stringify({ hides, unhides });
|
||||
}
|
||||
deserialize(data: string) {
|
||||
try {
|
||||
const { hides, unhides } = JSON.parse(data);
|
||||
|
||||
for (const by of Object.keys(hides)) {
|
||||
const hidesMap = new Map<string, number>();
|
||||
|
||||
for (const key of Object.keys(hides[by])) {
|
||||
hidesMap.set(key, hides[by][key]);
|
||||
}
|
||||
|
||||
this.hides.set(by, hidesMap);
|
||||
}
|
||||
|
||||
for (const key of Object.keys(unhides)) {
|
||||
this.unhides.set(key, unhides[key]);
|
||||
}
|
||||
|
||||
this.cleanup();
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
getStatsFor(account: string): HidingStats {
|
||||
const hides = this.hides.get(account);
|
||||
const hidden = hides ? Array.from(hides.keys()) : [];
|
||||
const hiddenBy: string[] = [];
|
||||
|
||||
this.hides.forEach((hides, by) => {
|
||||
if (hides.has(account)) {
|
||||
hiddenBy.push(by);
|
||||
}
|
||||
});
|
||||
|
||||
return { account, hidden, hiddenBy, permaHidden: [], permaHiddenBy: [] };
|
||||
}
|
||||
connected(client: IClient) {
|
||||
const hides = this.hides.get(client.accountId);
|
||||
|
||||
if (hides) {
|
||||
for (const id of Array.from(hides.keys())) {
|
||||
client.hides.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
requestHide(requester: IClient, target: IClient, timeout: number) {
|
||||
const hides = this.hides.get(requester.accountId);
|
||||
const count = hides && hides.size || 0;
|
||||
|
||||
if (requester.accountId === target.accountId) {
|
||||
saySystem(requester, `Cannot hide yourself`);
|
||||
} else if (requester.party && includes(requester.party.clients, target)) {
|
||||
this.notifications.addNotification(requester, simpleNotification(cannotHidePlayerInParty));
|
||||
} else if (isFriend(requester, target)) {
|
||||
this.notifications.addNotification(requester, simpleNotification(cannotHideFriends));
|
||||
} else if (count >= HIDE_LIMIT) {
|
||||
this.notifications.addNotification(requester, simpleNotification(hidePlayerLimit));
|
||||
} else {
|
||||
this.notifications.addNotification(requester, {
|
||||
id: 0,
|
||||
name: target.pony.name || '',
|
||||
entityId: target.pony.id,
|
||||
message: `Are you sure you want to hide <b>#NAME#</b> ?`,
|
||||
flags: NotificationFlags.Yes | NotificationFlags.No | (target.pony.nameBad ? NotificationFlags.NameBad : 0),
|
||||
accept: () => this.confirmHide(requester, target, timeout),
|
||||
});
|
||||
}
|
||||
}
|
||||
requestUnhideAll(requester: IClient) {
|
||||
const unhideTimestamp = this.unhides.get(requester.accountId) || 0;
|
||||
|
||||
if (unhideTimestamp > Date.now()) {
|
||||
this.notifications.addNotification(requester, simpleNotification(unhideAllLimit, unhideAllLimitNote));
|
||||
} else {
|
||||
this.notifications.addNotification(requester, {
|
||||
id: 0,
|
||||
name: '',
|
||||
message: 'Are you sure you want to unhide all temporarily hidden players ?',
|
||||
note: 'You can only do this once per hour. This action will require re-joining the game.',
|
||||
flags: NotificationFlags.Yes | NotificationFlags.No,
|
||||
accept: () => this.unhideAll(requester),
|
||||
});
|
||||
}
|
||||
}
|
||||
confirmHide(requester: IClient, target: IClient, timeout: number) {
|
||||
if (this.hide(requester, target, timeout)) {
|
||||
let message = `${requester.characterName} (${requester.account.name}) hides ${clientInfo(target)}`;
|
||||
|
||||
if (timeout === 0) {
|
||||
message += ' (permanent)';
|
||||
}
|
||||
|
||||
this.log(systemMessage(requester.accountId, message));
|
||||
}
|
||||
}
|
||||
private isHiddenInner(who: string, from: string): boolean {
|
||||
const hides = this.hides.get(who);
|
||||
return hides !== undefined && hides.has(from);
|
||||
}
|
||||
isHidden(who: string, from: string): boolean {
|
||||
return this.isHiddenInner(who, from) || this.isHiddenInner(from, who);
|
||||
}
|
||||
isHiddenClient(who: IClient, from: IClient) {
|
||||
return this.isHidden(who.accountId, from.accountId);
|
||||
}
|
||||
hide(byClient: IClient, whoClient: IClient, timeout: number) {
|
||||
const by = byClient.accountId;
|
||||
const who = whoClient.accountId;
|
||||
|
||||
if (timeout === 0) { // permanent
|
||||
addHide(by, who, getEntityName(whoClient.pony, byClient) || '[none]')
|
||||
.then(() => {
|
||||
byClient.permaHides.add(who);
|
||||
this.notify([{ by, who }]);
|
||||
})
|
||||
.catch(e => logger.error(e));
|
||||
return true;
|
||||
} else {
|
||||
|
||||
if (by === who)
|
||||
return false;
|
||||
|
||||
if (this.isHiddenInner(by, who))
|
||||
return false;
|
||||
|
||||
const hides = this.hides.get(by) || new Map<string, number>();
|
||||
hides.set(who, Date.now() + timeout);
|
||||
this.hides.set(by, hides);
|
||||
byClient.hides.add(who);
|
||||
this.notify([{ by, who }]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// TODO: remove ?
|
||||
unhide(byClient: IClient, whoClient: IClient) {
|
||||
const by = byClient.accountId;
|
||||
const who = whoClient.accountId;
|
||||
const hides = this.hides.get(by);
|
||||
|
||||
if (hides) {
|
||||
if (hides.has(who)) {
|
||||
hides.delete(who);
|
||||
|
||||
if (hides.size === 0) {
|
||||
this.hides.delete(by);
|
||||
}
|
||||
|
||||
byClient.hides.delete(who);
|
||||
|
||||
this.notify([{ by, who }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
unhideAll(byClient: IClient) {
|
||||
const by = byClient.accountId;
|
||||
|
||||
if (this.unhides.has(by))
|
||||
return;
|
||||
|
||||
const hides = this.hides.get(by);
|
||||
|
||||
if (hides) {
|
||||
const notify: Hide[] = [];
|
||||
hides.forEach((_, who) => notify.push({ by, who }));
|
||||
this.hides.delete(by);
|
||||
this.unhides.set(by, Date.now() + this.clearUnhides);
|
||||
byClient.hides.clear();
|
||||
this.notify(notify);
|
||||
this.unhidesAll.next(by);
|
||||
}
|
||||
|
||||
this.log(systemMessage(by, 'unhide all'));
|
||||
}
|
||||
merged(target: string, merge: string) {
|
||||
const targetHides = this.hides.get(target);
|
||||
const mergeHides = this.hides.get(merge);
|
||||
const notify: Hide[] = [];
|
||||
|
||||
if (targetHides) {
|
||||
targetHides.delete(merge);
|
||||
}
|
||||
|
||||
if (mergeHides) {
|
||||
const targetClient = this.findClient(target);
|
||||
mergeHides.delete(target);
|
||||
|
||||
if (targetHides) {
|
||||
for (const id of Array.from(mergeHides.keys())) {
|
||||
const who = targetHides.get(id);
|
||||
targetHides.set(id, Math.max(who || 0, mergeHides.get(id)!));
|
||||
targetClient && targetClient.hides.add(id);
|
||||
|
||||
if (!who) {
|
||||
notify.push({ by: target, who: id });
|
||||
notify.push({ by: merge, who: id });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.hides.set(target, mergeHides);
|
||||
|
||||
for (const id of Array.from(mergeHides.keys())) {
|
||||
targetClient && targetClient.hides.add(id);
|
||||
notify.push({ by: target, who: id });
|
||||
notify.push({ by: merge, who: id });
|
||||
}
|
||||
}
|
||||
|
||||
this.hides.delete(merge);
|
||||
}
|
||||
|
||||
const targetUnhides = this.unhides.get(target);
|
||||
const mergeUnhides = this.unhides.get(merge);
|
||||
|
||||
if (mergeUnhides) {
|
||||
this.unhides.set(target, Math.max(targetUnhides || 0, mergeUnhides));
|
||||
this.unhides.delete(merge);
|
||||
}
|
||||
|
||||
this.hides.forEach((_, by) => {
|
||||
const hides = this.hides.get(by)!;
|
||||
const mergeHide = hides.get(merge);
|
||||
|
||||
if (mergeHide) {
|
||||
hides.set(target, Math.max(mergeHide, hides.get(target) || 0));
|
||||
hides.delete(merge);
|
||||
const client = this.findClient(by);
|
||||
|
||||
if (client) {
|
||||
client.hides.delete(merge);
|
||||
|
||||
if (target !== by) {
|
||||
client && client.hides.add(target);
|
||||
}
|
||||
}
|
||||
|
||||
notify.push({ by, who: target });
|
||||
notify.push({ by, who: merge });
|
||||
}
|
||||
});
|
||||
|
||||
this.notify(notify);
|
||||
}
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
const notify: Hide[] = [];
|
||||
|
||||
for (const by of Array.from(this.hides.keys())) {
|
||||
const hides = this.hides.get(by)!;
|
||||
|
||||
for (const who of Array.from(hides.keys())) {
|
||||
if (hides.get(who)! < now) {
|
||||
hides.delete(who);
|
||||
const client = this.findClient(by);
|
||||
client && client.hides.delete(who);
|
||||
notify.push({ by, who });
|
||||
}
|
||||
}
|
||||
|
||||
if (hides.size === 0) {
|
||||
this.hides.delete(by);
|
||||
}
|
||||
}
|
||||
|
||||
this.notify(notify);
|
||||
|
||||
for (const key of Array.from(this.unhides.keys())) {
|
||||
if (this.unhides.get(key)! < now) {
|
||||
this.unhides.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
start() {
|
||||
this.interval = this.interval || setInterval(() => this.cleanup(), 10 * MINUTE);
|
||||
}
|
||||
stop() {
|
||||
clearInterval(this.interval);
|
||||
this.interval = undefined;
|
||||
}
|
||||
private notify(hides: Hide[]) {
|
||||
for (const hide of hides) {
|
||||
this.changes.next(hide);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Model } from 'mongoose';
|
||||
import { removeItem, maxDate } from '../../common/utils';
|
||||
import { logger as defaultLogger } from '../logger';
|
||||
import { Document } from '../../common/adminInterfaces';
|
||||
import { iterate } from '../db';
|
||||
|
||||
const tickInterval = 1000;
|
||||
|
||||
type Listener = (id: string, item: any) => void;
|
||||
|
||||
export interface LiveListConfig<T> {
|
||||
fields: (keyof T)[];
|
||||
noStore?: boolean;
|
||||
ignore?: (item: T) => boolean;
|
||||
clean: (item: T) => Partial<T>; // clean before sending to client
|
||||
fix?: (item: T) => void; // fix after getting from DB
|
||||
onSubscribeToMissing?: (id: string) => T;
|
||||
// events
|
||||
onAdd?: (item: T) => void;
|
||||
onUpdate?: (oldItem: T, newItem: T) => void;
|
||||
onDelete?: (item: T) => void;
|
||||
onFinished?: () => void;
|
||||
onAddedOrUpdated?: () => void;
|
||||
}
|
||||
|
||||
function fixDocumentId<T extends Document>(item: T) {
|
||||
item._id = item._id.toString();
|
||||
return item;
|
||||
}
|
||||
|
||||
export class LiveList<T extends Document> {
|
||||
items: T[] = [];
|
||||
private itemsMap = new Map<string, T>();
|
||||
private listeners = new Map<string, Listener[]>();
|
||||
private timestamp = new Date(0);
|
||||
private finished = false;
|
||||
private running = false;
|
||||
private timeout: any;
|
||||
private fieldsString: string;
|
||||
constructor(
|
||||
private model: Model<any>,
|
||||
private config: LiveListConfig<T>,
|
||||
private getId = (item: T) => item._id,
|
||||
private logger = defaultLogger,
|
||||
) {
|
||||
this.fieldsString = config.fields.join(' ');
|
||||
}
|
||||
get loaded() {
|
||||
return this.finished;
|
||||
}
|
||||
start() {
|
||||
if (this.config.noStore) {
|
||||
this.timestamp = new Date();
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
this.tick();
|
||||
}
|
||||
stop() {
|
||||
this.running = false;
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
get(id: string) {
|
||||
return this.itemsMap.get(id);
|
||||
}
|
||||
for(id: string | undefined, callback: (item: T) => void) {
|
||||
const item = id ? this.get(id) : undefined;
|
||||
item && callback(item);
|
||||
}
|
||||
add(item: T) {
|
||||
const id = this.getId(item);
|
||||
this.items.push(item);
|
||||
this.itemsMap.set(id, item);
|
||||
|
||||
if (this.config.onAdd) {
|
||||
this.config.onAdd(item);
|
||||
}
|
||||
|
||||
this.trigger(id, item);
|
||||
return item;
|
||||
}
|
||||
// NOTE: only _id
|
||||
async remove(id: string) {
|
||||
await this.model.deleteOne({ _id: id }).exec();
|
||||
this.removed(id);
|
||||
}
|
||||
removed(id: string) {
|
||||
const item = this.get(id);
|
||||
|
||||
if (item) {
|
||||
this.trigger(id, undefined);
|
||||
this.itemsMap.delete(id);
|
||||
removeItem(this.items, item);
|
||||
this.config.onDelete && this.config.onDelete(item);
|
||||
}
|
||||
}
|
||||
discard(id: string) {
|
||||
const item = this.get(id);
|
||||
|
||||
if (item) {
|
||||
this.itemsMap.delete(id);
|
||||
removeItem(this.items, item);
|
||||
}
|
||||
}
|
||||
trigger(id: string, item: T | undefined) {
|
||||
const listeners = this.listeners.get(id);
|
||||
|
||||
if (listeners) {
|
||||
const cleaned = item ? this.config.clean(item) : item;
|
||||
listeners.forEach(listener => listener(id, cleaned as any));
|
||||
}
|
||||
}
|
||||
subscribe(id: string, listener: Listener) {
|
||||
const listeners = this.listeners.get(id) || [];
|
||||
listeners.push(listener);
|
||||
this.listeners.set(id, listeners);
|
||||
|
||||
const item = this.get(id);
|
||||
|
||||
if (item) {
|
||||
listener(id, this.config.clean(item));
|
||||
} else if (this.config.onSubscribeToMissing) {
|
||||
this.add(this.config.onSubscribeToMissing(id));
|
||||
}
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
const listeners = this.listeners.get(id) || [];
|
||||
removeItem(listeners, listener);
|
||||
|
||||
if (listeners.length === 0) {
|
||||
this.listeners.delete(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
hasSubscriptions(id: string) {
|
||||
return !!this.listeners.get(id);
|
||||
}
|
||||
async tick() {
|
||||
if (this.running) {
|
||||
clearTimeout(this.timeout);
|
||||
|
||||
try {
|
||||
await this.update();
|
||||
} catch (e) {
|
||||
this.logger.error(e);
|
||||
} finally {
|
||||
this.timeout = setTimeout(() => this.tick(), tickInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
async fetch(search: any) {
|
||||
await this.internalUpdate(search, true);
|
||||
}
|
||||
async update() {
|
||||
await this.internalUpdate({ updatedAt: { $gt: this.timestamp } }, false);
|
||||
}
|
||||
private async internalUpdate(search: any, fetching: boolean) {
|
||||
const query = this.model.find(search, this.fieldsString);
|
||||
const applyUpdate = this.config.onUpdate || Object.assign;
|
||||
let addedOrUpdated = false;
|
||||
|
||||
await iterate(query.lean(), update => {
|
||||
try {
|
||||
fixDocumentId(update);
|
||||
|
||||
if (this.config.fix) {
|
||||
this.config.fix(update);
|
||||
}
|
||||
|
||||
if (!fetching) {
|
||||
this.timestamp = maxDate(this.timestamp, update.updatedAt)!;
|
||||
}
|
||||
|
||||
const doc = this.get(this.getId(update));
|
||||
|
||||
if (doc !== undefined) {
|
||||
applyUpdate(doc, update);
|
||||
this.trigger(this.getId(doc), doc);
|
||||
} else if (fetching || !(this.config.ignore && this.config.ignore(update))) {
|
||||
this.add(update);
|
||||
}
|
||||
|
||||
addedOrUpdated = true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
if (addedOrUpdated && this.config.onAddedOrUpdated) {
|
||||
this.config.onAddedOrUpdated();
|
||||
}
|
||||
|
||||
if (!this.finished) {
|
||||
this.finished = true;
|
||||
this.config.onFinished && this.config.onFinished();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { findById, removeById } from '../../common/utils';
|
||||
import { IClient, ServerNotification } from '../serverInterfaces';
|
||||
|
||||
const NOTIFICATION_LIMIT = 10;
|
||||
|
||||
function getId(notifications: ServerNotification[]) {
|
||||
for (let id = 1; id <= 0xffff; id++) {
|
||||
if (!findById(notifications, id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
throw new Error('Unable to get unique id for notification');
|
||||
}
|
||||
|
||||
function hasNotification(client: IClient, notification: ServerNotification) {
|
||||
return client.notifications.some(n =>
|
||||
n.message === notification.message &&
|
||||
n.flags === notification.flags &&
|
||||
n.note === notification.note &&
|
||||
n.sender === notification.sender &&
|
||||
n.entityId === notification.entityId);
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
addNotification(client: IClient, notification: ServerNotification) {
|
||||
if (client.notifications.length >= NOTIFICATION_LIMIT || hasNotification(client, notification)) {
|
||||
return 0;
|
||||
} else {
|
||||
notification.id = getId(client.notifications);
|
||||
client.notifications.push(notification);
|
||||
const { id, entityId = 0, name, message, note = '', flags = 0 } = notification;
|
||||
client.addNotification(id, entityId, name, message, note, flags);
|
||||
return notification.id;
|
||||
}
|
||||
}
|
||||
removeNotification(client: IClient, id: number) {
|
||||
if (removeById(client.notifications, id)) {
|
||||
client.removeNotification(id);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
acceptNotification(client: IClient, id: number) {
|
||||
const notification = findById(client.notifications, id);
|
||||
this.removeNotification(client, id);
|
||||
|
||||
if (notification && notification.accept) {
|
||||
notification.accept();
|
||||
}
|
||||
}
|
||||
rejectNotification(client: IClient, id: number) {
|
||||
const notification = findById(client.notifications, id);
|
||||
this.removeNotification(client, id);
|
||||
|
||||
if (notification && notification.reject) {
|
||||
notification.reject();
|
||||
}
|
||||
}
|
||||
rejectAll(client: IClient) {
|
||||
client.notifications.slice()
|
||||
.forEach(n => this.rejectNotification(client, n.id));
|
||||
}
|
||||
dismissAll(client: IClient) {
|
||||
while (client.notifications.length) {
|
||||
this.removeNotification(client, client.notifications[0].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { removeItem } from '../../common/utils';
|
||||
import { ListListener, IObservableList } from '../../common/adminInterfaces';
|
||||
import { pushOrdered } from '../../common/adminUtils';
|
||||
|
||||
export class ObservableList<T, V> implements IObservableList<T, V> {
|
||||
private listeners: ListListener<V>[] = [];
|
||||
constructor(private list: T[], private map: (item: T) => V) {
|
||||
}
|
||||
hasSubscribers() {
|
||||
return this.listeners.length > 0;
|
||||
}
|
||||
trigger() {
|
||||
if (this.listeners.length) {
|
||||
const items = this.list.map(this.map);
|
||||
|
||||
for (const listener of this.listeners) {
|
||||
listener(items);
|
||||
}
|
||||
}
|
||||
}
|
||||
push(item: T) {
|
||||
this.list.push(item);
|
||||
this.trigger();
|
||||
}
|
||||
pushOrdered(item: T, compare: (a: T, b: T) => number) {
|
||||
pushOrdered(this.list, item, compare);
|
||||
this.trigger();
|
||||
}
|
||||
remove(item: T) {
|
||||
const removed = removeItem(this.list, item);
|
||||
this.trigger();
|
||||
return removed;
|
||||
}
|
||||
replace(list: T[]) {
|
||||
this.list = list;
|
||||
this.trigger();
|
||||
}
|
||||
subscribe(listener: ListListener<V>) {
|
||||
this.listeners.push(listener);
|
||||
this.trigger();
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
removeItem(this.listeners, listener);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { Subject } from 'rxjs';
|
||||
import { remove } from 'lodash';
|
||||
import { PartyFlags, NotificationFlags } from '../../common/interfaces';
|
||||
import { AccountFlags } from '../../common/adminInterfaces';
|
||||
import { removeItem, hasFlag, includes } from '../../common/utils';
|
||||
import { PARTY_LIMIT, HOUR, SECOND } from '../../common/constants';
|
||||
import { ServerParty, IClient } from '../serverInterfaces';
|
||||
import { NotificationService } from './notification';
|
||||
import { ActionLimiter, LimiterResult } from './actionLimiter';
|
||||
import { saySystem } from '../chat';
|
||||
import { isFriend } from './friends';
|
||||
|
||||
export const LEADER_TIMEOUT = 5 * SECOND;
|
||||
export const INVITE_LIMIT = 5;
|
||||
export const INVITE_REJECTED_LIMIT = 5;
|
||||
export const INVITE_REJECTED_TIMEOUT = 1 * HOUR;
|
||||
|
||||
function toPartyMember(client: IClient, pending: boolean, leader: boolean): [number, PartyFlags] {
|
||||
const flags = (pending ? PartyFlags.Pending : 0)
|
||||
| (leader ? PartyFlags.Leader : 0)
|
||||
| (client.offline ? PartyFlags.Offline : 0);
|
||||
|
||||
return [client.pony.id, flags];
|
||||
}
|
||||
|
||||
function findClientInParties(parties: ServerParty[], accountId: string) {
|
||||
for (const party of parties) {
|
||||
for (let index = 0; index < party.clients.length; index++) {
|
||||
if (party.clients[index].accountId === accountId) {
|
||||
return { party, index };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { party: undefined, index: 0 };
|
||||
}
|
||||
|
||||
export class PartyService {
|
||||
parties: ServerParty[] = [];
|
||||
partyChanged = new Subject<IClient>();
|
||||
private id = 0;
|
||||
private limiter = new ActionLimiter(INVITE_REJECTED_TIMEOUT, INVITE_REJECTED_LIMIT);
|
||||
constructor(
|
||||
private notificationService: NotificationService,
|
||||
private reportInviteLimit: (client: IClient) => void,
|
||||
) {
|
||||
}
|
||||
dispose() {
|
||||
this.limiter.dispose();
|
||||
}
|
||||
clientConnected(client: IClient) {
|
||||
const { party, index } = findClientInParties(this.parties, client.accountId);
|
||||
|
||||
if (party) {
|
||||
const existing = party.clients[index];
|
||||
party.clients[index] = client;
|
||||
client.party = party;
|
||||
|
||||
if (party.leader === existing) {
|
||||
party.leader = client;
|
||||
clearTimeout(party.leaderTimeout);
|
||||
}
|
||||
|
||||
existing.party = undefined;
|
||||
existing.offlineAt = new Date();
|
||||
|
||||
this.sendPartyUpdateToAll(party);
|
||||
}
|
||||
}
|
||||
clientDisconnected(client: IClient) {
|
||||
const party = client.party;
|
||||
|
||||
if (party) {
|
||||
this.sendPartyUpdateToAll(party);
|
||||
|
||||
party.leaderTimeout = setTimeout(() => {
|
||||
const newLeader = party.clients.find(c => c !== client && !c.offline);
|
||||
|
||||
if (newLeader) {
|
||||
this.promoteLeader(client, newLeader);
|
||||
} else {
|
||||
this.destroyParty(party);
|
||||
}
|
||||
}, LEADER_TIMEOUT);
|
||||
} else {
|
||||
const pendingParty = this.parties.find(p => p.pending.some(x => x.client === client));
|
||||
|
||||
if (pendingParty) {
|
||||
remove(pendingParty.pending, x => x.client === client);
|
||||
this.sendPartyUpdateToAll(pendingParty);
|
||||
}
|
||||
}
|
||||
}
|
||||
remove(leader: IClient, client: IClient) {
|
||||
const party = leader.party;
|
||||
|
||||
if (!party || party.leader !== leader)
|
||||
return;
|
||||
|
||||
if (includes(party.clients, client)) {
|
||||
removeItem(party.clients, client);
|
||||
client.party = undefined;
|
||||
|
||||
if (party.leader === client && party.clients[0]) {
|
||||
party.leader = party.clients[0];
|
||||
}
|
||||
|
||||
client.updateParty(undefined);
|
||||
this.sendPartyUpdateToAll(party);
|
||||
this.partyChanged.next(client);
|
||||
} else {
|
||||
const pending = party.pending.find(p => p.client === client);
|
||||
|
||||
if (pending) {
|
||||
leader.reporter.systemLog(`Invite cancelled for [${client.accountId}]`);
|
||||
removeItem(party.pending, pending);
|
||||
this.notificationService.removeNotification(pending.client, pending.notificationId);
|
||||
this.sendPartyUpdateToAll(party);
|
||||
this.countReject(leader);
|
||||
}
|
||||
}
|
||||
|
||||
this.cleanupParty(party);
|
||||
}
|
||||
invite(leader: IClient, client: IClient) {
|
||||
let party = leader.party;
|
||||
|
||||
const can = this.limiter.canExecute(leader, client);
|
||||
|
||||
if (can === LimiterResult.LimitReached) {
|
||||
return saySystem(leader, 'Reached invite rejection limit');
|
||||
} else if (can !== LimiterResult.Yes) {
|
||||
return saySystem(leader, 'Cannot invite');
|
||||
}
|
||||
|
||||
if (client.shadowed)
|
||||
return saySystem(leader, 'Cannot invite');
|
||||
|
||||
if (hasFlag(leader.account.flags, AccountFlags.BlockPartyInvites))
|
||||
return saySystem(leader, 'Cannot invite');
|
||||
|
||||
if (party && party.leader !== leader)
|
||||
return saySystem(leader, 'You need to be party leader');
|
||||
|
||||
if (party && (party.clients.length + party.pending.length) >= PARTY_LIMIT)
|
||||
return saySystem(leader, 'Party is full');
|
||||
|
||||
if (client.party)
|
||||
return saySystem(leader, 'Already in a party');
|
||||
|
||||
if (party && party.pending.some(p => p.client === client))
|
||||
return saySystem(leader, 'Already invited');
|
||||
|
||||
if (client.accountSettings.ignorePartyInvites && !isFriend(client, leader))
|
||||
return saySystem(leader, 'Cannot invite');
|
||||
|
||||
if (this.parties.reduce((sum, p) => sum + p.pending.filter(x => x.client === client).length, 0) >= INVITE_LIMIT)
|
||||
return saySystem(leader, 'Too many pending invites');
|
||||
|
||||
const partyExisted = !!leader.party;
|
||||
|
||||
if (!partyExisted) {
|
||||
party = this.createParty(leader);
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (!party)
|
||||
throw new Error(`Party not created`);
|
||||
|
||||
const notificationId = this.addInviteNotification(client, leader, party);
|
||||
|
||||
if (!notificationId) {
|
||||
if (!partyExisted) {
|
||||
leader.party = undefined;
|
||||
removeItem(this.parties, party);
|
||||
}
|
||||
|
||||
return saySystem(leader, 'Cannot invite');
|
||||
}
|
||||
|
||||
party.pending.push({ client, notificationId });
|
||||
this.sendPartyUpdateToAll(party);
|
||||
leader.reporter.systemLog(`Invite to party [${client.accountId}]`);
|
||||
|
||||
if (!partyExisted) {
|
||||
this.partyChanged.next(leader);
|
||||
}
|
||||
}
|
||||
leave(client: IClient) {
|
||||
if (client.party) {
|
||||
this.remove(client.party.leader, client);
|
||||
}
|
||||
}
|
||||
promoteLeader(leader: IClient, client: IClient) {
|
||||
const party = leader.party;
|
||||
|
||||
if (!party)
|
||||
return;
|
||||
|
||||
if (leader === client)
|
||||
return;
|
||||
|
||||
if (client.offline)
|
||||
return saySystem(leader, 'Player is offline');
|
||||
|
||||
if (party.leader !== leader)
|
||||
return saySystem(leader, 'You need to be party leader');
|
||||
|
||||
if (!includes(party.clients, client))
|
||||
return saySystem(leader, 'Not in the party');
|
||||
|
||||
party.leader = client;
|
||||
this.sendPartyUpdateToAll(party);
|
||||
}
|
||||
cleanupParties() {
|
||||
const now = Date.now();
|
||||
|
||||
for (let i = this.parties.length - 1; i >= 0; i--) {
|
||||
const party = this.parties[i];
|
||||
|
||||
if (party.clients.every(c => c.offline)) {
|
||||
party.cleanup = party.cleanup || now;
|
||||
|
||||
if ((now - party.cleanup) > (10 * SECOND)) {
|
||||
this.destroyParty(party);
|
||||
}
|
||||
} else if (party.cleanup !== undefined) {
|
||||
party.cleanup = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
private createParty(leader: IClient) {
|
||||
const party: ServerParty = {
|
||||
id: `party-${this.id++}`,
|
||||
leader,
|
||||
clients: [leader],
|
||||
pending: [],
|
||||
};
|
||||
|
||||
leader.party = party;
|
||||
this.parties.push(party);
|
||||
return party;
|
||||
}
|
||||
private destroyParty(party: ServerParty) {
|
||||
const clients = party.clients;
|
||||
clients.forEach(c => c.party = undefined);
|
||||
clients.forEach(c => c.updateParty(undefined));
|
||||
party.pending.forEach(p => this.notificationService.removeNotification(p.client, p.notificationId));
|
||||
party.clients = [];
|
||||
party.pending = [];
|
||||
removeItem(this.parties, party);
|
||||
clients.forEach(c => this.partyChanged.next(c));
|
||||
}
|
||||
private sendPartyUpdate(client: IClient, party: ServerParty) {
|
||||
const clients = party.clients.map(c => toPartyMember(c, false, c === party.leader));
|
||||
const pending = party.pending.map(c => toPartyMember(c.client, true, false));
|
||||
client.updateParty([...clients, ...pending]);
|
||||
}
|
||||
private sendPartyUpdateToAll(party: ServerParty) {
|
||||
party.clients
|
||||
.filter(c => !c.offline)
|
||||
.forEach(c => this.sendPartyUpdate(c, party));
|
||||
}
|
||||
private cleanupParty(party: ServerParty) {
|
||||
if (party.clients.length === 0 || (party.clients.length + party.pending.length) <= 1) {
|
||||
this.destroyParty(party);
|
||||
}
|
||||
}
|
||||
private acceptInvitation(party: ServerParty, client: IClient, invitedBy: IClient) {
|
||||
const removed = remove(party.pending, p => p.client === client)[0];
|
||||
|
||||
if (!client.party && removed) {
|
||||
party.leader.reporter.systemLog(`Invite accepted by [${client.accountId}]`);
|
||||
party.clients.push(client);
|
||||
client.party = party;
|
||||
this.notificationService.removeNotification(client, removed.notificationId);
|
||||
this.sendPartyUpdateToAll(party);
|
||||
|
||||
this.parties
|
||||
.filter(p => p.pending.some(x => x.client === client))
|
||||
.forEach(p => this.rejectInvitation(p, client, invitedBy));
|
||||
|
||||
this.partyChanged.next(client);
|
||||
}
|
||||
}
|
||||
private rejectInvitation(party: ServerParty, client: IClient, invitedBy: IClient) {
|
||||
const removed = remove(party.pending, p => p.client === client)[0];
|
||||
|
||||
if (removed) {
|
||||
party.leader.reporter.systemLog(`Invite rejected by [${client.accountId}]`);
|
||||
this.notificationService.removeNotification(client, removed.notificationId);
|
||||
this.sendPartyUpdateToAll(party);
|
||||
this.cleanupParty(party);
|
||||
this.countReject(invitedBy);
|
||||
}
|
||||
}
|
||||
private countReject(invitedBy: IClient) {
|
||||
const count = this.limiter.count(invitedBy);
|
||||
|
||||
if (count >= INVITE_REJECTED_LIMIT) {
|
||||
this.reportInviteLimit(invitedBy);
|
||||
}
|
||||
}
|
||||
private addInviteNotification(client: IClient, leader: IClient, party: ServerParty) {
|
||||
return this.notificationService.addNotification(client, {
|
||||
id: 0,
|
||||
sender: leader,
|
||||
name: leader.pony.name || '',
|
||||
entityId: leader.pony.id,
|
||||
message: `<div class="text-party"><b>Party invite</b></div><b>#NAME#</b> invited you to a party`,
|
||||
flags: NotificationFlags.Accept | NotificationFlags.Reject | NotificationFlags.Ignore |
|
||||
(client.pony.nameBad ? NotificationFlags.NameBad : 0),
|
||||
accept: () => this.acceptInvitation(party, client, leader),
|
||||
reject: () => this.rejectInvitation(party, client, leader),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { groupBy, toPairs, compact } from 'lodash';
|
||||
import { IClient } from '../serverInterfaces';
|
||||
import { NotificationService } from './notification';
|
||||
import { NotificationFlags, SupporterInvite } from '../../common/interfaces';
|
||||
import { systemMessage } from '../logger';
|
||||
import { UserError } from '../userError';
|
||||
import { compareDates, fromNow, flatten } from '../../common/utils';
|
||||
import { DAY, HOUR } from '../../common/constants';
|
||||
import { Model } from 'mongoose';
|
||||
import { ISupporterInvite, IAccount } from '../db';
|
||||
import { ActionLimiter, LimiterResult } from './actionLimiter';
|
||||
import { saySystem } from '../chat';
|
||||
import { getSupporterInviteLimit } from '../accountUtils';
|
||||
|
||||
export const INVITE_REJECTED_TIMEOUT = 1 * HOUR;
|
||||
export const INVITE_REJECTED_LIMIT = 5;
|
||||
|
||||
function formatMessage(requester: IClient, target: IClient, message: string) {
|
||||
const requesterInfo = `${requester.characterName} (${requester.account.name})`;
|
||||
const targetInfo = `${target.characterName} (${target.account.name}) [${target.accountId}]`;
|
||||
return systemMessage(requester.accountId, `${requesterInfo} ${message} ${targetInfo}`);
|
||||
}
|
||||
|
||||
export class SupporterInvitesService {
|
||||
private limiter = new ActionLimiter(INVITE_REJECTED_TIMEOUT, INVITE_REJECTED_LIMIT);
|
||||
constructor(
|
||||
private model: Model<ISupporterInvite>,
|
||||
private notifications: NotificationService,
|
||||
private log: (message: string) => void,
|
||||
) {
|
||||
}
|
||||
dispose() {
|
||||
this.limiter.dispose();
|
||||
}
|
||||
async getInvites(source: IClient): Promise<SupporterInvite[]> {
|
||||
const items = await this.model.find({ source: source.account._id }).exec();
|
||||
return items.map(({ _id, name, info, active }) => ({ id: _id.toString(), name, info, active }));
|
||||
}
|
||||
async isInvited(target: IClient): Promise<boolean> {
|
||||
const count = await this.model.countDocuments({ target: target.account._id, active: true }).exec();
|
||||
return count > 0;
|
||||
}
|
||||
async requestInvite(requester: IClient, target: IClient) {
|
||||
const items = await this.getInvites(requester);
|
||||
const limit = getSupporterInviteLimit(requester.account);
|
||||
|
||||
if (items.length >= limit)
|
||||
return saySystem(requester, 'Invite limit reached');
|
||||
|
||||
if (this.limiter.canExecute(requester, target) !== LimiterResult.Yes)
|
||||
return saySystem(requester, 'Cannot invite');
|
||||
|
||||
this.log(formatMessage(requester, target, 'invited to supporter server'));
|
||||
|
||||
this.notifications.addNotification(target, {
|
||||
id: 0,
|
||||
sender: requester,
|
||||
name: requester.pony.name || '',
|
||||
entityId: requester.pony.id,
|
||||
message: `<b>#NAME#</b> invited you to supporter servers`,
|
||||
flags: NotificationFlags.Accept | NotificationFlags.Reject | NotificationFlags.Ignore |
|
||||
(requester.pony.nameBad ? NotificationFlags.NameBad : 0),
|
||||
accept: () => this.acceptInvite(requester, target),
|
||||
reject: () => this.rejectInvite(requester, target),
|
||||
});
|
||||
}
|
||||
acceptInvite(requester: IClient, target: IClient) {
|
||||
this.log(formatMessage(requester, target, 'supporter invite accepted by'));
|
||||
this.invite(requester, target);
|
||||
}
|
||||
rejectInvite(requester: IClient, target: IClient) {
|
||||
this.log(formatMessage(requester, target, 'supporter invite rejected by'));
|
||||
this.limiter.count(requester);
|
||||
}
|
||||
async invite(requester: IClient, target: IClient) {
|
||||
const limit = getSupporterInviteLimit(requester.account);
|
||||
const items = await this.getInvites(requester);
|
||||
|
||||
if (items.length >= limit) {
|
||||
throw new UserError('Invite limit reached');
|
||||
}
|
||||
|
||||
await this.model.create({
|
||||
source: requester.account._id,
|
||||
target: target.account._id,
|
||||
name: target.characterName,
|
||||
info: target.character.info,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
uninvite(requester: IClient, inviteId: string) {
|
||||
return Promise.resolve(this.model.deleteOne({ _id: inviteId, source: requester.account._id }).exec());
|
||||
}
|
||||
}
|
||||
|
||||
type LeanInvite = ISupporterInvite & { source: IAccount };
|
||||
|
||||
export async function updateSupporterInvites(model: Model<ISupporterInvite>) {
|
||||
const invites: LeanInvite[] = await model.find({}, '_id active')
|
||||
.populate('source', '_id supporter patreon roles')
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
const itemsBySource = toPairs(groupBy(invites, i => i.source._id as string));
|
||||
const itemsToUpdate = itemsBySource
|
||||
.map(([_, items]) => {
|
||||
const source = items[0].source;
|
||||
const limit = getSupporterInviteLimit(source);
|
||||
|
||||
return compact(items
|
||||
.sort((a, b) => compareDates(a.createdAt, b.createdAt))
|
||||
.map((item, i) => {
|
||||
const active = i < limit;
|
||||
return item.active === active ? undefined : { id: item._id, active };
|
||||
}));
|
||||
});
|
||||
|
||||
const groups = toPairs(groupBy(flatten(itemsToUpdate), i => i.active));
|
||||
|
||||
await Promise.all(groups.map(([_, items]) => {
|
||||
const active = items[0].active;
|
||||
const ids = items.map(i => i.id);
|
||||
return model.updateMany({ _id: { $in: ids } }, { active }).exec();
|
||||
}));
|
||||
|
||||
await model.deleteMany({ active: false, updatedAt: { $lt: fromNow(-100 * DAY) } }).exec();
|
||||
}
|
||||
Reference in New Issue
Block a user