rename internal-api to api, that's us!

This commit is contained in:
zkldi
2021-05-12 06:18:30 +01:00
parent 5bb7ed5383
commit 2e55a0cc5b
6 changed files with 14 additions and 14 deletions
+10
View File
@@ -0,0 +1,10 @@
import { Router } from "express";
import authRouter from "./auth/auth";
import importRouter from "./import/import";
const router = Router({ mergeParams: true });
router.use("/auth", authRouter);
router.use("/import", importRouter);
export default router;
+274
View File
@@ -0,0 +1,274 @@
import { Router } from "express";
import Prudence from "prudence";
import {
AddNewUser,
AddNewUserAPIKey,
PasswordCompare,
ReinstateInvite,
ValidatePassword,
} from "../../core/auth-core";
import { ValidateCaptcha } from "../../core/captcha-core";
import { FormatUserDoc } from "../../core/format-user";
import { GetUserCaseInsensitive, PRIVATEINFO_GetUserCaseInsensitive } from "../../core/user-core";
import db from "../../db/db";
import CreateLogCtx from "../../logger";
import prValidate from "../../middleware/prudence-validate";
import { RequireLoggedIn } from "../../middleware/require-logged-in";
const logger = CreateLogCtx("auth.ts");
const router: Router = Router({ mergeParams: true });
const LAZY_EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/u;
const BASE_DOMAIN = process.env.NODE_ENV === "production" ? ".kamaitachi.xyz" : "127.0.0.1";
const SHOULD_COOKIES_SECURE = process.env.NODE_ENV === "production";
/**
* Logs in a user.
* @name /api/auth/login
*/
router.post(
"/login",
prValidate(
{
username: Prudence.regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u),
password: ValidatePassword,
captcha: "string",
},
{
username: "Invalid username.",
captcha: "Please fill out the captcha.",
}
),
async (req, res) => {
if (req.session.ktchi?.userID) {
logger.info(`Dual log-in attempted from ${req.session.ktchi.userID}`);
return res.status(409).json({
success: false,
description: `You are already logged in as someone.`,
});
}
logger.verbose(`Recieved login request with username ${req.body.username} (${req.ip})`);
if (process.env.NODE_ENV === "production") {
logger.verbose("Validating captcha...");
let validCaptcha = await ValidateCaptcha(req.body.recaptcha, req.socket.remoteAddress);
if (!validCaptcha) {
logger.verbose("Captcha failed.");
return res.status(400).json({
success: false,
description: `Captcha failed.`,
});
}
logger.verbose("Captcha validated!");
} else {
logger.info("Skipped captcha check because not in production.");
}
let requestedUser = await PRIVATEINFO_GetUserCaseInsensitive(req.body.username);
if (!requestedUser) {
logger.verbose(`Invalid username for login ${req.body.username}.`);
return res.status(400).json({
success: false,
description: `This user does not exist.`,
});
}
let passwordMatch = await PasswordCompare(req.body.password, requestedUser.password);
if (!passwordMatch) {
logger.verbose("Invalid password provided.");
return res.status(400).json({
success: false,
description: `Invalid password.`,
});
}
// username and password match up, we're good to check onwards
let apiKeyDoc = await db["public-api-keys"].findOne({
assignedTo: requestedUser.id,
"permissions.selfkey": true,
});
if (!apiKeyDoc) {
logger.warn(
`User ${FormatUserDoc(requestedUser)} did not have an apikey. Creating a new one.`
);
let newApiKey = await AddNewUserAPIKey(requestedUser);
if (!newApiKey) {
logger.error(
`Bailed on user login ${FormatUserDoc(
requestedUser
)}. Could not create new apikey.`
);
throw new Error("FATAL in /register - apikey was unable to be created?");
}
apiKeyDoc = newApiKey;
}
req.session.ktchi = {
userID: requestedUser.id,
apiKey: apiKeyDoc.apiKey,
};
req.session.cookie.maxAge = 3.154e10; // 1 year
// API wants a cookie called "apikey" in order to make authorised requests. This might change.
res.cookie("apikey", apiKeyDoc.apiKey, {
maxAge: 3.154e10,
domain: BASE_DOMAIN,
secure: SHOULD_COOKIES_SECURE,
});
logger.verbose(`${FormatUserDoc(requestedUser)} Logged in.`);
return res.status(200).json({
success: true,
description: `Successfully logged in as ${FormatUserDoc(requestedUser)}`,
body: {
userID: requestedUser.id,
apiKey: apiKeyDoc.apiKey,
},
});
}
);
/**
* Registers a new user.
* @name /api/auth/register
*/
router.post(
"/register",
prValidate(
{
username: Prudence.regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u),
password: ValidatePassword,
email: Prudence.regex(LAZY_EMAIL_REGEX),
inviteCode: "string",
captcha: "string",
},
{
username:
"Usernames must be between 3 and 20 characters long, and can only contain alphanumeric characters!",
email: "Invalid email.",
inviteCode: "Invalid invite code.",
captcha: "Please fill out the captcha.",
}
),
async (req, res) => {
logger.verbose(`Recieved register request with username ${req.body.username} (${req.ip})`);
if (process.env.NODE_ENV === "production") {
logger.verbose("Validating captcha...");
let validCaptcha = await ValidateCaptcha(req.body.recaptcha, req.socket.remoteAddress);
if (!validCaptcha) {
logger.verbose("Captcha failed.");
return res.status(400).json({
success: false,
description: `Captcha failed.`,
});
}
logger.verbose("Captcha validated!");
} else {
logger.info("Skipped captcha check because not in production.");
}
let existingUser = await GetUserCaseInsensitive(req.body.username);
if (existingUser) {
logger.verbose(`Invalid username ${req.body.username}, already in use.`);
return res.status(409).json({
success: false,
description: "This username is already in use.",
});
}
let inviteCodeDoc = await db.invites.findOneAndUpdate(
{
code: req.body.inviteCode,
consumed: false,
},
{
$set: {
consumed: true,
},
}
);
if (!inviteCodeDoc) {
logger.info(`Invalid invite code given: ${req.body.inviteCode}.`);
return res.status(401).json({
success: false,
description: `This invite code is not valid.`,
});
}
logger.info(`Consumed invite ${inviteCodeDoc.code}.`);
// if we get to this point, We're good to create the user.
try {
let newUser = await AddNewUser(req.body.username, req.body.password, req.body.email);
if (!newUser) {
throw new Error("AddNewUser failed to create a user.");
}
let apiKeyDoc = await AddNewUserAPIKey(newUser);
if (!apiKeyDoc) {
throw new Error("AddNewUserAPIKey failed to create an api key.");
}
return res.status(200).json({
success: true,
description: `Successfully created account ${req.body.username}!`,
body: {
id: newUser.id,
username: newUser.username,
},
});
} catch (err) {
logger.error(
`Bailed on user creation ${req.body.username} with invite code ${req.body.inviteCode}.`,
{ err }
);
await ReinstateInvite(inviteCodeDoc);
return res.status(500).json({
success: false,
description: "An internal server error has occured.",
});
}
}
);
/**
* Logs out the requesting user.
* @name /api/auth/logout
*/
router.post("/logout", RequireLoggedIn, (req, res) => {
req.session.destroy(() => 0);
res.clearCookie("apikey");
return res.status(200).json({
success: true,
description: `Logged Out.`,
body: {},
});
});
export default router;
+188
View File
@@ -0,0 +1,188 @@
import t from "tap";
import mockApi from "../../test-utils/mock-api";
import {
GetKTDataBuffer,
GetKTDataJSON,
TestingIIDXEamusementCSV26,
TestingIIDXEamusementCSV27,
} from "../../test-utils/test-data";
import { CloseAllConnections } from "../../test-utils/close-connections";
import { RequireNeutralAuthentication } from "../../test-utils/api-common";
import { CreateFakeAuthCookie } from "../../test-utils/fake-session";
import ResetDBState from "../../test-utils/reset-db-state";
import db from "../../db/db";
async function LoadKTBlackIIDXData() {
let songs = GetKTDataJSON("./kamaitachi/ktblack-songs-iidx.json");
let charts = GetKTDataJSON("./kamaitachi/ktblack-charts-iidx.json");
await db.songs.iidx.remove({});
await db.songs.iidx.insert(songs);
await db.charts.iidx.remove({});
await db.charts.iidx.insert(charts);
}
// reset DB handles the post-stuff
t.test("POST /api/import/file", async (t) => {
const cookie = await CreateFakeAuthCookie(mockApi);
t.beforeEach(ResetDBState);
RequireNeutralAuthentication("/api/import/file", "POST");
t.test("file/csv:eamusement-iidx", (t) => {
t.beforeEach(LoadKTBlackIIDXData);
t.test("Mini HV import", async (t) => {
let res = await mockApi
.post("/api/import/file")
.set("Cookie", cookie)
.attach(
"scoreData",
GetKTDataBuffer("./csv_eamusement-iidx/small-hv-file.csv"),
"my_csv.csv"
)
.field("importType", "file/csv:eamusement-iidx")
.field("playtype", "SP");
t.equal(res.body.success, true, "Should be successful.");
t.equal(res.body.body.errors.length, 0, "Mini HV Import Should have 0 failed scores.");
t.equal(res.body.body.scoreIDs.length, 2, "Should have 2 successful scores.");
let scoreCount = await db.scores.find({
scoreID: { $in: res.body.body.scoreIDs },
});
t.equal(
scoreCount.length,
res.body.body.scoreIDs.length,
"All returned scoreIDs should be inserted to the DB."
);
t.end();
});
t.test("Valid Rootage CSV import", async (t) => {
let res = await mockApi
.post("/api/import/file")
.set("Cookie", cookie)
.attach("scoreData", TestingIIDXEamusementCSV26, "my_csv.csv")
.field("importType", "file/csv:eamusement-iidx")
.field("playtype", "SP");
t.equal(res.body.success, true, "Should be successful.");
t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores.");
let scoreCount = await db.scores.find({
scoreID: { $in: res.body.body.scoreIDs },
});
t.equal(
scoreCount.length,
res.body.body.scoreIDs.length,
"All returned scoreIDs should be inserted to the DB."
);
t.end();
});
t.test("Valid Heroic Verse CSV import", async (t) => {
let res = await mockApi
.post("/api/import/file")
.set("Cookie", cookie)
.attach("scoreData", TestingIIDXEamusementCSV27, "my_csv.csv")
.field("importType", "file/csv:eamusement-iidx")
.field("playtype", "SP");
t.equal(res.body.success, true, "Should be successful.");
t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores.");
let scoreCount = await db.scores.find({
scoreID: { $in: res.body.body.scoreIDs },
});
t.equal(
scoreCount.length,
res.body.body.scoreIDs.length,
"All returned scoreIDs should be inserted to the DB."
);
t.end();
});
t.end();
});
t.test("file/json:batch-manual", (t) => {
t.test("Empty import", async (t) => {
let res = await mockApi
.post("/api/import/file")
.set("Cookie", cookie)
.attach(
"scoreData",
GetKTDataBuffer("./json_batch-manual/empty-file.json"),
"empty-file.json"
)
.field("importType", "file/json:batch-manual");
t.equal(res.body.success, true, "Should be successful.");
t.equal(res.body.body.errors.length, 0, "Import Should have 0 failed scores.");
t.equal(res.body.body.scoreIDs.length, 0, "Should have 0 successful scores.");
let scoreCount = await db.scores.find({
scoreID: { $in: res.body.body.scoreIDs },
});
t.equal(
scoreCount.length,
res.body.body.scoreIDs.length,
"All returned scoreIDs should be inserted to the DB."
);
t.end();
});
t.test("Single import", async (t) => {
let res = await mockApi
.post("/api/import/file")
.set("Cookie", cookie)
.attach(
"scoreData",
GetKTDataBuffer("./json_batch-manual/small-file.json"),
"small-file.json"
)
.field("importType", "file/json:batch-manual");
t.equal(res.body.success, true, "Should be successful.");
t.equal(res.body.body.errors.length, 0, "Import Should have 0 failed scores.");
t.equal(res.body.body.scoreIDs.length, 1, "Should have 1 successful score.");
let scoreCount = await db.scores.find({
scoreID: { $in: res.body.body.scoreIDs },
});
t.equal(
scoreCount.length,
res.body.body.scoreIDs.length,
"All returned scoreIDs should be inserted to the DB."
);
t.end();
});
t.end();
});
t.end();
});
t.teardown(CloseAllConnections);
+161
View File
@@ -0,0 +1,161 @@
import { Router, NextFunction, Request, Response } from "express";
import { FileUploadImportTypes } from "kamaitachi-common";
import { fileImportTypes } from "kamaitachi-common/js/config";
import multer, { MulterError } from "multer";
import Prudence from "prudence";
import { GetUserWithID } from "../../core/user-core";
import CreateLogCtx from "../../logger";
import prValidate from "../../middleware/prudence-validate";
import { RequireLoggedIn } from "../../middleware/require-logged-in";
import ScoreImportFatalError from "../../score-import/framework/score-importing/score-import-error";
import ScoreImportMain from "../../score-import/framework/score-import-main";
import { KtLogger, ParserFunctionReturns } from "../../types";
const logger = CreateLogCtx("import.ts");
const router: Router = Router({ mergeParams: true });
// multer config
const upload = multer({ limits: { fileSize: 1024 * 1024 * 16 } }); // basically 16mb
const uploadMW = upload.single("scoreData");
const parseMultipartScoredata = (req: Request, res: Response, next: NextFunction) => {
uploadMW(req, res, (err: unknown) => {
if (err instanceof MulterError) {
logger.info(`Multer Error.`, { err });
return res.status(400).json({
success: false,
description:
"File provided was too large, corrupt, or provided in the wrong field.",
});
} else if (err) {
logger.error(`Unknown file import error: ${err}`, { err });
return res.status(500).json({
success: false,
description: `An internal server error has occured.`,
});
}
next();
});
};
/**
* Import scores from a file. Expects the post request to be multipart, and to provide a scoreData file.
* @name /api/import/file
*/
router.post(
"/file",
RequireLoggedIn,
parseMultipartScoredata,
prValidate(
{
importType: Prudence.isIn(fileImportTypes),
},
{},
{ allowExcessKeys: true }
),
async (req, res) => {
if (!req.file) {
return res.status(400).json({
success: false,
description: `No file provided.`,
});
}
try {
let importType = req.body.importType as FileUploadImportTypes;
const inputParser = (logger: KtLogger) =>
ResolveFileUploadData(importType, req.file, req.body, logger);
const userDoc = await GetUserWithID(req.session.ktchi!.userID);
if (!userDoc) {
logger.severe(
`User ${req.session.ktchi!.userID} does not have an associated user document.`
);
return res.status(500).json({
success: false,
description: "An internal error has occured.",
});
}
// This is deliberate - TS picks the IIDX-CSV generic values
// for this function call because it sees them first
// but that is ABSOLUTELY not what is actually occuring.
// We use this as an override because we know better.
// see: https://www.typescriptlang.org/play?ts=4.3.0-beta#code/GYVwdgxgLglg9mABAQQDwBUB8AKYc4BciAYvhpgJSIDeiAsAFCKID0LiAJnAKYDOivKCGDBGAX0aMYYKNwBOwAIYRuJMlhqNmzAEaK5RdOMkNQkWAkQAlbkLlgAynAC23UnGxVqW7W0QAHOVtuMA5EKAALGH5o8IjVIN4QABsoRDhgARdVaX8QNOlBbkUwjMQ5RVCXH2YYTOwAWUVIgDoKqudPRFREAAYWgFYvGu1mILskWj0DRAAiQTlpAHNZxAkmbXXRkfGQexpEaaIARgAmAGY14wZGZNt0vfdEAF5rWz3HbPdPAG4TZGwcEe+AoPyAA
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let importDocument = await ScoreImportMain<any, any>(
userDoc,
true,
importType,
inputParser
);
return res.status(200).json({
success: true,
description: "Import successful.",
body: importDocument,
});
} catch (err) {
if (err instanceof ScoreImportFatalError) {
logger.info(err.message);
return res.status(err.statusCode).json({
success: false,
description: err.message,
});
} else {
logger.error(err);
return res.status(500).json({
success: false,
description: `An internal service error has occured.`,
});
}
}
}
);
import ParseEamusementCSV from "../../score-import/import-types/file/csv_eamusement-iidx/parser";
import ParseBatchManual from "../../score-import/import-types/file/json_batch-manual/parser";
import {
IIDXEamusementCSVContext,
IIDXEamusementCSVData,
} from "../../score-import/import-types/file/csv_eamusement-iidx/types";
import {
BatchManualContext,
BatchManualScore,
} from "../../score-import/import-types/file/json_batch-manual/types";
/**
* Resolves the data from a file upload into an iterable,
* The appropriate processing function to map that iterable over,
* and and any context the processing may need (such as playtype)
*
* This also performs validation on the type of file uploaded.
* @param importType - The type of import request this was.
* @param fileData - The data sent by the user.
* @param body - Other data passed by the user in the request body.
*/
export function ResolveFileUploadData(
importType: FileUploadImportTypes,
fileData: Express.Multer.File,
body: Record<string, unknown>,
logger: KtLogger
) {
switch (importType) {
case "file/csv:eamusement-iidx":
return ParseEamusementCSV(fileData, body, logger);
case "file/json:batch-manual":
return ParseBatchManual(fileData, body, logger);
default:
logger.error(
`importType ${importType} made it into ResolveFileUploadData, but should have been rejected by Prudence.`
);
throw new ScoreImportFatalError(400, `Invalid importType of ${importType}.`);
}
}
export default router;