You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

342 lines
13 KiB
TypeScript

/*
Copyright (C) 2020 Leon Löchner
This file is part of fLotte-API-Server.
fLotte-API-Server is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
fLotte-API-Server is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with fLotte-API-Server. If not, see <https://www.gnu.org/licenses/>.
*/
import { Connection, EntityManager, ObjectType } from 'typeorm';
import { Lockable } from '../../model/CargoBike';
import { ActionLog, Actions } from '../../model/ActionLog';
import { UserInputError } from 'apollo-server-express';
import { ResourceLockedError } from '../../errors/ResourceLockedError';
import { NotFoundError } from '../../errors/NotFoundError';
export function genDateRange (struct: any) {
if (!struct.dateRange || !struct.dateRange.from) {
delete struct.dateRange;
return;
} else if (!struct.dateRange?.to) {
struct.dateRange.to = '';
} else if (struct.dateRange.to === struct.dateRange.from) {
throw new UserInputError('Date Range can not be empty, provide different dates.');
}
struct.dateRange = `[${struct.dateRange.from},${struct.dateRange.to})`;
}
/**
* This function helps prepare the cargoBike struct, to be used in an update or create.
* It creates the numrange attributes than can be understood by postgres.
* @param range
*/
function genNumRange (range: { min: number, max: number}) :string {
if (!range || (!range.max && !range.min)) {
return null;
} else if (range.min === null || range.min === undefined) {
range.min = range.max;
} else if (range.max === null || range.max === undefined) {
range.max = range.min;
}
if (range.min < 0) {
throw new UserInputError('Minimal value must be greater or equal to 0');
}
return `[${range.min},${range.max}]`;
}
/**
* This function prepares the cargoBike struct, to be used in an update or create.
* It creates the numrange attributes than can be understood by postgres.
* @param cargoBike
*/
export function genBoxDimensions (cargoBike: any) {
if (!cargoBike.dimensionsAndLoad) { return; }
cargoBike.dimensionsAndLoad.boxLengthRange = genNumRange(cargoBike.dimensionsAndLoad.boxLengthRange);
cargoBike.dimensionsAndLoad.boxWidthRange = genNumRange(cargoBike.dimensionsAndLoad.boxWidthRange);
cargoBike.dimensionsAndLoad.boxHeightRange = genNumRange(cargoBike.dimensionsAndLoad.boxHeightRange);
}
/**
* Can be used in resolvers to specify, if entry is locked by other user.
* Returns true if locked by other user.
* @param parent
* @param req
*/
export function isLocked (parent: any, { req }: { req: any }) {
return req.userId !== parent.lockedBy && new Date() <= new Date(parent.lockedUntil);
}
/**
* Can be used in resolvers to specify, if entry is locked by the current user.
* @param parent
* @param req
*/
export function isLockedByMe (parent: any, { req }: { req: any }) {
return req.userId === parent.lockedBy && new Date() <= new Date(parent.lockedUntil);
}
/**
* Some utility functions for the database
*/
export class DBUtils {
/**
* Delete any instance of an entity that implements the Lockable interface.
* It must implement the interface, so it can be be ensured, that the instance is not locked by another user.
* @param connection
* @param target
* @param alias
* @param id
* @param userId
*/
static async deleteEntity (connection: Connection, target: ObjectType<Lockable>, alias: string, id: number, userId: number): Promise<Boolean> {
return await connection.transaction(async (entityManger: EntityManager) => {
if (await LockUtils.isLocked(entityManger, target, alias, id, userId)) {
throw new ResourceLockedError('Connection', 'Attempting to delete locked resource');
}
await ActionLogger.log(entityManger, target, alias, { id: id }, userId, Actions.DELETE);
return await entityManger.getRepository(target)
.createQueryBuilder(alias)
.delete()
.where('id = :id', { id: id })
.execute().then(value => value.affected === 1);
});
}
/**
* Return all instances of the given entity called target.
* When offset or limit is not specified, both values are ignored.
* @param connection
* @param target
* @param alias
* @param offset
* @param limit
*/
static async getAllEntity (connection: Connection, target: ObjectType<any>, alias: string, offset?: number, limit?: number) {
if (offset === null || limit === null) {
return await connection.getRepository(target)
.createQueryBuilder(alias)
.select()
.getMany();
} else {
return await connection.getRepository(target)
.createQueryBuilder(alias)
.select()
.skip(offset)
.take(limit)
.getMany();
}
}
}
/**
* Some static functions for the locking feature.
*/
export class LockUtils {
/**
* A helper function to find an instance of any entity that implements Lockable.
* It will throw an error, if nothing is found.
* Using this function only makes sense to use in the context of locking because there is no point in locking
* an instance that does not exist.
* @param connection
* @param target
* @param alias
* @param id
* @private
*/
private static async findById (connection: Connection, target: ObjectType<Lockable>, alias: string, id: number): Promise<Lockable> {
return await connection.getRepository(target)
.createQueryBuilder(alias)
.select()
.where(alias + '.id = :id', { id: id })
.getOne().catch(() => {
throw new NotFoundError(target.name, 'id', id);
});
}
/**
* Lock an instance of an entity target that implements the Lockable interface and return that instance.
* If lock could not be set, it will still return the entity.
* If lock was set or not can be obtained by the field isLockedByMe in the graphql interface,
* or the the fields lockedBy and lockedUntil in the database.
* @param connection
* @param target
* @param alias
* @param id
* @param userId
*/
static async lockEntity (connection: Connection, target: ObjectType<Lockable>, alias: string, id: number, userId: number): Promise<Lockable> {
const lock = await connection.getRepository(target)
.createQueryBuilder(alias)
.select([
alias + '.lockedUntil',
alias + '.lockedBy'
])
.where('id = :id', {
id: id
})
.andWhere(alias + '.lockedUntil > CURRENT_TIMESTAMP')
.getOne();
// eslint-disable-next-line eqeqeq
if (!lock?.lockedUntil || lock?.lockedBy == userId) {
// no lock -> set lock
await connection.getRepository(target)
.createQueryBuilder(alias)
.update()
.set({
lockedUntil: () => 'CURRENT_TIMESTAMP + INTERVAL \'10 MINUTE\'',
lockedBy: userId
})
.where('id = :id', { id: id })
.execute();
}
return await this.findById(connection, target, alias, id);
}
/**
* Unlock an instance of an entity target that implements the Lockable interface and return that instance.
* If lock could not be unset, it will still return the entity.
* If lock was set or not can be obtained by the field isLockedByMe in the graphql interface,
* or the the fields lockedBy and lockedUntil in the database.
* @param connection
* @param target
* @param alias
* @param id
* @param userId
*/
static async unlockEntity (connection: Connection, target: ObjectType<Lockable>, alias: string, id: number, userId: number): Promise<Lockable> {
await connection.getRepository(target)
.createQueryBuilder(alias)
.update()
.set({
lockedUntil: null,
lockedBy: null
})
.where('id = :id', { id: id })
.andWhere('lockedBy = :uid OR lockedUntil < CURRENT_TIMESTAMP', { uid: userId })
.execute();
return await this.findById(connection, target, alias, id);
}
/**
* Returns true if Entity is locked by another user.
* @param connection
* @param target
* @param alias
* @param id
* @param userId
*/
static async isLocked (connection: EntityManager, target: ObjectType<Lockable>, alias: string, id: number, userId: number) {
const lock = await connection.getRepository(target)
.createQueryBuilder(alias)
.select([
alias + '.lockedUntil',
alias + '.lockedBy'
])
.where('id = :id', {
id: id
})
.andWhere(alias + '.lockedUntil > CURRENT_TIMESTAMP')
.getOne();
if (!lock?.lockedUntil) {
// no lock
return false;
// eslint-disable-next-line eqeqeq
} else return lock?.lockedBy != userId;
}
}
/**
* Some utility function for the logging features.
*/
export class ActionLogger {
/**
* Create array of strings, that can be used to select them form the database.
* If you want to avoid logging all old values, for an update, but only the ones that are updated,
* use this function. If updates are null, ['*'] will be returned. Use this for delete actions.
* @param updates
* @param alias
* @private
*/
private static buildSelect (updates: any, alias: string) : string[] {
// this hacky shit makes it possible to select subfields like the address or insurance data. Only one layer at the moment
if (updates === null) {
return ['*'];
}
const ret :string[] = [];
Object.keys(updates).forEach(value => {
// sometimes updates[value] is an array, e.g. timePeriods that are saved as a simple array in postgres
if (updates[value] && typeof updates[value] === 'object' && !Array.isArray(updates[value])) {
Object.keys(updates[value]).forEach(subValue => {
ret.push(`${alias}."${value}${subValue[0].toUpperCase()}${subValue.substr(1).toLowerCase()}"`);
});
} else {
ret.push(`${alias}."${value}"`);
}
});
return ret;
}
/**
* Insert an entry in the log. The log ist just another entity in the database.
* You can only use this in a transaction. So you have to pass an entity manager.
* @param em
* @param target
* @param alias
* @param updates
* @param userId
* @param action
*/
static async log (em: EntityManager, target: ObjectType<any>, alias: string, updates: any, userId: number, action: Actions = Actions.UPDATE) {
const oldValues = await em.getRepository(target).createQueryBuilder(alias)
.select(this.buildSelect(updates, alias))
.where('id = :id', { id: updates.id })
.getRawOne().then(value => {
if (value === undefined) {
throw new NotFoundError(target.name, 'id', updates.id);
}
return value;
}); // use getRawOne to also get ids of related entities
Object.keys(oldValues).forEach(value => {
if (value.match(alias + '_')) {
oldValues[value.replace(alias + '_', '')] = oldValues[value];
delete oldValues[value];
}
});
// TODO: check if new values are different from old note: the commented section will probably fail for nested objects.
/*
const newValues = { ...updates }; // copy updates to mimic call by value
Object.keys(updates).forEach((key, i) => {
// eslint-disable-next-line eqeqeq
if (newValues[key] == oldValues[key]) {
delete newValues[key];
delete oldValues[key];
}
});
*/
const logEntry : ActionLog = {
userId: userId,
entity: target.name,
action: action,
entriesOld: JSON.stringify(oldValues),
entriesNew: JSON.stringify(updates)
};
await em.getRepository(ActionLog)
.createQueryBuilder('al')
.insert()
.values([logEntry])
.execute();
}
}