Files
zkldi_Tachi/server/src/utils/user.ts
T
2022-06-05 14:50:24 +01:00

288 lines
6.4 KiB
TypeScript

import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
import { GetGamePTConfig, UserAuthLevels } from "tachi-common";
import type { ProfileRatingAlgs } from "./string-checks";
import type { FindOneResult } from "monk";
import type {
APITokenDocument,
Game,
IDStrings,
integer,
Playtype,
PublicUserDocument,
UGSRatingsLookup,
UserGameStats,
} from "tachi-common";
const logger = CreateLogCtx(__filename);
/**
* Returns a user's username from their ID. Throws if no user with that ID exists.
*/
export async function GetUsernameFromUserID(userID: integer): Promise<string> {
const partialDoc = await db.users.findOne(
{
id: userID,
},
{
projection: {
username: 1,
},
}
);
if (!partialDoc) {
throw new Error(`Could not find username for userID ${userID}.`);
}
return partialDoc.username;
}
/**
* Gets a user based on their username case-insensitively.
*/
export function GetUserCaseInsensitive(
username: string
): Promise<FindOneResult<PublicUserDocument>> {
return db.users.findOne({
usernameLowercase: username.toLowerCase(),
});
}
export async function CheckIfEmailInUse(email: string) {
const doc = await db["user-private-information"].findOne({ email });
return !!doc;
}
export function GetUserPrivateInfo(userID: integer) {
return db["user-private-information"].findOne({ userID });
}
/**
* Gets a user from their userID.
*/
export function GetUserWithID(userID: integer): Promise<FindOneResult<PublicUserDocument>> {
return db.users.findOne({
id: userID,
});
}
export function GetSettingsForUser(userID: integer) {
return db["user-settings"].findOne({
userID,
});
}
/**
* Gets the users for these user IDs.
*/
export async function GetUsersWithIDs(userIDs: Array<integer>) {
const users = await db.users.find({
id: { $in: userIDs },
});
// Note that we should dedupe this by making a set
// as passing [1, 1, 1] is perfectly legal to this function.
if (users.length !== new Set(userIDs).size) {
logger.severe(
`GetUsersWithIDs was given ${userIDs.length} userIDs, but only matched ${users.length} -- state desync likely.`,
{ userIDs, users }
);
throw new Error(
`GetUsersWithIDs was given ${userIDs.length} userIDs, but only matched ${users.length}.`
);
}
return users;
}
/**
* Retrieve a user document that is expected to exist.
* If the user document is not found, a severe error is logged, and this
* function throws.
*/
export async function GetUserWithIDGuaranteed(userID: integer): Promise<PublicUserDocument> {
const userDoc = await GetUserWithID(userID);
if (!userDoc) {
logger.severe(
`User ${userID} does not have an associated user document, but one was expected.`
);
throw new Error(
`User ${userID} does not have an associated user document, but one was expected.`
);
}
return userDoc;
}
/**
* Gets a user based on either their username case-insensitively, or a direct lookup of their ID.
* This is used in URLs to resolve the passed user.
*/
export function ResolveUser(usernameOrID: string) {
// user ID passed
if (/^[0-9]+$/u.exec(usernameOrID)) {
const intID = Number(usernameOrID);
return GetUserWithID(intID);
}
return GetUserCaseInsensitive(usernameOrID);
}
/**
* Returns a formatted string indicating the user. This is used for logging.
*/
export function FormatUserDoc(userdoc: PublicUserDocument) {
return `${userdoc.username} (#${userdoc.id})`;
}
export async function GetUsersRanking(stats: UserGameStats) {
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
const aggRes: [{ _id: null; ranking: integer }] = await db["game-stats"].aggregate([
{
$match: {
game: stats.game,
playtype: stats.playtype,
},
},
{
$group: {
_id: null,
ranking: {
$sum: {
$cond: {
if: {
$gt: [
`$ratings.${gptConfig.defaultProfileRatingAlg}`,
stats.ratings[gptConfig.defaultProfileRatingAlg],
],
},
then: 1,
else: 0,
},
},
},
},
},
]);
return aggRes[0].ranking + 1;
}
export function GetUGPTPlaycount(userID: integer, game: Game, playtype: Playtype) {
return db.scores.count({ userID, game, playtype });
}
export async function GetAllRankings(stats: UserGameStats) {
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
const entries = await Promise.all(
gptConfig.profileRatingAlgs.map((k) =>
GetUsersRankingAndOutOf(stats, k).then((r) => [k, r])
)
);
return Object.fromEntries(entries) as Record<
ProfileRatingAlgs,
{ ranking: integer; outOf: integer }
>;
}
export async function GetUsersRankingAndOutOf(
stats: UserGameStats,
alg?: UGSRatingsLookup[IDStrings]
) {
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
const ratingAlg = alg ?? gptConfig.defaultProfileRatingAlg;
const aggRes: [
{
_id: null;
outOf: integer;
ranking: integer;
}
] = await db["game-stats"].aggregate([
{
$match: {
game: stats.game,
playtype: stats.playtype,
},
},
{
$group: {
_id: null,
outOf: { $sum: 1 },
ranking: {
$sum: {
$cond: {
if: {
$gt: [`$ratings.${ratingAlg}`, stats.ratings[ratingAlg]],
},
then: 1,
else: 0,
},
},
},
},
},
]);
return {
ranking: aggRes[0].ranking + 1,
outOf: aggRes[0].outOf,
};
}
const FIVE_MINUTES = 1000 * 60 * 5;
/**
* Returns the cutoff point for "being online" in tachi. This means the user
* has made any page request in the past 5 minutes.
*/
export function GetOnlineCutoff() {
return Date.now() - FIVE_MINUTES;
}
/**
* Returns whether a given userID is an administrator or not.
*/
export async function IsRequesterAdmin(request: APITokenDocument) {
// API Tokens created on the behalf of an admin do NOT inherit admin permissions.
if (request.token !== null) {
return false;
}
if (request.userID === null) {
return false;
}
const user = await GetUserWithIDGuaranteed(request.userID);
return user.authLevel === UserAuthLevels.ADMIN;
}
/**
* Return all the GPTs this userID has played.
*/
export async function GetUserPlayedGPTs(userID: integer) {
const gpts = (await db["game-stats"].find(
{ userID },
{ projection: { game: 1, playtype: 1 } }
)) as Array<Pick<UserGameStats, "game" | "playtype">>;
return gpts;
}
export async function GetAllUserRivals(userID: integer) {
const rivals = (await db["game-settings"].find({ userID }, { projection: { rivals: 1 } }))
.map((e) => e.rivals)
.flat();
return rivals;
}