base structure

transfered from ff admin
This commit is contained in:
Julian Krauser 2025-02-16 10:48:12 +01:00
parent e37c4e90c4
commit 1d56c7f798
101 changed files with 9773 additions and 2 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
# NodeJs
node_modules/
dist/
.git/
files/
.env

47
.env.example Normal file
View file

@ -0,0 +1,47 @@
DB_TYPE = (mysql|sqlite|postgres) # default ist mysql
## BSP für mysql
DB_PORT = 3306
DB_HOST = database_host
DB_NAME = database_name
DB_USERNAME = database_username
DB_PASSWORD = database_password
## BSP für postgres
DB_PORT = 5432
DB_HOST = database_host
DB_NAME = database_name
DB_USERNAME = database_username
DB_PASSWORD = database_password
## BSP für sqlite
DB_HOST = filename.db
SERVER_PORT = portnumber
JWT_SECRET = ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890 # besitzt default
JWT_EXPIRATION = [0-9]*(y|d|h|m|s) # default ist 15m
REFRESH_EXPIRATION = [0-9]*(y|d|h|m|s) # default ist 1d
PWA_REFRESH_EXPIRATION = [0-9]*(y|d|h|m|s) # default ist 5d
MAIL_USERNAME = mail_username
MAIL_PASSWORD = mail_password
MAIL_HOST = mail_hoststring
MAIL_PORT = mail_portnumber # default ist 587
MAIL_SECURE = (true|false) # true für port 465, false für anders gewählten port
CLUB_NAME = clubname #default FF Admin
CLUB_WEBSITE = https://my-club-website-url #optional, muss aber mit http:// oder https:// beginnen
BACKUP_INTERVAL = number of days (min 1) # default 1
BACKUP_COPIES = number of parallel copies # default 7
BACKUP_AUTO_RESTORE = (true|false) # default ist true
USE_SECURITY_STRICT_LIMIT = (true|false) # default ist true
SECURITY_STRICT_LIMIT_WINDOW = [0-9]*(y|d|h|m|s) # default ist 15m
SECURITY_STRICT_LIMIT_REQUEST_COUNT = strict_request_count # default ist 15
USE_SECURITY_LIMIT = (true|false) # default ist true
SECURITY_LIMIT_WINDOW = [0-9]*(y|d|h|m|s) # default ist 1m
SECURITY_LIMIT_REQUEST_COUNT = request_count # default ist 500
TRUST_PROXY = <boolean|number|ip|ip1,ip2,...> # wenn leer, wird dieser Wert nicht angewendet.

4
.gitignore vendored
View file

@ -130,3 +130,7 @@ dist
.yarn/install-state.gz
.pnp.*
files
.idea
*.db

5
.prettierrc Normal file
View file

@ -0,0 +1,5 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 120
}

46
Dockerfile Normal file
View file

@ -0,0 +1,46 @@
FROM node:18-alpine AS build
RUN apk add --no-cache \
chromium \
nss \
freetype \
harfbuzz \
ca-certificates \
ttf-freefont
WORKDIR /app
COPY package*.json ./
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
RUN npm install
COPY . /app
RUN npm run build
FROM node:18-alpine AS prod
RUN apk add --no-cache \
chromium \
nss \
freetype \
harfbuzz \
ca-certificates \
ttf-freefont
WORKDIR /app
RUN mkdir -p /app/files
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
COPY --from=build /app/src/templates /app/src/templates
COPY --from=build /app/dist /app/dist
COPY --from=build /app/node_modules /app/node_modules
COPY --from=build /app/package.json /app/package.json
EXPOSE 5000
CMD [ "npm", "run", "start" ]

119
README.md
View file

