Skip to content

Commit

Permalink
Create an endpoint to join a class
Browse files Browse the repository at this point in the history
- Either a user joins the class directly
- Or the user creates a request to join
- Fix a bug regarding usernames/display names
- Patch a security vulnerability regarding refresh tokens
  • Loading branch information
Dlurak committed Mar 30, 2024
1 parent 83ea381 commit 49f3144
Show file tree
Hide file tree
Showing 12 changed files with 329 additions and 13 deletions.
26 changes: 24 additions & 2 deletions dbschema/default.esdl
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ module default {
scalar type Authmethod extending enum<Password>;

type User {
required username: str;
required displayname: str {
required username: str {
constraint exclusive;
};
required displayname: str;

required multi authmethod: Authmethod;
multi tokens: RefreshToken {
Expand Down Expand Up @@ -66,4 +66,26 @@ module default {
readonly := true;
};
}

scalar type Status extending enum<Pending, Accepted, Rejected>;

type JoinRequest {
required wantsToJoin: Class {
readonly := true;
};
required user: User {
readonly := true;
};
required created: datetime {
default := datetime_current();
readonly := true;
};

required status: Status {
default := 'Pending';
};

reviewedAt: datetime;
reviewedBy: User;
}
}
20 changes: 20 additions & 0 deletions dbschema/migrations/00008.edgeql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
CREATE MIGRATION m1n5yfow7yvygzyzix542fym6svcupjqrwdsj6zu54subohzbz52eq
ONTO m1psgxwipft63xhdvv24e4bboazf26v6doqseu3nyzyoa3oecmjp4q
{
CREATE SCALAR TYPE default::Status EXTENDING enum<Pending, Accepted, Rejected>;
CREATE TYPE default::JoinRequest {
CREATE REQUIRED LINK user: default::User {
SET readonly := true;
};
CREATE REQUIRED LINK wantsToJoin: default::Class {
SET readonly := true;
};
CREATE REQUIRED PROPERTY created: std::datetime {
SET default := (std::datetime_current());
SET readonly := true;
};
CREATE REQUIRED PROPERTY status: default::Status {
SET default := 'Pending';
};
};
};
16 changes: 16 additions & 0 deletions dbschema/migrations/00009.edgeql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
CREATE MIGRATION m1g3bsb7rj3ypvusgnkekg6g7zetyzd7xvl564gqjkdb4zwjfphyyq
ONTO m1n5yfow7yvygzyzix542fym6svcupjqrwdsj6zu54subohzbz52eq
{
ALTER TYPE default::JoinRequest {
CREATE LINK reviewedBy: default::User;
CREATE PROPERTY reviewedAt: std::datetime;
};
ALTER TYPE default::User {
ALTER PROPERTY displayname {
DROP CONSTRAINT std::exclusive;
};
ALTER PROPERTY username {
CREATE CONSTRAINT std::exclusive;
};
};
};
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { accessTokenRouter } from "routes/auth/accessToken";
import { refreshTokenRouter } from "routes/auth/refreshToken";
import { registerRouter } from "routes/auth/register";
import { classRouter } from "routes/classes";
import { moderationRouter } from "routes/moderation";
import { schoolRouter } from "routes/school";
import { userInfoRouter } from "routes/user/info";
import { edgedb } from "../dbschema/edgeql-js/imports";
Expand All @@ -16,6 +17,7 @@ const app = new Elysia()
.use(swagger(DOCUMENTATION_OPTIONS))
.use(schoolRouter)
.use(classRouter)
.use(moderationRouter)
.get(
"/",
() => ({
Expand Down
16 changes: 6 additions & 10 deletions src/routes/auth/refreshToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { HttpStatusCode } from "elysia-http-status-code";
import { client } from "index";
import { passowrdAuthSecret } from "schemas/auth";
import { createToken } from "utils/auth/jwt";
import { promiseResult } from "utils/errors";
import { randomNumber } from "utils/random";
import { responseBuilder } from "utils/response";
import { wait } from "utils/time";
Expand Down Expand Up @@ -55,23 +56,18 @@ export const refreshTokenRouter = new Elysia({ prefix: "/refresh-token" })
type: "refresh",
});

const query = e.update(e.User, () => ({
filter: e.op(e.User.username, "=", body.username),
const query = e.update(e.User, (u) => ({
filter_single: e.op(u.username, "=", body.username),
set: {
tokens: {
"+=": e.insert(e.RefreshToken, { token: tokenRefresh }),
},
},
}));

const result = await promiseResult(() => query.run(client));

const result = await query.run(client).catch(() =>
responseBuilder("error", {
error: "An error occurred while updating the user",
}),
);

// a positive result is a array
if (!Array.isArray(result)) {
if (result.isError) {
set.status = httpStatus.HTTP_500_INTERNAL_SERVER_ERROR;
return result;
}
Expand Down
6 changes: 6 additions & 0 deletions src/routes/classes/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ export const createClass = new Elysia()
set.status = httpStatus.HTTP_500_INTERNAL_SERVER_ERROR;
return DATABASE_WRITE_FAILED;
}
if (!createResult.data) {
set.status = httpStatus.HTTP_404_NOT_FOUND;
return responseBuilder("error", {
error: "School not found",
});
}

set.status = httpStatus.HTTP_201_CREATED;
return responseBuilder("success", {
Expand Down
154 changes: 154 additions & 0 deletions src/routes/moderation/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import e from "@edgedb";
import {
DATABASE_READ_FAILED,
DATABASE_WRITE_FAILED,
UNAUTHORIZED,
} from "constants/responses";
import Elysia, { t } from "elysia";
import { HttpStatusCode } from "elysia-http-status-code";
import { client } from "index";
import { auth } from "plugins/auth";
import { getAmountOfMembersOfClass } from "utils/db/classes";
import { doesRequestAlreadyExist } from "utils/db/requests";
import { promiseResult } from "utils/errors";
import { responseBuilder } from "utils/response";

export const createJoinRequest = new Elysia()
.use(auth)
.use(HttpStatusCode())
.post(
"/",
async ({ auth, body, httpStatus, set }) => {
if (!auth.isAuthorized) {
return UNAUTHORIZED;
}

const countResult = await promiseResult(() =>
getAmountOfMembersOfClass({
className: body.class,
schoolName: body.school,
}),
);
if (countResult.status === "error") {
set.status = httpStatus.HTTP_500_INTERNAL_SERVER_ERROR;
return DATABASE_READ_FAILED;
}

if (countResult.data === 0) {
const joinClassquery = e.update(e.Class, (c) => {
const classNameMatches = e.op(c.name, "=", body.class);
const schoolNameMatches = e.op(c.school.name, "=", body.school);

return {
filter_single: e.op(classNameMatches, "and", schoolNameMatches),
set: {
students: {
"+=": e.select(e.User, (u) => ({
filter_single: e.op(u.username, "=", auth.username),
})),
},
},
};
});
const joinResult = await promiseResult(() =>
joinClassquery.run(client),
);
if (joinResult.status === "error") {
set.status = httpStatus.HTTP_500_INTERNAL_SERVER_ERROR;
return DATABASE_WRITE_FAILED;
}
if (!joinResult.data) {
set.status = httpStatus.HTTP_404_NOT_FOUND;
return responseBuilder("error", {
error: "Class or school not found",
});
}

return responseBuilder("success", {
message: "Joined class successfully!",
data: null,
});
}

const isUserAlreadyInClassQuery = e.count(e.select(e.Class, (c) => {
const classNameMatches = e.op(c.name, "=", body.class);
const schoolNameMatches = e.op(c.school.name, "=", body.school);
const userMatches = e.op(c.students.username, "=", auth.username);

return {
filter_single: e.op(
e.op(classNameMatches, "and", schoolNameMatches),
"and",
userMatches,
),
};
}));
const isUserAlreadyInClassResult = await promiseResult(() =>
isUserAlreadyInClassQuery.run(client),
);
if (isUserAlreadyInClassResult.isError) {
set.status = httpStatus.HTTP_500_INTERNAL_SERVER_ERROR;
return DATABASE_READ_FAILED;
}
if (isUserAlreadyInClassResult.data > 0) {
set.status = httpStatus.HTTP_400_BAD_REQUEST;
return responseBuilder("error", {
error: "User is already in class",
});
}

const doesReqAlreadyExistResult = await promiseResult(() =>
doesRequestAlreadyExist({
username: auth.username,
class: body.class,
school: body.school,
}),
);
if (doesReqAlreadyExistResult.status === "error") {
set.status = httpStatus.HTTP_500_INTERNAL_SERVER_ERROR;
return DATABASE_READ_FAILED;
}
if (doesReqAlreadyExistResult.data) {
return responseBuilder("error", {
error: "Request already exists",
});
}

const joinRequestQuery = e.insert(e.JoinRequest, {
user: e.select(e.User, (u) => ({
filter_single: e.op(u.username, "=", auth.username),
})),
wantsToJoin: e.select(e.Class, (c) => {
const classNameMatches = e.op(c.name, "=", body.class);
const schoolNameMatches = e.op(c.school.name, "=", body.school);
return {
filter_single: e.op(classNameMatches, "and", schoolNameMatches),
};
}),
});
const joinRequestResult = await promiseResult(() =>
joinRequestQuery.run(client),
);
if (joinRequestResult.status === "error") {
set.status = httpStatus.HTTP_500_INTERNAL_SERVER_ERROR;
return DATABASE_WRITE_FAILED;
}

return responseBuilder("success", {
message: "Join request created successfully!",
data: null,
});
},
{
body: t.Object({
school: t.String({
minLength: 1,
description: "The name of the school to join",
}),
class: t.String({
minLength: 1,
description: "The name of the class to join",
}),
}),
},
);
6 changes: 6 additions & 0 deletions src/routes/moderation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Elysia from "elysia";
import { createJoinRequest } from "./create";

export const moderationRouter = new Elysia({ prefix: "/mod" }).use(
createJoinRequest,
);
13 changes: 13 additions & 0 deletions src/utils/db/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Database Utilities

This is a collection of complexer database operations.
Only put your queries here if it is simpler to read and understand it as a function call instead of the direct query builder.

## Rules

- Always use the query builder to build your queries!
- Always document your function with a js-doc comment!
- The comment must specify if the database will be read or written to
- The comment must specify if an error might be thrown
- If the query fails, throw an error!
- You may want to use the `promiseResult` utility to abstract errors into values
39 changes: 39 additions & 0 deletions src/utils/db/classes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import e from "@edgedb";
import { client } from "index";
import { promiseResult } from "utils/errors";

/**
* Get the amount of members in a class
* This only gives the accepted members - requests are ignored
* It doesn't modify the database but reads from it
* @throws Error if the database query fails
*/
export async function getAmountOfMembersOfClass(props: {
className: string;
schoolName: string;
}) {
const selectClassMembersQuery = e.select(e.User, (u) => {
const classNameMatches = e.op(
u["<students[is Class]"].name,
"=",
props.className,
);
const schoolNameMatches = e.op(
u["<students[is Class]"].school.name,
"=",
props.schoolName,
);

return {
filter: e.op(classNameMatches, "and", schoolNameMatches),
};
});
const countClassMembersQuery = e.count(selectClassMembersQuery);
const result = await promiseResult(() => countClassMembersQuery.run(client));

if (result.status === "error") {
throw new Error("Failed to get amount of class members");
}

return result.data;
}
39 changes: 39 additions & 0 deletions src/utils/db/requests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import e from "@edgedb";
import { client } from "index";
import { promiseResult } from "utils/errors";

interface DoesReqAlreadyExistProps {
username: string;
class: string;
school: string;
}

/**
* Check if a join-request already exists for a given user, class and school
* It doesn't modify the database but reads from it
* @throws Error if the database query fails
* @readonly
*/
export async function doesRequestAlreadyExist(props: DoesReqAlreadyExistProps) {
const selectRequestQuery = e.select(e.JoinRequest, (jr) => {
const userMatches = e.op(jr.user.username, "=", props.username);
const classMatches = e.op(jr.wantsToJoin.name, "=", props.class);
const schoolMatches = e.op(jr.wantsToJoin.school.name, "=", props.school);

return {
filter: e.op(
userMatches,
"and",
e.op(classMatches, "and", schoolMatches),
),
};
});
const countRequestQuery = e.count(selectRequestQuery);
const result = await promiseResult(() => countRequestQuery.run(client));

if (result.status === "error") {
throw new Error("Failed to check if request already exists");
}

return result.data > 0;
}
Loading

0 comments on commit 49f3144

Please sign in to comment.