Massive Update to sync with client

This commit is contained in:
zkldi
2021-07-21 04:51:31 +01:00
parent 20fb635791
commit 652333d3b4
23 changed files with 423 additions and 51 deletions
+11 -11
View File
@@ -1,17 +1,6 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "buildrun",
"problemMatcher": [],
"label": "npm: buildrun",
"detail": "tsc && export NODE_ENV=\"dev\" && node js/main.js",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"type": "npm",
"script": "fulltest",
@@ -37,6 +26,17 @@
"focus": false,
"panel": "dedicated"
}
},
{
"type": "npm",
"script": "start",
"problemMatcher": [],
"label": "npm: start",
"detail": "tsc --project tsconfig.build.json && node js/main.js",
"group": {
"kind": "build",
"isDefault": true
}
}
]
}
+1 -1
View File
@@ -8,7 +8,7 @@
"watchtest": "tap --watch",
"build": "tsc --project tsconfig.build.json",
"lint": "eslint ./src --ext .ts --fix",
"buildrun": "tsc --project tsconfig.build.json && node js/main.js"
"start": "tsc --project tsconfig.build.json && node js/main.js"
},
"author": "zkldi",
"license": "AGPL3",
+4 -4
View File
@@ -80,7 +80,7 @@ dependencies:
rate-limit-redis: 2.1.0
redis: 3.1.2
rimraf: 3.0.2
tachi-common: github.com/zkldi/tachi-common/20ff262e31119c918684141bf82d92076425a77d_ts-node@10.0.0+typescript@4.3.4
tachi-common: github.com/zkldi/tachi-common/7f0ff9c3905018070c291ae07f3f937d142f925d_ts-node@10.0.0+typescript@4.3.4
typescript: 4.3.4
winston: 3.3.3
@@ -4204,9 +4204,9 @@ packages:
engines: {node: '>=6'}
dev: true
github.com/zkldi/tachi-common/20ff262e31119c918684141bf82d92076425a77d_ts-node@10.0.0+typescript@4.3.4:
resolution: {tarball: https://codeload.github.com/zkldi/tachi-common/tar.gz/20ff262e31119c918684141bf82d92076425a77d}
id: github.com/zkldi/tachi-common/20ff262e31119c918684141bf82d92076425a77d
github.com/zkldi/tachi-common/7f0ff9c3905018070c291ae07f3f937d142f925d_ts-node@10.0.0+typescript@4.3.4:
resolution: {tarball: https://codeload.github.com/zkldi/tachi-common/tar.gz/7f0ff9c3905018070c291ae07f3f937d142f925d}
id: github.com/zkldi/tachi-common/7f0ff9c3905018070c291ae07f3f937d142f925d
name: tachi-common
version: 0.1.0
dependencies:
@@ -0,0 +1,24 @@
import db from "../../src/external/mongo/db";
import { Command } from "commander";
import CreateLogCtx from "../../src/lib/logger/logger";
const logger = CreateLogCtx(__filename);
const program = new Command();
program.option("-c, --code <code>", "The code for this invite.");
program.parse(process.argv);
const options = program.opts();
db.invites
.insert({
code: options.code,
createdBy: 1,
consumed: false,
createdOn: Date.now(),
})
.then(() => {
logger.info(`Created invite ${options.code}.`);
process.exit(0);
});
+13
View File
@@ -0,0 +1,13 @@
import { CounterDocument } from "tachi-common";
import db from "../../src/external/mongo/db";
const Counters: CounterDocument[] = [
{
counterName: "users",
value: 1,
},
];
db.counters.insert(Counters).then(() => {
process.exit(0);
});
@@ -27,7 +27,7 @@ t.test("#CreatePBDoc", (t) => {
chartID,
userID: 1,
songID: 1,
// rankingInfo -- is not present because it is not added until post-processing.
// rankingData -- is not present because it is not added until post-processing.
highlight: false,
isPrimary: true,
timeAchieved: 1619454485988,
@@ -79,7 +79,7 @@ export async function UpdateChartRanking(chartID: string) {
filter: { chartID: score.chartID, userID: score.userID },
update: {
$set: {
rankingInfo: {
rankingData: {
rank,
outOf: scores.length,
},
@@ -35,7 +35,7 @@ export async function ProcessPBs(
}
// so here's the kinda awkward part - for the time between this operation
// and the next one - THE SCORE PBS ARE IN THE DATABASE WITHOUT RANKINGINFO.
// and the next one - THE SCORE PBS ARE IN THE DATABASE WITHOUT RANKINGDATA.
// this *is* bad behaviour, but I don't have a nice way to fix it.
// This should be fixed in the future to avoid crashes between these two
// calls - but that is unlikely.
+3
View File
@@ -53,6 +53,7 @@ export interface TachiConfig {
CLIENT_INDEX_HTML_PATH: string;
ENABLE_SERVER_HTTPS: boolean;
RUN_OWN_CDN: boolean;
CLIENT_DEV_SERVER?: string | null;
TYPE_INFO: StaticConfig.ServerConfig;
}
@@ -71,6 +72,7 @@ const err = p(config, {
CLIENT_INDEX_HTML_PATH: "string",
ENABLE_SERVER_HTTPS: "boolean",
RUN_OWN_CDN: "boolean",
CLIENT_DEV_SERVER: "*?string",
TYPE: p.isIn("ktchi", "btchi", "omni"),
});
@@ -112,3 +114,4 @@ export const CONFIG = tachiConfig;
export const CLIENT_INDEX_HTML_PATH = tachiConfig.CLIENT_INDEX_HTML_PATH;
export const ENABLE_SERVER_HTTPS = tachiConfig.ENABLE_SERVER_HTTPS;
export const RUN_OWN_CDN = tachiConfig.RUN_OWN_CDN;
export const CLIENT_DEV_SERVER = tachiConfig.CLIENT_DEV_SERVER;
@@ -1,4 +1,5 @@
import t from "tap";
import db from "../../../../../external/mongo/db";
import { CloseAllConnections } from "../../../../../test-utils/close-connections";
import mockApi from "../../../../../test-utils/mock-api";
import ResetDBState from "../../../../../test-utils/resets";
@@ -111,4 +112,160 @@ t.test("POST /api/v1/auth/login", (t) => {
t.end();
});
t.test("POST /api/v1/auth/register", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(() =>
db.invites.insert({ code: "code", createdBy: 1, createdOn: 0, consumed: false })
);
t.test("Should register a new user.", async (t) => {
const res = await mockApi.post("/api/v1/auth/register").send({
username: "foo",
password: "password",
email: "foo@bar.com",
captcha: "1",
inviteCode: "code",
});
t.equal(res.statusCode, 200);
t.equal(res.body.success, true);
t.equal(res.body.body.username, "foo");
const doc = await db.users.findOne({ username: "foo" });
t.not(doc, null);
t.end();
});
t.test("Should disallow users with matching names.", async (t) => {
const res = await mockApi.post("/api/v1/auth/register").send({
username: "test_zkldi",
password: "password",
email: "foo@bar.com",
captcha: "1",
inviteCode: "code",
});
t.equal(res.statusCode, 409);
t.equal(res.body.success, false);
t.end();
});
t.test("Should disallow users with matching names case insensitively.", async (t) => {
const res = await mockApi.post("/api/v1/auth/register").send({
username: "test_zKLdi",
password: "password",
email: "foo@bar.com",
captcha: "1",
inviteCode: "code",
});
t.equal(res.statusCode, 409);
t.equal(res.body.success, false);
t.end();
});
t.test("Should disallow email if it is already used.", async (t) => {
const res = await mockApi.post("/api/v1/auth/register").send({
username: "foo",
password: "password",
email: "thepasswordis@password.com", // this is our test docs email, apparently.
captcha: "1",
inviteCode: "code",
});
t.equal(res.statusCode, 409);
t.equal(res.body.success, false);
t.end();
});
t.test("Should disallow invalid emails.", async (t) => {
const res = await mockApi.post("/api/v1/auth/register").send({
username: "foo",
password: "password",
email: "nonsense+email",
captcha: "1",
inviteCode: "code",
});
t.equal(res.statusCode, 400);
t.equal(res.body.success, false);
t.end();
});
t.test("Should disallow short passwords.", async (t) => {
const res = await mockApi.post("/api/v1/auth/register").send({
username: "foo",
password: "pass",
email: "foo@bar.com",
captcha: "1",
inviteCode: "code",
});
t.equal(res.statusCode, 400);
t.equal(res.body.success, false);
t.end();
});
t.test("Should disallow invalid usernames.", async (t) => {
const res = await mockApi.post("/api/v1/auth/register").send({
username: "3foo",
password: "password",
email: "foo@bar.com",
captcha: "1",
inviteCode: "code",
});
t.equal(res.statusCode, 400);
t.equal(res.body.success, false);
const res2 = await mockApi.post("/api/v1/auth/register").send({
username: "f",
password: "password",
email: "foo@bar.com",
captcha: "1",
inviteCode: "code",
});
t.equal(res2.statusCode, 400);
t.equal(res2.body.success, false);
t.end();
});
t.test("Should recover from a fatal error without breaking state.", async (t) => {
await db.counters.update({ counterName: "users" }, { $set: { value: 1 } }); // this will cause a userID collision
const res = await mockApi.post("/api/v1/auth/register").send({
username: "foo",
password: "password",
email: "foo@bar.com",
captcha: "1",
inviteCode: "code",
});
t.equal(res.statusCode, 500);
const counter = await db.counters.findOne({ counterName: "users" });
// value should not stay incremented
t.equal(counter?.value, 1);
const invite = await db.invites.findOne({ code: "code" });
// invite should not be consumed
t.equal(invite?.consumed, false);
t.end();
});
t.end();
});
t.teardown(CloseAllConnections);
+29 -7
View File
@@ -10,11 +10,14 @@ import {
import {
FormatUserDoc,
GetUserCaseInsensitive,
GetUserWithEmail,
GetUserWithID,
PRIVATEINFO_GetUserCaseInsensitive,
} from "../../../../../utils/user";
import db from "../../../../../external/mongo/db";
import CreateLogCtx from "../../../../../lib/logger/logger";
import prValidate from "../../../../middleware/prudence-validate";
import { DecrementCounterValue } from "../../../../../utils/db";
const logger = CreateLogCtx(__filename);
@@ -126,7 +129,7 @@ router.post(
},
{
username:
"Usernames must be between 3 and 20 characters long, and can only contain alphanumeric characters!",
"Usernames must be between 3 and 20 characters long, can only contain alphanumeric characters and cannot start with a number.",
email: "Invalid email.",
inviteCode: "Invalid invite code.",
captcha: "Please fill out the captcha.",
@@ -151,9 +154,9 @@ router.post(
});
}
logger.debug("Captcha validated.");
logger.verbose("Captcha validated.");
} else {
logger.info("Skipped captcha check because not in production.");
logger.warn("Skipped captcha check because not in production.");
}
const existingUser = await GetUserCaseInsensitive(req.body.username);
@@ -166,6 +169,16 @@ router.post(
});
}
const existingEmail = await GetUserWithEmail(req.body.email);
if (existingEmail) {
logger.info(`User attempted to use email ${req.body.email}, but was already in use.`);
return res.status(409).json({
success: false,
description: `This email is already in use.`,
});
}
const inviteCodeDoc = await db.invites.findOneAndUpdate(
{
code: req.body.inviteCode,
@@ -197,13 +210,21 @@ 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);
return res.status(200).json({
success: true,
description: `Successfully created account ${req.body.username}!`,
body: {
id: newUser.id,
username: newUser.username,
},
body: user,
});
} catch (err) {
logger.error(
@@ -212,6 +233,7 @@ router.post(
);
await ReinstateInvite(inviteCodeDoc);
await DecrementCounterValue("users");
return res.status(500).json({
success: false,
@@ -1,7 +1,7 @@
import { Router } from "express";
import { SYMBOL_TachiData } from "../../../../../../../lib/constants/tachi";
import { FormatGPT, IsString } from "../../../../../../../utils/misc";
import { GetGamePTConfig, UserGameStats, integer } from "tachi-common";
import { IsString } from "../../../../../../../utils/misc";
import { GetGamePTConfig, UserGameStats, integer, FormatGame } from "tachi-common";
import { FindOptions } from "monk";
import db from "../../../../../../../external/mongo/db";
import { ParseStrPositiveInt, CheckStrProfileAlg } from "../../../../../../../utils/string-checks";
@@ -11,7 +11,6 @@ import songIDRouter from "./songs/_songID/router";
import { ValidatePlaytypeFromParam } from "./middleware";
import foldersRouter from "./folders/router";
import tablesRouter from "./tables/router";
const router: Router = Router({ mergeParams: true });
router.use(ValidatePlaytypeFromParam);
@@ -27,7 +26,7 @@ router.get("/", (req, res) => {
return res.status(200).json({
success: true,
description: `Retrieved information about ${FormatGPT(game, playtype)}`,
description: `Retrieved information about ${FormatGame(game, playtype)}`,
body: {
config: GetGamePTConfig(game, playtype),
},
@@ -94,7 +94,7 @@ router.get("/best", async (req, res) => {
success: true,
description: `Retrieved ${pbs.length} personal bests.`,
body: {
scores: pbs,
pbs,
songs,
charts,
},
@@ -134,4 +134,51 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/sessions/highlighted", (
t.end();
});
t.test("GET /api/v1/users/:userID/games/:game/:playtype/sessions/recent", (t) => {
t.beforeEach(ResetDBState);
t.test("Should return the users most recent sessions.", async (t) => {
await db.sessions.remove({});
await db.sessions.insert([
{
highlight: false,
userID: 1,
game: "iidx",
playtype: "SP",
sessionID: "recent_id1",
timeEnded: 1234,
},
{
highlight: false,
userID: 1,
game: "iidx",
playtype: "SP",
sessionID: "recent_id2",
timeEnded: 12345,
},
{
highlight: false,
userID: 1,
game: "iidx",
playtype: "SP",
sessionID: "recent_id3",
timeEnded: 12344,
},
] as SessionDocument[]);
const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/sessions/recent");
t.equal(res.body.body.length, 3);
t.strictSame(
// @ts-expect-error temporary type hack
res.body.body.map((e) => e.sessionID),
["recent_id1", "recent_id3", "recent_id2"]
);
t.end();
});
t.end();
});
t.teardown(CloseAllConnections);
@@ -89,4 +89,26 @@ router.get("/highlighted", async (req, res) => {
});
});
/**
* Returns a users 100 most recent sessions. Returned in timeEnded order.
*
* @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/recent
*/
router.get("/recent", async (req, res) => {
const user = req[SYMBOL_TachiData]!.requestedUser!;
const game = req[SYMBOL_TachiData]!.game!;
const playtype = req[SYMBOL_TachiData]!.playtype!;
const sessions = await db.sessions.find(
{ userID: user.id, game, playtype },
{ sort: { timeEnded: -1 }, limit: 100 }
);
return res.status(200).json({
success: true,
description: `Returned ${sessions.length} sessions.`,
body: sessions,
});
});
export default router;
@@ -15,7 +15,7 @@ export const GetUserFromParam: RequestHandler = async (req, res, next) => {
if (req.params.userID === "me") {
if (!req[SYMBOL_TachiAPIAuth].userID) {
return res.status(403).json({
return res.status(401).json({
success: false,
description: "Cannot use 'me' userID with no authentication.",
});
+33 -2
View File
@@ -5,8 +5,10 @@ import { integer } from "tachi-common";
import { RedisClient } from "../external/redis/redis";
import {
CDN_FILE_ROOT,
CLIENT_DEV_SERVER,
CLIENT_INDEX_HTML_PATH,
CONFIG,
ENABLE_SERVER_HTTPS,
RUN_OWN_CDN,
SESSION_SECRET,
} from "../lib/setup/config";
@@ -20,6 +22,7 @@ const logger = CreateLogCtx(__filename);
let store;
if (process.env.NODE_ENV !== "test") {
logger.info("Connecting ExpressSession to Redis.");
const RedisStore = connectRedis(expressSession);
store = new RedisStore({
host: "localhost",
@@ -38,13 +41,33 @@ const userSessionMiddleware = expressSession({
resave: true,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
secure: process.env.NODE_ENV === "production" || ENABLE_SERVER_HTTPS,
},
});
const app: Express = express();
app.use(helmet());
if (process.env.NODE_ENV !== "production" && CLIENT_DEV_SERVER) {
logger.warn(`Enabling CORS requests from ${CLIENT_DEV_SERVER}.`);
// Allow CORS requests from another server (since we have our dev server hosted separately).
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", CLIENT_DEV_SERVER!);
res.header(
"Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept"
);
res.header("Access-Control-Allow-Credentials", "true");
res.header("Access-Control-Allow-Methods", "GET,POST,PATCH,PUT,DELETE,OPTIONS");
next();
});
// hack to allow all OPTIONS requests. Remember that this setting should not be on in production!
// app.options("*", (req, res) => res.send());
} else {
logger.info("Enabling Helmet, as no CLIENT_DEV_SERVER was set, or we are in production.");
app.use(helmet());
}
app.use(userSessionMiddleware);
@@ -77,6 +100,7 @@ app.use((req, res, next) => {
});
import mainRouter from "./router/router";
import { SYMBOL_TachiAPIAuth } from "../lib/constants/tachi";
app.use("/", mainRouter);
@@ -88,9 +112,12 @@ if (RUN_OWN_CDN) {
logger.warn(
`Running OWN_CDN in production. Consider making a separate process handle your CDN for performance.`
);
} else {
logger.info("Running own CDN.");
}
app.use("/cdn", express.static(CDN_FILE_ROOT));
app.get("/cdn/*", (req, res) => res.status(404).send("No content here."));
}
const indexHTMLContent = fs.readFileSync(CLIENT_INDEX_HTML_PATH);
@@ -122,6 +149,10 @@ const MAIN_ERR_HANDLER: express.ErrorRequestHandler = (err, req, res, next) => {
if (err instanceof SyntaxError) {
const expErr: ExpressJSONErr = err as ExpressJSONErr;
if (expErr.status === 400 && "body" in expErr) {
logger.info(`JSON Parsing Error?`, {
url: req.originalUrl,
userID: req[SYMBOL_TachiAPIAuth]?.userID,
});
return res.status(400).send({ success: false, description: err.message });
}
@@ -2,5 +2,9 @@
{
"counterName": "real-counter",
"value": 2
},
{
"counterName": "users",
"value": 2
}
]
+26 -1
View File
@@ -1,4 +1,4 @@
import { GetNextCounterValue } from "./db";
import { DecrementCounterValue, GetNextCounterValue } from "./db";
import t from "tap";
import db from "../external/mongo/db";
import ResetDBState from "../test-utils/resets";
@@ -28,4 +28,29 @@ t.test("#GetNextCounterValue", (t) => {
t.end();
});
t.test("#DecrementCounterValue", (t) => {
t.beforeEach(ResetDBState);
t.test("Should decrease a counter.", async (t) => {
await db.counters.insert({ counterName: "foo", value: 3 });
const res = await DecrementCounterValue("foo");
t.equal(res, 2);
const doc = await db.counters.findOne({ counterName: "foo" });
t.equal(doc?.value, 2);
t.end();
});
t.rejects(
() => GetNextCounterValue("fake-counter"),
"Could not find sequence document for fake-counter."
);
t.end();
});
t.teardown(CloseAllConnections);
+25
View File
@@ -28,6 +28,31 @@ export async function GetNextCounterValue(counterName: string): Promise<integer>
return sequenceDoc.value;
}
export async function DecrementCounterValue(counterName: string): Promise<integer> {
logger.verbose(`Decrementing Counter Value ${counterName}.`);
const sequenceDoc = await db.counters.findOneAndUpdate(
{
counterName,
},
{
$inc: {
value: -1,
},
},
{
returnOriginal: false,
}
);
if (!sequenceDoc) {
logger.error(`Could not find sequence document for ${counterName}`);
throw new Error(`Could not find sequence document for ${counterName}.`);
}
return sequenceDoc.value;
}
export async function GetRelevantSongsAndCharts(
scores: (ScoreDocument | PBScoreDocument)[],
game: Game
-14
View File
@@ -69,20 +69,6 @@ export function IsValidPlaytype(game: Game, str: string): str is Playtypes[Game]
return GetGameConfig(game).validPlaytypes.includes(str as Playtypes[Game]);
}
/**
* Formats a game and playtype depending on how many possible playtypes
* this game has.
*/
export function FormatGPT(game: Game, playtype: Playtypes[Game]) {
const gameConfig = GetGameConfig(game);
if (gameConfig.validPlaytypes.length === 1) {
return game;
}
return `${game} (${playtype})`;
}
export function IsString(val: unknown): val is string {
return typeof val === "string";
}
+15 -1
View File
@@ -49,6 +49,20 @@ export function GetUserCaseInsensitive(
) as Promise<FindOneResult<PublicUserDocument>>;
}
/**
* Returns the user with this email.
*/
export function GetUserWithEmail(email: string): Promise<FindOneResult<PublicUserDocument>> {
return db.users.findOne(
{
email,
},
{
projection: OMIT_PRIVATE_USER_RETURNS,
}
);
}
/**
* Returns GetUserCaseInsensitive, but without the private field omission.
* @see GetUserCaseInsensitive
@@ -157,7 +171,7 @@ export async function GetUsersRanking(stats: UserGameStats) {
$sum: {
$cond: {
if: {
$gte: [
$gt: [
`$ratings.${gptConfig.defaultProfileRatingAlg}`,
stats.ratings[gptConfig.defaultProfileRatingAlg],
],
+1 -1
View File
@@ -19,7 +19,7 @@
"typeRoots": [ "@types", "node_modules/@types" ]
},
"include": [
"src/**/*.ts",
"src/**/*.ts", "scripts/single-use/counters.ts",
],
"exclude": [
"node_modules",