@ -1,3 +1,118 @@
# ff-operation-server
# ff-admin-server
Einsatzverwaltung für Feuerwehren und Vereine.
Administration für Feuerwehren und Vereine (Backend).
## Einleitung
Dieses Projekt, `ff-admin-server`, ist das Backend zur Verwaltung von Mitgliederdaten. Die zugehörige Webapp ist im Repository [ff-admin-ui](https://forgejo.jk-effects.cloud/Ehrenamt/ff-admin) zu finden.
Eine Demo zusammen mit der `ff-admin` finden Sie unter [https://admin-demo.ff-admin.de](https://admin-demo.ff-admin.de).
## Installation
Das Image exposed nur den Port 5000. Die Env-Variable SERVER_PORT kann nur im lokal ausführenden dev-Kontext verwendet werden.
### Docker Compose Setup
Um den Container hochzufahren, erstellen Sie eine `docker-compose.yml` Datei mit folgendem Inhalt:
```yaml
version: "3"
services:
ff-admin-server:
image: docker.registry.jk-effects.cloud/ehrenamt/ff-admin/server:latest
container_name: ff_member_administration_server
restart: unless-stopped
environment:
- DB_TYPE=<mysql|sqlite|postgres> # default ist auf mysql gesetzt
- DB_HOST=ff-db
- DB_PORT=<number> # default ist auf 3306 gesetzt
- DB_NAME=ffadmin
- DB_USERNAME=administration_backend
- DB_PASSWORD=<dbuserpasswd>
- JWT_SECRET=<tobemodified>
- JWT_EXPIRATION=<number[m|d] - bsp.:15m> # default ist auf 15m gesetzt
- REFRESH_EXPIRATION=<number[m|d] - bsp.:1d> # default ist auf 1d gesetzt
- PWA_REFRESH_EXPIRATION=<number[m|d] - bsp.:5d> # default ist auf 5d gesetzt
- MAIL_USERNAME=<mailadress|username>
- MAIL_PASSWORD=<password>
- MAIL_HOST=<url>
- MAIL_PORT=<port> # default ist auf 587 gesetzt
- MAIL_SECURE=<boolean> # default ist auf false gesetzt
- CLUB_NAME=<tobemodified> # default ist auf FF Admin gesetzt
- CLUB_WEBSITE=<tobemodified>
- BACKUP_INTERVAL=<number of days (min. 1)> # alle x Tage, sonst keine
- BACKUP_COPIES=<number of parallel copies> # Anzahl parallel bestehender Backups
- BACKUP_AUTO_RESTORE=<boolean> # default ist auf true gesetzt
- USE_SECURITY_STRICT_LIMIT = (true|false) # default ist true
- SECURITY_STRICT_LIMIT_WINDOW = [0-9]*(y|d|h|m|s) # default ist 15
- SECURITY_STRICT_LIMIT_REQUEST_COUNT = strict_request_count # default ist 15
- USE_SECURITY_LIMIT = (true|false) # default ist true
- SECURITY_LIMIT_WINDOW = [0-9]*(y|d|h|m|s) # default ist 1m
- SECURITY_LIMIT_REQUEST_COUNT = request_count # default ist 500
- TRUST_PROXY = <boolean|number|ip|ip1,ip2,...> # wenn leer, wird dieser Wert nicht angewendet.
volumes:
- <volume|local path>:/app/files
networks:
- ff_internal
depends_on:
- ff-db
ff-db:
image: mariadb:11.2
container_name: ff_db
restart: unless-stopped
environment:
- MYSQL_DATABASE=ffadmin
- MYSQL_USER=administration_backend
- MYSQL_PASSWORD=<dbuserpasswd>
- MYSQL_ROOT_PASSWORD=<dbrootpasswd>
volumes:
- <volume|local path>:/var/lib/mysql
networks:
- ff_internal
# OR
image: postgres:16
container_name: ff_db
restart: unless-stopped
environment:
- POSTGRES_DB=ffadmin
- POSTGRES_USER=administration_backend
- POSTGRES_PASSWORD=<dbuserpasswd>
volumes:
- <volume|local path>:/var/lib/postgresql/data
networks:
- ff_internal
networks:
ff_internal:
```
Die Verwendung von postgres wird aufgrund des Verhaltens bei Datenbank-Update-Fehlern empfohlen.
Die Verwendung von SQLite wird nur für die Entwicklung oder lokale Tests empfohlen.
Führen Sie dann den folgenden Befehl im Verzeichnis der compose-Datei aus, um den Container zu starten:
```sh
docker-compose up -d
```
### Manuelle Installation
Klonen Sie dieses Repository und installieren Sie die Abhängigkeiten:
```sh
git clone https://forgejo.jk-effects.cloud/Ehrenamt/ff-admin-server.git
cd ff-admin-server
npm install
npm run build
npm run start
```
## Fragen und Wünsche
Bei Fragen, Anregungen oder Wünschen können Sie sich gerne melden.\
Wir freuen uns über Ihr Feedback und helfen Ihnen gerne weiter.\
Schreiben Sie dafür eine Mail an julian.krauser@jk-effects.com.

4829
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

74
package.json Normal file
View file

@ -0,0 +1,74 @@
{
"name": "ff-operation-server",
"version": "0.0.0",
"description": "Feuerwehr/Verein Einsatzverwaltung Server",
"main": "dist/index.js",
"scripts": {
"start_ts": "ts-node src/index.ts",
"typeorm": "typeorm-ts-node-commonjs",
"migrate": "set DBMODE=migration && npx typeorm-ts-node-commonjs migration:generate ./src/migrations/%npm_config_name% -d ./src/data-source.ts",
"synchronize-database": "set DBMODE=update-database && npx typeorm-ts-node-commonjs schema:sync -d ./src/data-source.ts",
"update-database": "set DBMODE=update-database && npx typeorm-ts-node-commonjs migration:run -d ./src/data-source.ts",
"revert-database": "set DBMODE=update-database && npx typeorm-ts-node-commonjs migration:revert -d ./src/data-source.ts",
"build": "tsc",
"start": "node .",
"dev": "npm run build && set NODE_ENV=development && npm run start"
},
"repository": {
"type": "git",
"url": "https://forgejo.jk-effects.cloud/Ehrenamt/ff-operation-server.git"
},
"keywords": [
"Feuerwehr"
],
"author": "JK Effects",
"license": "AGPL-3.0-only",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^5.0.0-beta.3",
"express-rate-limit": "^7.5.0",
"express-validator": "^7.2.1",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
"ics": "^3.8.1",
"ip": "^2.0.1",
"jsonwebtoken": "^9.0.2",
"lodash.uniqby": "^4.7.0",
"moment": "^2.30.1",
"morgan": "^1.10.0",
"ms": "^2.1.3",
"multer": "^1.4.5-lts.1",
"mysql": "^2.18.1",
"node-schedule": "^2.1.1",
"nodemailer": "^6.10.0",
"pg": "^8.13.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rss-parser": "^3.13.0",
"socket.io": "^4.7.5",
"speakeasy": "^2.0.0",
"sqlite3": "^5.1.7",
"typeorm": "^0.3.20",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/cors": "^2.8.14",
"@types/express": "^4.17.17",
"@types/ip": "^1.1.3",
"@types/jsonwebtoken": "^9.0.6",
"@types/lodash.uniqby": "^4.7.9",
"@types/morgan": "^1.9.9",
"@types/ms": "^0.7.34",
"@types/multer": "^1.4.12",
"@types/mysql": "^2.15.21",
"@types/node": "^16.18.41",
"@types/node-schedule": "^2.1.6",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "~1.5.5",
"@types/speakeasy": "^2.0.10",
"@types/uuid": "^9.0.2",
"ts-node": "10.7.0",
"typescript": "^4.5.2"
}
}

View file

@ -0,0 +1,22 @@
export interface CreateMemberCommand {
salutationId: number;
firstname: string;
lastname: string;
nameaffix: string;
birthdate: Date;
internalId?: string;
}
export interface UpdateMemberCommand {
id: string;
salutationId: number;
firstname: string;
lastname: string;
nameaffix: string;
birthdate: Date;
internalId?: string;
}
export interface DeleteMemberCommand {
id: string;
}

View file

@ -0,0 +1,70 @@
import { dataSource } from "../../../data-source";
import { member } from "../../../entity/configuration/member";
import DatabaseActionException from "../../../exceptions/databaseActionException";
import { CreateMemberCommand, DeleteMemberCommand, UpdateMemberCommand } from "./memberCommand";
export default abstract class MemberCommandHandler {
/**
* @description create member
* @param {CreateMemberCommand} createMember
* @returns {Promise<number>}
*/
static async create(createMember: CreateMemberCommand): Promise<number> {
return await dataSource
.createQueryBuilder()
.insert()
.into(member)
.values({
firstname: createMember.firstname,
lastname: createMember.lastname,
nameaffix: createMember.nameaffix,
})
.execute()
.then((result) => {
return result.identifiers[0].id;
})
.catch((err) => {
throw new DatabaseActionException("CREATE", "member", err);
});
}
/**
* @description update member
* @param {UpdateMemberCommand} updateMember
* @returns {Promise<void>}
*/
static async update(updateMember: UpdateMemberCommand): Promise<void> {
return await dataSource
.createQueryBuilder()
.update(member)
.set({
firstname: updateMember.firstname,
lastname: updateMember.lastname,
nameaffix: updateMember.nameaffix,
})
.where("id = :id", { id: updateMember.id })
.execute()
.then(() => {})
.catch((err) => {
throw new DatabaseActionException("UPDATE", "member", err);
});
}
/**
* @description delete member
* @param {DeleteMemberCommand} deleteMember
* @returns {Promise<void>}
*/
static async delete(deleteMember: DeleteMemberCommand): Promise<void> {
return await dataSource
.createQueryBuilder()
.delete()
.from(member)
.where("id = :id", { id: deleteMember.id })
.execute()
.then(() => {})
.catch((err) => {
throw new DatabaseActionException("DELETE", "member", err);
});
}
}

View file

@ -0,0 +1,12 @@
export interface CreateRoleCommand {
role: string;
}
export interface UpdateRoleCommand {
id: number;
role: string;
}
export interface DeleteRoleCommand {
id: number;
}

View file

@ -0,0 +1,67 @@
import { dataSource } from "../../../data-source";
import { role } from "../../../entity/management/role";
import DatabaseActionException from "../../../exceptions/databaseActionException";
import InternalException from "../../../exceptions/internalException";
import { CreateRoleCommand, DeleteRoleCommand, UpdateRoleCommand } from "./roleCommand";
export default abstract class RoleCommandHandler {
/**
* @description create role
* @param {CreateRoleCommand} createRole
* @returns {Promise<number>}
*/
static async create(createRole: CreateRoleCommand): Promise<number> {
return await dataSource
.createQueryBuilder()
.insert()
.into(role)
.values({
role: createRole.role,
})
.execute()
.then((result) => {
return result.identifiers[0].id;
})
.catch((err) => {
throw new DatabaseActionException("CREATE", "role", err);
});
}
/**
* @description update role
* @param {UpdateRoleCommand} updateRole
* @returns {Promise<void>}
*/
static async update(updateRole: UpdateRoleCommand): Promise<void> {
return await dataSource
.createQueryBuilder()
.update(role)
.set({
role: updateRole.role,
})
.where("id = :id", { id: updateRole.id })
.execute()
.then(() => {})
.catch((err) => {
throw new DatabaseActionException("UPDATE", "role", err);
});
}
/**
* @description delete role
* @param {DeleteRoleCommand} deleteRole
* @returns {Promise<void>}
*/
static async delete(deleteRole: DeleteRoleCommand): Promise<void> {
return await dataSource
.createQueryBuilder()
.delete()
.from(role)
.where("id = :id", { id: deleteRole.id })
.execute()
.then(() => {})
.catch((err) => {
throw new DatabaseActionException("DELETE", "role", err);
});
}
}

View file

@ -0,0 +1,16 @@
import { PermissionString } from "../../../type/permissionTypes";
export interface CreateRolePermissionCommand {
permission: PermissionString;
roleId: number;
}
export interface DeleteRolePermissionCommand {
permission: PermissionString;
roleId: number;
}
export interface UpdateRolePermissionsCommand {
roleId: number;
permissions: Array<PermissionString>;
}

View file

@ -0,0 +1,75 @@
import { DeleteResult, EntityManager, InsertResult } from "typeorm";
import { dataSource } from "../../../data-source";
import { rolePermission } from "../../../entity/management/role_permission";
import InternalException from "../../../exceptions/internalException";
import RoleService from "../../../service/management/roleService";
import {
CreateRolePermissionCommand,
DeleteRolePermissionCommand,
UpdateRolePermissionsCommand,
} from "./rolePermissionCommand";
import PermissionHelper from "../../../helpers/permissionHelper";
import RolePermissionService from "../../../service/management/rolePermissionService";
import { PermissionString } from "../../../type/permissionTypes";
import DatabaseActionException from "../../../exceptions/databaseActionException";
export default abstract class RolePermissionCommandHandler {
/**
* @description update role permissions
* @param {UpdateRolePermissionsCommand} updateRolePermissions
* @returns {Promise<void>}
*/
static async updatePermissions(updateRolePermissions: UpdateRolePermissionsCommand): Promise<void> {
let currentPermissions = (await RolePermissionService.getByRole(updateRolePermissions.roleId)).map(
(r) => r.permission
);
return await dataSource.manager
.transaction(async (manager) => {
let newPermissions = PermissionHelper.getWhatToAdd(currentPermissions, updateRolePermissions.permissions);
let removePermissions = PermissionHelper.getWhatToRemove(currentPermissions, updateRolePermissions.permissions);
if (newPermissions.length != 0) {
await this.updatePermissionsAdd(manager, updateRolePermissions.roleId, newPermissions);
}
if (removePermissions.length != 0) {
await this.updatePermissionsRemove(manager, updateRolePermissions.roleId, removePermissions);
}
})
.then(() => {})
.catch((err) => {
throw new DatabaseActionException("UPDATE", "rolePermissions", err);
});
}
private static async updatePermissionsAdd(
manager: EntityManager,
roleId: number,
permissions: Array<PermissionString>
): Promise<InsertResult> {
return await manager
.createQueryBuilder()
.insert()
.into(rolePermission)
.values(
permissions.map((p) => ({
permission: p,
roleId: roleId,
}))
)
.orIgnore()
.execute();
}
private static async updatePermissionsRemove(
manager: EntityManager,
roleId: number,
permissions: Array<PermissionString>
): Promise<DeleteResult> {
return await manager
.createQueryBuilder()
.delete()
.from(rolePermission)
.where("roleId = :id", { id: roleId })
.andWhere("permission IN (:...permission)", { permission: permissions })
.execute();
}
}

View file

@ -0,0 +1,12 @@
export interface CreateInviteCommand {
mail: string;
username: string;
firstname: string;
lastname: string;
secret: string;
}
export interface DeleteInviteCommand {
token: string;
mail: string;
}

View file

@ -0,0 +1,75 @@
import { dataSource } from "../../../data-source";
import { invite } from "../../../entity/management/invite";
import DatabaseActionException from "../../../exceptions/databaseActionException";
import InternalException from "../../../exceptions/internalException";
import { StringHelper } from "../../../helpers/stringHelper";
import { CreateInviteCommand, DeleteInviteCommand } from "./inviteCommand";
export default abstract class InviteCommandHandler {
/**
* @description create user
* @param CreateInviteCommand
* @returns {Promise<string>}
*/
static async create(createInvite: CreateInviteCommand): Promise<string> {
const token = StringHelper.random(32);
return await dataSource
.createQueryBuilder()
.insert()
.into(invite)
.values({
mail: createInvite.mail,
token: token,
username: createInvite.username,
firstname: createInvite.firstname,
lastname: createInvite.lastname,
secret: createInvite.secret,
})
.orUpdate(["firstName", "lastName", "token", "secret"], ["mail"])
.execute()
.then((result) => {
return token;
})
.catch((err) => {
throw new DatabaseActionException("CREATE", "invite", err);
});
}
/**
* @description delete invite by mail and token
* @param DeleteInviteCommand
* @returns {Promise<any>}
*/
static async deleteByTokenAndMail(deleteInvite: DeleteInviteCommand): Promise<any> {
return await dataSource
.createQueryBuilder()
.delete()
.from(invite)
.where("invite.token = :token", { token: deleteInvite.token })
.andWhere("invite.mail = :mail", { mail: deleteInvite.mail })
.execute()
.then((res) => {})
.catch((err) => {
throw new DatabaseActionException("DELETE", "invite", err);
});
}
/**
* @description delete invite by mail
* @param DeleteByMailInviteCommand
* @returns {Promise<any>}
*/
static async deleteByMail(mail: string): Promise<any> {
return await dataSource
.createQueryBuilder()
.delete()
.from(invite)
.where("invite.mail = :mail", { mail })
.execute()
.then((res) => {})
.catch((err) => {
throw new DatabaseActionException("DELETE", "invite", err);
});
}
}

View file

@ -0,0 +1,35 @@
export interface CreateUserCommand {
mail: string;
username: string;
firstname: string;
lastname: string;
secret: string;
isOwner: boolean;
}
export interface UpdateUserCommand {
id: string;
mail: string;
username: string;
firstname: string;
lastname: string;
}
export interface UpdateUserSecretCommand {
id: string;
secret: string;
}
export interface TransferUserOwnerCommand {
fromId: string;
toId: string;
}
export interface UpdateUserRolesCommand {
id: string;
roleIds: Array<number>;
}
export interface DeleteUserCommand {
id: string;
}

View file

@ -0,0 +1,170 @@
import { EntityManager } from "typeorm";
import { dataSource } from "../../../data-source";
import { user } from "../../../entity/management/user";
import InternalException from "../../../exceptions/internalException";
import {
CreateUserCommand,
DeleteUserCommand,
TransferUserOwnerCommand,
UpdateUserCommand,
UpdateUserRolesCommand,
UpdateUserSecretCommand,
} from "./userCommand";
import UserService from "../../../service/management/userService";
import DatabaseActionException from "../../../exceptions/databaseActionException";
export default abstract class UserCommandHandler {
/**
* @description create user
* @param {CreateUserCommand} createUser
* @returns {Promise<string>}
*/
static async create(createUser: CreateUserCommand): Promise<string> {
return await dataSource
.createQueryBuilder()
.insert()
.into(user)
.values({
username: createUser.username,
mail: createUser.mail,
firstname: createUser.firstname,
lastname: createUser.lastname,
secret: createUser.secret,
isOwner: createUser.isOwner,
})
.execute()
.then((result) => {
return result.identifiers[0].id;
})
.catch((err) => {
throw new DatabaseActionException("CREATE", "user", err);
});
}
/**
* @description update user
* @param {UpdateUserCommand} updateUser
* @returns {Promise<void>}
*/
static async update(updateUser: UpdateUserCommand): Promise<void> {
return await dataSource
.createQueryBuilder()
.update(user)
.set({
mail: updateUser.mail,
firstname: updateUser.firstname,
lastname: updateUser.lastname,
username: updateUser.username,
})
.where("id = :id", { id: updateUser.id })
.execute()
.then(() => {})
.catch((err) => {
throw new DatabaseActionException("UPDATE", "user", err);
});
}
/**
* @description update user
* @param {UpdateUserSecretCommand} updateUser
* @returns {Promise<void>}
*/
static async updateSecret(updateUser: UpdateUserSecretCommand): Promise<void> {
return await dataSource
.createQueryBuilder()
.update(user)
.set({
secret: updateUser.secret,
})
.where("id = :id", { id: updateUser.id })
.execute()
.then(() => {})
.catch((err) => {
throw new DatabaseActionException("UPDATE", "user", err);
});
}
/**
* @description update user roles
* @param {UpdateUserRolesCommand} updateUserRoles
* @returns {Promise<void>}
*/
static async updateRoles(updateUserRoles: UpdateUserRolesCommand): Promise<void> {
let currentRoles = (await UserService.getAssignedRolesByUserId(updateUserRoles.id)).map((r) => r.id);
return await dataSource.manager
.transaction(async (manager) => {
let newRoles = updateUserRoles.roleIds.filter((r) => !currentRoles.includes(r));
let removeRoles = currentRoles.filter((r) => !updateUserRoles.roleIds.includes(r));
for (let role of newRoles) {
await this.updateRolesAdd(manager, updateUserRoles.id, role);
}
for (let role of removeRoles) {
await this.updateRolesRemove(manager, updateUserRoles.id, role);
}
})
.then(() => {})
.catch((err) => {
throw new DatabaseActionException("UPDATE", "userRoles", err);
});
}
private static async updateRolesAdd(manager: EntityManager, userId: string, roleId: number): Promise<void> {
return await manager.createQueryBuilder().relation(user, "roles").of(userId).add(roleId);
}
private static async updateRolesRemove(manager: EntityManager, userId: string, roleId: number): Promise<void> {
return await manager.createQueryBuilder().relation(user, "roles").of(userId).remove(roleId);
}
/**
* @description transfer ownership
* @param {TransferUserOwnerCommand} transferOwnership
* @returns {Promise<void>}
*/
static async transferOwnership(transferOwnership: TransferUserOwnerCommand): Promise<void> {
return await dataSource.manager
.transaction(async (manager) => {
await manager
.createQueryBuilder()
.update(user)
.set({
isOwner: false,
})
.where("id = :id", { id: transferOwnership.fromId })
.execute();
await manager
.createQueryBuilder()
.update(user)
.set({
isOwner: true,
})
.where("id = :id", { id: transferOwnership.toId })
.execute();
})
.then(() => {})
.catch((err) => {
throw new DatabaseActionException("ABORT", "transfer owner", err);
});
}
/**
* @description delete user
* @param DeleteUserCommand
* @returns {Promise<void>}
*/
static async delete(deleteUser: DeleteUserCommand): Promise<void> {
return await dataSource
.createQueryBuilder()
.delete()
.from(user)
.where("id = :id", { id: deleteUser.id })
.execute()
.then(() => {})
.catch((err) => {
throw new DatabaseActionException("DELETE", "user", err);
});
}
}

View file

@ -0,0 +1,16 @@
import { PermissionString } from "../../../type/permissionTypes";
export interface CreateUserPermissionCommand {
permission: PermissionString;
userId: string;
}
export interface DeleteUserPermissionCommand {
permission: PermissionString;
userId: string;
}
export interface UpdateUserPermissionsCommand {
userId: string;
permissions: Array<PermissionString>;
}

View file

@ -0,0 +1,76 @@
import { DeleteResult, EntityManager, InsertResult } from "typeorm";
import { dataSource } from "../../../data-source";
import { user } from "../../../entity/management/user";
import { userPermission } from "../../../entity/management/user_permission";
import InternalException from "../../../exceptions/internalException";
import {
CreateUserPermissionCommand,
DeleteUserPermissionCommand,
UpdateUserPermissionsCommand,
} from "./userPermissionCommand";
import UserPermissionService from "../../../service/management/userPermissionService";
import PermissionHelper from "../../../helpers/permissionHelper";
import { PermissionString } from "../../../type/permissionTypes";
import DatabaseActionException from "../../../exceptions/databaseActionException";
export default abstract class UserPermissionCommandHandler {
/**
* @description update user permissions
* @param {UpdateUserPermissionsCommand} updateUserPermissions
* @returns {Promise<void>}
*/
static async updatePermissions(updateUserPermissions: UpdateUserPermissionsCommand): Promise<void> {
let currentPermissions = (await UserPermissionService.getByUser(updateUserPermissions.userId)).map(
(r) => r.permission
);
return await dataSource.manager
.transaction(async (manager) => {
let newPermissions = PermissionHelper.getWhatToAdd(currentPermissions, updateUserPermissions.permissions);
let removePermissions = PermissionHelper.getWhatToRemove(currentPermissions, updateUserPermissions.permissions);
if (newPermissions.length != 0) {
await this.updatePermissionsAdd(manager, updateUserPermissions.userId, newPermissions);
}
if (removePermissions.length != 0) {
await this.updatePermissionsRemove(manager, updateUserPermissions.userId, removePermissions);
}
})
.then(() => {})
.catch((err) => {
throw new DatabaseActionException("UPDATE", "userPermissions", err);
});
}
private static async updatePermissionsAdd(
manager: EntityManager,
userId: string,
permissions: Array<PermissionString>
): Promise<InsertResult> {
return await manager
.createQueryBuilder()
.insert()
.into(userPermission)
.values(
permissions.map((p) => ({
permission: p,
userId: userId,
}))
)
.orIgnore()
.execute();
}
private static async updatePermissionsRemove(
manager: EntityManager,
userId: string,
permissions: Array<PermissionString>
): Promise<DeleteResult> {
return await manager
.createQueryBuilder()
.delete()
.from(userPermission)
.where("userId = :id", { id: userId })
.andWhere("permission IN (:...permission)", { permission: permissions })
.execute();
}
}

View file

@ -0,0 +1,9 @@
export interface CreateRefreshCommand {
userId: string;
isFromPwa?: boolean;
}
export interface DeleteRefreshCommand {
token: string;
userId: string;
}

View file

@ -0,0 +1,74 @@
import { dataSource } from "../data-source";
import { refresh } from "../entity/refresh";
import { PWA_REFRESH_EXPIRATION, REFRESH_EXPIRATION } from "../env.defaults";
import DatabaseActionException from "../exceptions/databaseActionException";
import InternalException from "../exceptions/internalException";
import { StringHelper } from "../helpers/stringHelper";
import UserService from "../service/management/userService";
import { CreateRefreshCommand, DeleteRefreshCommand } from "./refreshCommand";
import ms from "ms";
export default abstract class RefreshCommandHandler {
/**
* @description create and save refreshToken to user
* @param {CreateRefreshCommand} createRefresh
* @returns {Promise<string>}
*/
static async create(createRefresh: CreateRefreshCommand): Promise<string> {
const refreshToken = StringHelper.random(32);
return await dataSource
.createQueryBuilder()
.insert()
.into(refresh)
.values({
token: refreshToken,
userId: createRefresh.userId,
expiry: createRefresh.isFromPwa
? new Date(Date.now() + ms(PWA_REFRESH_EXPIRATION))
: new Date(Date.now() + ms(REFRESH_EXPIRATION)),
})
.execute()
.then((result) => {
return refreshToken;
})
.catch((err) => {
throw new DatabaseActionException("CREATE", "refresh", err);
});
}
/**
* @description delete refresh by user and token
* @param {DeleteRefreshCommand} deleteRefresh
* @returns {Promise<any>}
*/
static async deleteByToken(deleteRefresh: DeleteRefreshCommand): Promise<any> {
return await dataSource
.createQueryBuilder()
.delete()
.from(refresh)
.where({ token: deleteRefresh.token, userId: deleteRefresh.userId })
.execute()
.then((res) => {})
.catch((err) => {
throw new DatabaseActionException("DELETE", "refresh", err);
});
}
/**
* @description delete expired
* @returns {Promise<any>}
*/
static async deleteExpired(): Promise<any> {
return await dataSource
.createQueryBuilder()
.delete()
.from(refresh)
.where("refresh.expiry < :expiry", { expiry: new Date() })
.execute()
.then((res) => {})
.catch((err) => {
throw new DatabaseActionException("DELETE", "refresh", err);
});
}
}

View file

@ -0,0 +1,10 @@
export interface CreateResetCommand {
mail: string;
username: string;
secret: string;
}
export interface DeleteResetCommand {
token: string;
mail: string;
}

View file

@ -0,0 +1,55 @@
import { dataSource } from "../data-source";
import { reset } from "../entity/reset";
import DatabaseActionException from "../exceptions/databaseActionException";
import InternalException from "../exceptions/internalException";
import { StringHelper } from "../helpers/stringHelper";
import { CreateResetCommand, DeleteResetCommand } from "./resetCommand";
export default abstract class ResetCommandHandler {
/**
* @description create user
* @param {CreateResetCommand} createReset
* @returns {Promise<string>}
*/
static async create(createReset: CreateResetCommand): Promise<string> {
const token = StringHelper.random(32);
return await dataSource
.createQueryBuilder()
.insert()
.into(reset)
.values({
token: token,
mail: createReset.mail,
username: createReset.username,
secret: createReset.secret,
})
.orUpdate(["token", "secret"], ["mail"])
.execute()
.then((result) => {
return token;
})
.catch((err) => {
throw new DatabaseActionException("CREATE", "reset", err);
});
}
/**
* @description delete reset by mail and token
* @param {DeleteRefreshCommand} deleteReset
* @returns {Promise<any>}
*/
static async deleteByTokenAndMail(deleteReset: DeleteResetCommand): Promise<any> {
return await dataSource
.createQueryBuilder()
.delete()
.from(reset)
.where("reset.token = :token", { token: deleteReset.token })
.andWhere("reset.mail = :mail", { mail: deleteReset.mail })
.execute()
.then((res) => {})
.catch((err) => {
throw new DatabaseActionException("DELETE", "reset", err);
});
}
}

View file

@ -0,0 +1,137 @@
import { Request, Response } from "express";
import MemberService from "../../../service/configuration/memberService";
import MemberFactory from "../../../factory/admin/configuration/member";
import {
CreateMemberCommand,
DeleteMemberCommand,
UpdateMemberCommand,
} from "../../../command/configuration/member/memberCommand";
import MemberCommandHandler from "../../../command/configuration/member/memberCommandHandler";
/**
* @description get all members
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function getAllMembers(req: Request, res: Response): Promise<any> {
let offset = parseInt((req.query.offset as string) ?? "0");
let count = parseInt((req.query.count as string) ?? "25");
let search = (req.query.search as string) ?? "";
let noLimit = req.query.noLimit === "true";
let ids = ((req.query.ids ?? "") as string).split(",").filter((i) => i);
let [members, total] = await MemberService.getAll({ offset, count, search, noLimit, ids });
res.json({
members: MemberFactory.mapToBase(members),
total: total,
offset: offset,
count: count,
});
}
/**
* @description get members by Ids
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function getMembersByIds(req: Request, res: Response): Promise<any> {
let ids = req.body.ids as Array<string>;
let [members, total] = await MemberService.getAll({ noLimit: true, ids });
res.json({
members: MemberFactory.mapToBase(members),
total: total,
offset: 0,
count: total,
});
}
/**
* @description get member by id
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function getMemberById(req: Request, res: Response): Promise<any> {
const memberId = req.params.id;
let member = await MemberService.getById(memberId);
res.json(MemberFactory.mapToSingle(member));
}
/**
* @description create member
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function createMember(req: Request, res: Response): Promise<any> {
const salutationId = parseInt(req.body.salutationId);
const firstname = req.body.firstname;
const lastname = req.body.lastname;
const nameaffix = req.body.nameaffix;
const birthdate = req.body.birthdate;
const internalId = req.body.internalId || null;
let createMember: CreateMemberCommand = {
salutationId,
firstname,
lastname,
nameaffix,
birthdate,
internalId,
};
let memberId = await MemberCommandHandler.create(createMember);
res.status(200).send(memberId);
}
/**
* @description update member by id
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function updateMemberById(req: Request, res: Response): Promise<any> {
const memberId = req.params.id;
const salutationId = parseInt(req.body.salutationId);
const firstname = req.body.firstname;
const lastname = req.body.lastname;
const nameaffix = req.body.nameaffix;
const birthdate = req.body.birthdate;
const internalId = req.body.internalId || null;
let updateMember: UpdateMemberCommand = {
id: memberId,
salutationId,
firstname,
lastname,
nameaffix,
birthdate,
internalId,
};
await MemberCommandHandler.update(updateMember);
res.sendStatus(204);
}
/**
* @description delete member by id
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function deleteMemberById(req: Request, res: Response): Promise<any> {
const memberId = req.params.id;
let deleteMember: DeleteMemberCommand = {
id: memberId,
};
await MemberCommandHandler.delete(deleteMember);
res.sendStatus(204);
}

View file

@ -0,0 +1,126 @@
import { Request, Response } from "express";
import { FileSystemHelper } from "../../../helpers/fileSystemHelper";
import BackupHelper from "../../../helpers/backupHelper";
import InternalException from "../../../exceptions/internalException";
/**
* @description get generated backups
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function getGeneratedBackups(req: Request, res: Response): Promise<any> {
let filesInFolder = FileSystemHelper.getFilesInDirectory(`backup`);
let sorted = filesInFolder.sort((a, b) => new Date(b.split(".")[0]).getTime() - new Date(a.split(".")[0]).getTime());
res.json(sorted);
}
/**
* @description download backup file
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function downloadBackupFile(req: Request, res: Response): Promise<any> {
let filename = req.params.filename;
let filepath = FileSystemHelper.formatPath("backup", filename);
res.sendFile(filepath, {
headers: {
"Content-Type": "application/json",
},
});
}
/**
* @description get uploaded backups
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function getUploadedBackups(req: Request, res: Response): Promise<any> {
let filesInFolder = FileSystemHelper.getFilesInDirectory("uploaded-backup");
let sorted = filesInFolder.sort((a, b) => new Date(b.split("_")[0]).getTime() - new Date(a.split("_")[0]).getTime());
res.json(sorted);
}
/**
* @description download uploaded backup file
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function downloadUploadedBackupFile(req: Request, res: Response): Promise<any> {
let filename = req.params.filename;
let filepath = FileSystemHelper.formatPath("uploaded-backup", filename);
res.sendFile(filepath, {
headers: {
"Content-Type": "application/json",
},
});
}
/**
* @description create backup manually
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function createManualBackup(req: Request, res: Response): Promise<any> {
await BackupHelper.createBackup({});
res.sendStatus(204);
}
/**
* @description restore backup by selected
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function restoreBackupByLocalFile(req: Request, res: Response): Promise<any> {
let filename = req.body.filename;
let partial = req.body.partial;
let include = req.body.include;
let overwrite = req.body.overwrite;
await BackupHelper.loadBackup({ filename, include, partial, overwrite });
res.sendStatus(204);
}
/**
* @description restore uploaded backup by selected
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function restoreBackupByUploadedFile(req: Request, res: Response): Promise<any> {
let filename = req.body.filename;
let partial = req.body.partial;
let include = req.body.include;
await BackupHelper.loadBackup({ filename, path: "uploaded-backup", include, partial });
res.sendStatus(204);
}
/**
* @description upload backup
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function uploadBackupFile(req: Request, res: Response): Promise<any> {
if (!req.file) {
throw new InternalException("File upload failed");
}
res.sendStatus(204);
}

View file

@ -0,0 +1,121 @@
import { Request, Response } from "express";
import RoleService from "../../../service/management/roleService";
import RoleFactory from "../../../factory/admin/management/role";
import RolePermissionService from "../../../service/management/rolePermissionService";
import PermissionHelper from "../../../helpers/permissionHelper";
import { CreateRoleCommand, DeleteRoleCommand, UpdateRoleCommand } from "../../../command/management/role/roleCommand";
import RoleCommandHandler from "../../../command/management/role/roleCommandHandler";
import { UpdateRolePermissionsCommand } from "../../../command/management/role/rolePermissionCommand";
import RolePermissionCommandHandler from "../../../command/management/role/rolePermissionCommandHandler";
/**
* @description get All roles
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function getAllRoles(req: Request, res: Response): Promise<any> {
let roles = await RoleService.getAll();
res.json(RoleFactory.mapToBase(roles));
}
/**
* @description get role by id
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function getRoleById(req: Request, res: Response): Promise<any> {
const id = parseInt(req.params.id);
let role = await RoleService.getById(id);
res.json(RoleFactory.mapToSingle(role));
}
/**
* @description get permissions by role
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function getRolePermissions(req: Request, res: Response): Promise<any> {
const id = parseInt(req.params.id);
let permissions = await RolePermissionService.getByRole(id);
res.json(PermissionHelper.convertToObject(permissions.map((p) => p.permission)));
}
/**
* @description create new role
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function createRole(req: Request, res: Response): Promise<any> {
let role = req.body.role;
let createRole: CreateRoleCommand = {
role: role,
};
await RoleCommandHandler.create(createRole);
res.sendStatus(204);
}
/**
* @description update role data
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function updateRole(req: Request, res: Response): Promise<any> {
const id = parseInt(req.params.id);
let role = req.body.role;
let updateRole: UpdateRoleCommand = {
id: id,
role: role,
};
await RoleCommandHandler.update(updateRole);
res.sendStatus(204);
}
/**
* @description update role assigned permission strings
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function updateRolePermissions(req: Request, res: Response): Promise<any> {
const id = parseInt(req.params.id);
let permissions = req.body.permissions;
let permissionStrings = PermissionHelper.convertToStringArray(permissions);
let updateRolePermissions: UpdateRolePermissionsCommand = {
roleId: id,
permissions: permissionStrings,
};
await RolePermissionCommandHandler.updatePermissions(updateRolePermissions);
res.sendStatus(204);
}
/**
* @description delete role by id
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function deleteRole(req: Request, res: Response): Promise<any> {
const id = parseInt(req.params.id);
let deleteRole: DeleteRoleCommand = {
id: id,
};
await RoleCommandHandler.delete(deleteRole);
res.sendStatus(204);
}

View file

@ -0,0 +1,166 @@
import { Request, Response } from "express";
import UserService from "../../../service/management/userService";
import UserFactory from "../../../factory/admin/management/user";
import UserPermissionService from "../../../service/management/userPermissionService";
import PermissionHelper from "../../../helpers/permissionHelper";
import RoleFactory from "../../../factory/admin/management/role";
import {
DeleteUserCommand,
UpdateUserCommand,
UpdateUserRolesCommand,
} from "../../../command/management/user/userCommand";
import UserCommandHandler from "../../../command/management/user/userCommandHandler";
import MailHelper from "../../../helpers/mailHelper";
import { CLUB_NAME } from "../../../env.defaults";
import { UpdateUserPermissionsCommand } from "../../../command/management/user/userPermissionCommand";
import UserPermissionCommandHandler from "../../../command/management/user/userPermissionCommandHandler";
import BadRequestException from "../../../exceptions/badRequestException";
/**
* @description get All users
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function getAllUsers(req: Request, res: Response): Promise<any> {
let users = await UserService.getAll();
res.json(UserFactory.mapToBase(users));
}
/**
* @description get user by id
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function getUserById(req: Request, res: Response): Promise<any> {
const id = req.params.id;
let user = await UserService.getById(id);
res.json(UserFactory.mapToSingle(user));
}
/**
* @description get permissions by user
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function getUserPermissions(req: Request, res: Response): Promise<any> {
const id = req.params.id;
let permissions = await UserPermissionService.getByUser(id);
res.json(PermissionHelper.convertToObject(permissions.map((p) => p.permission)));
}
/**
* @description get assigned roles by user
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function getUserRoles(req: Request, res: Response): Promise<any> {
const id = req.params.id;
let roles = await UserService.getAssignedRolesByUserId(id);
res.json(RoleFactory.mapToBase(roles));
}
/**
* @description update user data
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function updateUser(req: Request, res: Response): Promise<any> {
const id = req.params.id;
let mail = req.body.mail;
let firstname = req.body.firstname;
let lastname = req.body.lastname;
let username = req.body.username;
let updateUser: UpdateUserCommand = {
id: id,
mail: mail,
firstname: firstname,
lastname: lastname,
username: username,
};
await UserCommandHandler.update(updateUser);
res.sendStatus(204);
}
/**
* @description update user assigned permission strings
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function updateUserPermissions(req: Request, res: Response): Promise<any> {
const id = req.params.id;
let permissions = req.body.permissions;
let permissionStrings = PermissionHelper.convertToStringArray(permissions);
let updateUserPermissions: UpdateUserPermissionsCommand = {
userId: id,
permissions: permissionStrings,
};
await UserPermissionCommandHandler.updatePermissions(updateUserPermissions);
res.sendStatus(204);
}
/**
* @description update user assigned roles
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function updateUserRoles(req: Request, res: Response): Promise<any> {
const id = req.params.id;
let roleIds = req.body.roleIds as Array<number>;
let updateRoles: UpdateUserRolesCommand = {
id: id,
roleIds: roleIds,
};
await UserCommandHandler.updateRoles(updateRoles);
res.sendStatus(204);
}
/**
* @description delete user by id
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function deleteUser(req: Request, res: Response): Promise<any> {
const id = req.params.id;
let { mail, isOwner } = await UserService.getById(id);
if (isOwner) {
throw new BadRequestException("Owner cannot be deleted");
}
let deleteUser: DeleteUserCommand = {
id: id,
};
await UserCommandHandler.delete(deleteUser);
try {
// sendmail
await MailHelper.sendMail(
mail,
`Email Bestätigung für Mitglieder Admin-Portal von ${CLUB_NAME}`,
`Ihr Nutzerkonto des Adminportals wurde erfolgreich gelöscht.`
);
} catch (error) {}
res.sendStatus(204);
}

View file

@ -0,0 +1,98 @@
import { Request, Response } from "express";
import { JWTHelper } from "../helpers/jwtHelper";
import { JWTToken } from "../type/jwtTypes";
import InternalException from "../exceptions/internalException";
import RefreshCommandHandler from "../command/refreshCommandHandler";
import { CreateRefreshCommand, DeleteRefreshCommand } from "../command/refreshCommand";
import UserService from "../service/management/userService";
import speakeasy from "speakeasy";
import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException";
import RefreshService from "../service/refreshService";
/**
* @description Check authentication status by token
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function login(req: Request, res: Response): Promise<any> {
let username = req.body.username;
let totp = req.body.totp;
let { id, secret } = await UserService.getByUsername(username);
let valid = speakeasy.totp.verify({
secret: secret,
encoding: "base32",
token: totp,
window: 2,
});
if (!valid) {
throw new UnauthorizedRequestException("Token not valid or expired");
}
let accessToken = await JWTHelper.buildToken(id);
let refreshCommand: CreateRefreshCommand = {
userId: id,
isFromPwa: req.isPWA,
};
let refreshToken = await RefreshCommandHandler.create(refreshCommand);
res.json({
accessToken,
refreshToken,
});
}
/**
* @description logout user by token (invalidate refresh token)
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function logout(req: Request, res: Response): Promise<any> {}
/**
* @description refresh expired token
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function refresh(req: Request, res: Response): Promise<any> {
let token = req.body.accessToken;
let refresh = req.body.refreshToken;
const tokenUser = await JWTHelper.decode(token);
if (typeof tokenUser == "string" || !tokenUser) {
throw new InternalException("process failed");
}
let tokenUserId = (tokenUser as JWTToken).userId;
let { user } = await RefreshService.getByToken(refresh);
if (tokenUserId != user.id) {
throw new UnauthorizedRequestException("user not identified with token and refresh");
}
let accessToken = await JWTHelper.buildToken(tokenUserId);
let refreshCommand: CreateRefreshCommand = {
userId: tokenUserId,
isFromPwa: req.isPWA,
};
let refreshToken = await RefreshCommandHandler.create(refreshCommand);
let removeToken: DeleteRefreshCommand = {
userId: tokenUserId,
token: refresh,
};
await RefreshCommandHandler.deleteByToken(removeToken);
res.json({
accessToken,
refreshToken,
});
}

View file

@ -0,0 +1,175 @@
import { Request, Response } from "express";
import { JWTHelper } from "../helpers/jwtHelper";
import { JWTToken } from "../type/jwtTypes";
import InternalException from "../exceptions/internalException";
import RefreshCommandHandler from "../command/refreshCommandHandler";
import { CreateRefreshCommand } from "../command/refreshCommand";
import speakeasy from "speakeasy";
import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException";
import QRCode from "qrcode";
import { CreateUserCommand } from "../command/management/user/userCommand";
import UserCommandHandler from "../command/management/user/userCommandHandler";
import { CreateInviteCommand, DeleteInviteCommand } from "../command/management/user/inviteCommand";
import InviteCommandHandler from "../command/management/user/inviteCommandHandler";
import MailHelper from "../helpers/mailHelper";
import InviteService from "../service/management/inviteService";
import UserService from "../service/management/userService";
import CustomRequestException from "../exceptions/customRequestException";
import { CLUB_NAME } from "../env.defaults";
import { CreateUserPermissionCommand } from "../command/management/user/userPermissionCommand";
import UserPermissionCommandHandler from "../command/management/user/userPermissionCommandHandler";
import InviteFactory from "../factory/admin/management/invite";
/**
* @description get all invites
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function getInvites(req: Request, res: Response): Promise<any> {
let invites = await InviteService.getAll();
res.json(InviteFactory.mapToBase(invites));
}
/**
* @description start first user
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function inviteUser(req: Request, res: Response, isInvite: boolean = true): Promise<any> {
let origin = req.headers.origin;
let username = req.body.username;
let mail = req.body.mail;
let firstname = req.body.firstname;
let lastname = req.body.lastname;
let users = await UserService.getByMailOrUsername(mail, username);
if (users.length == 1) {
// username or mail is used
if (users[0].username == username && users[0].mail == mail) {
throw new CustomRequestException(409, "Username and Mail are already in use");
} else if (users[0].username == username) {
throw new CustomRequestException(409, "Username is already in use");
} else {
throw new CustomRequestException(409, "Mail is already in use");
}
} else if (users.length >= 2) {
throw new CustomRequestException(409, "Username and Mail are already in use");
}
var secret = speakeasy.generateSecret({ length: 20, name: `FF Operation ${CLUB_NAME}` });
let createInvite: CreateInviteCommand = {
username: username,
mail: mail,
firstname: firstname,
lastname: lastname,
secret: secret.base32,
};
let token = await InviteCommandHandler.create(createInvite);
// sendmail
await MailHelper.sendMail(
mail,
`Email Bestätigung für Mitglieder Admin-Portal von ${CLUB_NAME}`,
`Öffne folgenden Link: ${origin}/${isInvite ? "invite" : "setup"}/verify?mail=${mail}&token=${token}`
);
res.sendStatus(204);
}
/**
* @description Create first user
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function verifyInvite(req: Request, res: Response): Promise<any> {
let mail = req.body.mail;
let token = req.body.token;
let { secret, username } = await InviteService.getByMailAndToken(mail, token);
const url = `otpauth://totp/FF Operation ${CLUB_NAME}?secret=${secret}`;
QRCode.toDataURL(url)
.then((result) => {
res.json({
dataUrl: result,
otp: secret,
username,
});
})
.catch((err) => {
throw new InternalException("QRCode not created", err);
});
}
/**
* @description Create first user
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function finishInvite(req: Request, res: Response, grantAdmin: boolean = false): Promise<any> {
let mail = req.body.mail;
let token = req.body.token;
let totp = req.body.totp;
let { secret, username, firstname, lastname } = await InviteService.getByMailAndToken(mail, token);
let valid = speakeasy.totp.verify({
secret: secret,
encoding: "base32",
token: totp,
window: 2,
});
if (!valid) {
throw new UnauthorizedRequestException("Token not valid or expired");
}
let createUser: CreateUserCommand = {
username: username,
firstname: firstname,
lastname: lastname,
mail: mail,
secret: secret,
isOwner: grantAdmin,
};
let id = await UserCommandHandler.create(createUser);
let accessToken = await JWTHelper.buildToken(id);
let refreshCommand: CreateRefreshCommand = {
userId: id,
};
let refreshToken = await RefreshCommandHandler.create(refreshCommand);
let deleteInvite: DeleteInviteCommand = {
mail: mail,
token: token,
};
await InviteCommandHandler.deleteByTokenAndMail(deleteInvite);
res.json({
accessToken,
refreshToken,
});
}
/**
* @description delete invite by mail
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function deleteInvite(req: Request, res: Response): Promise<any> {
const mail = req.params.mail;
await InviteCommandHandler.deleteByMail(mail);
res.sendStatus(204);
}

View file

@ -0,0 +1,124 @@
import { Request, Response } from "express";
import { JWTHelper } from "../helpers/jwtHelper";
import InternalException from "../exceptions/internalException";
import RefreshCommandHandler from "../command/refreshCommandHandler";
import { CreateRefreshCommand } from "../command/refreshCommand";
import speakeasy from "speakeasy";
import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException";
import QRCode from "qrcode";
import { CreateResetCommand, DeleteResetCommand } from "../command/resetCommand";
import ResetCommandHandler from "../command/resetCommandHandler";
import MailHelper from "../helpers/mailHelper";
import ResetService from "../service/resetService";
import UserService from "../service/management/userService";
import { CLUB_NAME } from "../env.defaults";
import { UpdateUserSecretCommand } from "../command/management/user/userCommand";
import UserCommandHandler from "../command/management/user/userCommandHandler";
/**
* @description request totp reset
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function startReset(req: Request, res: Response): Promise<any> {
let origin = req.headers.origin;
let username = req.body.username;
let { mail } = await UserService.getByUsername(username);
var secret = speakeasy.generateSecret({ length: 20, name: `FF Operation ${CLUB_NAME}` });
let createReset: CreateResetCommand = {
username: username,
mail: mail,
secret: secret.base32,
};
let token = await ResetCommandHandler.create(createReset);
// sendmail
await MailHelper.sendMail(
mail,
`Email Bestätigung für Einsatzverwaltung Admin-Portal von ${CLUB_NAME}`,
`Öffne folgenden Link: ${origin}/reset/reset?mail=${mail}&token=${token}`
);
res.sendStatus(204);
}
/**
* @description verify reset link
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function verifyReset(req: Request, res: Response): Promise<any> {
let mail = req.body.mail;
let token = req.body.token;
let { secret } = await ResetService.getByMailAndToken(mail, token);
const url = `otpauth://totp/FF Operation ${CLUB_NAME}?secret=${secret}`;
QRCode.toDataURL(url)
.then((result) => {
res.json({
dataUrl: result,
otp: secret,
});
})
.catch((err) => {
throw new InternalException("QRCode not created", err);
});
}
/**
* @description finishReset
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function finishReset(req: Request, res: Response): Promise<any> {
let mail = req.body.mail;
let token = req.body.token;
let totp = req.body.totp;
let { secret, username } = await ResetService.getByMailAndToken(mail, token);
let valid = speakeasy.totp.verify({
secret: secret,
encoding: "base32",
token: totp,
window: 2,
});
if (!valid) {
throw new UnauthorizedRequestException("Token not valid or expired");
}
let { id } = await UserService.getByUsername(username);
let updateUserSecret: UpdateUserSecretCommand = {
id,
secret,
};
await UserCommandHandler.updateSecret(updateUserSecret);
let accessToken = await JWTHelper.buildToken(id);
let refreshCommand: CreateRefreshCommand = {
userId: id,
};
let refreshToken = await RefreshCommandHandler.create(refreshCommand);
let deleteReset: DeleteResetCommand = {
mail: mail,
token: token,
};
await ResetCommandHandler.deleteByTokenAndMail(deleteReset);
res.json({
accessToken,
refreshToken,
});
}

View file

@ -0,0 +1,11 @@
import { Request, Response } from "express";
/**
* @description Service is currently not configured
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function isSetup(req: Request, res: Response): Promise<any> {
res.sendStatus(204);
}

View file

@ -0,0 +1,121 @@
import { Request, Response } from "express";
import speakeasy from "speakeasy";
import QRCode from "qrcode";
import InternalException from "../exceptions/internalException";
import { CLUB_NAME } from "../env.defaults";
import UserService from "../service/management/userService";
import UserFactory from "../factory/admin/management/user";
import { TransferUserOwnerCommand, UpdateUserCommand } from "../command/management/user/userCommand";
import UserCommandHandler from "../command/management/user/userCommandHandler";
import ForbiddenRequestException from "../exceptions/forbiddenRequestException";
/**
* @description get my by id
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function getMeById(req: Request, res: Response): Promise<any> {
const id = req.userId;
let user = await UserService.getById(id);
res.json(UserFactory.mapToSingle(user));
}
/**
* @description get my totp
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function getMyTotp(req: Request, res: Response): Promise<any> {
const userId = req.userId;
let { secret } = await UserService.getById(userId);
const url = `otpauth://totp/FF Operation ${CLUB_NAME}?secret=${secret}`;
QRCode.toDataURL(url)
.then((result) => {
res.json({
dataUrl: result,
otp: secret,
});
})
.catch((err) => {
throw new InternalException("QRCode not created", err);
});
}
/**
* @description verify my totp
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function verifyMyTotp(req: Request, res: Response): Promise<any> {
const userId = req.userId;
let totp = req.body.totp;
let { secret } = await UserService.getById(userId);
let valid = speakeasy.totp.verify({
secret: secret,
encoding: "base32",
token: totp,
window: 2,
});
if (!valid) {
throw new InternalException("Token not valid or expired");
}
res.sendStatus(204);
}
/**
* @description transferOwnership
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function transferOwnership(req: Request, res: Response): Promise<any> {
const userId = req.userId;
let toId = req.body.toId;
let { isOwner } = await UserService.getById(userId);
if (!isOwner) {
throw new ForbiddenRequestException("Action only allowed to owner.");
}
let transfer: TransferUserOwnerCommand = {
toId: toId,
fromId: userId,
};
await UserCommandHandler.transferOwnership(transfer);
res.sendStatus(204);
}
/**
* @description update my data
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function updateMe(req: Request, res: Response): Promise<any> {
const id = req.userId;
let mail = req.body.mail;
let firstname = req.body.firstname;
let lastname = req.body.lastname;
let username = req.body.username;
let updateUser: UpdateUserCommand = {
id: id,
mail: mail,
firstname: firstname,
lastname: lastname,
username: username,
};
await UserCommandHandler.update(updateUser);
res.sendStatus(204);
}

34
src/data-source.ts Normal file
View file

@ -0,0 +1,34 @@
import "dotenv/config";
import "reflect-metadata";
import { DataSource } from "typeorm";
import { DB_HOST, DB_USERNAME, DB_PASSWORD, DB_NAME, DB_TYPE, DB_PORT } from "./env.defaults";
import { user } from "./entity/management/user";
import { refresh } from "./entity/refresh";
import { invite } from "./entity/management/invite";
import { userPermission } from "./entity/management/user_permission";
import { role } from "./entity/management/role";
import { rolePermission } from "./entity/management/role_permission";
import { member } from "./entity/configuration/member";
import { reset } from "./entity/reset";
import { CreateSchema1739697068682 } from "./migrations/1739697068682-CreateSchema";
const dataSource = new DataSource({
type: DB_TYPE as any,
host: DB_HOST,
port: DB_PORT,
username: DB_USERNAME,
password: DB_PASSWORD,
database: DB_NAME,
synchronize: false,
logging: process.env.NODE_ENV ? true : ["schema", "error", "warn", "log", "migration"],
bigNumberStrings: false,
entities: [user, refresh, invite, reset, userPermission, role, rolePermission, member],
migrations: [CreateSchema1739697068682],
migrationsRun: true,
migrationsTransactionMode: "each",
subscribers: [],
});
export { dataSource };

View file

@ -0,0 +1,16 @@
import { Column, Entity, PrimaryColumn } from "typeorm";
@Entity()
export class member {
@PrimaryColumn({ generated: "uuid", type: "varchar" })
id: string;
@Column({ type: "varchar", length: 255 })
firstname: string;
@Column({ type: "varchar", length: 255 })
lastname: string;
@Column({ type: "varchar", length: 255 })
nameaffix: string;
}

View file

@ -0,0 +1,22 @@
import { Column, Entity, PrimaryColumn } from "typeorm";
@Entity()
export class invite {
@PrimaryColumn({ type: "varchar", length: 255 })
mail: string;
@Column({ type: "varchar", length: 255 })
token: string;
@Column({ type: "varchar", length: 255 })
username: string;
@Column({ type: "varchar", length: 255 })
firstname: string;
@Column({ type: "varchar", length: 255 })
lastname: string;
@Column({ type: "varchar", length: 255 })
secret: string;
}

View file

@ -0,0 +1,22 @@
import { Column, Entity, ManyToMany, OneToMany, PrimaryColumn } from "typeorm";
import { user } from "./user";
import { rolePermission } from "./role_permission";
@Entity()
export class role {
@PrimaryColumn({ generated: "increment", type: "int" })
id: number;
@Column({ type: "varchar", length: 255, unique: true })
role: string;
@ManyToMany(() => user, (user) => user.roles, {
nullable: false,
onDelete: "CASCADE",
onUpdate: "RESTRICT",
})
users: user[];
@OneToMany(() => rolePermission, (rolePermission) => rolePermission.role, { cascade: ["insert"] })
permissions: rolePermission[];
}

View file

@ -0,0 +1,19 @@
import { Column, Entity, ManyToOne, PrimaryColumn } from "typeorm";
import { PermissionString } from "../../type/permissionTypes";
import { role } from "./role";
@Entity()
export class rolePermission {
@PrimaryColumn({ type: "int" })
roleId: number;
@PrimaryColumn({ type: "varchar", length: 255 })
permission: PermissionString;
@ManyToOne(() => role, {
nullable: false,
onDelete: "CASCADE",
onUpdate: "RESTRICT",
})
role: role;
}

View file

@ -0,0 +1,44 @@
import { Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryColumn } from "typeorm";
import { role } from "./role";
import { userPermission } from "./user_permission";
@Entity()
export class user {
@PrimaryColumn({ generated: "uuid", type: "varchar" })
id: string;
@Column({ type: "varchar", unique: true, length: 255 })
mail: string;
@Column({ type: "varchar", unique: true, length: 255 })
username: string;
@Column({ type: "varchar", length: 255 })
firstname: string;
@Column({ type: "varchar", length: 255 })
lastname: string;
@Column({ type: "varchar", length: 255 })
secret: string;
@Column({ type: "boolean", default: false })
static: boolean;
@Column({ type: "boolean", default: false })
isOwner: boolean;
@ManyToMany(() => role, (role) => role.users, {
nullable: false,
onDelete: "CASCADE",
onUpdate: "RESTRICT",
cascade: ["insert"],
})
@JoinTable({
name: "user_roles",
})
roles: role[];
@OneToMany(() => userPermission, (userPermission) => userPermission.user, { cascade: ["insert"] })
permissions: userPermission[];
}

View file

@ -0,0 +1,19 @@
import { Column, Entity, ManyToOne, PrimaryColumn } from "typeorm";
import { user } from "./user";
import { PermissionObject, PermissionString } from "../../type/permissionTypes";
@Entity()
export class userPermission {
@PrimaryColumn()
userId: string;
@PrimaryColumn({ type: "varchar", length: 255 })
permission: PermissionString;
@ManyToOne(() => user, {
nullable: false,
onDelete: "CASCADE",
onUpdate: "RESTRICT",
})
user: user;
}

View file

@ -0,0 +1,27 @@
import { Column, ColumnType, CreateDateColumn, Entity, OneToMany, PrimaryColumn } from "typeorm";
import { webapiPermission } from "./webapi_permission";
import { getTypeByORM } from "../../migrations/ormHelper";
@Entity()
export class webapi {
@PrimaryColumn({ generated: "increment", type: "int" })
id: number;
@Column({ type: "text", unique: true, select: false })
token: string;
@Column({ type: "varchar", length: 255, unique: true })
title: string;
@CreateDateColumn()
createdAt: Date;
@Column({ type: getTypeByORM("datetime").type as ColumnType, nullable: true })
lastUsage?: Date;
@Column({ type: getTypeByORM("date").type as ColumnType, nullable: true })
expiry?: Date;
@OneToMany(() => webapiPermission, (apiPermission) => apiPermission.webapi, { cascade: ["insert"] })
permissions: webapiPermission[];
}

View file

@ -0,0 +1,19 @@
import { Column, Entity, ManyToOne, OneToMany, PrimaryColumn } from "typeorm";
import { PermissionObject, PermissionString } from "../../type/permissionTypes";
import { webapi } from "./webapi";
@Entity()
export class webapiPermission {
@PrimaryColumn({ type: "int" })
webapiId: number;
@PrimaryColumn({ type: "varchar", length: 255 })
permission: PermissionString;
@ManyToOne(() => webapi, {
nullable: false,
onDelete: "CASCADE",
onUpdate: "RESTRICT",
})
webapi: webapi;
}

22
src/entity/refresh.ts Normal file
View file

@ -0,0 +1,22 @@
import { Column, ColumnType, Entity, ManyToOne, PrimaryColumn } from "typeorm";
import { user } from "./management/user";
import { getTypeByORM } from "../migrations/ormHelper";
@Entity()
export class refresh {
@PrimaryColumn({ type: "varchar", length: 255 })
token: string;
@PrimaryColumn()
userId: string;
@Column({ type: getTypeByORM("datetime").type as ColumnType })
expiry: Date;
@ManyToOne(() => user, {
nullable: false,
onDelete: "CASCADE",
onUpdate: "RESTRICT",
})
user: user;
}

16
src/entity/reset.ts Normal file
View file

@ -0,0 +1,16 @@
import { Column, Entity, PrimaryColumn } from "typeorm";
@Entity()
export class reset {
@PrimaryColumn({ type: "varchar", length: 255 })
mail: string;
@Column({ type: "varchar", length: 255 })
token: string;
@Column({ type: "varchar", length: 255 })
username: string;
@Column({ type: "varchar", length: 255 })
secret: string;
}

116
src/env.defaults.ts Normal file
View file

@ -0,0 +1,116 @@
import "dotenv/config";
import ms from "ms";
import ip from "ip";
export const DB_TYPE = process.env.DB_TYPE ?? "mysql";
export const DB_HOST = process.env.DB_HOST ?? "";
export const DB_PORT = Number(process.env.DB_PORT ?? 3306);
export const DB_NAME = process.env.DB_NAME ?? "";
export const DB_USERNAME = process.env.DB_USERNAME ?? "";
export const DB_PASSWORD = process.env.DB_PASSWORD ?? "";
export const SERVER_PORT = Number(process.env.SERVER_PORT ?? 5000);
export const JWT_SECRET = process.env.JWT_SECRET ?? "my_jwt_secret_string_ilughfnadiuhgq§$IUZGFVRweiouarbt1oub3h5q4a";
export const JWT_EXPIRATION = process.env.JWT_EXPIRATION ?? "15m";
export const REFRESH_EXPIRATION = process.env.REFRESH_EXPIRATION ?? "1d";
export const PWA_REFRESH_EXPIRATION = process.env.PWA_REFRESH_EXPIRATION ?? "5d";
export const MAIL_USERNAME = process.env.MAIL_USERNAME ?? "";
export const MAIL_PASSWORD = process.env.MAIL_PASSWORD ?? "";
export const MAIL_HOST = process.env.MAIL_HOST ?? "";
export const MAIL_PORT = Number(process.env.MAIL_PORT ?? "587");
export const MAIL_SECURE = process.env.MAIL_SECURE ?? "false";
export const CLUB_NAME = process.env.CLUB_NAME ?? "FF Admin";
export const CLUB_WEBSITE = process.env.CLUB_WEBSITE ?? "";
export const BACKUP_INTERVAL = Number(process.env.BACKUP_INTERVAL ?? "1");
export const BACKUP_COPIES = Number(process.env.BACKUP_COPIES ?? "7");
export const BACKUP_AUTO_RESTORE = process.env.BACKUP_AUTO_RESTORE ?? "true";
export const USE_SECURITY_STRICT_LIMIT = process.env.USE_SECURITY_STRICT_LIMIT ?? "true";
export const SECURITY_STRICT_LIMIT_WINDOW = process.env.SECURITY_STRICT_LIMIT_WINDOW ?? "15m";
export const SECURITY_STRICT_LIMIT_REQUEST_COUNT = Number(process.env.SECURITY_STRICT_LIMIT_REQUEST_COUNT ?? "15");
export const USE_SECURITY_LIMIT = process.env.USE_SECURITY_LIMIT ?? "true";
export const SECURITY_LIMIT_WINDOW = process.env.SECURITY_LIMIT_WINDOW ?? "1m";
export const SECURITY_LIMIT_REQUEST_COUNT = Number(process.env.SECURITY_LIMIT_REQUEST_COUNT ?? "500");
export const TRUST_PROXY = ((): Array<string> | string | boolean | number | null => {
const proxyVal = process.env.TRUST_PROXY;
if (!proxyVal) return null;
if (proxyVal == "true" || proxyVal == "false") {
return proxyVal == "true";
}
if (!isNaN(Number(proxyVal))) {
return Number(proxyVal);
}
if (proxyVal.includes(",") && proxyVal.split(",").every((pv) => ip.isV4Format(pv) || ip.isV6Format(pv))) {
return proxyVal.split(",");
}
if (ip.isV4Format(proxyVal) || ip.isV6Format(proxyVal)) {
return proxyVal;
}
return null;
})();
export function configCheck() {
if (DB_TYPE != "mysql" && DB_TYPE != "sqlite" && DB_TYPE != "postgres")
throw new Error("set valid value to DB_TYPE (mysql|sqlite|postgres)");
if ((DB_HOST == "" || typeof DB_HOST != "string") && DB_TYPE != "sqlite")
throw new Error("set valid value to DB_HOST");
if (DB_NAME == "" || typeof DB_NAME != "string") throw new Error("set valid value to DB_NAME");
if ((DB_USERNAME == "" || typeof DB_USERNAME != "string") && DB_TYPE != "sqlite")
throw new Error("set valid value to DB_USERNAME");
if ((DB_PASSWORD == "" || typeof DB_PASSWORD != "string") && DB_TYPE != "sqlite")
throw new Error("set valid value to DB_PASSWORD");
if (isNaN(SERVER_PORT)) throw new Error("set valid numeric value to SERVER_PORT");
if (JWT_SECRET == "" || typeof JWT_SECRET != "string") throw new Error("set valid value to JWT_SECRET");
checkMS(JWT_EXPIRATION, "JWT_EXPIRATION");
checkMS(REFRESH_EXPIRATION, "REFRESH_EXPIRATION");
checkMS(PWA_REFRESH_EXPIRATION, "PWA_REFRESH_EXPIRATION");
if (MAIL_USERNAME == "" || typeof MAIL_USERNAME != "string") throw new Error("set valid value to MAIL_USERNAME");
if (MAIL_PASSWORD == "" || typeof MAIL_PASSWORD != "string") throw new Error("set valid value to MAIL_PASSWORD");
if (MAIL_HOST == "" || typeof MAIL_HOST != "string") throw new Error("set valid value to MAIL_HOST");
if (isNaN(MAIL_PORT)) throw new Error("set valid numeric value to MAIL_PORT");
if (MAIL_SECURE != "true" && MAIL_SECURE != "false") throw new Error("set 'true' or 'false' to MAIL_SECURE");
if (
CLUB_WEBSITE != "" &&
!/^(http(s):\/\/.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/.test(CLUB_WEBSITE)
)
throw new Error("CLUB_WEBSITE is not valid url");
if (BACKUP_AUTO_RESTORE != "true" && BACKUP_AUTO_RESTORE != "false")
throw new Error("set 'true' or 'false' to BACKUP_AUTO_RESTORE");
if (BACKUP_INTERVAL < 1) throw new Error("BACKUP_INTERVAL has to be at least 1");
if (BACKUP_COPIES < 1) throw new Error("BACKUP_COPIES has to be at least 1");
if (USE_SECURITY_STRICT_LIMIT != "true" && USE_SECURITY_STRICT_LIMIT != "false")
throw new Error("set 'true' or 'false' to USE_SECURITY_STRICT_LIMIT");
checkMS(SECURITY_STRICT_LIMIT_WINDOW, "SECURITY_STRICT_LIMIT_WINDOW");
if (isNaN(SECURITY_STRICT_LIMIT_REQUEST_COUNT))
throw new Error("set valid numeric value to SECURITY_STRICT_LIMIT_REQUEST_COUNT");
if (USE_SECURITY_LIMIT != "true" && USE_SECURITY_LIMIT != "false")
throw new Error("set 'true' or 'false' to USE_SECURITY_LIMIT");
checkMS(SECURITY_LIMIT_WINDOW, "SECURITY_LIMIT_WINDOW");
if (isNaN(SECURITY_LIMIT_REQUEST_COUNT)) throw new Error("set valid numeric value to SECURITY_LIMIT_REQUEST_COUNT");
if (!TRUST_PROXY && process.env.TRUST_PROXY) {
throw new Error("set valid boolean, number, ip or ips value to TRUST_PROXY");
}
}
function checkMS(input: string, origin: string) {
try {
const result = ms(input);
if (result === undefined) {
throw new Error(`set valid ms value to ${origin} -> [0-9]*(y|d|h|m|s)`);
}
} catch (e) {
throw new Error(`set valid ms value to ${origin} -> [0-9]*(y|d|h|m|s)`);
}
}

View file

@ -0,0 +1,7 @@
import CustomRequestException from "./customRequestException";
export default class BadRequestException extends CustomRequestException {
constructor(msg: string, err?: any) {
super(400, msg, err);
}
}

View file

@ -0,0 +1,12 @@
import { ExceptionBase } from "./exceptionsBaseType";
export default class CustomRequestException extends Error implements ExceptionBase {
statusCode: number;
err?: any;
constructor(status: number, msg: string, err?: any) {
super(msg);
this.statusCode = status;
this.err = err;
}
}

View file

@ -0,0 +1,8 @@
import CustomRequestException from "./customRequestException";
export default class DatabaseActionException extends CustomRequestException {
constructor(action: string, table: string, err: any) {
let errstring = `${action} on ${table} with ${err?.code ?? "XX"} at ${err?.sqlMessage ?? "XX"}`;
super(500, errstring, err);
}
}

View file

@ -0,0 +1,3 @@
export type ExceptionBase = {
statusCode: number;
} & Error;

View file

@ -0,0 +1,7 @@
import CustomRequestException from "./customRequestException";
export default class ForbiddenRequestException extends CustomRequestException {
constructor(msg: string, err?: any) {
super(403, msg, err);
}
}

View file

@ -0,0 +1,7 @@
import CustomRequestException from "./customRequestException";
export default class InternalException extends CustomRequestException {
constructor(msg: string, err?: any) {
super(500, msg, err);
}
}

View file

@ -0,0 +1,7 @@
import CustomRequestException from "./customRequestException";
export default class UnauthorizedRequestException extends CustomRequestException {
constructor(msg: string, err?: any) {
super(401, msg, err);
}
}

View file

@ -0,0 +1,27 @@
import { member } from "../../../entity/configuration/member";
import { MemberViewModel } from "../../../viewmodel/admin/configuration/member.models";
export default abstract class MemberFactory {
/**
* @description map record to member
* @param {member} record
* @returns {MemberViewModel}
*/
public static mapToSingle(record: member): MemberViewModel {
return {
id: record?.id,
firstname: record?.firstname,
lastname: record?.lastname,
nameaffix: record?.nameaffix,
};
}
/**
* @description map records to member
* @param {Array<member>} records
* @returns {Array<MemberViewModel>}
*/
public static mapToBase(records: Array<member>): Array<MemberViewModel> {
return records.map((r) => this.mapToSingle(r));
}
}

