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:{