diff --git a/admin/index.html b/admin/index.html
index 73b8d7b..1bc2077 100644
--- a/admin/index.html
+++ b/admin/index.html
@@ -15,7 +15,7 @@
}
.task-state-running {
- color: darkblue;
+ color: #204d74;
}
.task-state-failed {
diff --git a/server/utils/resources.js b/server/utils/resources.js
deleted file mode 100644
index 39cf467..0000000
--- a/server/utils/resources.js
+++ /dev/null
@@ -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');
- }
-}
diff --git a/server/utils/resources.ts b/server/utils/resources.ts
new file mode 100644
index 0000000..a2faa9d
--- /dev/null
+++ b/server/utils/resources.ts
@@ -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(entities: ArrayLike, allowedSortFields: string[], restParams: RestParams): ArrayLike {
+ 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');
+}
diff --git a/server/validation/constraints.js b/server/validation/constraints.js
deleted file mode 120000
index 5b44939..0000000
--- a/server/validation/constraints.js
+++ /dev/null
@@ -1 +0,0 @@
-../../shared/validation/constraints.js
\ No newline at end of file
diff --git a/server/validation/constraints.ts b/server/validation/constraints.ts
new file mode 100644
index 0000000..44f896a
--- /dev/null
+++ b/server/validation/constraints.ts
@@ -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;
diff --git a/server/validation/validator.ts b/server/validation/validator.ts
index fe6278f..1879f0e 100644
--- a/server/validation/validator.ts
+++ b/server/validation/validator.ts
@@ -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.)
diff --git a/shared/validation/constraints.js b/shared/validation/constraints.js
index c9ca4a8..9c4d31f 100644
--- a/shared/validation/constraints.js
+++ b/shared/validation/constraints.js
@@ -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:{