Typescript migration:

* utils/resources.js
* validation/constraints.js (only server side)
This commit is contained in:
baldo 2020-04-09 01:41:45 +02:00
parent d97635d32a
commit b1755047af
7 changed files with 459 additions and 234 deletions

View file

@ -15,7 +15,7 @@
}
.task-state-running {
color: darkblue;
color: #204d74;
}
.task-state-failed {

View file

@ -1,229 +0,0 @@
'use strict';
const _ = require('lodash')
const Constraints = require('../validation/constraints')
const ErrorTypes = require('../utils/errorTypes')
const Logger = require('../logger')
const Validator = require('../validation/validator')
function respond(res, httpCode, data, type) {
switch (type) {
case 'html':
res.writeHead(httpCode, {'Content-Type': 'text/html'});
res.end(data);
break;
default:
res.writeHead(httpCode, {'Content-Type': 'application/json'});
res.end(JSON.stringify(data));
break;
}
}
function orderByClause(restParams, defaultSortField, allowedSortFields) {
let sortField = _.includes(allowedSortFields, restParams._sortField) ? restParams._sortField : undefined;
if (!sortField) {
sortField = defaultSortField;
}
return {
query: 'ORDER BY ' + sortField + ' ' + (restParams._sortDir === 'ASC' ? 'ASC' : 'DESC'),
params: []
};
}
function limitOffsetClause(restParams) {
const page = restParams._page;
const perPage = restParams._perPage;
return {
query: 'LIMIT ? OFFSET ?',
params: [perPage, ((page - 1) * perPage)]
};
}
function escapeForLikePattern(str) {
return str
.replace(/\\/g, '\\\\')
.replace(/%/g, '\\%')
.replace(/_/g, '\\_');
}
function filterCondition(restParams, filterFields) {
if (_.isEmpty(filterFields)) {
return {
query: '1 = 1',
params: []
};
}
let query = _.join(
_.map(filterFields, function (field) {
return 'LOWER(' + field + ') LIKE ?';
}),
' OR '
);
query += ' ESCAPE \'\\\'';
const search = '%' + (_.isString(restParams.q) ? escapeForLikePattern(_.toLower(restParams.q.trim())) : '') + '%';
const params = _.times(filterFields.length, _.constant(search));
return {
query: query,
params: params
};
}
function getConstrainedValues(data, constraints) {
const values = {};
_.each(_.keys(constraints), function (key) {
const value = data[key];
values[key] =
_.isUndefined(value) && !_.isUndefined(constraints[key].default) ? constraints[key].default : value;
});
return values;
}
module.exports = {
getData (req) {
return _.extend({}, req.body, req.params, req.query);
},
getValidRestParams(type, subtype, req, callback) {
const constraints = Constraints.rest[type];
if (!_.isPlainObject(constraints)) {
Logger.tag('validation', 'rest').error('Unknown REST resource type: {}', type);
return callback({data: 'Internal error.', type: ErrorTypes.internalError});
}
let filterConstraints = {};
if (subtype) {
filterConstraints = Constraints[subtype + 'Filters'];
if (!_.isPlainObject(filterConstraints)) {
Logger.tag('validation', 'rest').error('Unknown REST resource subtype: {}', subtype);
return callback({data: 'Internal error.', type: ErrorTypes.internalError});
}
}
const data = this.getData(req);
const restParams = getConstrainedValues(data, constraints);
const filterParams = getConstrainedValues(data, filterConstraints);
const areValidParams = Validator.forConstraints(constraints);
const areValidFilters = Validator.forConstraints(filterConstraints);
if (!areValidParams(restParams) || !areValidFilters(filterParams)) {
return callback({data: 'Invalid REST parameters.', type: ErrorTypes.badRequest});
}
restParams.filters = filterParams;
callback(null, restParams);
},
filter (entities, allowedFilterFields, restParams) {
let query = restParams.q;
if (query) {
query = _.toLower(query.trim());
}
function queryMatches(entity) {
if (!query) {
return true;
}
return _.some(allowedFilterFields, function (field) {
let value = entity[field];
if (_.isNumber(value)) {
value = value.toString();
}
if (!_.isString(value) || _.isEmpty(value)) {
return false;
}
value = _.toLower(value);
if (field === 'mac') {
return _.includes(value.replace(/:/g, ''), query.replace(/:/g, ''));
}
return _.includes(value, query);
});
}
const filters = restParams.filters;
function filtersMatch(entity) {
if (_.isEmpty(filters)) {
return true;
}
return _.every(filters, function (value, key) {
if (_.isUndefined(value)) {
return true;
}
if (_.startsWith(key, 'has')) {
const entityKey = key.substr(3, 1).toLowerCase() + key.substr(4);
return _.isEmpty(entity[entityKey]).toString() !== value;
}
return entity[key] === value;
});
}
return _.filter(entities, function (entity) {
return queryMatches(entity) && filtersMatch(entity);
});
},
sort (entities, allowedSortFields, restParams) {
const sortField = _.includes(allowedSortFields, restParams._sortField) ? restParams._sortField : undefined;
if (!sortField) {
return entities;
}
const sorted = _.sortBy(entities, [sortField]);
return restParams._sortDir === 'ASC' ? sorted : _.reverse(sorted);
},
getPageEntities (entities, restParams) {
const page = restParams._page;
const perPage = restParams._perPage;
return entities.slice((page - 1) * perPage, page * perPage);
},
whereCondition: filterCondition,
filterClause (restParams, defaultSortField, allowedSortFields, filterFields) {
const orderBy = orderByClause(
restParams,
defaultSortField,
allowedSortFields
);
const limitOffset = limitOffsetClause(restParams);
const filter = filterCondition(
restParams,
filterFields
);
return {
query: filter.query + ' ' + orderBy.query + ' ' + limitOffset.query,
params: _.concat(filter.params, orderBy.params, limitOffset.params)
};
},
success (res, data) {
respond(res, 200, data, 'json');
},
successHtml (res, html) {
respond(res, 200, html, 'html');
},
error (res, err) {
respond(res, err.type.code, err.data, 'json');
}
}

271
server/utils/resources.ts Normal file
View file

@ -0,0 +1,271 @@
import _ from "lodash";
import CONSTRAINTS from "../validation/constraints";
import ErrorTypes from "../utils/errorTypes";
import Logger from "../logger";
import {Constraints, forConstraints, isConstraints} from "../validation/validator";
import {Request, Response} from "express";
export type Entity = {[key: string]: any};
export type RestParams = {
q?: string;
_sortField?: string;
_sortDir?: string;
_page: number;
_perPage: number;
filters?: FilterClause;
};
export type OrderByClause = {query: string, params: any[]};
export type LimitOffsetClause = {query: string, params: any[]};
export type FilterClause = {query: string, params: any[]};
function respond(res: Response, httpCode: number, data: any, type: string): void {
switch (type) {
case 'html':
res.writeHead(httpCode, {'Content-Type': 'text/html'});
res.end(data);
break;
default:
res.writeHead(httpCode, {'Content-Type': 'application/json'});
res.end(JSON.stringify(data));
break;
}
}
function orderByClause(
restParams: RestParams,
defaultSortField: string,
allowedSortFields: string[]
): OrderByClause {
let sortField = _.includes(allowedSortFields, restParams._sortField) ? restParams._sortField : undefined;
if (!sortField) {
sortField = defaultSortField;
}
return {
query: 'ORDER BY ' + sortField + ' ' + (restParams._sortDir === 'ASC' ? 'ASC' : 'DESC'),
params: []
};
}
function limitOffsetClause(restParams: RestParams): LimitOffsetClause {
const page = restParams._page;
const perPage = restParams._perPage;
return {
query: 'LIMIT ? OFFSET ?',
params: [perPage, ((page - 1) * perPage)]
};
}
function escapeForLikePattern(str: string): string {
return str
.replace(/\\/g, '\\\\')
.replace(/%/g, '\\%')
.replace(/_/g, '\\_');
}
function filterCondition(restParams: RestParams, filterFields: string[]): FilterClause {
if (_.isEmpty(filterFields)) {
return {
query: '1 = 1',
params: []
};
}
let query = _.join(
_.map(filterFields, function (field) {
return 'LOWER(' + field + ') LIKE ?';
}),
' OR '
);
query += ' ESCAPE \'\\\'';
const search = '%' + (_.isString(restParams.q) ? escapeForLikePattern(_.toLower(restParams.q.trim())) : '') + '%';
const params = _.times(filterFields.length, _.constant(search));
return {
query: query,
params: params
};
}
function getConstrainedValues(data: {[key: string]: any}, constraints: Constraints): {[key: string]: any} {
const values: {[key: string]: any} = {};
_.each(_.keys(constraints), (key: string): void => {
const value = data[key];
values[key] =
_.isUndefined(value) && key in constraints && !_.isUndefined(constraints[key].default)
? constraints[key].default
: value;
});
return values;
}
export function getData (req: Request): any {
return _.extend({}, req.body, req.params, req.query);
}
// TODO: Promisify.
export function getValidRestParams(
type: string,
subtype: string,
req: Request,
callback: (err: {data: any, type: {code: number}} | null, restParams?: RestParams) => void
) {
const restConstraints = CONSTRAINTS.rest as {[key: string]: any};
let constraints: Constraints;
if (!(type in restConstraints) || !isConstraints(restConstraints[type])) {
Logger.tag('validation', 'rest').error('Unknown REST resource type: {}', type);
return callback({data: 'Internal error.', type: ErrorTypes.internalError});
}
constraints = restConstraints[type];
let filterConstraints: Constraints = {};
if (subtype) {
const subtypeFilters = subtype + 'Filters';
const constraintsObj = CONSTRAINTS as {[key: string]: any};
if (!(subtypeFilters in constraintsObj) || !isConstraints(constraintsObj[subtypeFilters])) {
Logger.tag('validation', 'rest').error('Unknown REST resource subtype: {}', subtype);
return callback({data: 'Internal error.', type: ErrorTypes.internalError});
}
filterConstraints = constraintsObj[subtypeFilters];
}
const data = getData(req);
const restParams = getConstrainedValues(data, constraints);
const filterParams = getConstrainedValues(data, filterConstraints);
const areValidParams = forConstraints(constraints, false);
const areValidFilters = forConstraints(filterConstraints, false);
if (!areValidParams(restParams) || !areValidFilters(filterParams)) {
return callback({data: 'Invalid REST parameters.', type: ErrorTypes.badRequest});
}
restParams.filters = filterParams;
callback(null, restParams as RestParams);
}
export function filter (entities: {[key: string]: Entity}, allowedFilterFields: string[], restParams: RestParams) {
let query = restParams.q;
if (query) {
query = _.toLower(query.trim());
}
function queryMatches(entity: Entity): boolean {
if (!query) {
return true;
}
return _.some(allowedFilterFields, (field: string): boolean => {
if (!query) {
return true;
}
let value = entity[field];
if (_.isNumber(value)) {
value = value.toString();
}
if (!_.isString(value) || _.isEmpty(value)) {
return false;
}
value = _.toLower(value);
if (field === 'mac') {
return _.includes(value.replace(/:/g, ''), query.replace(/:/g, ''));
}
return _.includes(value, query);
});
}
const filters = restParams.filters;
function filtersMatch(entity: Entity): boolean {
if (_.isEmpty(filters)) {
return true;
}
return _.every(filters, (value: any, key: string): boolean => {
if (_.isUndefined(value)) {
return true;
}
if (_.startsWith(key, 'has')) {
const entityKey = key.substr(3, 1).toLowerCase() + key.substr(4);
return _.isEmpty(entity[entityKey]).toString() !== value;
}
return entity[key] === value;
});
}
return _.filter(entities, function (entity) {
return queryMatches(entity) && filtersMatch(entity);
});
}
export function sort<T>(entities: ArrayLike<T>, allowedSortFields: string[], restParams: RestParams): ArrayLike<T> {
const sortField = _.includes(allowedSortFields, restParams._sortField) ? restParams._sortField : undefined;
if (!sortField) {
return entities;
}
const sorted: T[] = _.sortBy(entities, [sortField]);
if (restParams._sortDir === 'ASC') {
return sorted;
} else {
return _.reverse(sorted);
}
}
export function getPageEntities (entities: Entity[], restParams: RestParams) {
const page = restParams._page;
const perPage = restParams._perPage;
return entities.slice((page - 1) * perPage, page * perPage);
}
export {filterCondition as whereCondition};
export function filterClause (
restParams: RestParams,
defaultSortField: string,
allowedSortFields: string[],
filterFields: string[],
): FilterClause {
const orderBy = orderByClause(
restParams,
defaultSortField,
allowedSortFields
);
const limitOffset = limitOffsetClause(restParams);
const filter = filterCondition(
restParams,
filterFields
);
return {
query: filter.query + ' ' + orderBy.query + ' ' + limitOffset.query,
params: _.concat(filter.params, orderBy.params, limitOffset.params)
};
}
export function success (res: Response, data: any) {
respond(res, 200, data, 'json');
}
export function successHtml (res: Response, html: string) {
respond(res, 200, html, 'html');
}
export function error (res: Response, err: {data: any, type: {code: number}}) {
respond(res, err.type.code, err.data, 'json');
}

View file

@ -1 +0,0 @@
../../shared/validation/constraints.js

View file

@ -0,0 +1,119 @@
// ATTENTION: Those constraints are no longer the same file as for the client / admin interface.
// Make sure changes are also reflected in /shared/validation/constraints.js.
const CONSTRAINTS = {
id:{
type: 'string',
regex: /^[1-9][0-9]*$/,
optional: false
},
token:{
type: 'string',
regex: /^[0-9a-f]{16}$/i,
optional: false
},
node: {
hostname: {
type: 'string',
regex: /^[-a-z0-9_]{1,32}$/i,
optional: false
},
key: {
type: 'string',
regex: /^([a-f0-9]{64})$/i,
optional: true
},
email: {
type: 'string',
regex: /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i,
optional: false
},
nickname: {
type: 'string',
regex: /^[-a-z0-9_ äöüß]{1,64}$/i,
optional: false
},
mac: {
type: 'string',
regex: /^([a-f0-9]{12}|([a-f0-9]{2}:){5}[a-f0-9]{2})$/i,
optional: false
},
coords: {
type: 'string',
regex: /^(-?[0-9]{1,3}(\.[0-9]{1,15})? -?[0-9]{1,3}(\.[0-9]{1,15})?)$/,
optional: true
},
monitoring: {
type: 'boolean',
optional: false
}
},
nodeFilters: {
hasKey: {
type: 'boolean',
optional: true
},
hasCoords: {
type: 'boolean',
optional: true
},
onlineState: {
type: 'string',
regex: /^(ONLINE|OFFLINE)$/,
optional: true
},
monitoringState: {
type: 'string',
regex: /^(disabled|active|pending)$/,
optional: true
},
site: {
type: 'string',
regex: /^[a-z0-9_-]{1,32}$/,
optional: true
},
domain: {
type: 'string',
regex: /^[a-z0-9_-]{1,32}$/,
optional: true
}
},
rest: {
list: {
_page: {
type: 'number',
min: 1,
optional: true,
default: 1
},
_perPage: {
type: 'number',
min: 1,
max: 50,
optional: true,
default: 20
},
_sortDir: {
type: 'enum',
allowed: ['ASC', 'DESC'],
optional: true,
default: 'ASC'
},
_sortField: {
type: 'string',
regex: /^[a-zA-Z0-9_]{1,32}$/,
optional: true
},
q: {
type: 'string',
regex: /^[äöüß a-z0-9!#$%&@:.'*+/=?^_`{|}~-]{1,64}$/i,
optional: true
}
}
}
};
export default CONSTRAINTS;
// TODO: Remove after refactoring.
module.exports = CONSTRAINTS;

View file

@ -3,9 +3,11 @@ import _ from "lodash";
import {parseInteger} from "../utils/strings";
import Logger from "../logger";
interface Constraint {
export interface Constraint {
type: string,
default?: any,
optional?: boolean,
allowed?: string[],
@ -16,8 +18,68 @@ interface Constraint {
regex?: RegExp,
}
type Constraints = {[key: string]: Constraint};
type Values = {[key: string]: any};
export type Constraints = {[key: string]: Constraint};
export type Values = {[key: string]: any};
function isStringArray(arr: any): arr is string[] {
return _.isArray(arr) && _.every(arr, (val: any) => _.isString(val));
}
export function isConstraint(val: any): val is Constraint {
if (!_.isObject(val)) {
return false;
}
const constraint = val as {[key: string]: any};
if (!("type" in constraint) || !_.isString(constraint.type)) {
return false;
}
if ("optional" in constraint
&& !_.isUndefined(constraint.optional)
&& !_.isBoolean(constraint.optional)) {
return false;
}
if ("allowed" in constraint
&& !_.isUndefined(constraint.allowed)
&& !isStringArray(constraint.allowed)) {
return false;
}
if ("min" in constraint
&& !_.isUndefined(constraint.min)
&& !_.isNumber(constraint.min)) {
return false;
}
if ("max" in constraint
&& !_.isUndefined(constraint.max)
&& !_.isNumber(constraint.max)) {
return false;
}
// noinspection RedundantIfStatementJS
if ("regex" in constraint
&& !_.isUndefined(constraint.regex)
&& !_.isRegExp(constraint.regex)) {
return false;
}
return true;
}
export function isConstraints(constraints: any): constraints is Constraints {
if (!_.isObject(constraints)) {
return false;
}
return _.every(
constraints,
(constraint: any, key: any) => _.isString(key) && isConstraint(constraint)
);
}
// TODO: sanitize input for further processing as specified by constraints (correct types, trimming, etc.)

View file

@ -1,5 +1,8 @@
'use strict';
// ATTENTION: Those constraints are no longer the same file as for the server.
// Make sure changes are also reflected in /server/validation/constraints.ts.
(function () {
var constraints = {
id:{