View file

@ -0,0 +1,27 @@
import { invite } from "../../../entity/management/invite";
import { InviteViewModel } from "../../../viewmodel/admin/management/invite.models";
export default abstract class InviteFactory {
/**
* @description map record to invite
* @param {invite} record
* @returns {InviteViewModel}
*/
public static mapToSingle(record: invite): InviteViewModel {
return {
mail: record.mail,
username: record.username,
firstname: record.firstname,
lastname: record.lastname,
};
}
/**
* @description map records to invite
* @param {Array<invite>} records
* @returns {Array<InviteViewModel>}
*/
public static mapToBase(records: Array<invite>): Array<InviteViewModel> {
return records.map((r) => this.mapToSingle(r));
}
}

View file

@ -0,0 +1,27 @@
import { role } from "../../../entity/management/role";
import PermissionHelper from "../../../helpers/permissionHelper";
import { RoleViewModel } from "../../../viewmodel/admin/management/role.models";
export default abstract class RoleFactory {
/**
* @description map record to role
* @param {role} record
* @returns {roleViewModel}
*/
public static mapToSingle(record: role): RoleViewModel {
return {
id: record.id,
permissions: PermissionHelper.convertToObject(record.permissions.map((e) => e.permission)),
role: record.role,
};
}
/**
* @description map records to role
* @param {Array<role>} records
* @returns {Array<roleViewModel>}
*/
public static mapToBase(records: Array<role>): Array<RoleViewModel> {
return records.map((r) => this.mapToSingle(r));
}
}

