Merge pull request #52 from detleph/prepare-release

Prepare release
This commit is contained in:
Stefan
2022-05-19 13:04:40 +02:00
committed by GitHub
48 changed files with 5551 additions and 186 deletions
+2
View File
@@ -0,0 +1,2 @@
*.env
dist/
+2 -2
View File
@@ -4,9 +4,9 @@ name: Check prettier formatting
on:
push:
branches: [ main ]
branches: [ main, dev ]
pull_request:
branches: [ main ]
branches: [ main, dev ]
workflow_dispatch:
+6
View File
@@ -105,3 +105,9 @@ dist
# Prisma
prisma/migrations
# Ignore config files for docker
host/
# Ignore flags
INITIALIZED
+3
View File
@@ -0,0 +1,3 @@
[submodule "resources/email/templates"]
path = resources/email/templates
url = ../email-templates.git
+31
View File
@@ -0,0 +1,31 @@
# Dockerfile for the event management server
FROM node:17-alpine3.12
# Create directory for the app
RUN mkdir /app
# Copy dependency files
COPY package.json package-lock.json /app/
# Change directory into the container
WORKDIR /app
# Install dependencies
RUN npm i -g typescript
RUN npm i
# Selectively copy the required files and directories
ADD prisma/ /app/prisma/
ADD scripts/ /app/scripts/
ADD src/ /app/src/
ADD resources/ /app/resources
COPY tsconfig.json /app/
# Generate the prisma client
RUN npx prisma generate
# Compile TypeScript to JavaScript
RUN tsc
CMD ["sh", "scripts/docker-entrypoint.sh"]
+5
View File
@@ -9,5 +9,10 @@ Before Runningthis on you local machine some things have to be setup
```
DATABASE_URL: ADDRESS TO YOUR DATABASE
DATABASE_PASSWORD: PASSOWRD OF THE DATABASE
PORT: PORT WHERE THE API SHOULD RUN
DOMAIN: THE DOMAIN NAME OF THE SERVER
MAILPASSWORD: THE PASSWORD FOR THE MAIL ACCOUNT
DEV: SWITCH FOR DEV MODE AFFECTS EMAIL SERVER
ALLOW_ORIGIN: ORIGIN OF THE PRODUCTION CLIENT (FOR CORS)
```
Executable
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
D_SERVICES="mail postgres redis"
DATABASE_PASSWORD=server COMPOSE_PROJECT_NAME=detleph_server docker-compose up -d $D_SERVICES
# Flag: Should the container be deleted and created again?
RECREATE=true
D_PORT="${PORT:-3000}"
if [ "$(docker ps -a | grep detleph_server_dev)" ]; then
echo "The dev server is already on the system!"
RECREATE=false
# Use -s flag to skip any prompts
if [ "$1" != "-s" ]; then
echo "Do you want to recreate it? [y/n]"
read input
if [ "$input" = "y" ]; then
RECREATE=true
fi
fi
if [ "$RECREATE" = true ]; then
echo "Removing container"
docker rm detleph_server_dev
rm -f INITIALIZED # Flag has to be reset
echo "Do you also want to reset the other services? [y/n]"
read input
if [ "$input" = "y" ]; then
COMPOSE_PROJECT_NAME=detleph_server docker-compose down
DATABASE_PASSWORD=server COMPOSE_PROJECT_NAME=detleph_server docker-compose up -d $D_SERVICES
fi
else
echo "Starting existing container"
docker start -ia detleph_server_dev
fi
fi
if [ "$RECREATE" = true ]; then
echo "Creating the dev container"
docker run -it \
--name detleph_server_dev \
--mount type=bind,source="$(pwd)",target=/app \
--network detleph_server_default \
-e DATABASE_PASSWORD=server \
-e DATABASE_URL="postgresql://server:server@postgres:5432/management?schema=public" \
-e NODE_ENV="development" \
--entrypoint "/app/scripts/docker-entrypoint.dev.sh" \
node
fi
COMPOSE_PROJECT_NAME=detleph_server docker-compose stop
+37
View File
@@ -0,0 +1,37 @@
version: "3.4"
services:
mail:
image: boky/postfix
restart: always
environment:
- ALLOWED_SENDER_DOMAINS=mail.${DOMAIN}
ports:
- "587:587"
container_name: "postfix"
volumes:
- ./host/keys/:/etc/opendkim/keys
postgres:
image: postgres
restart: always
environment:
- POSTGRES_USER=server
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
container_name: "postgres"
redis:
container_name: redis
image: redis
restart: always
server:
# REVIEW: This is setting the network context to host (during build time) which is generally considered a bad idea
build:
context: .
network: host
restart: always
container_name: "server"
ports:
- ${PORT:-3000}:${PORT:-3000}
environment:
- PORT=${PORT:-3000}
- DATABASE_URL=postgresql://server:${DATABASE_PASSWORD}@postgres:5432/management?schema=public
- DATABASE_USER=server
- DATABASE_PASSWORD=${DATABASE_PASSWORD}
+3163 -64
View File
File diff suppressed because it is too large Load Diff
+25 -6
View File
@@ -19,20 +19,39 @@
},
"homepage": "https://github.com/detleph/server#readme",
"dependencies": {
"@prisma/client": "^3.2.1",
"@types/chai-http": "^4.2.0",
"chai-http": "^4.3.0",
"@prisma/client": "^3.3.0",
"@types/cors": "^2.8.12",
"@types/handlebars": "^4.1.0",
"@types/jsonwebtoken": "^8.5.5",
"@types/mjml": "^4.7.0",
"@types/node": "^16.10.3",
"@types/nodemailer": "^6.4.4",
"@types/redis": "^2.8.32",
"argon2": "^0.28.2",
"cors": "^2.8.5",
"dotenv": "^10.0.0",
"express": "^4.17.1"
"express": "^4.17.1",
"express-async-errors": "^3.1.1",
"handlebars": "^4.7.7",
"jsonwebtoken": "^8.5.1",
"mjml": "^4.11.0",
"nanoid": "^3.3.3",
"nodemailer": "^6.7.0",
"redis": "^3.1.2",
"winston": "^3.7.2",
"zod": "^3.14.4"
},
"devDependencies": {
"@types/chai": "^4.2.22",
"@types/chai-as-promised": "^7.1.4",
"@types/chai-http": "^4.2.0",
"@types/express": "^4.17.13",
"@types/mocha": "^9.0.0",
"@types/node": "^16.10.3",
"chai": "^4.3.4",
"chai-as-promised": "^7.1.1",
"chai-http": "^4.3.0",
"mocha": "^9.1.3",
"prisma": "^3.2.1",
"prisma": "^3.3.0",
"ts-node": "^10.2.1",
"typescript": "^4.4.3"
}
+139 -100
View File
@@ -1,100 +1,139 @@
// Schema for the main database
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Event {
id Int @id @default(autoincrement()) // Primary key
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid // Public key
date DateTime
name String
disciplines Discipline[]
admins Admin[]
organisations Oragnisation[]
campaigns Campaign[]
}
model Admin {
id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
password String // TODO: Probably specify hash size (VarChar or some other type)
permission_level Int // TODO: Maybe extract into an enum
event Event @relation(fields: [eventId], references: [id])
eventId Int
}
model Campaign {
id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
expiresAt DateTime
user_limit Int @default(40)
user_number Int @default(0)
links String[]
group Group @relation(fields: [groupId], references: [id])
groupId Int
event Event @relation(fields: [eventId], references: [id])
eventId Int
}
model Discipline {
id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
minTeamSize Int @default(1)
maxTeamSize Int @default(1)
teams Team[]
event Event @relation(fields: [eventId], references: [id])
eventId Int
}
model Team {
id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
participants Participant[]
discipline Discipline @relation(fields: [disciplineId], references: [id])
disciplineId Int
}
model Participant {
id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
firstName String
lastName String
email String
team Team @relation(fields: [teamId], references: [id])
teamId Int
}
model Oragnisation {
id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
groups Group[]
event Event @relation(fields: [eventId], references: [id])
eventId Int
}
model Group {
id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
oragnisation Oragnisation @relation(fields: [oragnisationId], references: [id])
oragnisationId Int
campaign Campaign[]
}
// Schema for the main database
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Event {
id Int @id @default(autoincrement()) // Primary key
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid // Public key
date DateTime
name String
description String
disciplines Discipline[]
organisations Organisation[]
visual Media[]
}
model Admin {
id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String @unique
password String // TODO: Probably specify hash size (VarChar or some other type)
permission_level AdminLevel @default(STANDARD)
revision DateTime @default(now())
groups Group[]
}
model Discipline {
id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
minTeamSize Int
maxTeamSize Int
roles RoleSchema[]
teams Team[]
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
eventId Int
visual Media[]
}
model RoleSchema {
id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
schema Json
roles Role[]
discipline Discipline @relation(fields: [disciplineId], references: [id], onDelete: Cascade)
disciplineId Int
visual Media[]
}
model Team {
id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
leaderEmail String
roles Role[] @relation(name: "participants")
discipline Discipline @relation(fields: [disciplineId], references: [id], onDelete: Cascade)
disciplineId Int
}
model Participant {
id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
firstName String
lastName String
relevance Job
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
groupId Int
roles Role[]
}
model Role {
id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()"))
score String
participant Participant? @relation(fields: [participantId], references: [id], onDelete: SetNull)
participantId Int?
team Team @relation(name: "participants", fields: [teamId], references: [id], onDelete: Cascade)
teamId Int
schema RoleSchema @relation(fields: [schemaId], references: [id], onDelete: Cascade)
schemaId Int
}
model Organisation {
id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
groups Group[]
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
eventId Int
}
model Group {
id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
user_limit Int @default(40)
level Int
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
organisationId Int
participants Participant[]
admins Admin[]
}
model Media {
id Int @id @default(autoincrement())
pid String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
description String
location String @unique
event Event[]
discipline Discipline[]
role RoleSchema[]
}
enum AdminLevel {
STANDARD
ELEVATED
}
enum Job {
TEAMLEADER
MEMBER
}
+33
View File
@@ -0,0 +1,33 @@
#! /bin/bash
#load .env file
[[ -f .env ]] || { echo "`tput setaf 1`✗`tput sgr0` Could not find .env file are you in the correct directory (you should be in the server directory)"; exit 1;}
set -o allexport
[[ -f .env ]] && source .env
set +o allexport
#static variables
NC='tput sgr0' #No Color
GREEN='tput setaf 2'
RED='tput setaf 1'
#check dependencies
command -v opendkim-genkey >/dev/null 2>&1 || { echo >&2 "`$RED`✗`$NC` Opendkim not installed install with your favorite package manager (opendkim-tools | opendkim-utils)"; exit 1;}
#generate dkim keys
mkdir -p ./host/keys
echo "`$GREEN`✓`$NC` Created folder"
cd ./host/keys
opendkim-genkey -b 2048 -h rsa-sha256 -r -v --subdomains -s mail -d mail.$DOMAIN
sed -i 's/h=rsa-sha256/sha256/' mail.txt
mv mail.private mail.$DOMAIN.private
mv mail.txt mail.$DOMAIN.txt
echo "`$GREEN`✓`$NC` Generated dkim keys"
exit 0
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
if ! test -f INITIALIZED; then
touch INITIALIZED
cd /app
npm i -g ts-node nodemon
npm i
npx prisma db push
fi
nodemon --watch /app /app/src/app.ts
+11
View File
@@ -0,0 +1,11 @@
# Entry point for docker
if ! test -f INITIALIZED; then
# Migrate the database
# TODO: Update to use prisma migrate
npx prisma db push
touch INITIALIZED
fi
# Start the server
node ./dist/app.js
+3
View File
@@ -0,0 +1,3 @@
#! /bin/bash
docker-compose up -d
+212
View File
@@ -0,0 +1,212 @@
import prisma from "../lib/prisma";
import { Request, Response } from "express";
import { AdminLevel } from "@prisma/client";
import argon2 from "argon2";
import { AUTH_ERROR, createInsufficientPermissionsError, DataType, generateInvalidBodyError } from "./common";
import { authClient } from "../lib/redis";
export const regenerateRevision = async (pid: string) => {
// TOOO: Add error handling
const { revision } = await prisma.admin.update({
where: { pid },
data: { revision: new Date() },
select: { revision: true },
});
await authClient.set(pid, revision.toISOString());
};
// requires: auth(elevated)
export const getAllAdmins = async (req: Request, res: Response) => {
if (!req.auth?.isAuthenticated) {
return res.status(500).json(AUTH_ERROR);
}
if (req.auth.permission_level !== "ELEVATED") {
return res.status(403).json(createInsufficientPermissionsError());
}
// TODO: Add exception handling
const users = await prisma.admin.findMany({ select: { pid: true, name: true, permission_level: true } });
res.status(200).json({
type: "success",
payload: {
users,
},
});
};
interface CreateAdminBody {
name?: string;
password: string;
permission_level: string;
groups?: string[];
}
const PERMISSION_LEVELS: readonly AdminLevel[] = ["ELEVATED", "STANDARD"]; // TODO: Enforce completeness
const isPermissionLevel = (level: string): level is AdminLevel => PERMISSION_LEVELS.includes(level as any);
// requires: auth(elevated)
export const createAdmin = async (req: Request<{}, {}, CreateAdminBody>, res: Response) => {
if (!req.auth?.isAuthenticated) {
return res.status(500).json(AUTH_ERROR);
}
if (req.auth.permission_level !== "ELEVATED") {
return res.status(403).json(createInsufficientPermissionsError());
}
const { name, password, permission_level, groups } = req.body || {};
const groupsIsValid = groups ? groups.filter((e) => typeof e !== "string").length === 0 : true;
if (
!(typeof name === "string" && typeof password == "string" && isPermissionLevel(permission_level) && groupsIsValid)
) {
return res.status(400).json(
generateInvalidBodyError({
name: DataType.STRING,
password: DataType.STRING,
permission_level: DataType.PERMISSION_LEVEL,
})
);
}
const password_hash = await argon2.hash(password, { type: argon2.argon2id });
// Check if all gropus exist
for (const groupId of groups || []) {
if (!(await prisma.group.findUnique({ where: { pid: groupId } }))) {
return res.status(404).json({
type: "error",
payload: {
message: `The group with ID '${groupId}' could not be found!`,
},
});
}
}
// TODO: Check for uniqueness of the name
const user = await prisma.admin.create({
data: {
name,
password: password_hash,
permission_level,
groups: { connect: groups?.map((group) => ({ pid: group })) },
},
select: { pid: true, name: true, permission_level: true },
});
res.status(201).json({
type: "success",
payload: {
user,
},
});
};
// Expects a valid username (Should be tested beforehand)
const updatePasswordField = async (pid: string, new_password: string) => {
const new_password_hash = await argon2.hash(new_password, { type: argon2.argon2id });
await prisma.admin.update({ where: { pid }, data: { password: new_password_hash } });
};
interface UpdateForeignPasswordBody {
new_password?: string;
}
interface UpdateForeignPasswordQueryParams {
pid: string;
}
export const updateForeignPassword = async (
req: Request<UpdateForeignPasswordQueryParams, {}, UpdateForeignPasswordBody>,
res: Response
) => {
if (!req.auth?.isAuthenticated) {
return res.status(500).json(AUTH_ERROR);
}
if (typeof req.body.new_password !== "string") {
return res.status(400).json(generateInvalidBodyError({ new_password: DataType.STRING }));
}
const user_to_upate = await prisma.admin.findUnique({ where: { pid: req.params.pid } });
if (!user_to_upate) {
// REVIEW: This allows potential attackers (which are authorized with some account)
// to test account names
return res.status(404).json({
type: "error",
payload: {
message: "The requested user was not found",
},
});
}
if (req.auth.permission_level == "ELEVATED" && user_to_upate.permission_level == "STANDARD") {
await updatePasswordField(req.params.pid, req.body.new_password); // REVIEW: Should this be awaited?
await regenerateRevision(req.params.pid);
res.status(200).json({
type: "success",
});
} else {
res.status(403).json({
type: "error",
payload: {
message: "Operation not permitted; Try logging in as another user",
},
});
}
};
interface UpdatePasswordBody {
password?: string;
new_password?: string;
}
// requires: auth
export const updateOwnPassword = async (req: Request<{}, {}, UpdatePasswordBody>, res: Response) => {
if (!req.auth?.isAuthenticated) {
return res.status(500).json(AUTH_ERROR);
}
const pid = req.auth.pid;
if (typeof req.body.password !== "string" || typeof req.body.new_password !== "string") {
return res.status(400).json(generateInvalidBodyError({ password: DataType.STRING, new_password: DataType.STRING }));
}
const user_to_upate = await prisma.admin.findUnique({ where: { pid } });
if (!user_to_upate) {
// REVIEW: This allows potential attackers (which are authorized with some account)
// to test account names
return res.status(404).json({
type: "error",
payload: {
message: "The requested user was not found",
},
});
}
if (await argon2.verify(user_to_upate.password, req.body.password, { type: argon2.argon2id })) {
await updatePasswordField(pid, req.body.new_password);
await regenerateRevision(pid);
return res.status(200).json({
type: "success",
});
}
return res.status(401).json({
type: "error",
payload: {
message: "The provided password is not valid",
},
});
};
+79
View File
@@ -0,0 +1,79 @@
import { Request, Response } from "express";
import { Admin, AdminLevel, Group } from "@prisma/client";
import { authClient } from "../lib/redis";
import prisma from "../lib/prisma";
import argon2 from "argon2";
import jwt from "jsonwebtoken";
import { DataType, generateInvalidBodyError } from "./common";
const JWT_SECRET = process.env.JWT_SECRET || "secret";
const TOKEN_EXPIRY = "4 days";
export interface AuthJWTPayload {
pid: string;
name: string;
permission_level: AdminLevel;
revision: string;
groups: string[];
}
function createAdminJWT(admin: Admin & { groups: Group[] }) {
const payload: AuthJWTPayload = {
pid: admin.pid,
name: admin.name,
permission_level: admin.permission_level,
revision: admin.revision.toISOString(),
groups: admin.groups.map((group) => group.pid),
};
return jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY });
}
interface AuthenticateUserBody {
name?: string;
password?: string;
}
export const authenticateUser = async (req: Request<{}, {}, AuthenticateUserBody>, res: Response) => {
// TODO: Provide more useful error messages (Maybe express-validator?)
const { name, password } = req.body || {};
if (name == null || password == null) {
return res.status(400).json(generateInvalidBodyError({ name: DataType.STRING, password: DataType.STRING }));
}
const user = await prisma.admin.findFirst({ where: { name }, include: { groups: true } });
// REVIEW: It is possible to initiate a timing attack here (To see which users exist)
// This should, however, not be of too much concern, as one can basically do nothing
// with only the username
if (!user) {
return res.status(403).json({
type: "error",
payload: {
message: `The provided credentials are not valid`,
},
});
}
if (await argon2.verify(user.password, password, { type: argon2.argon2id })) {
// Set password revision ID in redis
await authClient.set(user.pid, user.revision.toISOString());
return res.status(200).json({
type: "success",
payload: {
token: createAdminJWT(user),
},
});
}
return res.status(403).json({
type: "error",
payload: {
message: "The provided credentials are not valid",
},
});
};
+153
View File
@@ -0,0 +1,153 @@
import { AdminLevel, Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime";
import { Request, Response } from "express";
import prisma from "../lib/prisma";
export enum DataType {
STRING = "string",
NUMBER = "number",
INTEGER = "integer",
PERMISSION_LEVEL = "'ELEVATED' | 'STANDARD'",
DATETIME = "ISOstring",
UUID = "string",
RESULT_SCHEMA = "result_schema",
}
interface Body {
[k: string]: DataType;
}
export function generateInvalidBodyError(body: Body) {
return {
type: "error",
payload: {
message: "The body of your request did not conform to the requirements",
schema: { body },
},
};
}
export const AUTH_ERROR = {
type: "failure",
payload: {
message: "The server was not able to validate your credentials; Please try again later",
},
};
export const createInsufficientPermissionsError = (required: AdminLevel = "ELEVATED") => ({
type: "error",
payload: {
message: "You do not have sufficient permissions to use this feature",
required_level: required,
},
_links: [
{
rel: "authentication",
href: "/api/authentication",
type: "POST",
},
],
});
export const generateError = (message: string) => {
return {
type: "error",
payload: {
message,
},
};
};
export const genericError = {
type: "error",
payload: {
message: "There was an error processing your request, please try again later",
},
};
export const NAME_ERROR = {
type: "error",
payload: {
message: "The name has to be at least 1 character long",
},
};
export function validateName(name: string) {
return name.length > 0;
}
type Organisation = Partial<Prisma.OrganisationCreateArgs["data"]>;
type Group = Partial<Prisma.GroupCreateArgs["data"]>;
export async function handleCreateByName(
create: { type: "organisation"; data: Organisation },
link: { type: "event"; id: string },
req: Request<any>,
res: Response
): Promise<unknown>;
export async function handleCreateByName(
create: { type: "group"; data: Group },
link: { type: "organisation"; id: string },
req: Request<any>,
res: Response
): Promise<unknown>;
export async function handleCreateByName(
create: { type: "organisation" | "group"; data: Organisation | Group },
link: { type: "event" | "organisation"; id: string },
req: Request<any>,
res: Response
) {
if (!req.auth?.isAuthenticated) {
return res.status(500).json(AUTH_ERROR);
}
if (req.auth.permission_level !== "ELEVATED") {
return res.status(403).json(createInsufficientPermissionsError());
}
const { name } = create.data;
if (typeof name !== "string") {
return res
.status(400)
.json(generateInvalidBodyError({ name: DataType.STRING, [link.type + "Pid"]: DataType.STRING }));
}
if (!validateName(name)) {
return res.status(400).json(NAME_ERROR);
}
// Check if link object exsits
try {
// @ts-ignore
const linked = await prisma[link.type].findUnique({ where: { pid: link.id }, select: { id: true } });
if (!linked) {
return res.status(404).json({
type: "error",
payload: {
message: `Could not link to ${link.type} with ID '${link.id}'`,
},
});
}
} catch (e) {
// REVIEW: Check for valid UUID
if (e instanceof PrismaClientUnknownRequestError) {
return res.status(400).json({
type: "error",
payload: {
message: "Unknown error occured. This could be due to malformed IDs",
},
});
}
}
// @ts-ignore
const object = await prisma[create.type].create({
data: { ...create.data, [link.type]: { connect: { pid: link.id } } },
select: { pid: true, name: true },
});
res.status(201).json({ type: "success", payload: { [create.type]: object } });
}
+189
View File
@@ -0,0 +1,189 @@
import { Prisma, Organisation, Admin, AdminLevel, Team } from "@prisma/client";
import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime";
import { Request, Response } from "express";
import prisma from "../lib/prisma";
import ForwardableError from "../Middleware/error/ForwardableError";
import NotFoundError from "../Middleware/error/NotFoundError";
import {
createInsufficientPermissionsError,
DataType,
generateError,
generateInvalidBodyError,
NAME_ERROR,
validateName,
} from "./common";
require("express-async-errors");
const basicDiscipline = {
pid: true,
name: true,
visual: { select: { pid: true, location: true } },
maxTeamSize: true,
minTeamSize: true,
event: { select: { pid: true, name: true } },
roles: { select: { pid: true, name: true } },
} as const;
const authenticatedDiscipline = {
...basicDiscipline,
teams: { select: { pid: true, name: true } },
} as const;
const elevatedDiscipline: Prisma.DisciplineFindManyArgs["select"] = {
...authenticatedDiscipline,
teams: { select: { pid: true, name: true, leaderEmail: true } },
};
export const _getAllDisciplines = async (
res: Response,
authenticated: boolean | undefined,
eventId: string | undefined
) => {
const disciplines = await prisma.discipline.findMany({
where: { event: { pid: eventId } },
select: !authenticated ? basicDiscipline : authenticatedDiscipline,
});
return res.status(200).json({
type: "success",
payload: {
disciplines,
},
});
};
interface GetAllDisciplinesSearchParams {
eventPid?: string;
}
export const getAllDisciplines = async (req: Request<{}, {}, {}, GetAllDisciplinesSearchParams>, res: Response) => {
return _getAllDisciplines(res, req.auth?.isAuthenticated, req.query.eventPid);
};
interface GetAllDisciplinesQueryParams {
eventPid: string;
}
export const GetAllDisciplinesWithParam = async (req: Request<GetAllDisciplinesQueryParams>, res: Response) => {
return _getAllDisciplines(res, req.auth?.isAuthenticated, req.params.eventPid);
};
interface GetDisciplineQueryParams {
pid: string;
}
export const getDiscipline = async (req: Request<GetDisciplineQueryParams>, res: Response) => {
const { pid } = req.params;
const discipline = await prisma.discipline.findUnique({
where: { pid },
select: !req.auth?.isAuthenticated
? basicDiscipline
: req.auth.permission_level !== "ELEVATED"
? authenticatedDiscipline
: elevatedDiscipline,
});
if (!discipline) {
throw new NotFoundError("discipline", pid);
}
return res.status(200).json({
type: "success",
payload: {
discipline: {
...discipline,
event: {
...discipline.event,
_links: [{ rel: "self", type: "GET", href: `/api/events/${discipline.event.pid}` }],
},
roles: discipline.roles.map((role) => ({
...role,
_links: [{ rel: "self", type: "GET", href: `/api/role/${role.pid}` }],
})),
...((discipline as any).teams
? (discipline as any).teams.map((team: Team) => ({
...team,
_links: [{ rel: "self", type: "GET", href: `/api/teams/${team.pid}` }],
}))
: {}),
},
},
});
};
interface CreateDisciplineBody {
name?: string;
minTeamSize?: number;
maxTeamSize?: number;
}
// require: auth(ELEVATED)
// at: POST /event/:eventPid/discipliens
export const createDiscipline = async (req: Request<{ eventPid: string }, {}, CreateDisciplineBody>, res: Response) => {
if (req.auth?.permission_level !== "ELEVATED") {
return res.status(403).json(createInsufficientPermissionsError());
}
const { name, minTeamSize, maxTeamSize } = req.body;
if (typeof name !== "string" || typeof minTeamSize !== "number" || typeof maxTeamSize !== "number") {
return res.status(400).json(
generateInvalidBodyError({
name: DataType.STRING,
minTeamSize: DataType.NUMBER,
maxTeamSize: DataType.NUMBER,
})
);
}
if (!validateName(name)) {
return res.status(400).json(NAME_ERROR);
}
try {
const discipline = await prisma.discipline.create({
data: { name, minTeamSize, maxTeamSize, event: { connect: { pid: req.params.eventPid } } },
select: {
pid: true,
name: true,
minTeamSize: true,
maxTeamSize: true,
event: { select: { pid: true, name: true } },
},
});
return res.status(201).json({ type: "success", payload: { discipline } });
} catch (e) {
if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") {
return res.status(404).json(generateError(`Could not link to event with ID '${req.params.eventPid}'`));
}
throw e;
}
};
interface DeleteDisciplineQueryParams {
pid: string;
}
// requires: auth(ELEVATED)
export const deleteDiscipline = async (req: Request<DeleteDisciplineQueryParams>, res: Response) => {
if (req.auth?.permission_level !== "ELEVATED") {
res.status(403).json(createInsufficientPermissionsError());
}
const { pid } = req.params;
try {
await prisma.discipline.delete({ where: { pid } });
return res.status(204).end();
} catch (e) {
if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") {
throw new NotFoundError("discipline", pid);
}
throw e;
}
};
+116 -9
View File
@@ -1,40 +1,147 @@
import { Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import { Request, Response } from "express";
import prisma from "../lib/prisma";
import { createInsufficientPermissionsError, DataType, generateError, generateInvalidBodyError } from "./common";
const getAllEvents = async (req: Request, res: Response) => {
export const getAllEvents = async (req: Request, res: Response) => {
const events = await prisma.event.findMany({
select: {
name: true,
description: true,
date: true,
pid: true,
id: false,
},
});
if (events.length > 0) res.status(200).send(events);
else res.status(204).send();
res.status(200).json({
type: "success",
payload: {
events,
},
});
};
const addEvent = async (req: Request, res: Response) => {
// TODO: Add checks if user has admin permissions
export const getEvent = async (req: Request, res: Response) => {
const eventId = req.params.eventId;
if (req.body.name == null || undefined || req.body.date == null || undefined) {
res.status(400).send("Malformed request");
if (typeof eventId !== "string") {
res.status(400).json(
generateInvalidBodyError({
eventId: DataType.UUID,
})
);
return;
}
try {
const event = await prisma.event.findUnique({
where: {
pid: eventId,
},
select: {
name: true,
description: true,
date: true,
pid: true,
id: false,
},
});
res.status(200).json({
type: "success",
payload: {
event,
},
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
res.status(500).json({
type: "error",
payload: {
message: `Internal Server error occured. Try again later`,
},
});
return;
}
if (e instanceof Prisma.PrismaClientUnknownRequestError) {
res.status(500).json({
type: "error",
payload: {
message: "Unknown error occurred with your request. Check if your parameters are correct",
schema: {
eventId: DataType.UUID,
},
},
});
return;
}
}
};
// requires: auth(ELEVATED)
export const addEvent = async (req: Request, res: Response) => {
if (req.auth?.permission_level !== "ELEVATED") {
res.status(403).json(createInsufficientPermissionsError());
}
if (
typeof req.body.name !== "string" ||
typeof req.body.date !== "string" ||
typeof req.body.description !== "string"
) {
generateInvalidBodyError({
name: DataType.STRING,
date: DataType.DATETIME,
description: DataType.STRING,
});
}
//TODO: Check if date is valid
const event = await prisma.event.create({
data: {
name: req.body.name,
date: req.body.date,
description: req.body.description,
},
select: {
name: true,
date: true,
pid: true,
id: false,
description: true,
},
});
res.status(201).json({
type: "success",
payload: {
event,
},
});
res.status(201).send(event);
};
export { getAllEvents, addEvent };
interface DeleteEventQueryParams {
pid: string;
}
// requires: auth(ELEVATED)
export const deleteEvent = (req: Request<DeleteEventQueryParams>, res: Response) => {
if (req.auth?.permission_level !== "ELEVATED") {
res.status(403).json(createInsufficientPermissionsError());
}
const { pid } = req.params;
try {
prisma.event.delete({ where: { pid } });
return res.status(204).end();
} catch (e) {
if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") {
return res.status(404).json(generateError(`The organisation with the ID ${pid} could not be found`));
}
throw e;
}
};
+140
View File
@@ -0,0 +1,140 @@
import { Admin } from "@prisma/client";
import { PrismaClientKnownRequestError, PrismaClientUnknownRequestError } from "@prisma/client/runtime";
import { Request, Response } from "express";
import prisma from "../lib/prisma";
import { createInsufficientPermissionsError, generateError, genericError, handleCreateByName } from "./common";
const basicGroup = {
pid: true,
name: true,
organisation: { select: { pid: true, name: true } },
} as const;
export const _getAllGroups = async (res: Response, organisationId: string | undefined) => {
const groups = await prisma.group.findMany({
where: { organisation: { pid: organisationId } },
select: basicGroup,
});
return res.status(200).json({
type: "success",
payload: {
groups,
},
});
};
interface GetAllGroupsSearchParams {
organisationPid?: string;
}
export const getAllGroups = async (req: Request<{}, {}, {}, GetAllGroupsSearchParams>, res: Response) => {
return _getAllGroups(res, req.query.organisationPid);
};
interface getAllGroupsWithParamQueryParams {
organisationPid: string;
}
export const getAllGroupsWithParam = async (req: Request<getAllGroupsWithParamQueryParams>, res: Response) => {
return _getAllGroups(res, req.params.organisationPid);
};
interface GetGroupQueryParams {
pid: string;
}
export const getGroup = async (req: Request<GetGroupQueryParams>, res: Response) => {
const { pid } = req.params;
try {
const group: {
pid: string;
name: string;
organisation: { pid: string; name: string };
admins?: { pid: string; name: string }[];
participants?: { pid: string }[];
} | null = await prisma.group.findUnique({
where: { pid },
select: req.auth?.isAuthenticated
? {
pid: true,
name: true,
organisation: { select: { pid: true, name: true } },
admins: { select: { pid: true, name: true } },
participants: { select: { pid: true } },
}
: basicGroup,
});
if (!group) {
return res.status(404).json(generateError(`The group with ID '${pid}' could not be found`));
}
return res.status(200).json({
type: "success",
payload: {
group: {
...group,
organisation: {
...group.organisation,
_links: [{ rel: "self", type: "GET", href: `/api/organisation/${group.organisation.pid}` }],
},
...(req.auth?.isAuthenticated
? {
admins: group.admins?.map((admin) => ({
...admin,
_links: [{ rel: "self", type: "GET", href: `/api/admins/${admin.pid}` }],
})),
participants: group.participants?.map((participant) => ({
...participant,
_links: [{ rel: "self", type: "GET", href: `/api/participant/${participant.pid}` }],
})),
}
: {}),
},
},
});
} catch (e) {
if (e instanceof PrismaClientUnknownRequestError) {
return res.status(400).json(generateError("Unknown error occured. This could be due to malformed IDs"));
}
}
return res.status(500).json(genericError);
};
// at: POST /api/organisations/:eventPid/groups
// requires: auth(ELEVATED)
export const createGroup = async (req: Request<{ organisationPid: string }, {}, { name?: string }>, res: Response) => {
return handleCreateByName(
{ type: "group", data: { name: req.body.name, level: 1 } },
{ type: "organisation", id: req.params.organisationPid },
req,
res
);
};
interface DeleteGroupQueryParams {
pid: string;
}
export const deleteGroup = async (req: Request<DeleteGroupQueryParams>, res: Response) => {
if (req.auth?.permission_level !== "ELEVATED") {
return res.status(403).json(createInsufficientPermissionsError());
}
const { pid } = req.params;
try {
prisma.group.delete({ where: { pid } });
return res.status(204).end();
} catch (e) {
if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") {
return res.status(404).json(generateError(`The group with the ID ${pid} could not be found`));
}
throw e;
}
};
+233
View File
@@ -0,0 +1,233 @@
import { PrismaClientUnknownRequestError } from "@prisma/client/runtime";
import { Request, Response } from "express";
import prisma from "../lib/prisma";
import {
AUTH_ERROR,
createInsufficientPermissionsError,
DataType,
generateError,
generateInvalidBodyError,
genericError,
handleCreateByName,
} from "./common";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import { Prisma } from "@prisma/client";
function validateOranisationName(name: string) {
return name.length > 0;
}
const detailedOrganisation = {
pid: true,
name: true,
event: {
select: {
pid: true,
name: true,
date: true,
description: true,
},
},
} as const;
const basicOrganisation = {
pid: true,
name: true,
event: {
select: {
pid: true,
name: true,
},
},
} as const;
export const _getAllOrganisations = async (res: Response, eventId: string | undefined = undefined) => {
const organisations = await prisma.organisation.findMany({
where: { event: { pid: eventId } },
select: basicOrganisation,
});
return res.status(200).json({
type: "success",
payload: {
organisations,
},
});
};
interface GetAllOrganisationsSearchParams {
eventId?: string;
}
export const getAllOrganisations = async (req: Request<{}, {}, {}, GetAllOrganisationsSearchParams>, res: Response) => {
// TODO: Maybe rename to eventPid
return _getAllOrganisations(res, req.query.eventId);
};
interface GetAllOrganisationsWithParamQueryParams {
eventPid: string;
}
export const getAllOrganisationsWithParam = async (
req: Request<GetAllOrganisationsWithParamQueryParams>,
res: Response
) => {
return _getAllOrganisations(res, req.params.eventPid);
};
interface GetOrganisationQueryParams {
pid: string;
}
export const getOrganisation = async (req: Request<GetOrganisationQueryParams>, res: Response) => {
const { pid } = req.params;
const organisation = await prisma.organisation.findUnique({
where: { pid },
select: detailedOrganisation,
});
if (!organisation) {
return res.status(404).json({
type: "error",
payload: {
message: `The organisation with ID '${pid} could not be found'`,
},
});
}
return res.status(200).json({
type: "success",
payload: {
organisation: {
...organisation,
event: {
...organisation.event,
_links: [{ rel: "self", type: "GET", href: `/api/event/${organisation.event.pid}` }],
},
},
},
});
};
interface CreateOrganisationQueryParams {
eventPid: string;
}
interface CreateOrganisationBody {
name?: string;
}
// at: POST /api/events/:eventPid/organisations
// requires: auth(ELEVATED)
export const createOrganisation = async (
req: Request<CreateOrganisationQueryParams, {}, CreateOrganisationBody>,
res: Response
) => {
return handleCreateByName(
{ type: "organisation", data: { name: req.body.name } },
{ type: "event", id: req.params.eventPid },
req,
res
);
};
interface UpdateOrganisationQueryParams {
pid: string;
}
interface UpdateOrganisationBody {
name?: string;
}
// requires: auth(ELEVATED)
export const updateOrganisation = async (
req: Request<UpdateOrganisationQueryParams, {}, UpdateOrganisationBody>,
res: Response
) => {
if (!req.auth?.isAuthenticated) {
return res.status(500).json(AUTH_ERROR);
}
if (req.auth.permission_level !== "ELEVATED") {
return res.status(403).json(createInsufficientPermissionsError());
}
const { pid } = req.params;
const { name } = req.body;
if (name !== undefined && typeof name !== "string") {
return res.status(400).json(
generateInvalidBodyError({
name: DataType.STRING,
})
);
}
if (name !== undefined && !validateOranisationName(name)) {
return res.status(400).json({
type: "error",
payload: {
message: "The name has to be at least 1 character long",
},
});
}
try {
const organisation = await prisma.organisation.update({
where: { pid },
data: { name },
select: detailedOrganisation,
});
return res.status(200).json({
type: "success",
payload: {
organisation,
},
});
} catch (e) {
if (e instanceof PrismaClientKnownRequestError) {
if (e.code === "P2025") {
return res.status(404).json(generateError(`The organisation with the ID ${pid} could not be found`));
}
} else if (e instanceof PrismaClientUnknownRequestError) {
return res.status(400).send(generateError("Unkonwn error occured. This could be due to malformed IDs"));
}
}
return res.status(500).json(genericError);
};
interface DeleteOrganisationQueryParams {
pid: string;
}
// requires: auth(ELEVATED)
export const deleteOrganisation = async (req: Request<DeleteOrganisationQueryParams>, res: Response) => {
if (!req.auth?.isAuthenticated) {
return res.status(500).json(AUTH_ERROR);
}
if (req.auth.permission_level !== "ELEVATED") {
return res.status(403).json(createInsufficientPermissionsError());
}
const { pid } = req.params;
try {
prisma.organisation.delete({ where: { pid } });
res.status(204).end();
} catch (e) {
if (e instanceof PrismaClientKnownRequestError) {
if ((e.code = "P2025")) {
return res.status(404).json(generateError(`The organisation with the ID ${pid} could not be found`));
}
} else if (e instanceof PrismaClientUnknownRequestError) {
return res.status(400).send(generateError("Unkonwn error occured. This could be due to malformed IDs"));
}
}
return res.status(500).json(genericError);
};
+125
View File
@@ -0,0 +1,125 @@
import { Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import { Request, Response } from "express";
import prisma from "../lib/prisma";
import { DurationSchemaT, parseSchema, PointSchemaT } from "../lib/result_schema";
import NotFoundError from "../Middleware/error/NotFoundError";
import SchemaError from "../Middleware/error/SchemaError";
import {
createInsufficientPermissionsError,
DataType,
generateInvalidBodyError,
NAME_ERROR,
validateName,
} from "./common";
const roleSchema = {
name: true,
schema: true,
discipline: { select: { pid: true, name: true } },
visual: { select: { pid: true, location: true } },
} as const;
export const _getAllRoleSchemas = async (res: Response, disciplinePid: string | undefined) => {
const schemas = await prisma.roleSchema.findMany({
where: { discipline: { pid: disciplinePid } },
select: roleSchema,
});
return res.status(200).json({
type: "success",
payload: {
roleSchemas: schemas,
},
});
};
interface GetAllRoleSchemasSearchParams {
disciplinePid?: string;
}
export const getAllRoleSchemas = async (req: Request<{}, {}, {}, GetAllRoleSchemasSearchParams>, res: Response) => {
return _getAllRoleSchemas(res, req.query.disciplinePid);
};
interface GetAllRoleSchemasWithParamQueryParams {
organisationPid: string;
}
export const getAllRoleSchemasWithParam = async (
req: Request<GetAllRoleSchemasWithParamQueryParams>,
res: Response
) => {
return _getAllRoleSchemas(res, req.params.organisationPid);
};
interface GetRoleSchemaQueryParams {
pid: string;
}
export const getRoleSchema = async (req: Request<GetRoleSchemaQueryParams>, res: Response) => {
const { pid } = req.params;
const schema = await prisma.roleSchema.findUnique({ where: { pid }, select: roleSchema });
if (!schema) {
throw new NotFoundError("roleSchema", pid);
}
return res.status(200).json({
type: "success",
payload: {
roleSchema: {
...schema,
discipline: {
...schema.discipline,
_links: [{ rel: "self", type: "GET", href: `/api/disciplines/${schema.discipline.pid}` }],
},
},
},
});
};
interface CreateRoleSchemaBody {
name?: string;
schema?: any;
}
// at: POST /discipline/:disciplinePid/role-schemas
// requires: auth(ELEVATED)
export const createRoleSchema = async (
req: Request<{ disciplinePid: string }, {}, CreateRoleSchemaBody>,
res: Response
) => {
if (req.auth?.permission_level !== "ELEVATED") {
return res.status(403).json(createInsufficientPermissionsError());
}
const { name, schema: resultSchema } = req.body;
if (typeof name !== "string") {
return res.status(400).json(generateInvalidBodyError({ name: DataType.STRING, schema: DataType.RESULT_SCHEMA }));
}
if (!validateName(name)) {
return res.status(400).json(NAME_ERROR);
}
// Validate the result schema (Errors should be handled by the default error handler)
const validatedSchema = parseSchema(resultSchema);
try {
const schema = await prisma.roleSchema.create({
data: { name, schema: validatedSchema, discipline: { connect: { pid: req.params.disciplinePid } } },
select: roleSchema,
});
return res.status(201).json({ type: "success", payload: { roleSchema: schema } });
} catch (e) {
if (e instanceof PrismaClientKnownRequestError && e.code === "P2025") {
throw new NotFoundError("discipline", req.params.disciplinePid);
}
throw e;
}
};
+56
View File
@@ -0,0 +1,56 @@
import { Request, Response } from "express";
import prisma from "../lib/prisma";
import { mailClient } from "../lib/redis";
import { nanoid } from "nanoid";
import { verificationMail } from "../lib/mail";
export const register = async (req: Request, res: Response) => {
//TODO: Implemnt user endpoint and use following code to send verification mail
const user = {
//Supposed to come from database
id: "10",
email: "[email protected]",
};
const usid = nanoid();
(await mailClient).set(usid, user.id);
verificationMail(user.email, "eventname", usid);
//Send status code
};
export const verifyEmail = async (req: Request, res: Response) => {
const { code } = req.body || {};
if (!(typeof code === "string")) {
return res.status(400).json({
type: "error",
payload: {
message: "Invalid Request parameter",
},
});
}
const acc = await mailClient.get(code);
if (!acc) {
return res.status(404).json({
type: "error",
payload: {
message: "Invalid token. It might be expired",
},
});
}
prisma.participant.update({
where: {
id: parseInt(acc),
},
data: {
verified: true,
},
});
};
+90
View File
@@ -0,0 +1,90 @@
/// <reference path="../../custom.d.ts" />
import { NextFunction, Request, Response } from "express";
import { AuthJWTPayload } from "../../Controllers/admin_auth.controller";
import { authClient } from "../../lib/redis";
import jwt, { JsonWebTokenError, JwtPayload } from "jsonwebtoken";
import prisma from "../../lib/prisma";
const JWT_SECRET = process.env.JWT_SECRET || "secret";
const verifyAuthorizationFormat = (authorization: string) => /^Bearer .+$/.test(authorization);
const getBearerToken = (authorization: string) => authorization.slice(7);
export const requireAuthentication = async (req: Request, res: Response, next: NextFunction) => {
const { authorization } = req.headers;
if (!authorization) {
return res.status(403).send({
type: "error",
payload: {
message: "The requeset did not include the Authorization header",
},
});
}
if (!verifyAuthorizationFormat(authorization)) {
return res.status(400).send({
type: "error",
payload: {
message: "Malformed Authorization header",
format: "Bearer <token>",
},
});
}
let token_payload_: string | JwtPayload;
try {
token_payload_ = jwt.verify(getBearerToken(authorization), JWT_SECRET);
} catch (e) {
if (e instanceof JsonWebTokenError) {
return res.status(403).json({
type: "error",
payload: {
message: "Token could not be verified; It might be expired",
},
});
}
throw e;
}
const token_payload = token_payload_ as AuthJWTPayload;
const { pid, revision } = token_payload;
let db_revision = await authClient.get(pid);
if (db_revision === null) {
// Load the revision ID from the main DB and cache it in redis
const user = await prisma.admin.findUnique({ where: { pid }, select: { revision: true } });
if (user) {
db_revision = user.revision.toISOString();
await authClient.set(pid, db_revision);
}
}
if (revision !== db_revision || !revision || !db_revision) {
return res.status(403).json({
type: "error",
payload: {
message: "Token could not be verified; It might be expired",
},
});
}
req.auth = {
isAuthenticated: true,
pid: token_payload.pid,
name: token_payload.name,
permission_level: token_payload.permission_level,
groups: token_payload.groups,
revision: token_payload.revision,
};
next();
};
+11
View File
@@ -0,0 +1,11 @@
import { NextFunction, Request, Response } from "express";
import logger from "../error/logger";
export default function debugLogger(req: Request, res: Response, next: NextFunction) {
// Only log when in development mode
if (process.env.NODE_ENV === "development") {
logger.debug(`Request to: ${req.url}`);
}
next();
}
+17
View File
@@ -0,0 +1,17 @@
export default class ForwardableError extends Error {
// If in different context
private readonly __id = "CUSTOM_ERROR";
protected readonly __oid?: string;
public readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
static isForwardableError(error: any): error is ForwardableError {
return error.__id === "CUSTOM_ERROR";
}
}
+13
View File
@@ -0,0 +1,13 @@
import ForwardableError from "./ForwardableError";
export default class NotFoundError extends ForwardableError {
protected __oid = "NOT_FOUND_ERROR";
constructor(resource?: string, pid?: string) {
super(404, `The requested ${resource ?? "resource"}${pid ? ` with PID '${pid}'` : ""} could not be found!`);
}
static isNotFoundError(err: any): err is NotFoundError {
return err.__oid === "NOT_FOUND_ERROR";
}
}
+13
View File
@@ -0,0 +1,13 @@
import ForwardableError from "./ForwardableError";
export default class SchemaError extends ForwardableError {
protected __oid = "SCHEMA_ERROR";
constructor(message: string) {
super(400, message);
}
public static isSchemaError(err: any): err is SchemaError {
return err.__oid === "SCHEMA_ERROR";
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Request, Response } from "express";
// Only called when no other route matches
export function notFoundHandler(req: Request, res: Response) {
return res.status(404).json({
type: "error",
payload: {
message: `The ${req.method} HTTP method is implemented for '${req.path}'`,
_links: [
{
rel: "root",
href: "/api",
},
],
},
});
}
export function rootHandler(req: Request, res: Response) {
return res.status(200).json({
type: "success",
payload: {
message: "Detleph event API",
detail: "This is the API for the Detleph event system",
},
});
}
+82
View File
@@ -0,0 +1,82 @@
import { PrismaClientUnknownRequestError } from "@prisma/client/runtime";
import { NextFunction, Request, Response } from "express";
import ForwardableError from "./ForwardableError";
import logger from "./logger";
const env = process.env.NODE_ENV || "production";
type ErrorHandler = (err: any, req: Request, res: Response) => boolean;
const customHandlers: ErrorHandler[] = [];
export function addCustomHandler(handler: ErrorHandler) {
customHandlers.push(handler);
}
export function removeCustomHandler(handler: ErrorHandler): Boolean {
const index = customHandlers.findIndex((h) => h === handler);
if (index < 0) {
return false;
}
customHandlers.splice(index);
return true;
}
export default function defaultErrorHandler(err: any, req: Request, res: Response, next: NextFunction) {
for (const handler of customHandlers) {
// Run custom handler
if (handler(err, req, res)) {
return;
}
}
if (ForwardableError.isForwardableError(err)) {
return res.status(err.status).json({
type: "error",
payload: {
message: err.message,
...(env === "development"
? {
stack: err.stack,
}
: {}),
},
});
}
if (err instanceof PrismaClientUnknownRequestError) {
logger.warning(err);
return res.status(404).json({
type: "error",
payload: {
message: "An unknown error occured. This could be due to malformed IDs",
...(env === "development"
? {
prisma: err.message,
stack: err.stack,
}
: {}),
},
});
}
logger.error("--- Unhandled error ---");
logger.error(err);
return res.status(err.status).json({
type: "error",
payload: {
message: err.message,
...(env === "development"
? {
notice: "This error was not caught by any handler, please add handling!",
stack: err.stack,
}
: {}),
},
});
}
+41
View File
@@ -0,0 +1,41 @@
import winston, { createLogger } from "winston";
const {
format: { printf, colorize, combine, timestamp, json, errors, prettyPrint },
} = winston;
const defaultJsonFormat = combine(errors({ stack: true }), timestamp(), json({ space: 2 }));
const customCLIFormat = printf(({ level, message, label, timestamp, stack }) => {
let output = `${level}${stack ? `(1/2:message)` : ""} at ${timestamp}${label ? ` (#${label})` : ""}: ${message}${
stack ? "\n" : ""
}`;
if (stack) {
output += `\n${level}(2/2:stack)${label ? ` (#${label})` : ""}: ${stack}\n\n`;
}
return output;
});
export default createLogger({
levels: winston.config.syslog.levels,
format: combine(errors({ stack: true }), timestamp()),
transports: [
new winston.transports.Console({
level: "debug",
format: combine(timestamp(), colorize(), customCLIFormat),
}),
new winston.transports.File({
filename: "/app/error.log",
level: "error",
format: defaultJsonFormat,
}),
new winston.transports.File({
level: "debug",
filename: "/app/debug.log",
format: defaultJsonFormat,
silent: !(process.env.NODE_ENV === "development"), // Silent when not in development
}),
],
});
+14
View File
@@ -0,0 +1,14 @@
import express from "express";
import { getAllAdmins, createAdmin, updateOwnPassword, updateForeignPassword } from "../Controllers/admin.controller";
import { requireAuthentication } from "../Middleware/auth/auth";
const router = express.Router();
router.get("/", requireAuthentication, getAllAdmins);
router.post("/", requireAuthentication, createAdmin);
router.put("/current/password", requireAuthentication, updateOwnPassword);
router.put<"/:pid/password", { pid: string }>("/:pid/password", requireAuthentication, updateForeignPassword);
export default router;
+8
View File
@@ -0,0 +1,8 @@
import express from "express";
import { authenticateUser } from "../Controllers/admin_auth.controller";
const router = express.Router();
router.post("/", authenticateUser);
export default router;
+19
View File
@@ -0,0 +1,19 @@
import express from "express";
import eventRouter from "./event.routes";
import {
createDiscipline,
deleteDiscipline,
getAllDisciplines,
getDiscipline,
} from "../Controllers/discipline.controller";
import { requireAuthentication } from "../Middleware/auth/auth";
const router = express.Router();
router.get("/", getAllDisciplines); // TODO: Optional auth
router.get("/:pid", getDiscipline);
router.delete<"/:pid", { pid: string }>("/:pid", requireAuthentication, deleteDiscipline);
eventRouter.post("/:eventPid/disciplines", requireAuthentication, createDiscipline);
export default router;
+5 -2
View File
@@ -1,9 +1,12 @@
import Express from "express";
import { addEvent, getAllEvents } from "../Controllers/event.controller";
import { addEvent, getAllEvents, getEvent } from "../Controllers/event.controller";
import { requireAuthentication } from "../Middleware/auth/auth";
const router = Express.Router();
router.get("/", getAllEvents);
router.post("/", addEvent);
router.get("/:eventId", getEvent);
router.post("/", requireAuthentication, addEvent);
export default router;
+21
View File
@@ -0,0 +1,21 @@
import express from "express";
import {
createGroup,
deleteGroup,
getAllGroups,
getAllGroupsWithParam,
getGroup,
} from "../Controllers/group.controllers";
import { requireAuthentication } from "../Middleware/auth/auth";
import organisationRouter from "./organisation.routes";
const router = express.Router();
router.get("/", getAllGroups);
router.get("/:pid", getGroup);
router.delete<"/:pid", { pid: string }>("/:pid", requireAuthentication, deleteGroup);
organisationRouter.get("/:organisationPid/groups", getAllGroupsWithParam);
organisationRouter.post("/:organisationPid/groups", requireAuthentication, createGroup);
export default router;
+27
View File
@@ -0,0 +1,27 @@
import express from "express";
import eventRouter from "./event.routes";
import {
createOrganisation,
deleteOrganisation,
getAllOrganisations,
getAllOrganisationsWithParam,
getOrganisation,
updateOrganisation,
} from "../Controllers/organisation.controller";
import { requireAuthentication } from "../Middleware/auth/auth";
const router = express.Router();
router.get("/", getAllOrganisations);
router.get("/:pid", getOrganisation);
router.put<"/:pid", { pid: string }>("/:pid", requireAuthentication, updateOrganisation);
router.delete<"/:pid", { pid: string }>("/:pid", requireAuthentication, deleteOrganisation);
eventRouter.get("/:eventPid/organisations", getAllOrganisationsWithParam);
eventRouter.post<"/:eventPid/organisations", { eventPid: string }>(
"/:eventPid/organisations",
requireAuthentication,
createOrganisation
);
export default router;
+19
View File
@@ -0,0 +1,19 @@
import express from "express";
import disciplineRouter from "./discipline.routes";
import {
createRoleSchema,
getAllRoleSchemas,
getAllRoleSchemasWithParam,
getRoleSchema,
} from "../Controllers/role_schema.controller";
import { requireAuthentication } from "../Middleware/auth/auth";
const router = express.Router();
router.get("/", getAllRoleSchemas);
router.get("/:pid", getRoleSchema);
disciplineRouter.get("/:disciplinePid/role-schemas", getAllRoleSchemasWithParam);
disciplineRouter.post("/:disciplinePid/role-schemas", requireAuthentication, createRoleSchema);
export default router;
+2 -1
View File
@@ -28,7 +28,7 @@ describe("events", () => {
it("should return an array with all the objects", (done) => {
prisma.event
.create({
data: { date: new Date(Date.now()).toISOString(), name: "yes" },
data: { date: new Date(Date.now()).toISOString(), name: "yes", description: "Hallo hansi" },
})
.then((a) => {
chai
@@ -62,6 +62,7 @@ describe("events", () => {
res.body.should.have.property("pid");
res.body.should.have.property("name");
res.body.should.have.property("date");
res.body.should.have.property("description");
done();
});
});
+17
View File
@@ -0,0 +1,17 @@
process.env.NODE_ENV = "test";
import chai, { expect } from "chai";
import chaiaspromised from "chai-as-promised";
import mail from "../lib/mail";
chai.use(chaiaspromised);
const should = chai.should();
describe("mail", () => {
it("should succesfully send a email", async () => {
//NOTE THESE TESTS ONLY WORK WITH DEV BEING TRUE
const info = await mail.sendMail('"Chai Testing suite" <[email protected]>', "[email protected]", "Hello", "Test");
info.rejected.length.should.eq(0);
info.accepted.length.should.eq(1);
});
});
+80 -2
View File
@@ -1,28 +1,106 @@
import express from "express";
import express, { ErrorRequestHandler, NextFunction, Request, Response } from "express";
import prisma from "./lib/prisma";
import eventRouter from "./Routes/event.routes";
import adminAuthRouter from "./Routes/admin_auth.routes";
import argon2 from "argon2";
import cors from "cors";
import adminRouter from "./Routes/admin.routes";
import organisationRouter from "./Routes/organisation.routes";
import groupRouter from "./Routes/group.routes";
import disciplineRouter from "./Routes/discipline.routes";
import roleSchemaRouter from "./Routes/role_schema.routes";
import defaultErrorHandler from "./Middleware/error/handler";
import logger from "./Middleware/error/logger";
import debugLogger from "./Middleware/debug/logger";
import { notFoundHandler, rootHandler } from "./Middleware/error/defaultRoutes";
// Set up async error handling
require("express-async-errors");
require("dotenv").config(); // Load dotenv config
const app = express();
async function main() {
if (process.env.NODE_ENV === "development") {
logger.info("Using development mode");
logger.warning(
"This mode should not be used in any production-near environment as it is significantly less secure than the production mode"
);
// TODO: How should you login to the prod server by default? Maybe random password?
await prisma.admin.upsert({
where: { id: 1 },
create: {
name: "admin",
password: await argon2.hash("test", { type: argon2.argon2id }),
permission_level: "ELEVATED",
},
update: {},
});
// Allow all CORS requests
app.use(cors());
} else {
logger.info("Using production mode");
// Configure cors
app.use(
cors({
origin: process.env.ALLOW_ORIGIN,
allowedHeaders: ["Content-Type", "Authorization"],
preflightContinue: false,
methods: ["GET", "PUT", "PATCH", "POST", "DELETE"],
optionsSuccessStatus: 204,
})
);
}
// Todo: Everything
// Bodyparser and urlencoded to parse post request bodies
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(debugLogger);
// Admin authentication endpoints
app.use("/api/authentication", adminAuthRouter);
// All API endpoints are behind /api/...
app.use("/api/events", eventRouter);
app.use("/api/admins", adminRouter);
app.use("/api/organisations", organisationRouter);
app.use("/api/groups", groupRouter);
app.use("/api/disciplines", disciplineRouter);
app.use("/api/role-schemas", roleSchemaRouter);
app.get("/", rootHandler);
// Error handling
app.use(defaultErrorHandler); // Not working
app.use(notFoundHandler);
app.listen(process.env.PORT, () => {
console.log(`Listening on Port: ${process.env.PORT}`);
logger.info(`Listening on port ${process.env.PORT}`);
});
logger.info("Server started");
process.on("exit", () => {
logger.info("Server stopping...");
});
}
main()
.catch((e) => {
logger.crit(e);
throw e;
})
.finally(async () => {
+7
View File
@@ -0,0 +1,7 @@
import { AuthJWTPayload } from "./Controllers/admin_auth.controller";
declare module "express-serve-static-core" {
interface Request {
auth?: AuthJWTPayload & { isAuthenticated: boolean };
}
}
+69
View File
@@ -0,0 +1,69 @@
import { writeFile } from "fs";
import Handlebars, { template } from "handlebars";
import { mailClient } from "./redis";
import nodemailer from "nodemailer";
import SMTPTransport from "nodemailer/lib/smtp-transport";
import mjml from "./mjml";
import { randomUUID } from "crypto";
export let mailAccount = { user: process.env.MAILUSER + "@mail." + process.env.DOMAIN, pass: process.env.MAILPASSWORD };
let transporter =
process.env.DEV == "true" || process.env.DOMAIN == undefined
? (async () => {
mailAccount = await nodemailer.createTestAccount();
if (process.env.NODE_ENV != "test") {
console.log(mailAccount);
}
return nodemailer.createTransport({
host: "smtp.ethereal.email",
port: 587,
secure: false, // true for 465, false for other ports
auth: {
user: mailAccount.user, // generated ethereal user
pass: mailAccount.pass, // generated ethereal password
},
});
})()
: nodemailer.createTransport(
new SMTPTransport({
host: "localhost",
port: 587,
secure: false,
auth: {
user: mailAccount.user,
pass: mailAccount.pass,
},
tls: {
rejectUnauthorized: false,
secureProtocol: "TLSv1_method",
},
})
);
const sendMail = async (from: string, to: string, subject: string, text?: string, html?: string) => {
return await (
await transporter
).sendMail({
from: from,
to: to,
subject: subject,
text: text,
html: html,
});
};
export const verificationMail = async (to: string, eventName: string, verificationLink: string) => {
const raw = mjml.getTemplate("emailVerification");
//TODO: Replace other handlebars with final values
const message = Handlebars.compile(raw);
const data = { eventName, verificationLink };
const compiled = message(data);
sendMail(mailAccount.user, to, "Verify Email", undefined, compiled);
};
export default { sendMail };
+33
View File
@@ -0,0 +1,33 @@
import fs from "fs";
import path from "path";
import mjml from "mjml";
const template_folder = "/app/resources/email/templates/";
const compileTemplates = () => {
console.log("Compiling templates: ");
fs.readdir(template_folder, (err, files) => {
if (err) console.log(err);
files.forEach((file) => {
if (path.extname(file) !== ".mjml") return;
let content = fs.readFileSync(template_folder + file);
let mjmlres = mjml(content.toString());
let hbs = template_folder + file.replace(".mjml", ".hbs");
fs.writeFileSync(hbs, mjmlres.html);
});
});
};
compileTemplates(); // This will compile all templates when this file is first included
const getTemplate = (name: string): string => {
let file = "";
file = fs.readFileSync(path.join(template_folder, name + ".hbs")).toString();
return file;
};
export default { getTemplate };
+37
View File
@@ -0,0 +1,37 @@
import redis from "redis";
import { promisify } from "util";
const REDIS_INDICES = {
auth: 0,
mail: 1,
};
const REDIS_HOST = process.env.REDIS_HOST || "redis";
const REDIS_PORT = process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : 6379;
// TODO: Extend
function createAsyncClient(client: redis.RedisClient) {
return {
get: promisify(client.get).bind(client),
set: promisify(client.set).bind(client),
};
}
// ----------------- //
// Redis auth client //
// ----------------- //
const auth = redis.createClient({ host: REDIS_HOST, port: REDIS_PORT });
auth.select(REDIS_INDICES.auth);
// ----------------- //
// Redis mail client //
// ----------------- //
const mail = redis.createClient({ host: REDIS_HOST, port: REDIS_PORT });
mail.select(REDIS_INDICES.mail);
export const authClient = createAsyncClient(auth);
export const mailClient = createAsyncClient(mail);
+65
View File
@@ -0,0 +1,65 @@
import { Schema, z, ZodError } from "zod";
import SchemaError from "../Middleware/error/SchemaError";
// -- Schema definitions --
const SchemaVersion = z.enum(["1.0"]);
const TimeUnit = z.enum(["days", "hours", "minutes", "seconds", "milliseconds"]);
const DurationSchema = z
.object({
type: z.literal("duration"),
min: z.number().int({ message: "min must be an integer (relative to smallestUnit)" }),
max: z.number().int({ message: "max must be an integer (relative to smallestUnit)" }),
smallestUnit: TimeUnit,
higherIsBetter: z.boolean(),
})
.refine(({ min, max }) => min < max, { message: "min must be smaller than max" });
const PointSchema = z
.object({
type: z.literal("points"),
min: z.number(),
max: z.number(),
step: z.number(),
start: z.number(),
unit: z.string(),
unitSign: z.string(),
higherIsBetter: z.boolean(),
})
.refine(({ min, max }) => min < max, { message: "min must be smaller than max" })
.refine(({ min, max, start }) => min <= start && max >= start, {
message: "start must be larger than or equal to min and smaller than or equal to max",
});
export type DurationSchemaT = z.infer<typeof DurationSchema>;
export type PointSchemaT = z.infer<typeof PointSchema>;
// -- Parser --
export function parseSchema(schema: any): DurationSchemaT | PointSchemaT {
try {
if (schema.type === "duration") {
return DurationSchema.parse(schema);
} else if (schema.type === "points") {
return PointSchema.parse(schema);
}
} catch (e) {
if (e instanceof ZodError) {
const issue = e.issues.at(0);
if (issue) {
throw new SchemaError(`Error with the schema: ${issue.path ? `${issue.path}:` : ""} ${issue.message}`);
}
throw new SchemaError("Unknown error occured while validating the schema");
}
throw e;
}
throw new SchemaError("Error with the schema: type must be either 'duration' or 'points'");
}