diff --git a/server/package.json b/server/package.json index fc2ef1088..a46408bfd 100644 --- a/server/package.json +++ b/server/package.json @@ -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" }, diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index d1365536c..376c53fa5 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -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 diff --git a/server/src/external/mongo/db.ts b/server/src/external/mongo/db.ts index 3d12db6ee..dbb22ec10 100644 --- a/server/src/external/mongo/db.ts +++ b/server/src/external/mongo/db.ts @@ -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("game-stats-snapshots"), "session-view-cache": monkDB.get("session-view-cache"), "arc-saved-profiles": monkDB.get("arc-saved-profiles"), + "user-settings": monkDB.get("user-settings"), }; export default db; diff --git a/server/src/external/mongo/indexes.ts b/server/src/external/mongo/indexes.ts index 8c7b1d606..221d1181e 100644 --- a/server/src/external/mongo/indexes.ts +++ b/server/src/external/mongo/indexes.ts @@ -85,6 +85,7 @@ const staticIndexes: Partial> = { index({ sessionID: 1, ip: 1 }, UNIQUE), index({ timestamp: 1 }, { expireAfterSeconds: ONE_DAY / 1000 }), ], + "user-settings": [index({ userID: 1 }, UNIQUE)], }; const indexes: Partial> = staticIndexes; diff --git a/server/src/server/middleware/auth.test.ts b/server/src/server/middleware/auth.test.ts index 1c40332c5..32fa2ca6b 100644 --- a/server/src/server/middleware/auth.test.ts +++ b/server/src/server/middleware/auth.test.ts @@ -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 }), }, }, }); diff --git a/server/src/server/middleware/auth.ts b/server/src/server/middleware/auth.ts index ad8980d6c..7a1fa8f83 100644 --- a/server/src/server/middleware/auth.ts +++ b/server/src/server/middleware/auth.ts @@ -37,10 +37,10 @@ export const AllPermissions: Record = { * 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, }; diff --git a/server/src/server/middleware/update-last-seen.ts b/server/src/server/middleware/update-last-seen.ts index b80b0e0be..9654a8f63 100644 --- a/server/src/server/middleware/update-last-seen.ts +++ b/server/src/server/middleware/update-last-seen.ts @@ -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(), diff --git a/server/src/server/router/api/v1/auth/auth.ts b/server/src/server/router/api/v1/auth/auth.ts index e49af1224..90f50344a 100644 --- a/server/src/server/router/api/v1/auth/auth.ts +++ b/server/src/server/router/api/v1/auth/auth.ts @@ -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> { +) { 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"; +} diff --git a/server/src/server/router/api/v1/auth/router.ts b/server/src/server/router/api/v1/auth/router.ts index 1b96bccd1..2e430ba66 100644 --- a/server/src/server/router/api/v1/auth/router.ts +++ b/server/src/server/router/api/v1/auth/router.ts @@ -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.`, diff --git a/server/src/server/router/api/v1/import/router.ts b/server/src/server/router/api/v1/import/router.ts index 740044a9c..92f334345 100644 --- a/server/src/server/router/api/v1/import/router.ts +++ b/server/src/server/router/api/v1/import/router.ts @@ -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 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); diff --git a/server/src/server/router/api/v1/users/_userID/middleware.ts b/server/src/server/router/api/v1/users/_userID/middleware.ts index 2394ced99..401b9fe1a 100644 --- a/server/src/server/router/api/v1/users/_userID/middleware.ts +++ b/server/src/server/router/api/v1/users/_userID/middleware.ts @@ -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.`, diff --git a/server/src/server/router/api/v1/users/_userID/router.ts b/server/src/server/router/api/v1/users/_userID/router.ts index 7403657a7..1d79d4508 100644 --- a/server/src/server/router/api/v1/users/_userID/router.ts +++ b/server/src/server/router/api/v1/users/_userID/router.ts @@ -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; diff --git a/server/src/server/router/api/v1/users/_userID/settings/router.test.ts b/server/src/server/router/api/v1/users/_userID/settings/router.test.ts new file mode 100644 index 000000000..d3850523c --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/settings/router.test.ts @@ -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(); +}); diff --git a/server/src/server/router/api/v1/users/_userID/settings/router.ts b/server/src/server/router/api/v1/users/_userID/settings/router.ts new file mode 100644 index 000000000..d5ee9f570 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/settings/router.ts @@ -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 = {}; + + 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; diff --git a/server/src/test-utils/mock-db/user-settings.json b/server/src/test-utils/mock-db/user-settings.json new file mode 100644 index 000000000..69e57218b --- /dev/null +++ b/server/src/test-utils/mock-db/user-settings.json @@ -0,0 +1,9 @@ +[ + { + "userID": 1, + "preferences": { + "developerMode": true, + "invisible": false + } + } +] \ No newline at end of file diff --git a/server/src/test-utils/resets.ts b/server/src/test-utils/resets.ts index 62cf090e2..9fc101284 100644 --- a/server/src/test-utils/resets.ts +++ b/server/src/test-utils/resets.ts @@ -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}`); diff --git a/server/src/utils/types.ts b/server/src/utils/types.ts index ebc02164c..197851ced 100644 --- a/server/src/utils/types.ts +++ b/server/src/utils/types.ts @@ -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 { diff --git a/server/src/utils/user.ts b/server/src/utils/user.ts index 4aca58a71..e66db953f 100644 --- a/server/src/utils/user.ts +++ b/server/src/utils/user.ts @@ -95,6 +95,12 @@ export function GetUserWithID(userID: integer): Promise>; } +export function GetSettingsForUser(userID: integer) { + return db["user-settings"].findOne({ + userID: userID, + }); +} + /** * Gets the users for these user IDs. */