View file

@ -0,0 +1,39 @@
import { user } from "../../../entity/management/user";
import PermissionHelper from "../../../helpers/permissionHelper";
import { UserViewModel } from "../../../viewmodel/admin/management/user.models";
import RoleFactory from "./role";
export default abstract class UserFactory {
/**
* @description map record to user
* @param {user} record
* @returns {UserViewModel}
*/
public static mapToSingle(record: user): UserViewModel {
let userPermissionStrings = record.permissions.map((e) => e.permission);
let rolePermissions = record.roles.map((e) => e.permissions).flat();
let rolePermissionStrings = rolePermissions.map((p) => p.permission);
let totalPermissions = PermissionHelper.convertToObject([...userPermissionStrings, ...rolePermissionStrings]);
return {
id: record.id,
username: record.username,
firstname: record.firstname,
lastname: record.lastname,
mail: record.mail,
isOwner: record.isOwner,
permissions: PermissionHelper.convertToObject(userPermissionStrings),
roles: RoleFactory.mapToBase(record.roles),
permissions_total: totalPermissions,
};
}
/**
* @description map records to user
* @param {Array<role>} records
* @returns {Array<UserViewModel>}
*/
public static mapToBase(records: Array<user>): Array<UserViewModel> {
return records.map((r) => this.mapToSingle(r));
}
}

