Merge pull request #308 from zkldi:zkldi/issue-305-Add-global-user-settings

Add global user settings
This commit is contained in:
zkldi
2021-09-01 02:16:35 +01:00
committed by GitHub
18 changed files with 404 additions and 36 deletions
+1 -1
View File
@@ -71,7 +71,7 @@
"redis": "3.1.2",
"rimraf": "3.0.2",
"safe-json-stringify": "1.2.0",
"tachi-common": "0.1.41",
"tachi-common": "0.1.43",
"typescript": "4.3.4",
"winston": "3.3.3"
},
+4 -4
View File
@@ -52,7 +52,7 @@ specifiers:
rimraf: 3.0.2
safe-json-stringify: 1.2.0
supertest: 6.1.3
tachi-common: 0.1.41
tachi-common: 0.1.43
tap: 15.0.9
ts-node: 10.0.0
tsconfig-paths: 3.10.1
@@ -85,7 +85,7 @@ dependencies:
redis: 3.1.2
rimraf: 3.0.2
safe-json-stringify: 1.2.0
tachi-common: 0.1.41_ts-node@10.0.0+typescript@4.3.4
tachi-common: 0.1.43_ts-node@10.0.0+typescript@4.3.4
typescript: 4.3.4
winston: 3.3.3
@@ -3666,8 +3666,8 @@ packages:
strip-ansi: 6.0.0
dev: true
/tachi-common/0.1.41_ts-node@10.0.0+typescript@4.3.4:
resolution: {integrity: sha512-3EdjrhDE8JNdxJg7Dsu9LfEeSet+0ghxdbIr9fBWDl++OtGJczL13NT3pLSc64ZZ5vkyFi6w6lO9NuSgpiZ5oQ==}
/tachi-common/0.1.43_ts-node@10.0.0+typescript@4.3.4:
resolution: {integrity: sha512-DBjICSN3lnQ0qmmo595CnWSJsZ/OfT+jsGDHsmzHEZc35KdTOdeNuYnDFU8zFNAWDBOAXtSiNQ/yAnraLhY+vQ==}
dependencies:
monk: 7.3.4
tap: 15.0.9_ts-node@10.0.0+typescript@4.3.4
+2
View File
@@ -28,6 +28,7 @@ import {
UGPTSettings,
SessionViewDocument,
ARCSavedProfileDocument,
UserSettings,
} from "tachi-common";
import monk, { TMiddleware } from "monk";
import CreateLogCtx from "lib/logger/logger";
@@ -165,6 +166,7 @@ const db = {
"game-stats-snapshots": monkDB.get<UserGameStatsSnapshot>("game-stats-snapshots"),
"session-view-cache": monkDB.get<SessionViewDocument>("session-view-cache"),
"arc-saved-profiles": monkDB.get<ARCSavedProfileDocument>("arc-saved-profiles"),
"user-settings": monkDB.get<UserSettings>("user-settings"),
};
export default db;
+1
View File
@@ -85,6 +85,7 @@ const staticIndexes: Partial<Record<ValidDatabases, Index[]>> = {
index({ sessionID: 1, ip: 1 }, UNIQUE),
index({ timestamp: 1 }, { expireAfterSeconds: ONE_DAY / 1000 }),
],
"user-settings": [index({ userID: 1 }, UNIQUE)],
};
const indexes: Partial<Record<ValidDatabases, Index[]>> = staticIndexes;
+2 -1
View File
@@ -99,7 +99,8 @@ t.test("#SetRequestPermissions", (t) => {
const { req } = await expMiddlewareMock(SetRequestPermissions, {
session: {
tachi: {
userID: 1,
user: await db.users.findOne({ id: 1 }),
settings: await db["user-settings"].findOne({ userID: 1 }),
},
},
});
+3 -3
View File
@@ -37,10 +37,10 @@ export const AllPermissions: Record<APIPermissions, true> = {
* This is set on req[SYMBOL_TachiAPIAuth].
*/
export const SetRequestPermissions: RequestHandler = async (req, res, next) => {
if (req.session?.tachi?.userID) {
if (req.session?.tachi?.user.id) {
req[SYMBOL_TachiAPIAuth] = {
userID: req.session.tachi.userID,
identifier: `Session-Key ${req.session.tachi.userID}`,
userID: req.session.tachi.user.id,
identifier: `Session-Key ${req.session.tachi.user.id}`,
token: null,
permissions: AllPermissions,
};
@@ -2,13 +2,17 @@ import { RequestHandler } from "express";
import db from "external/mongo/db";
export const UpdateLastSeen: RequestHandler = (req, res, next) => {
if (!req.session.tachi?.userID) {
if (!req.session.tachi?.user.id) {
return next();
}
if (req.session.tachi.settings.preferences.invisible) {
return next();
}
// fire, but we have no reason to await it.
db.users.update(
{ id: req.session.tachi.userID },
{ id: req.session.tachi.user.id },
{
$set: {
lastSeen: Date.now(),
+36 -4
View File
@@ -1,6 +1,5 @@
import bcrypt from "bcryptjs";
import { integer, PrivateUserDocument, PublicUserDocument } from "tachi-common";
import { InsertResult } from "monk";
import { integer, PrivateUserDocument, PublicUserDocument, UserSettings } from "tachi-common";
import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
import { FormatUserDoc } from "utils/user";
@@ -71,12 +70,17 @@ export async function AddNewInvite(user: PublicUserDocument) {
return result;
}
const DEFAULT_USER_SETTINGS: UserSettings["preferences"] = {
developerMode: false,
invisible: false,
};
export async function AddNewUser(
username: string,
password: string,
email: string,
userID: integer
): Promise<InsertResult<PrivateUserDocument>> {
) {
const hashedPassword = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
logger.verbose(`Hashed password for ${username}.`);
@@ -99,7 +103,21 @@ export async function AddNewUser(
badges: [],
};
return db.users.insert(userDoc);
const res = await db.users.insert(userDoc);
const settingsRes = await InsertDefaultUserSettings(userID);
return { newUser: res, newSettings: settingsRes };
}
export function InsertDefaultUserSettings(userID: integer) {
logger.verbose(`Inserting default settings for ${userID}.`);
const userSettings: UserSettings = {
userID,
preferences: DEFAULT_USER_SETTINGS,
};
return db["user-settings"].insert(userSettings);
}
export async function ValidateCaptcha(
@@ -122,3 +140,17 @@ export async function ValidateCaptcha(
return true;
}
export function MountAuthCookie(
req: Express.Request,
user: PublicUserDocument,
settings: UserSettings
) {
req.session.tachi = {
user,
settings,
};
req.session.cookie.maxAge = 3.154e10;
req.session.cookie.secure = process.env.NODE_ENV === "production";
}
+26 -16
View File
@@ -6,9 +6,12 @@ import {
ReinstateInvite,
ValidatePassword,
ValidateCaptcha,
MountAuthCookie,
InsertDefaultUserSettings,
} from "./auth";
import {
FormatUserDoc,
GetSettingsForUser,
GetUserCaseInsensitive,
GetUserWithEmail,
GetUserWithID,
@@ -44,8 +47,8 @@ router.post(
}
),
async (req, res) => {
if (req.session.tachi?.userID) {
logger.info(`Dual log-in attempted from ${req.session.tachi.userID}`);
if (req.session.tachi?.user.id) {
logger.info(`Dual log-in attempted from ${req.session.tachi.user.id}`);
return res.status(409).json({
success: false,
description: `You are already logged in as someone.`,
@@ -95,11 +98,24 @@ router.post(
});
}
req.session.tachi = {
userID: requestedUser.id,
};
const user = await GetUserWithID(requestedUser.id);
req.session.cookie.maxAge = 3.154e10; // 1 year
if (!user) {
logger.severe(`User logged in as someone who does not exist?`, { requestedUser });
return res.status(500).json({
success: false,
description: `An internal server error has occured.`,
});
}
let settings = await GetSettingsForUser(requestedUser.id);
if (!settings) {
logger.warn(`User ${FormatUserDoc(user)} has no settings. Inserting default settings.`);
settings = await InsertDefaultUserSettings(user.id);
}
MountAuthCookie(req, user, settings);
logger.verbose(`${FormatUserDoc(requestedUser)} Logged in.`);
@@ -207,7 +223,7 @@ router.post(
// if we get to this point, We're good to create the user.
const newUser = await AddNewUser(
const { newUser, newSettings } = await AddNewUser(
req.body.username,
req.body.password,
req.body.email,
@@ -218,17 +234,11 @@ router.post(
throw new Error("AddNewUser failed to create a user.");
}
// also set this as a cookie.
req.session.tachi = {
userID: newUser.id,
};
req.session.cookie.maxAge = 3.154e10;
req.session.cookie.secure = true;
// re-fetch the user like this so we guaranteeably omit the private fields.
const user = await GetUserWithID(newUser.id);
MountAuthCookie(req, user!, newSettings);
return res.status(200).json({
success: true,
description: `Successfully created account ${req.body.username}!`,
@@ -256,7 +266,7 @@ router.post(
* @name POST /api/v1/auth/logout
*/
router.post("/logout", (req, res) => {
if (!req.session?.tachi?.userID) {
if (!req.session?.tachi?.user.id) {
return res.status(409).json({
success: false,
description: `You are not logged in.`,
@@ -64,7 +64,7 @@ router.post(
const inputParser = (logger: KtLogger) =>
ResolveFileUploadData(importType, req.file!, req.body, logger);
const userDoc = await GetUserWithIDGuaranteed(req.session.tachi!.userID);
const userDoc = await GetUserWithIDGuaranteed(req.session.tachi!.user.id);
// The <any, any> here is deliberate - TS picks the IIDX-CSV generic values
// for this function call because it sees them first
@@ -102,7 +102,7 @@ router.post(
async (req, res) => {
const importType = req.body.importType as APIImportTypes;
const userDoc = await GetUserWithIDGuaranteed(req.session.tachi!.userID);
const userDoc = await GetUserWithIDGuaranteed(req.session.tachi!.user.id);
const inputParser = (logger: KtLogger) =>
ResolveAPIImportParser(userDoc.id, importType, logger);
@@ -69,7 +69,7 @@ export const RequireAuthedAsUser: RequestHandler = (req, res, next) => {
export const RequireSelfRequestFromUser: RequestHandler = (req, res, next) => {
const user = req[SYMBOL_TachiData]!.requestedUser!;
if (!req.session.tachi?.userID || req[SYMBOL_TachiAPIAuth].userID !== user.id) {
if (!req.session.tachi?.user.id || req[SYMBOL_TachiAPIAuth].userID !== user.id) {
return res.status(403).json({
success: false,
description: `This request cannot be performed by an API key, and requires authentication.`,
@@ -6,6 +6,7 @@ import gamePTRouter from "./games/_game/_playtype/router";
import bannerRouter from "./banner/router";
import pfpRouter from "./pfp/router";
import integrationsRouter from "./integrations/router";
import settingsRouter from "./settings/router";
import prValidate from "server/middleware/prudence-validate";
import p from "prudence";
import { optNull, optNullFluffStrField } from "utils/prudence";
@@ -187,5 +188,6 @@ router.use("/games/:game/:playtype", gamePTRouter);
router.use("/pfp", pfpRouter);
router.use("/banner", bannerRouter);
router.use("/integrations", integrationsRouter);
router.use("/settings", settingsRouter);
export default router;
@@ -0,0 +1,202 @@
import db from "external/mongo/db";
import t from "tap";
import mockApi from "test-utils/mock-api";
import ResetDBState from "test-utils/resets";
t.test("GET /api/v1/users/:userID/settings", (t) => {
t.beforeEach(ResetDBState);
t.test("Should return the users settings.", async (t) => {
const res = await mockApi.get("/api/v1/users/1/settings");
t.strictSame(res.body.body, {
userID: 1,
preferences: {
invisible: false,
developerMode: true,
},
});
t.end();
});
t.end();
});
t.test("PATCH /api/v1/users/:userID/settings", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(async () => {
await db["api-tokens"].insert({
identifier: "foo",
permissions: {
customise_profile: true,
},
token: "foo",
userID: 1,
});
});
t.test("Should mutate the users settings.", async (t) => {
const res = await mockApi
.patch("/api/v1/users/1/settings")
.set("Authorization", "Bearer foo")
.send({
developerMode: false,
invisible: true,
});
t.strictSame(res.body.body, {
userID: 1,
preferences: {
invisible: true,
developerMode: false,
},
});
const dbRes = await db["user-settings"].findOne({ userID: 1 });
t.strictSame(dbRes, {
userID: 1,
preferences: {
invisible: true,
developerMode: false,
},
});
t.end();
});
t.test("Should 400 if body is empty.", async (t) => {
const res = await mockApi
.patch("/api/v1/users/1/settings")
.set("Authorization", "Bearer foo")
.send({});
t.equal(res.statusCode, 400);
const dbRes = await db["user-settings"].findOne({ userID: 1 });
t.strictSame(
dbRes,
{
userID: 1,
preferences: {
invisible: false,
developerMode: true,
},
},
"User Settings should be unmodified."
);
t.end();
});
t.test("Should validate input.", async (t) => {
const res = await mockApi
.patch("/api/v1/users/1/settings")
.set("Authorization", "Bearer foo")
.send({
developerMode: "true",
});
t.equal(res.statusCode, 400);
const res2 = await mockApi
.patch("/api/v1/users/1/settings")
.set("Authorization", "Bearer foo")
.send({
invalid_prop: true,
});
t.equal(res2.statusCode, 400);
const res3 = await mockApi
.patch("/api/v1/users/1/settings")
.set("Authorization", "Bearer foo")
.send({
invisible: { $where: "alert(1)" },
});
t.equal(res3.statusCode, 400);
t.end();
});
t.test("Must be authenticated as that user.", async (t) => {
await db["api-tokens"].insert({
identifier: "not user1",
permissions: {
customise_profile: true,
},
token: "not_user1",
userID: 2,
});
const res = await mockApi
.patch("/api/v1/users/1/settings")
.set("Authorization", "Bearer not_user1")
.send({
developerMode: false,
});
t.equal(res.statusCode, 403);
const dbRes = await db["user-settings"].findOne({ userID: 1 });
t.equal(
dbRes?.preferences.developerMode,
true,
"Settings should not be modified in the database."
);
t.end();
});
t.test("Must have the customise_profile permission.", async (t) => {
await db["api-tokens"].insert({
identifier: "no perm",
permissions: {},
token: "no_perm",
userID: 1,
});
const res = await mockApi
.patch("/api/v1/users/1/settings")
.set("Authorization", "Bearer no_perm")
.send({
developerMode: false,
});
t.equal(res.statusCode, 403);
const dbRes = await db["user-settings"].findOne({ userID: 1 });
t.equal(
dbRes?.preferences.developerMode,
true,
"Settings should not be modified in the database."
);
t.end();
});
t.test("Must be authenticated.", async (t) => {
const res = await mockApi.patch("/api/v1/users/1/settings").send({
developerMode: false,
});
t.equal(res.statusCode, 401);
const dbRes = await db["user-settings"].findOne({ userID: 1 });
t.equal(
dbRes?.preferences.developerMode,
true,
"Settings should not be modified in the database."
);
t.end();
});
t.end();
});
@@ -0,0 +1,97 @@
import { Router } from "express";
import db from "external/mongo/db";
import { SYMBOL_TachiData } from "lib/constants/tachi";
import CreateLogCtx from "lib/logger/logger";
import { RequirePermissions } from "server/middleware/auth";
import prValidate from "server/middleware/prudence-validate";
import { DeleteUndefinedProps } from "utils/misc";
import { FormatUserDoc, GetSettingsForUser } from "utils/user";
import { RequireAuthedAsUser } from "../middleware";
const logger = CreateLogCtx(__filename);
const router: Router = Router({ mergeParams: true });
/**
* Retrieve this users settings. Note that these settings are NOT private.
*
* @name GET /api/v1/users/:userID/settings
*/
router.get("/", async (req, res) => {
const user = req[SYMBOL_TachiData]!.requestedUser!;
const settings = await db["user-settings"].findOne({
userID: user.id,
});
if (!settings) {
logger.severe(`User ${FormatUserDoc(user)} has no settings?`);
return res.status(500).json({
success: false,
description: `An internal server error has occured.`,
});
}
return res.status(200).json({
success: true,
description: `Retrieved settings.`,
body: settings,
});
});
/**
* Update a user's settings.
*
* @param invisible - Whether to set the user to invisible or not.
* @param developerMode - Whether to display developer specific information in the WebUI.
*
* @name PATCH /api/v1/users/:userID/settings
*/
router.patch(
"/",
RequirePermissions("customise_profile"),
RequireAuthedAsUser,
prValidate({
invisible: "*boolean",
developerMode: "*boolean",
}),
async (req, res) => {
const user = req[SYMBOL_TachiData]!.requestedUser!;
const preferences = {
invisible: req.body.invisible,
developerMode: req.body.developerMode,
};
DeleteUndefinedProps(preferences);
if (Object.keys(preferences).length === 0) {
return res.status(400).json({
success: false,
description: `Nothing was provided to change!`,
});
}
const modifyObject: Record<string, boolean> = {};
for (const [k, v] of Object.entries(preferences)) {
modifyObject[`preferences.${k}`] = v;
}
await db["user-settings"].update(
{
userID: user.id,
},
{ $set: modifyObject }
);
const settings = await GetSettingsForUser(user.id);
return res.status(200).json({
success: true,
description: `Updated settings.`,
body: settings,
});
}
);
export default router;
@@ -0,0 +1,9 @@
[
{
"userID": 1,
"preferences": {
"developerMode": true,
"invisible": false
}
}
]
+1
View File
@@ -89,6 +89,7 @@ export function ResetCDN() {
}
export async function SetIndexesForDB() {
await ResetDBState();
const url = `${ServerConfig.MONGO_CONNECTION_URL}/testingdb`;
logger.info(`Setting indexes for ${url}`);
+3 -2
View File
@@ -1,7 +1,6 @@
import {
FolderDocument,
TableDocument,
integer,
SessionDocument,
ScoreDocument,
ChartDocument,
@@ -11,6 +10,7 @@ import {
Playtypes,
TierlistParent,
SongDocument,
UserSettings,
} from "tachi-common";
declare module "express-session" {
@@ -21,7 +21,8 @@ declare module "express-session" {
}
export interface TachiSessionData {
userID: integer;
user: PublicUserDocument;
settings: UserSettings;
}
export interface TachiAPIFailResponse {
+6
View File
@@ -95,6 +95,12 @@ export function GetUserWithID(userID: integer): Promise<FindOneResult<PublicUser
) as Promise<FindOneResult<PublicUserDocument>>;
}
export function GetSettingsForUser(userID: integer) {
return db["user-settings"].findOne({
userID: userID,
});
}
/**
* Gets the users for these user IDs.
*/