246
src/helpers/backupHelper.ts Normal file
View file

@ -0,0 +1,246 @@
import { dataSource } from "../data-source";
import { FileSystemHelper } from "./fileSystemHelper";
import { EntityManager } from "typeorm";
import uniqBy from "lodash.uniqby";
import InternalException from "../exceptions/internalException";
import UserService from "../service/management/userService";
import { BACKUP_COPIES, BACKUP_INTERVAL } from "../env.defaults";
import DatabaseActionException from "../exceptions/databaseActionException";
export type BackupSection = "member" | "user";
export type BackupSectionRefered = {
[key in BackupSection]?: Array<string>;
};
export type BackupFileContent = { [key in BackupSection]?: BackupFileContentSection } & {
backup_file_details: { collectIds: boolean; createdAt: Date; version: 1 };
};
export type BackupFileContentSection = Array<any> | { [key: string]: Array<any> };
export default abstract class BackupHelper {
// ! Order matters because of foreign keys
private static readonly backupSection: Array<{ type: BackupSection; orderOnInsert: number; orderOnClear: number }> = [
{ type: "member", orderOnInsert: 2, orderOnClear: 2 },
{ type: "user", orderOnInsert: 1, orderOnClear: 1 },
];
private static readonly backupSectionRefered: BackupSectionRefered = {
member: ["member"],
user: ["user", "user_permission", "role", "role_permission", "invite"],
};
private static transactionManager: EntityManager;
static async createBackup({
filename,
path = "/backup",
collectIds = true,
}: {
filename?: string;
path?: string;
collectIds?: boolean;
}): Promise<void> {
if (!filename) {
filename = new Date().toISOString().split("T")[0];
}
let json: BackupFileContent = { backup_file_details: { collectIds, createdAt: new Date(), version: 1 } };
for (const section of this.backupSection) {
json[section.type] = await this.getSectionData(section.type, collectIds);
}
FileSystemHelper.writeFile(path, filename + ".json", JSON.stringify(json, null, 2));
let files = FileSystemHelper.getFilesInDirectory("backup", ".json");
let sorted = files.sort((a, b) => new Date(b.split(".")[0]).getTime() - new Date(a.split(".")[0]).getTime());
const filesToDelete = sorted.slice(BACKUP_COPIES);
for (const file of filesToDelete) {
FileSystemHelper.deleteFile("backup", file);
}
}
static async createBackupOnInterval() {
let files = FileSystemHelper.getFilesInDirectory("backup", ".json");
let newestFile = files.sort((a, b) => new Date(b.split(".")[0]).getTime() - new Date(a.split(".")[0]).getTime())[0];
let lastBackup = new Date(newestFile.split(".")[0]);
let diffInMs = new Date().getTime() - lastBackup.getTime();
let diffInDays = diffInMs / (1000 * 60 * 60 * 24);
if (diffInDays >= BACKUP_INTERVAL) {
await this.createBackup({});
}
}
static async loadBackup({
filename,
path = "/backup",
include = [],
partial = false,
overwrite = false,
}: {
filename: string;
path?: string;
partial?: boolean;
include?: Array<BackupSection>;
overwrite?: boolean;
}): Promise<void> {
this.transactionManager = undefined;
let file = FileSystemHelper.readFile(`${path}/${filename}`);
let backup: BackupFileContent = JSON.parse(file);
if ((partial && include.length == 0) || (!partial && include.length != 0)) {
throw new InternalException("partial and include have to be set correctly for restoring backup.");
}
await dataSource.manager
.transaction(async (transaction) => {
this.transactionManager = transaction;
const sections = this.backupSection
.filter((bs) => (partial ? include.includes(bs.type) : true))
.sort((a, b) => a.orderOnClear - b.orderOnClear);
if (!overwrite) {
for (const section of sections.filter((s) => Object.keys(backup).includes(s.type))) {
let refered = this.backupSectionRefered[section.type];
for (const ref of refered) {
await this.transactionManager.getRepository(ref).delete({});
}
}
}
for (const section of sections
.filter((s) => Object.keys(backup).includes(s.type))
.sort((a, b) => a.orderOnInsert - b.orderOnInsert)) {
await this.setSectionData(section.type, backup[section.type], backup.backup_file_details.collectIds ?? false);
}
this.transactionManager = undefined;
})
.catch((err) => {
console.log(err);
this.transactionManager = undefined;
throw new DatabaseActionException("BACKUP RESTORE", include.join(", ") || "FULL", err);
});
}
public static async autoRestoreBackup() {
let count = await UserService.count();
if (count == 0) {
let files = FileSystemHelper.getFilesInDirectory("/backup", ".json");
let newestFile = files.sort(
(a, b) => new Date(b.split(".")[0]).getTime() - new Date(a.split(".")[0]).getTime()
)[0];
if (newestFile) {
console.log(`${new Date().toISOString()}: auto-restoring ${newestFile}`);
await this.loadBackup({ filename: newestFile });
console.log(`${new Date().toISOString()}: finished auto-restore`);
} else {
console.log(`${new Date().toISOString()}: skip auto-restore as no backup was found`);
}
} else {
console.log(`${new Date().toISOString()}: skip auto-restore as users exist`);
}
}
private static async getSectionData(
section: BackupSection,
collectIds: boolean
): Promise<Array<any> | { [key: string]: any }> {
switch (section) {
case "member":
return await this.getMemberData(collectIds);
case "user":
return await this.getUser(collectIds);
default:
return [];
}
}
private static async getMemberData(collectIds: boolean): Promise<Array<any>> {
return await dataSource
.getRepository("member")
.createQueryBuilder("member")
.select([...(collectIds ? ["member.id"] : []), "member.firstname", "member.lastname", "member.nameaffix"])
.getMany();
}
private static async getUser(collectIds: boolean): Promise<{ [key: string]: Array<any> }> {
return {
user: await dataSource
.getRepository("user")
.createQueryBuilder("user")
.leftJoin("user.roles", "roles")
.leftJoin("roles.permissions", "role_permissions")
.leftJoin("user.permissions", "permissions")
.select([
...(collectIds ? ["user.id"] : []),
"user.mail",
"user.username",
"user.firstname",
"user.lastname",
"user.secret",
"user.isOwner",
])
.addSelect(["permissions.permission"])
.addSelect(["roles.role"])
.addSelect(["role_permissions.permission"])
.getMany(),
role: await dataSource
.getRepository("role")
.createQueryBuilder("role")
.leftJoin("role.permissions", "permissions")
.addSelect(["role.role"])
.addSelect(["permissions.permission"])
.getMany(),
invite: await dataSource.getRepository("invite").find(),
};
}
private static async setSectionData(
section: BackupSection,
data: BackupFileContentSection,
collectedIds: boolean
): Promise<void> {
if (section == "member" && Array.isArray(data)) await this.setMemberData(data);
if (section == "user" && !Array.isArray(data)) await this.setUser(data);
}
private static async setMemberData(data: Array<any>): Promise<void> {
await this.transactionManager.getRepository("member").save(data);
}
private static async setUser(data: { [key: string]: Array<any> }): Promise<void> {
let usedRoles = (data?.["user"] ?? [])
.map((d) => d.roles)
.flat()
.map((d) => ({ ...d, id: undefined }));
await this.transactionManager
.createQueryBuilder()
.insert()
.into("role")
.values(uniqBy([...(data?.["role"] ?? []), ...usedRoles], "role"))
.orIgnore()
.execute();
let roles = await this.transactionManager.getRepository("role").find();
let dataWithMappedIds = (data?.["user"] ?? []).map((u) => ({
...u,
roles: u.roles.map((r: any) => ({
...r,
id: roles.find((role) => role.role == r.role)?.id ?? undefined,
})),
}));
await this.transactionManager.getRepository("user").save(dataWithMappedIds);
await this.transactionManager
.createQueryBuilder()
.insert()
.into("invite")
.values(data["invite"])
.orIgnore()
.execute();
}
}

View file

@ -0,0 +1,71 @@
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
import { join } from "path";
import { readdirSync } from "fs";
export abstract class FileSystemHelper {
static createFolder(...args: string[]) {
const exportPath = this.formatPath(...args);
if (!existsSync(exportPath)) {
mkdirSync(exportPath, { recursive: true });
}
}
static readFile(...filePath: string[]) {
this.createFolder(...filePath);
return readFileSync(this.formatPath(...filePath), "utf8");
}
static readFileasBase64(...filePath: string[]) {
this.createFolder(...filePath);
return readFileSync(this.formatPath(...filePath), "base64");
}
static readTemplateFile(filePath: string) {
this.createFolder(filePath);
return readFileSync(process.cwd() + filePath, "utf8");
}
static writeFile(filePath: string, filename: string, file: any) {
this.createFolder(filePath);
let path = this.formatPath(filePath, filename);
writeFileSync(path, file);
}
static deleteFile(...filePath: string[]) {
const path = this.formatPath(...filePath);
if (existsSync(path)) {
unlinkSync(path);
}
}
static formatPath(...args: string[]) {
return join(process.cwd(), "files", ...args);
}
static normalizePath(...args: string[]) {
return join(...args);
}
static getFilesInDirectory(directoryPath: string, filetype?: string): string[] {
const fullPath = this.formatPath(directoryPath);
if (!existsSync(fullPath)) {
return [];
}
return readdirSync(fullPath, { withFileTypes: true })
.filter((dirent) => !dirent.isDirectory() && (!filetype || dirent.name.endsWith(filetype)))
.map((dirent) => dirent.name);
}
static clearDirectoryByFiletype(directoryPath: string, filetype: string) {
const fullPath = this.formatPath(directoryPath);
if (!existsSync(fullPath)) {
return;
}
readdirSync(fullPath, { withFileTypes: true })
.filter((dirent) => !dirent.isDirectory() && dirent.name.endsWith(filetype))
.forEach((dirent) => {
const filePath = join(fullPath, dirent.name);
unlinkSync(filePath);
});
}
}

78
src/helpers/jwtHelper.ts Normal file
View file

@ -0,0 +1,78 @@
import jwt from "jsonwebtoken";
import { JWTData, JWTToken } from "../type/jwtTypes";
import { JWT_SECRET, JWT_EXPIRATION } from "../env.defaults";
import InternalException from "../exceptions/internalException";
import RolePermissionService from "../service/management/rolePermissionService";
import UserPermissionService from "../service/management/userPermissionService";
import UserService from "../service/management/userService";
import PermissionHelper from "./permissionHelper";
export abstract class JWTHelper {
static validate(token: string): Promise<string | jwt.JwtPayload> {
return new Promise<string | jwt.JwtPayload>((resolve, reject) => {
jwt.verify(token, JWT_SECRET, (err, decoded) => {
if (err) reject(err.message);
else resolve(decoded);
});
});
}
static create(
data: JWTData,
{ expOverwrite, useExpiration }: { expOverwrite?: number; useExpiration?: boolean } = { useExpiration: true }
): Promise<string> {
return new Promise<string>((resolve, reject) => {
jwt.sign(
data,
JWT_SECRET,
{
...(useExpiration ?? true ? { expiresIn: expOverwrite ?? JWT_EXPIRATION } : {}),
},
(err, token) => {
if (err) reject(err.message);
else resolve(token);
}
);
});
}
static decode(token: string): Promise<string | jwt.JwtPayload> {
return new Promise<string | jwt.JwtPayload>((resolve, reject) => {
try {
let decoded = jwt.decode(token);
resolve(decoded);
} catch (err) {
reject(err.message);
}
});
}
static async buildToken(id: string): Promise<string> {
let { firstname, lastname, mail, username, isOwner } = await UserService.getById(id);
let userPermissions = await UserPermissionService.getByUser(id);
let userPermissionStrings = userPermissions.map((e) => e.permission);
let userRoles = await UserService.getAssignedRolesByUserId(id);
let rolePermissions =
userRoles.length != 0 ? await RolePermissionService.getByRoles(userRoles.map((e) => e.id)) : [];
let rolePermissionStrings = rolePermissions.map((e) => e.permission);
let permissionObject = PermissionHelper.convertToObject([...userPermissionStrings, ...rolePermissionStrings]);
let jwtData: JWTToken = {
userId: id,
mail: mail,
username: username,
firstname: firstname,
lastname: lastname,
isOwner: isOwner,
permissions: permissionObject,
};
return await JWTHelper.create(jwtData)
.then((result) => {
return result;
})
.catch((err) => {
throw new InternalException("Failed accessToken creation", err);
});
}
}

43
src/helpers/mailHelper.ts Normal file
View file

@ -0,0 +1,43 @@
import { Transporter, createTransport, TransportOptions } from "nodemailer";
import { CLUB_NAME, MAIL_HOST, MAIL_PASSWORD, MAIL_PORT, MAIL_SECURE, MAIL_USERNAME } from "../env.defaults";
import { Attachment } from "nodemailer/lib/mailer";
export default abstract class MailHelper {
private static readonly transporter: Transporter = createTransport({
host: MAIL_HOST,
port: MAIL_PORT,
secure: (MAIL_SECURE as "true" | "false") == "true",
auth: {
user: MAIL_USERNAME,
pass: MAIL_PASSWORD,
},
} as TransportOptions);
/**
* @description send mail
* @param {string} target
* @param {string} subject
* @param {string} content
* @returns {Prmose<*>}
*/
static async sendMail(
target: string,
subject: string,
content: string,
attach: Array<Attachment> = []
): Promise<any> {
return new Promise((resolve, reject) => {
this.transporter
.sendMail({
from: `"${CLUB_NAME}" <${MAIL_USERNAME}>`,
to: target,
subject,
text: content,
html: content,
attachments: attach,
})
.then((info) => resolve(info.messageId))
.catch((e) => reject(e));
});
}
}

View file

@ -0,0 +1,28 @@
import { Request, Response } from "express";
import BadRequestException from "../exceptions/badRequestException";
export default class ParamaterPassCheckHelper {
static requiredIncluded(testfor: Array<string>, obj: object) {
let result = testfor.every((key) => Object.keys(obj).includes(key));
if (!result) throw new BadRequestException(`not all required parameters included: ${testfor.join(",")}`);
}
static forbiddenIncluded(testfor: Array<string>, obj: object) {
let result = testfor.some((key) => Object.keys(obj).includes(key));
if (!result) throw new BadRequestException(`PPC: forbidden parameters included: ${testfor.join(",")}`);
}
static requiredIncludedMiddleware(testfor: Array<string>): (req: Request, res: Response, next: Function) => void {
return (req: Request, res: Response, next: Function) => {
this.requiredIncluded(testfor, req.body);
next();
};
}
static forbiddenIncludedMiddleware(testfor: Array<string>): (req: Request, res: Response, next: Function) => void {
return (req: Request, res: Response, next: Function) => {
this.requiredIncluded(testfor, req.body);
next();
};
}
}

View file

@ -0,0 +1,255 @@
import { Request, Response } from "express";
import {
PermissionModule,
permissionModules,
PermissionObject,
PermissionSection,
PermissionString,
PermissionType,
permissionTypes,
} from "../type/permissionTypes";
import ForbiddenRequestException from "../exceptions/forbiddenRequestException";
export default class PermissionHelper {
static can(
permissions: PermissionObject,
type: PermissionType | "admin",
section: PermissionSection,
module?: PermissionModule
) {
if (type == "admin") return permissions?.admin ?? false;
if (permissions?.admin) return true;
if (
(!module &&
permissions[section] != undefined &&
(permissions[section]?.all == "*" || permissions[section]?.all?.includes(type))) ||
permissions[section]?.all == "*" ||
permissions[section]?.all?.includes(type)
)
return true;
if (module && (permissions[section]?.[module] == "*" || permissions[section]?.[module]?.includes(type)))
return true;
return false;
}
static canSome(
permissions: PermissionObject,
checks: Array<{
requiredPermissions: PermissionType | "admin";
section: PermissionSection;
module?: PermissionModule;
}>
) {
checks.reduce<boolean>((prev, curr) => {
return prev || this.can(permissions, curr.requiredPermissions, curr.section, curr.module);
}, false);
}
static canSection(
permissions: PermissionObject,
type: PermissionType | "admin",
section: PermissionSection
): boolean {
if (type == "admin") return permissions?.admin ?? false;
if (permissions?.admin) return true;
if (
permissions[section]?.all == "*" ||
permissions[section]?.all?.includes(type) ||
permissions[section] != undefined
)
return true;
return false;
}
static canSomeSection(
permissions: PermissionObject,
checks: Array<{
requiredPermissions: PermissionType | "admin";
section: PermissionSection;
}>
): boolean {
return checks.reduce<boolean>((prev, curr) => {
return prev || this.can(permissions, curr.requiredPermissions, curr.section);
}, false);
}
static passCheckMiddleware(
requiredPermissions: PermissionType | "admin",
section: PermissionSection,
module?: PermissionModule
): (req: Request, res: Response, next: Function) => void {
return (req: Request, res: Response, next: Function) => {
const permissions = req.permissions;
const isOwner = req.isOwner;
if (isOwner || this.can(permissions, requiredPermissions, section, module)) {
next();
} else {
throw new ForbiddenRequestException(`missing permission for ${section}.${module}.${requiredPermissions}`);
}
};
}
static passCheckSomeMiddleware(
checks: Array<{
requiredPermissions: PermissionType | "admin";
section: PermissionSection;
module?: PermissionModule;
}>
): (req: Request, res: Response, next: Function) => void {
return (req: Request, res: Response, next: Function) => {
const permissions = req.permissions;
const isOwner = req.isOwner;
if (isOwner || this.canSome(permissions, checks)) {
next();
} else {
let permissionsToPass = checks.reduce<string>((prev, curr) => {
return prev + (prev != " or " ? "" : "") + `${curr.section}.${curr.module}.${curr.requiredPermissions}`;
}, "");
throw new ForbiddenRequestException(`missing permission for ${permissionsToPass}`);
}
};
}
static sectionPassCheckMiddleware(
requiredPermissions: PermissionType | "admin",
section: PermissionSection
): (req: Request, res: Response, next: Function) => void {
return (req: Request, res: Response, next: Function) => {
const permissions = req.permissions;
const isOwner = req.isOwner;
if (isOwner || this.canSection(permissions, requiredPermissions, section)) {
next();
} else {
throw new ForbiddenRequestException(`missing permission for ${section}.${module}.${requiredPermissions}`);
}
};
}
static sectionPassCheckSomeMiddleware(
checks: Array<{ requiredPermissions: PermissionType | "admin"; section: PermissionSection }>
): (req: Request, res: Response, next: Function) => void {
return (req: Request, res: Response, next: Function) => {
const permissions = req.permissions;
const isOwner = req.isOwner;
if (isOwner || this.canSomeSection(permissions, checks)) {
next();
} else {
let permissionsToPass = checks.reduce<string>((prev, curr) => {
return prev + (prev != " or " ? "" : "") + `${curr.section}.${curr.requiredPermissions}`;
}, "");
throw new ForbiddenRequestException(`missing permission for ${permissionsToPass}`);
}
};
}
static isAdminMiddleware(): (req: Request, res: Response, next: Function) => void {
return (req: Request, res: Response, next: Function) => {
const permissions = req.permissions;
const isOwner = req.isOwner;
if (isOwner || permissions.admin) {
next();
} else {
throw new ForbiddenRequestException(`missing admin permission`);
}
};
}
static convertToObject(permissions: Array<PermissionString>): PermissionObject {
if (permissions.includes("*")) {
return {
admin: true,
};
}
let output: PermissionObject = {};
let splitPermissions = permissions.map((e) => e.split(".")) as Array<
[PermissionSection, PermissionModule | PermissionType | "*", PermissionType | "*"]
>;
for (let split of splitPermissions) {
if (!output[split[0]]) {
output[split[0]] = {};
}
if (split[1] == "*" || output[split[0]].all == "*") {
output[split[0]] = { all: "*" };
} else if (permissionTypes.includes(split[1] as PermissionType)) {
if (!output[split[0]].all || !Array.isArray(output[split[0]].all)) {
output[split[0]].all = [];
}
const permissionIndex = permissionTypes.indexOf(split[1] as PermissionType);
const appliedPermissions = permissionTypes.slice(0, permissionIndex + 1);
if (output[split[0]].all != "*") {
output[split[0]].all = [
...new Set([...output[split[0]].all, ...appliedPermissions]),
] as Array<PermissionType>;
}
} else {
if (split[2] == "*" || output[split[0]][split[1] as PermissionModule] == "*") {
output[split[0]][split[1] as PermissionModule] = "*";
} else {
if (
!output[split[0]][split[1] as PermissionModule] ||
!Array.isArray(output[split[0]][split[1] as PermissionModule])
) {
output[split[0]][split[1] as PermissionModule] = [];
}
const permissionIndex = permissionTypes.indexOf(split[2] as PermissionType);
const appliedPermissions = permissionTypes.slice(0, permissionIndex + 1);
output[split[0]][split[1] as PermissionModule] = appliedPermissions;
if (output[split[0]][split[1] as PermissionModule] != "*") {
output[split[0]][split[1] as PermissionModule] = [
...new Set([...output[split[0]][split[1] as PermissionModule], ...appliedPermissions]),
] as Array<PermissionType>;
}
}
}
}
return output;
}
static convertToStringArray(permissions: PermissionObject): Array<PermissionString> {
if (permissions?.admin) {
return ["*"];
}
let output: Array<PermissionString> = [];
let sections = Object.keys(permissions) as Array<PermissionSection>;
for (let section of sections) {
if (permissions[section].all) {
let types = permissions[section].all;
if (types == "*" || types.length == permissionTypes.length) {
output.push(`${section}.*`);
} else {
for (let type of types) {
output.push(`${section}.${type}`);
}
}
}
let modules = Object.keys(permissions[section]).filter((m: PermissionModule) =>
permissionModules.includes(m)
) as Array<PermissionModule>;
for (let module of modules) {
let types = permissions[section][module];
if (types == "*" || types.length == permissionTypes.length) {
output.push(`${section}.${module}.*`);
} else {
for (let type of types) {
output.push(`${section}.${module}.${type}`);
}
}
}
}
return output;
}
static getWhatToAdd(before: Array<PermissionString>, after: Array<PermissionString>): Array<PermissionString> {
return after.filter((permission) => !before.includes(permission));
}
static getWhatToRemove(before: Array<PermissionString>, after: Array<PermissionString>): Array<PermissionString> {
return before.filter((permission) => !after.includes(permission));
}
}

View file

@ -0,0 +1,17 @@
import crypto from "crypto";
export abstract class StringHelper {
static random(len: number, charSet?: string): string {
// charSet = charSet || "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
// var randomString = "";
// for (var i = 0; i < len; i++) {
// var randomPoz = Math.floor(Math.random() * charSet.length);
// randomString += charSet.substring(randomPoz, randomPoz + 1);
// }
// return randomString;
return crypto
.randomBytes(len)
.toString("base64")
.replace(/[^a-zA-Z0-9]/g, "");
}
}

44
src/index.ts Normal file
View file

@ -0,0 +1,44 @@
import "dotenv/config";
import express from "express";
import { BACKUP_AUTO_RESTORE, configCheck, SERVER_PORT } from "./env.defaults";
configCheck();
import { PermissionObject } from "./type/permissionTypes";
declare global {
namespace Express {
export interface Request {
userId: string;
username: string;
isOwner: boolean;
permissions: PermissionObject;
isPWA: boolean;
isWebApiRequest: boolean;
}
}
}
import { dataSource } from "./data-source";
import BackupHelper from "./helpers/backupHelper";
dataSource.initialize().then(async () => {
if ((BACKUP_AUTO_RESTORE as "true" | "false") == "true" && (await dataSource.createQueryRunner().hasTable("user"))) {
await BackupHelper.autoRestoreBackup().catch((err) => {
console.log(`${new Date().toISOString()}: failed auto-restoring database`, err);
});
}
});
const app = express();
import router from "./routes/index";
router(app);
app.listen(process.env.NODE_ENV ? SERVER_PORT : 5000, () => {
console.log(`${new Date().toISOString()}: listening on port ${process.env.NODE_ENV ? SERVER_PORT : 5000}`);
});
import schedule from "node-schedule";
import RefreshCommandHandler from "./command/refreshCommandHandler";
const job = schedule.scheduleJob("0 0 * * *", async () => {
console.log(`${new Date().toISOString()}: running Cron`);
await RefreshCommandHandler.deleteExpired();
await BackupHelper.createBackupOnInterval();
});

View file

@ -0,0 +1,12 @@
import { NextFunction, Request, Response } from "express";
import UserService from "../service/management/userService";
import CustomRequestException from "../exceptions/customRequestException";
export default async function allowSetup(req: Request, res: Response, next: NextFunction) {
let count = await UserService.count();
if (count != 0) {
throw new CustomRequestException(405, "service is already set up");
}
next();
}

View file

@ -0,0 +1,43 @@
import { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
import BadRequestException from "../exceptions/badRequestException";
import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException";
import InternalException from "../exceptions/internalException";
import { JWTHelper } from "../helpers/jwtHelper";
export default async function authenticate(req: Request, res: Response, next: NextFunction) {
const bearer = req.headers.authorization?.split(" ")?.[1] ?? undefined;
if (!bearer) {
throw new BadRequestException("Provide valid Authorization Header");
}
let decoded: string | jwt.JwtPayload;
await JWTHelper.validate(bearer)
.then((result) => {
decoded = result;
})
.catch((err) => {
if (err == "jwt expired") {
throw new UnauthorizedRequestException("Token expired", err);
} else {
throw new BadRequestException("Failed Authorization Header decoding", err);
}
});
if (typeof decoded == "string" || !decoded) {
throw new InternalException("process failed");
}
if (decoded?.sub == "api_token_retrieve") {
throw new BadRequestException("This token is only authorized to get temporary access tokens via GET /api/webapi");
}
req.userId = decoded.userId;
req.username = decoded.username;
req.isOwner = decoded.isOwner;
req.permissions = decoded.permissions;
req.isWebApiRequest = decoded?.sub == "webapi_access_token";
next();
}

View file

@ -0,0 +1,11 @@
import { NextFunction, Request, Response } from "express";
export default async function detectPWA(req: Request, res: Response, next: NextFunction) {
const userAgent = req.headers["user-agent"] || "";
if ((userAgent.includes("Mobile") && userAgent.includes("Standalone")) || req.headers["x-pwa-client"] === "true") {
req.isPWA = true;
} else {
req.isPWA = false;
}
next();
}

View file

@ -0,0 +1,22 @@
import { NextFunction, Request, Response } from "express";
import { ExceptionBase } from "../exceptions/exceptionsBaseType";
import CustomRequestException from "../exceptions/customRequestException";
import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException";
export default function errorHandler(err: ExceptionBase | Error, req: Request, res: Response, next: NextFunction) {
let status = 500;
let msg = "Internal Server Error";
if (err instanceof CustomRequestException) {
status = err.statusCode;
msg = err.message;
}
if (err instanceof CustomRequestException) {
console.log("Custom Handler", status, msg);
} else {
console.log("Error Handler", err);
}
res.status(status).send(msg);
}

View file

@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from "typeorm";
import {
invite_table,
refresh_table,
reset_table,
role_permission_table,
role_table,
user_permission_table,
user_roles_table,
user_table,
} from "./baseSchemaTables/admin";
import { member_table } from "./baseSchemaTables/member";
export class CreateSchema1739697068682 implements MigrationInterface {
name = "CreateSchema1739697068682";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(reset_table, true, true, true);
await queryRunner.createTable(invite_table, true, true, true);
await queryRunner.createTable(role_table, true, true, true);
await queryRunner.createTable(role_permission_table, true, true, true);
await queryRunner.createTable(user_table, true, true, true);
await queryRunner.createTable(user_roles_table, true, true, true);
await queryRunner.createTable(user_permission_table, true, true, true);
await queryRunner.createTable(refresh_table, true, true, true);
await queryRunner.createTable(member_table, true, true, true);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("member", true, true, true);
await queryRunner.dropTable("refresh", true, true, true);
await queryRunner.dropTable("user_permission", true, true, true);
await queryRunner.dropTable("user_roles", true, true, true);
await queryRunner.dropTable("user", true, true, true);
await queryRunner.dropTable("role_permission", true, true, true);
await queryRunner.dropTable("role", true, true, true);
await queryRunner.dropTable("invite", true, true, true);
await queryRunner.dropTable("reset", true, true, true);
}
}

View file

@ -0,0 +1,121 @@
import { Table, TableForeignKey } from "typeorm";
import { getDefaultByORM, getTypeByORM, isIncrementPrimary, isUUIDPrimary } from "../ormHelper";
export const invite_table = new Table({
name: "invite",
columns: [
{ name: "mail", ...getTypeByORM("varchar"), isPrimary: true },
{ name: "token", ...getTypeByORM("varchar") },
{ name: "username", ...getTypeByORM("varchar") },
{ name: "firstname", ...getTypeByORM("varchar") },
{ name: "lastname", ...getTypeByORM("varchar") },
{ name: "secret", ...getTypeByORM("varchar") },
],
});
export const role_table = new Table({
name: "role",
columns: [
{ name: "id", ...getTypeByORM("int"), ...isIncrementPrimary },
{ name: "role", ...getTypeByORM("varchar"), isUnique: true },
],
});
export const role_permission_table = new Table({
name: "role_permission",
columns: [
{ name: "roleId", ...getTypeByORM("int"), isPrimary: true },
{ name: "permission", ...getTypeByORM("varchar"), isPrimary: true },
],
foreignKeys: [
new TableForeignKey({
columnNames: ["roleId"],
referencedColumnNames: ["id"],
referencedTableName: "role",
onDelete: "CASCADE",
onUpdate: "RESTRICT",
}),
],
});
export const user_table = new Table({
name: "user",
columns: [
{ name: "id", ...getTypeByORM("uuid"), ...isUUIDPrimary },
{ name: "mail", ...getTypeByORM("varchar"), isUnique: true },
{ name: "username", ...getTypeByORM("varchar"), isUnique: true },
{ name: "firstname", ...getTypeByORM("varchar") },
{ name: "lastname", ...getTypeByORM("varchar") },
{ name: "secret", ...getTypeByORM("varchar") },
{ name: "static", ...getTypeByORM("boolean"), default: getDefaultByORM("boolean", false) },
{ name: "isOwner", ...getTypeByORM("boolean"), default: getDefaultByORM("boolean", false) },
],
});
export const user_roles_table = new Table({
name: "user_roles",
columns: [
{ name: "userId", ...getTypeByORM("uuid"), isPrimary: true },
{ name: "roleId", ...getTypeByORM("int"), isPrimary: true },
],
foreignKeys: [
new TableForeignKey({
columnNames: ["userId"],
referencedColumnNames: ["id"],
referencedTableName: "user",
onDelete: "CASCADE",
onUpdate: "RESTRICT",
}),
new TableForeignKey({
columnNames: ["roleId"],
referencedColumnNames: ["id"],
referencedTableName: "role",
onDelete: "CASCADE",
onUpdate: "RESTRICT",
}),
],
});
export const user_permission_table = new Table({
name: "user_permission",
columns: [
{ name: "userId", ...getTypeByORM("uuid"), isPrimary: true },
{ name: "permission", ...getTypeByORM("varchar"), isPrimary: true },
],
foreignKeys: [
new TableForeignKey({
columnNames: ["userId"],
referencedColumnNames: ["id"],
referencedTableName: "user",
onDelete: "CASCADE",
onUpdate: "RESTRICT",
}),
],
});
export const refresh_table = new Table({
name: "refresh",
columns: [
{ name: "token", ...getTypeByORM("varchar"), isPrimary: true },
{ name: "expiry", ...getTypeByORM("datetime", false, 6) },
{ name: "userId", ...getTypeByORM("uuid"), isPrimary: true },
],
foreignKeys: [
new TableForeignKey({
columnNames: ["userId"],
referencedColumnNames: ["id"],
referencedTableName: "user",
onDelete: "CASCADE",
onUpdate: "RESTRICT",
}),
],
});
export const reset_table = new Table({
name: "reset",
columns: [
{ name: "mail", ...getTypeByORM("varchar"), isPrimary: true },
{ name: "token", ...getTypeByORM("varchar") },
{ name: "username", ...getTypeByORM("varchar") },
{ name: "secret", ...getTypeByORM("varchar") },
],
});

View file

@ -0,0 +1,12 @@
import { Table } from "typeorm";
import { getTypeByORM, isUUIDPrimary } from "../ormHelper";
export const member_table = new Table({
name: "member",
columns: [
{ name: "id", ...getTypeByORM("uuid"), ...isUUIDPrimary },
{ name: "firstname", ...getTypeByORM("varchar") },
{ name: "lastname", ...getTypeByORM("varchar") },
{ name: "nameaffix", ...getTypeByORM("varchar") },
],
});

105
src/migrations/ormHelper.ts Normal file
View file

@ -0,0 +1,105 @@
export type ORMType = "int" | "bigint" | "boolean" | "date" | "datetime" | "time" | "text" | "varchar" | "uuid";
export type ORMDefault = "currentTimestamp" | "string" | "boolean" | "number" | "null";
export type ColumnConfig = {
type: string;
length?: string;
precision?: number;
isNullable: boolean;
};
export type Primary = {
isPrimary: boolean;
isGenerated: boolean;
generationStrategy: "increment" | "uuid" | "rowid" | "identity";
};
export function getTypeByORM(type: ORMType, nullable: boolean = false, length: number = 255): ColumnConfig {
const dbType = process.env.DB_TYPE;
const typeMap: Record<string, Record<ORMType, string>> = {
mysql: {
int: "int",
bigint: "bigint",
boolean: "tinyint",
date: "date",
datetime: "datetime",
time: "time",
text: "text",
varchar: "varchar",
uuid: "varchar",
},
postgres: {
int: "integer",
bigint: "bigint",
boolean: "boolean",
date: "date",
datetime: "timestamp",
time: "time",
text: "text",
varchar: "character varying",
uuid: "uuid",
},
sqlite: {
int: "integer",
bigint: "integer",
boolean: "integer",
date: "date",
datetime: "datetime",
time: "text",
text: "text",
varchar: "varchar",
uuid: "varchar",
},
};
let obj: ColumnConfig = {
type: typeMap[dbType]?.[type] || type,
isNullable: nullable,
};
if (type == "datetime") obj.precision = length;
else if (dbType != "sqlite" && (obj.type == "varchar" || type == "varchar")) obj.length = `${length}`;
else if (dbType != "postgres" && type == "uuid") obj.length = "36";
return obj;
}
export function getDefaultByORM<T = string | null>(type: ORMDefault, data?: string | number | boolean): T {
const dbType = process.env.DB_TYPE;
const typeMap: Record<string, Record<ORMDefault, string | null>> = {
mysql: {
currentTimestamp: `CURRENT_TIMESTAMP(${data ?? 6})`,
string: `'${data ?? ""}'`,
boolean: Boolean(data).toString(),
number: Number(data).toString(),
null: null,
},
postgres: {
currentTimestamp: `now()`,
string: `'${data ?? ""}'`,
boolean: Boolean(data) == true ? "true" : "false",
number: Number(data).toString(),
null: null,
},
sqlite: {
currentTimestamp: `datetime('now')`,
string: `'${data ?? ""}'`,
boolean: Boolean(data) == true ? "1" : "0",
number: Number(data).toString(),
null: null,
},
};
return (typeMap[dbType]?.[type] || type) as T;
}
export const isIncrementPrimary: Primary = {
isPrimary: true,
isGenerated: true,
generationStrategy: "increment",
};
export const isUUIDPrimary: Primary = {
isPrimary: true,
isGenerated: true,
generationStrategy: "uuid",
};

View file

@ -0,0 +1,50 @@
import express, { Request, Response } from "express";
import {
createMember,
deleteMemberById,
getAllMembers,
getMemberById,
getMembersByIds,
updateMemberById,
} from "../../../controller/admin/configuration/memberController";
import PermissionHelper from "../../../helpers/permissionHelper";
var router = express.Router({ mergeParams: true });
router.get("/", async (req: Request, res: Response) => {
await getAllMembers(req, res);
});
router.post("/ids", async (req: Request, res: Response) => {
await getMembersByIds(req, res);
});
router.get("/:id", async (req: Request, res: Response) => {
await getMemberById(req, res);
});
router.post(
"/",
PermissionHelper.passCheckMiddleware("create", "operation", "force"),
async (req: Request, res: Response) => {
await createMember(req, res);
}
);
router.patch(
"/:id",
PermissionHelper.passCheckMiddleware("update", "operation", "force"),
async (req: Request, res: Response) => {
await updateMemberById(req, res);
}
);
router.delete(
"/:id",
PermissionHelper.passCheckMiddleware("delete", "operation", "force"),
async (req: Request, res: Response) => {
await deleteMemberById(req, res);
}
);
export default router;

27
src/routes/admin/index.ts Normal file
View file

@ -0,0 +1,27 @@
import express from "express";
import PermissionHelper from "../../helpers/permissionHelper";
import member from "./configuration/member";
import role from "./management/role";
import user from "./management/user";
import invite from "./management/invite";
import backup from "./management/backup";
var router = express.Router({ mergeParams: true });
router.use("/member", PermissionHelper.passCheckMiddleware("read", "configuration", "force"), member);
router.use("/role", PermissionHelper.passCheckMiddleware("read", "management", "role"), role);
router.use(
"/user",
PermissionHelper.passCheckSomeMiddleware([
{ requiredPermissions: "read", section: "management", module: "user" },
{ requiredPermissions: "read", section: "management", module: "role" },
]),
user
);
router.use("/invite", PermissionHelper.passCheckMiddleware("read", "management", "user"), invite);
router.use("/backup", PermissionHelper.passCheckMiddleware("read", "management", "backup"), backup);
export default router;

View file

@ -0,0 +1,88 @@
import express, { Request, Response } from "express";
import PermissionHelper from "../../../helpers/permissionHelper";
import multer from "multer";
import {
createManualBackup,
downloadBackupFile,
downloadUploadedBackupFile,
getGeneratedBackups,
getUploadedBackups,
restoreBackupByLocalFile,
restoreBackupByUploadedFile,
uploadBackupFile,
} from "../../../controller/admin/management/backupController";
import { FileSystemHelper } from "../../../helpers/fileSystemHelper";
const storage = multer.diskStorage({
destination: (req, file, cb) => {
FileSystemHelper.createFolder("uploaded-backup");
cb(null, "files/uploaded-backup/");
},
filename: (req, file, cb) => {
cb(null, `${new Date().toISOString().split("T")[0]}_${file.originalname}`);
},
});
const upload = multer({
storage,
fileFilter: (req: Request, file, cb) => {
if (file.mimetype === "application/json") {
cb(null, true);
} else {
cb(new Error("Only JSON files are allowed!"));
}
},
});
var router = express.Router({ mergeParams: true });
router.get("/generated", async (req: Request, res: Response) => {
await getGeneratedBackups(req, res);
});
router.get("/generated/:filename", async (req: Request, res: Response) => {
await downloadBackupFile(req, res);
});
router.get("/uploaded", async (req: Request, res: Response) => {
await getUploadedBackups(req, res);
});
router.get("/uploaded/:filename", async (req: Request, res: Response) => {
await downloadUploadedBackupFile(req, res);
});
router.post(
"/",
PermissionHelper.passCheckMiddleware("create", "management", "backup"),
async (req: Request, res: Response) => {
await createManualBackup(req, res);
}
);
router.post(
"/generated/restore",
PermissionHelper.passCheckMiddleware("admin", "management", "backup"),
async (req: Request, res: Response) => {
await restoreBackupByLocalFile(req, res);
}
);
router.post(
"/uploaded/restore",
PermissionHelper.passCheckMiddleware("admin", "management", "backup"),
async (req: Request, res: Response) => {
await restoreBackupByUploadedFile(req, res);
}
);
router.post(
"/upload",
PermissionHelper.passCheckMiddleware("create", "management", "backup"),
upload.single("file"),
async (req: Request, res: Response) => {
await uploadBackupFile(req, res);
}
);
export default router;

View file

@ -0,0 +1,27 @@
import express, { Request, Response } from "express";
import PermissionHelper from "../../../helpers/permissionHelper";
import { deleteInvite, getInvites, inviteUser } from "../../../controller/inviteController";
var router = express.Router({ mergeParams: true });
router.get("/", async (req: Request, res: Response) => {
await getInvites(req, res);
});
router.post(
"/",
PermissionHelper.passCheckMiddleware("create", "management", "user"),
async (req: Request, res: Response) => {
await inviteUser(req, res);
}
);
router.delete(
"/:mail",
PermissionHelper.passCheckMiddleware("delete", "management", "user"),
async (req: Request, res: Response) => {
await deleteInvite(req, res);
}
);
export default router;

View file

@ -0,0 +1,59 @@
import express, { Request, Response } from "express";
import PermissionHelper from "../../../helpers/permissionHelper";
import {
createRole,
deleteRole,
getAllRoles,
getRoleById,
getRolePermissions,
updateRole,
updateRolePermissions,
} from "../../../controller/admin/management/roleController";
var router = express.Router({ mergeParams: true });
router.get("/", async (req: Request, res: Response) => {
await getAllRoles(req, res);
});
router.get("/:id", async (req: Request, res: Response) => {
await getRoleById(req, res);
});
router.get("/:id/permissions", async (req: Request, res: Response) => {
await getRolePermissions(req, res);
});
router.post(
"/",
PermissionHelper.passCheckMiddleware("create", "management", "role"),
async (req: Request, res: Response) => {
await createRole(req, res);
}
);
router.patch(
"/:id",
PermissionHelper.passCheckMiddleware("update", "management", "role"),
async (req: Request, res: Response) => {
await updateRole(req, res);
}
);
router.patch(
"/:id/permissions",
PermissionHelper.passCheckMiddleware("admin", "management", "role"),
async (req: Request, res: Response) => {
await updateRolePermissions(req, res);
}
);
router.delete(
"/:id",
PermissionHelper.passCheckMiddleware("delete", "management", "role"),
async (req: Request, res: Response) => {
await deleteRole(req, res);
}
);
export default router;

View file

@ -0,0 +1,65 @@
import express, { Request, Response } from "express";
import PermissionHelper from "../../../helpers/permissionHelper";
import {
deleteUser,
getAllUsers,
getUserById,
getUserPermissions,
getUserRoles,
updateUser,
updateUserPermissions,
updateUserRoles,
} from "../../../controller/admin/management/userController";
import { inviteUser } from "../../../controller/inviteController";
var router = express.Router({ mergeParams: true });
router.get("/", async (req: Request, res: Response) => {
await getAllUsers(req, res);
});
router.get("/:id", async (req: Request, res: Response) => {
await getUserById(req, res);
});
router.get("/:id/permissions", async (req: Request, res: Response) => {
await getUserPermissions(req, res);
});
router.get("/:id/roles", async (req: Request, res: Response) => {
await getUserRoles(req, res);
});
router.patch(
"/:id",
PermissionHelper.passCheckMiddleware("update", "management", "user"),
async (req: Request, res: Response) => {
await updateUser(req, res);
}
);
router.patch(
"/:id/permissions",
PermissionHelper.passCheckMiddleware("admin", "management", "user"),
async (req: Request, res: Response) => {
await updateUserPermissions(req, res);
}
);
router.patch(
"/:id/roles",
PermissionHelper.passCheckMiddleware("update", "management", "user"),
async (req: Request, res: Response) => {
await updateUserRoles(req, res);
}
);
router.delete(
"/:id",
PermissionHelper.passCheckMiddleware("delete", "management", "user"),
async (req: Request, res: Response) => {
await deleteUser(req, res);
}
);
export default router;

18
src/routes/auth.ts Normal file
View file

@ -0,0 +1,18 @@
import express from "express";
import { login, logout, refresh } from "../controller/authController";
var router = express.Router({ mergeParams: true });
router.post("/login", async (req, res) => {
await login(req, res);
});
router.post("/logout", async (req, res) => {
await logout(req, res);
});
router.post("/refresh", async (req, res) => {
await refresh(req, res);
});
export default router;

88
src/routes/index.ts Normal file
View file

@ -0,0 +1,88 @@
import express from "express";
import type { Express, NextFunction, Request, RequestHandler, Response } from "express";
import cors from "cors";
import helmet from "helmet";
import morgan from "morgan";
import rateLimit from "express-rate-limit";
import allowSetup from "../middleware/allowSetup";
import authenticate from "../middleware/authenticate";
import errorHandler from "../middleware/errorHandler";
import setup from "./setup";
import invite from "./invite";
import reset from "./reset";
import auth from "./auth";
import admin from "./admin/index";
import user from "./user";
import detectPWA from "../middleware/detectPWA";
import server from "./server";
import PermissionHelper from "../helpers/permissionHelper";
import ms from "ms";
import {
SECURITY_LIMIT_REQUEST_COUNT,
SECURITY_LIMIT_WINDOW,
SECURITY_STRICT_LIMIT_REQUEST_COUNT,
SECURITY_STRICT_LIMIT_WINDOW,
TRUST_PROXY,
USE_SECURITY_LIMIT,
USE_SECURITY_STRICT_LIMIT,
} from "../env.defaults";
const strictLimiter = rateLimit({
windowMs: ms(SECURITY_STRICT_LIMIT_WINDOW),
max: SECURITY_STRICT_LIMIT_REQUEST_COUNT,
message: `Zu viele Anmeldeversuche innerhalb von ${SECURITY_STRICT_LIMIT_WINDOW}. Bitte warten.`,
skipSuccessfulRequests: true,
skip: () => {
return USE_SECURITY_STRICT_LIMIT == "false";
},
});
const generalLimiter = rateLimit({
windowMs: ms(SECURITY_LIMIT_WINDOW),
max: SECURITY_LIMIT_REQUEST_COUNT,
message: `Zu viele Anfragen innerhalb von ${SECURITY_LIMIT_WINDOW}. Bitte warten.`,
skipSuccessfulRequests: true,
skip: () => {
return USE_SECURITY_LIMIT == "false";
},
});
function excludePaths(middleware: RequestHandler, excludedPaths: Array<string>) {
return (req: Request, res: Response, next: NextFunction) => {
if (excludedPaths.includes(req.path)) {
return next();
}
return middleware(req, res, next);
};
}
export default (app: Express) => {
if (TRUST_PROXY) {
app.set("trust proxy", TRUST_PROXY);
}
app.set("query parser", "extended");
app.use(cors());
app.options("*", cors());
app.use(helmet());
app.use(morgan("short"));
app.use(express.json());
app.use(
express.urlencoded({
extended: true,
})
);
app.use(detectPWA);
app.use("/api/setup", strictLimiter, allowSetup, setup);
app.use("/api/reset", strictLimiter, reset);
app.use("/api/invite", strictLimiter, invite);
app.use("/api/auth", strictLimiter, auth);
app.use(authenticate);
app.use(excludePaths(generalLimiter, ["/synchronize"]));
app.use("/api/admin", admin);
app.use("/api/user", user);
app.use("/api/server", PermissionHelper.isAdminMiddleware(), server);
app.use(errorHandler);
};

16
src/routes/invite.ts Normal file
View file

@ -0,0 +1,16 @@
import express from "express";
import { isSetup } from "../controller/setupController";
import { finishInvite, inviteUser, verifyInvite } from "../controller/inviteController";
import ParamaterPassCheckHelper from "../helpers/parameterPassCheckHelper";
var router = express.Router({ mergeParams: true });
router.post("/verify", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token"]), async (req, res) => {
await verifyInvite(req, res);
});
router.put("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "totp"]), async (req, res) => {
await finishInvite(req, res);
});
export default router;

19
src/routes/reset.ts Normal file
View file

@ -0,0 +1,19 @@
import express from "express";
import ParamaterPassCheckHelper from "../helpers/parameterPassCheckHelper";
import { finishReset, startReset, verifyReset } from "../controller/resetController";
var router = express.Router({ mergeParams: true });
router.post("/verify", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token"]), async (req, res) => {
await verifyReset(req, res);
});
router.post("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["username"]), async (req, res) => {
await startReset(req, res);
});
router.put("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "totp"]), async (req, res) => {
await finishReset(req, res);
});
export default router;

35
src/routes/server.ts Normal file
View file

@ -0,0 +1,35 @@
import express, { Request, Response } from "express";
import { FileSystemHelper } from "../helpers/fileSystemHelper";
import Parser from "rss-parser";
var router = express.Router({ mergeParams: true });
router.get("/version", async (req: Request, res: Response) => {
let serverPackage = FileSystemHelper.readTemplateFile("/package.json");
let serverJson = JSON.parse(serverPackage);
res.send({
name: serverJson.name,
description: serverJson.description,
version: serverJson.version,
author: serverJson.author,
license: serverJson.license,
});
});
router.get("/settings", async (req: Request, res: Response) => {
res.json({});
});
router.get("/serverrss", async (req: Request, res: Response) => {
const parser = new Parser();
let feed = await parser.parseURL("https://forgejo.jk-effects.cloud/Ehrenamt/ff-operation-server/releases.rss");
res.json(feed);
});
router.get("/clientrss", async (req: Request, res: Response) => {
const parser = new Parser();
let feed = await parser.parseURL("https://forgejo.jk-effects.cloud/Ehrenamt/ff-operation/releases.rss");
res.json(feed);
});
export default router;

28
src/routes/setup.ts Normal file
View file

@ -0,0 +1,28 @@
import express from "express";
import { isSetup } from "../controller/setupController";
import { finishInvite, inviteUser, verifyInvite } from "../controller/inviteController";
import ParamaterPassCheckHelper from "../helpers/parameterPassCheckHelper";
var router = express.Router({ mergeParams: true });
router.get("/", async (req, res) => {
await isSetup(req, res);
});
router.post("/verify", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token"]), async (req, res) => {
await verifyInvite(req, res);
});
router.post(
"/",
ParamaterPassCheckHelper.requiredIncludedMiddleware(["username", "mail", "firstname", "lastname"]),
async (req, res) => {
await inviteUser(req, res, false);
}
);
router.put("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "totp"]), async (req, res) => {
await finishInvite(req, res, true);
});
export default router;

26
src/routes/user.ts Normal file
View file

@ -0,0 +1,26 @@
import express from "express";
import { getMeById, getMyTotp, transferOwnership, updateMe, verifyMyTotp } from "../controller/userController";
var router = express.Router({ mergeParams: true });
router.get("/me", async (req, res) => {
await getMeById(req, res);
});
router.get("/totp", async (req, res) => {
await getMyTotp(req, res);
});
router.post("/verify", async (req, res) => {
await verifyMyTotp(req, res);
});
router.put("/transferOwner", async (req, res) => {
await transferOwnership(req, res);
});
router.patch("/me", async (req, res) => {
await updateMe(req, res);
});
export default router;

View file

@ -0,0 +1,80 @@
import { dataSource } from "../../data-source";
import { member } from "../../entity/configuration/member";
import DatabaseActionException from "../../exceptions/databaseActionException";
export default abstract class MemberService {
/**
* @description get all members
* @returns {Promise<[Array<member>, number]>}
*/
static async getAll({
offset = 0,
count = 25,
search = "",
noLimit = false,
ids = [],
}: {
offset?: number;
count?: number;
search?: string;
noLimit?: boolean;
ids?: Array<string>;
}): Promise<[Array<member>, number]> {
let query = dataSource.getRepository(member).createQueryBuilder("member");
if (search != "") {
search.split(" ").forEach((term, index) => {
const searchQuery = `%${term}%`;
const dynamic = "searchQuery" + Math.random().toString(36).substring(2);
if (index == 0) {
query = query.where(`member.firstname LIKE :${dynamic} OR member.lastname LIKE :${dynamic}`, {
[dynamic]: searchQuery,
});
} else {
query = query.orWhere(`member.firstname LIKE :${dynamic} OR member.lastname LIKE :${dynamic}`, {
[dynamic]: searchQuery,
});
}
});
}
if (ids.length != 0) {
query = query.where("member.id IN (:...ids)", { ids: ids });
}
if (!noLimit) {
query = query.offset(offset).limit(count);
}
return await query
.orderBy("member.lastname")
.addOrderBy("member.firstname")
.addOrderBy("member.nameaffix")
.getManyAndCount()
.then((res) => {
return res;
})
.catch((err) => {
throw new DatabaseActionException("SELECT", "member", err);
});
}
/**
* @description get member by id
* @param {string} id
* @returns {Promise<member>}
*/
static async getById(id: string): Promise<member> {
return dataSource
.getRepository(member)
.createQueryBuilder("member")
.where("member.id = :id", { id: id })
.getOneOrFail()
.then((res) => {
return res;
})
.catch((err) => {
throw new DatabaseActionException("SELECT", "member", err);
});
}
}

View file

@ -0,0 +1,44 @@
import { dataSource } from "../../data-source";
import { invite } from "../../entity/management/invite";
import DatabaseActionException from "../../exceptions/databaseActionException";
import InternalException from "../../exceptions/internalException";
export default abstract class InviteService {
/**
* @description get all invites
* @returns {Promise<Array<invite>>}
*/
static async getAll(): Promise<Array<invite>> {
return await dataSource
.getRepository(invite)
.createQueryBuilder("invite")
.getMany()
.then((res) => {
return res;
})
.catch((err) => {
throw new DatabaseActionException("SELECT", "invite", err);
});
}
/**
* @description get invite by id
* @param mail string
* @param token string
* @returns {Promise<invite>}
*/
static async getByMailAndToken(mail: string, token: string): Promise<invite> {
return await dataSource
.getRepository(invite)
.createQueryBuilder("invite")
.where("invite.mail = :mail", { mail: mail })
.andWhere("invite.token = :token", { token: token })
.getOneOrFail()
.then((res) => {
return res;
})
.catch((err) => {
throw new DatabaseActionException("SELECT", "invite", err);
});
}
}

View file

@ -0,0 +1,45 @@
import { dataSource } from "../../data-source";
import { rolePermission } from "../../entity/management/role_permission";
import { userPermission } from "../../entity/management/user_permission";
import DatabaseActionException from "../../exceptions/databaseActionException";
import InternalException from "../../exceptions/internalException";
export default abstract class RolePermissionService {
/**
* @description get permission by role
* @param roleId number
* @returns {Promise<Array<rolePermission>>}
*/
static async getByRole(roleId: number): Promise<Array<rolePermission>> {
return await dataSource
.getRepository(rolePermission)
.createQueryBuilder("permission")
.where("permission.roleId = :roleId", { roleId: roleId })
.getMany()
.then((res) => {
return res;
})
.catch((err) => {
throw new DatabaseActionException("SELECT", "rolePermission", err);
});
}
/**
* @description get permission by roles
* @param roleIds Array<number>
* @returns {Promise<Array<rolePermission>>}
*/
static async getByRoles(roleIds: Array<number>): Promise<Array<rolePermission>> {
return await dataSource
.getRepository(rolePermission)
.createQueryBuilder("permission")
.where("permission.roleId IN (:...roleIds)", { roleIds: roleIds })
.getMany()
.then((res) => {
return res;
})
.catch((err) => {
throw new DatabaseActionException("SELECT", "rolePermission", err);
});
}
}

View file

@ -0,0 +1,45 @@
import { dataSource } from "../../data-source";
import { role } from "../../entity/management/role";
import DatabaseActionException from "../../exceptions/databaseActionException";
import InternalException from "../../exceptions/internalException";
export default abstract class RoleService {
/**
* @description get roles
* @returns {Promise<Array<role>>}
*/
static async getAll(): Promise<Array<role>> {
return await dataSource
.getRepository(role)
.createQueryBuilder("role")
.leftJoinAndSelect("role.permissions", "role_permissions")
.orderBy("role", "ASC")
.getMany()
.then((res) => {
return res;
})
.catch((err) => {
throw new DatabaseActionException("SELECT", "roles", err);
});
}
/**
* @description get role by id
* @param id number
* @returns {Promise<role>}
*/
static async getById(id: number): Promise<role> {
return await dataSource
.getRepository(role)
.createQueryBuilder("role")
.leftJoinAndSelect("role.permissions", "role_permissions")
.where("role.id = :id", { id: id })
.getOneOrFail()
.then((res) => {
return res;
})
.catch((err) => {
throw new DatabaseActionException("SELECT", "role", err);
});
}
}

View file

@ -0,0 +1,25 @@
import { dataSource } from "../../data-source";
import { userPermission } from "../../entity/management/user_permission";
import DatabaseActionException from "../../exceptions/databaseActionException";
import InternalException from "../../exceptions/internalException";
export default abstract class UserPermissionService {
/**
* @description get permission by user
* @param userId string
* @returns {Promise<Array<userPermission>>}
*/
static async getByUser(userId: string): Promise<Array<userPermission>> {
return await dataSource
.getRepository(userPermission)
.createQueryBuilder("permission")
.where({ userId: userId })
.getMany()
.then((res) => {
return res;
})
.catch((err) => {
throw new DatabaseActionException("SELECT", "userPermission", err);
});
}
}

View file

@ -0,0 +1,132 @@
import { dataSource } from "../../data-source";
import { role } from "../../entity/management/role";
import { user } from "../../entity/management/user";
import DatabaseActionException from "../../exceptions/databaseActionException";
import InternalException from "../../exceptions/internalException";
export default abstract class UserService {
/**
* @description get users
* @returns {Promise<Array<user>>}
*/
static async getAll(): Promise<Array<user>> {
return await dataSource
.getRepository(user)
.createQueryBuilder("user")
.leftJoinAndSelect("user.roles", "roles")
.leftJoinAndSelect("user.permissions", "permissions")
.leftJoinAndSelect("roles.permissions", "role_permissions")
.orderBy("firstname", "ASC")
.addOrderBy("lastname", "ASC")
.getMany()
.then((res) => {
return res;
})
.catch((err) => {
throw new DatabaseActionException("SELECT", "user", err);
});
}
/**
* @description get user by id
* @param id string
* @returns {Promise<user>}
*/
static async getById(id: string): Promise<user> {
return await dataSource
.getRepository(user)
.createQueryBuilder("user")
.leftJoinAndSelect("user.roles", "roles")
.leftJoinAndSelect("user.permissions", "permissions")
.leftJoinAndSelect("roles.permissions", "role_permissions")
.where("user.id = :id", { id: id })
.getOneOrFail()
.then((res) => {
return res;
})
.catch((err) => {
throw new DatabaseActionException("SELECT", "user", err);
});
}
/**
* @description get user by username
* @param username string
* @returns {Promise<user>}
*/
static async getByUsername(username: string): Promise<user> {
return await dataSource
.getRepository(user)
.createQueryBuilder("user")
.select()
.where("user.username = :username", { username: username })
.getOneOrFail()
.then((res) => {
return res;
})
.catch((err) => {
throw new DatabaseActionException("SELECT", "user", err);
});
}
/**
* @description get users by mail or username
* @param username string
* @param mail string
* @returns {Promise<Array<user>>}
*/
static async getByMailOrUsername(mail?: string, username?: string): Promise<Array<user>> {
return await dataSource
.getRepository(user)
.createQueryBuilder("user")
.select()
.where("user.mail = :mail", { mail: mail })
.orWhere("user.username = :username", { username: username })
.getMany()
.then((res) => {
return res;
})
.catch((err) => {
throw new DatabaseActionException("SELECT", "user", err);
});
}
/**
* @description get count of users
* @returns {Promise<number>}
*/
static async count(): Promise<number> {
return await dataSource
.getRepository(user)
.createQueryBuilder("user")
.select()
.getCount()
.then((res) => {
return res;
})
.catch((err) => {
throw new DatabaseActionException("COUNT", "users", err);
});
}
/**
* @description get roles assigned to user
* @param userId string
* @returns {Promise<Array<role>>}
*/
static async getAssignedRolesByUserId(userId: string): Promise<Array<role>> {
return await dataSource
.getRepository(user)
.createQueryBuilder("user")
.leftJoinAndSelect("user.roles", "roles")
.leftJoinAndSelect("roles.permissions", "role_permissions")
.where("user.id = :id", { id: userId })
.getOneOrFail()
.then((res) => {
return res.roles;
})
.catch((err) => {
throw new DatabaseActionException("SELECT", "userRoles", err);
});
}
}

View file

@ -0,0 +1,26 @@
import { dataSource } from "../data-source";
import { refresh } from "../entity/refresh";
import InternalException from "../exceptions/internalException";
export default abstract class RefreshService {
/**
* @description get refresh by token
* @param token string
* @returns {Promise<refresh>}
*/
static async getByToken(token: string): Promise<refresh> {
return await dataSource
.getRepository(refresh)
.createQueryBuilder("refresh")
.leftJoinAndSelect("refresh.user", "user")
.where("refresh.token = :token", { token: token })
.andWhere("refresh.expiry >= :expiry", { expiry: new Date() })
.getOneOrFail()
.then((res) => {
return res;
})
.catch((err) => {
throw new InternalException("refresh not found", err);
});
}
}

View file

@ -0,0 +1,27 @@
import { dataSource } from "../data-source";
import { reset } from "../entity/reset";
import DatabaseActionException from "../exceptions/databaseActionException";
import InternalException from "../exceptions/internalException";
export default abstract class ResetService {
/**
* @description get reset by id
* @param mail string
* @param token string
* @returns {Promise<reset>}
*/
static async getByMailAndToken(mail: string, token: string): Promise<reset> {
return await dataSource
.getRepository(reset)
.createQueryBuilder("reset")
.where("reset.mail = :mail", { mail: mail })
.andWhere("reset.token = :token", { token: token })
.getOneOrFail()
.then((res) => {
return res;
})
.catch((err) => {
throw new DatabaseActionException("SELECT", "reset", err);
});
}
}

19
src/type/jwtTypes.ts Normal file
View file

@ -0,0 +1,19 @@
import { PermissionObject } from "./permissionTypes";
export type JWTData = {
[key: string]: string | number | boolean | PermissionObject;
};
export type JWTToken = {
userId: string;
mail: string;
username: string;
firstname: string;
lastname: string;
isOwner: boolean;
permissions: PermissionObject;
} & JWTData;
export type JWTRefresh = {
userId: number;
} & JWTData;

View file

@ -0,0 +1,33 @@
export type PermissionSection = "operation" | "configuration" | "management";
export type PermissionModule = "mission" | "force" | "user" | "role" | "backup";
export type PermissionType = "read" | "create" | "update" | "delete";
export type PermissionString =
| `${PermissionSection}.${PermissionModule}.${PermissionType}` // für spezifische Berechtigungen
| `${PermissionSection}.${PermissionModule}.*` // für alle Berechtigungen in einem Modul
| `${PermissionSection}.${PermissionType}` // für spezifische Berechtigungen in einem Abschnitt
| `${PermissionSection}.*` // für alle Berechtigungen in einem Abschnitt
| "*"; // für Admin
export type PermissionObject = {
[section in PermissionSection]?: {
[module in PermissionModule]?: Array<PermissionType> | "*";
} & { all?: Array<PermissionType> | "*" };
} & {
admin?: boolean;
};
export type SectionsAndModulesObject = {
[section in PermissionSection]: Array<PermissionModule>;
};
export const permissionSections: Array<PermissionSection> = ["operation", "configuration", "management"];
export const permissionModules: Array<PermissionModule> = ["mission", "force", "user", "role", "backup"];
export const permissionTypes: Array<PermissionType> = ["read", "create", "update", "delete"];
export const sectionsAndModules: SectionsAndModulesObject = {
operation: ["mission"],
configuration: ["force"],
management: ["user", "role", "backup"],
};

View file

@ -0,0 +1,6 @@
export interface MemberViewModel {
id: string;
firstname: string;
lastname: string;
nameaffix: string;
}

View file

@ -0,0 +1,6 @@
export interface InviteViewModel {
mail: string;
username: string;
firstname: string;
lastname: string;
}

View file

@ -0,0 +1,7 @@
import { PermissionObject } from "../../../type/permissionTypes";
export interface RoleViewModel {
id: number;
permissions: PermissionObject;
role: string;
}

View file

@ -0,0 +1,14 @@
import { PermissionObject } from "../../../type/permissionTypes";
import { RoleViewModel } from "./role.models";
export interface UserViewModel {
id: string;
username: string;
mail: string;
firstname: string;
lastname: string;
isOwner: boolean;
permissions: PermissionObject;
roles: Array<RoleViewModel>;
permissions_total: PermissionObject;
}

View file

@ -0,0 +1 @@
export interface PermissionViewModel {}

Some files were not shown because too many files have changed in this diff Show more