From 250353edbf7552a48fabb07ffa4907306aa552ea Mon Sep 17 00:00:00 2001 From: baldo Date: Thu, 21 Jul 2022 18:39:33 +0200 Subject: [PATCH] Major refactoring and fixes. * Split Node into multiple types and make sure fields are actually set when type says so. * Refactor request handling. * Start getting rid of moment as a dependency by using UnixTimestampSeconds instead. --- server/config.ts | 2 +- server/resources/configResource.ts | 10 +- server/resources/mailResource.ts | 51 +++---- server/resources/monitoringResource.ts | 42 ++---- server/resources/nodeResource.ts | 123 +++++++--------- server/resources/statisticsResource.ts | 27 ++-- server/resources/taskResource.ts | 69 +++++---- server/resources/versionResource.ts | 14 +- server/services/monitoringService.test.ts | 93 ++++-------- server/services/monitoringService.ts | 172 +++++++++++----------- server/services/nodeService.ts | 167 +++++++++++---------- server/types/index.ts | 71 ++++++++- server/types/shared.ts | 133 ++++++++++++++--- server/utils/resources.ts | 47 +++++- server/utils/time.test.ts | 53 +++++++ server/utils/time.ts | 57 +++++++ 16 files changed, 676 insertions(+), 455 deletions(-) create mode 100644 server/utils/time.test.ts create mode 100644 server/utils/time.ts diff --git a/server/config.ts b/server/config.ts index a82ef61..a835a47 100644 --- a/server/config.ts +++ b/server/config.ts @@ -72,7 +72,7 @@ export function parseCommandLine(): void { function stripTrailingSlash(url: Url): Url { return url.endsWith("/") - ? url.substr(0, url.length - 1) as Url + ? url.substring(0, url.length - 1) as Url : url; } diff --git a/server/resources/configResource.ts b/server/resources/configResource.ts index 17c2928..43d9dfa 100644 --- a/server/resources/configResource.ts +++ b/server/resources/configResource.ts @@ -1,10 +1,4 @@ -import {success} from "../utils/resources"; +import {handleJSON} from "../utils/resources"; import {config} from "../config"; -import {Request, Response} from "express"; -export function get (req: Request, res: Response): void { - success( - res, - config.client - ); -} +export const get = handleJSON(async () => config.client); diff --git a/server/resources/mailResource.ts b/server/resources/mailResource.ts index e3c6dc7..240ce16 100644 --- a/server/resources/mailResource.ts +++ b/server/resources/mailResource.ts @@ -2,15 +2,20 @@ import CONSTRAINTS from "../validation/constraints"; import ErrorTypes from "../utils/errorTypes"; import * as MailService from "../services/mailService"; import * as Resources from "../utils/resources"; +import {handleJSONWithData, RequestData} from "../utils/resources"; import {normalizeString, parseInteger} from "../utils/strings"; import {forConstraint} from "../validation/validator"; import {Request, Response} from "express"; -import {Mail, MailId} from "../types"; +import {isString, Mail, MailId} from "../types"; const isValidId = forConstraint(CONSTRAINTS.id, false); -async function withValidMailId(req: Request): Promise { - const id = normalizeString(Resources.getData(req).id); +async function withValidMailId(data: RequestData): Promise { + if (!isString(data.id)) { + throw {data: 'Missing mail id.', type: ErrorTypes.badRequest}; + } + + const id = normalizeString(data.id); if (!isValidId(id)) { throw {data: 'Invalid mail id.', type: ErrorTypes.badRequest}; @@ -19,23 +24,17 @@ async function withValidMailId(req: Request): Promise { return parseInteger(id) as MailId; } -async function doGet(req: Request): Promise { - const id = await withValidMailId(req); +export const get = handleJSONWithData(async data => { + const id = await withValidMailId(data); return await MailService.getMail(id); -} +}); -export function get(req: Request, res: Response): void { - doGet(req) - .then(mail => Resources.success(res, mail)) - .catch(err => Resources.error(res, err)) -} - -async function doGetAll(req: Request): Promise<{total: number, mails: Mail[]}> { +async function doGetAll(req: Request): Promise<{ total: number, mails: Mail[] }> { const restParams = await Resources.getValidRestParams('list', null, req); return await MailService.getPendingMails(restParams); } -export function getAll (req: Request, res: Response): void { +export function getAll(req: Request, res: Response): void { doGetAll(req) .then(({total, mails}) => { res.set('X-Total-Count', total.toString(10)); @@ -44,24 +43,12 @@ export function getAll (req: Request, res: Response): void { .catch(err => Resources.error(res, err)) } -async function doRemove(req: Request): Promise { - const id = await withValidMailId(req); +export const remove = handleJSONWithData(async data => { + const id = await withValidMailId(data); await MailService.deleteMail(id); -} +}); -export function remove (req: Request, res: Response): void { - doRemove(req) - .then(() => Resources.success(res, {})) - .catch(err => Resources.error(res, err)); -} - -async function doResetFailures(req: Request): Promise { - const id = await withValidMailId(req); +export const resetFailures = handleJSONWithData(async data => { + const id = await withValidMailId(data); return await MailService.resetFailures(id); -} - -export function resetFailures (req: Request, res: Response): void { - doResetFailures(req) - .then(mail => Resources.success(res, mail)) - .catch(err => Resources.error(res, err)); -} +}); diff --git a/server/resources/monitoringResource.ts b/server/resources/monitoringResource.ts index 3451641..b86a983 100644 --- a/server/resources/monitoringResource.ts +++ b/server/resources/monitoringResource.ts @@ -4,14 +4,15 @@ import CONSTRAINTS from "../validation/constraints"; import ErrorTypes from "../utils/errorTypes"; import * as MonitoringService from "../services/monitoringService"; import * as Resources from "../utils/resources"; +import {handleJSONWithData} from "../utils/resources"; import {normalizeString} from "../utils/strings"; import {forConstraint} from "../validation/validator"; import {Request, Response} from "express"; -import {MonitoringToken} from "../types"; +import {MonitoringResponse, MonitoringToken, toMonitoringResponse} from "../types"; const isValidToken = forConstraint(CONSTRAINTS.token, false); -async function doGetAll(req: Request): Promise<{total: number, result: any}> { +async function doGetAll(req: Request): Promise<{ total: number, result: any }> { const restParams = await Resources.getValidRestParams('list', null, req); const {monitoringStates, total} = await MonitoringService.getAll(restParams); return { @@ -32,41 +33,24 @@ export function getAll(req: Request, res: Response): void { .catch(err => Resources.error(res, err)); } -export function confirm(req: Request, res: Response): void { - const data = Resources.getData(req); - +export const confirm = handleJSONWithData(async data => { const token = normalizeString(data.token); if (!isValidToken(token)) { - return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest}); + throw {data: 'Invalid token.', type: ErrorTypes.badRequest}; } const validatedToken: MonitoringToken = token as MonitoringToken; - MonitoringService.confirm(validatedToken) - .then(node => Resources.success(res, { - hostname: node.hostname, - mac: node.mac, - email: node.email, - monitoring: node.monitoring, - monitoringConfirmed: node.monitoringConfirmed - })) - .catch(err => Resources.error(res, err)); -} - -export function disable(req: Request, res: Response): void { - const data = Resources.getData(req); + const node = await MonitoringService.confirm(validatedToken); + return toMonitoringResponse(node); +}); +export const disable = handleJSONWithData(async data => { const token = normalizeString(data.token); if (!isValidToken(token)) { - return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest}); + throw {data: 'Invalid token.', type: ErrorTypes.badRequest}; } const validatedToken: MonitoringToken = token as MonitoringToken; - MonitoringService.disable(validatedToken) - .then(node => Resources.success(res, { - hostname: node.hostname, - mac: node.mac, - email: node.email, - monitoring: node.monitoring - })) - .catch(err => Resources.error(res, err)); -} + const node = await MonitoringService.disable(validatedToken); + return toMonitoringResponse(node); +}); diff --git a/server/resources/nodeResource.ts b/server/resources/nodeResource.ts index 1331c2a..190f820 100644 --- a/server/resources/nodeResource.ts +++ b/server/resources/nodeResource.ts @@ -1,5 +1,4 @@ import _ from "lodash"; -import deepExtend from "deep-extend"; import Constraints from "../validation/constraints"; import ErrorTypes from "../utils/errorTypes"; @@ -8,13 +7,28 @@ import * as NodeService from "../services/nodeService"; import {normalizeMac, normalizeString} from "../utils/strings"; import {forConstraint, forConstraints} from "../validation/validator"; import * as Resources from "../utils/resources"; +import {handleJSONWithData} from "../utils/resources"; import {Request, Response} from "express"; -import {EnhancedNode, isNodeSortField, MAC, Node, Token} from "../types"; +import { + CreateOrUpdateNode, + DomainSpecificNodeResponse, + isNodeSortField, + isToken, JSONObject, + MAC, + NodeResponse, + NodeStateData, + NodeTokenResponse, + StoredNode, + toDomainSpecificNodeResponse, + Token, + toNodeResponse, + toNodeTokenResponse +} from "../types"; const nodeFields = ['hostname', 'key', 'email', 'nickname', 'mac', 'coords', 'monitoring']; -function getNormalizedNodeData(reqData: any): Node { - const node: {[key: string]: any} = {}; +function getNormalizedNodeData(reqData: any): CreateOrUpdateNode { + const node: { [key: string]: any } = {}; _.each(nodeFields, function (field) { let value = normalizeString(reqData[field]); if (field === 'mac') { @@ -22,69 +36,54 @@ function getNormalizedNodeData(reqData: any): Node { } node[field] = value; }); - return node as Node; + return node as CreateOrUpdateNode; } const isValidNode = forConstraints(Constraints.node, false); const isValidToken = forConstraint(Constraints.token, false); -export function create (req: Request, res: Response): void { - const data = Resources.getData(req); - - const node = getNormalizedNodeData(data); - if (!isValidNode(node)) { - return Resources.error(res, {data: 'Invalid node data.', type: ErrorTypes.badRequest}); +function getValidatedToken(data: JSONObject): Token { + if (!isToken(data.token)) { + throw {data: 'Missing token.', type: ErrorTypes.badRequest}; } - - NodeService.createNode(node) - .then(result => Resources.success(res, result)) - .catch(err => Resources.error(res, err)); -} - -export function update (req: Request, res: Response): void { - const data = Resources.getData(req); - const token = normalizeString(data.token); if (!isValidToken(token)) { - return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest}); + throw {data: 'Invalid token.', type: ErrorTypes.badRequest}; } - const validatedToken: Token = token as Token; - - const node = getNormalizedNodeData(data); - if (!isValidNode(node)) { - return Resources.error(res, {data: 'Invalid node data.', type: ErrorTypes.badRequest}); - } - - NodeService.updateNode(validatedToken, node) - .then(result => Resources.success(res, result)) - .catch(err => Resources.error(res, err)); + return token as Token; } -export function remove(req: Request, res: Response): void { - const data = Resources.getData(req); - - const token = normalizeString(data.token); - if (!isValidToken(token)) { - return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest}); +export const create = handleJSONWithData(async data => { + const baseNode = getNormalizedNodeData(data); + if (!isValidNode(baseNode)) { + throw {data: 'Invalid node data.', type: ErrorTypes.badRequest}; } - const validatedToken: Token = token as Token; - NodeService.deleteNode(validatedToken) - .then(() => Resources.success(res, {})) - .catch(err => Resources.error(res, err)); -} + const node = await NodeService.createNode(baseNode); + return toNodeTokenResponse(node); +}); -export function get(req: Request, res: Response): void { - const token = normalizeString(Resources.getData(req).token); - if (!isValidToken(token)) { - return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest}); +export const update = handleJSONWithData(async data => { + const validatedToken: Token = getValidatedToken(data); + const baseNode = getNormalizedNodeData(data); + if (!isValidNode(baseNode)) { + throw {data: 'Invalid node data.', type: ErrorTypes.badRequest}; } - const validatedToken: Token = token as Token; - NodeService.getNodeDataByToken(validatedToken) - .then(node => Resources.success(res, node)) - .catch(err => Resources.error(res, err)); -} + const node = await NodeService.updateNode(validatedToken, baseNode); + return toNodeTokenResponse(node); +}); + +export const remove = handleJSONWithData(async data => { + const validatedToken = getValidatedToken(data); + await NodeService.deleteNode(validatedToken); +}); + +export const get = handleJSONWithData(async data => { + const validatedToken: Token = getValidatedToken(data); + const node = await NodeService.getNodeDataByToken(validatedToken); + return toNodeResponse(node); +}); async function doGetAll(req: Request): Promise<{ total: number; pageNodes: any }> { const restParams = await Resources.getValidRestParams('list', 'node', req); @@ -96,24 +95,16 @@ async function doGetAll(req: Request): Promise<{ total: number; pageNodes: any } !!node.token ); - const macs: MAC[] = _.map(realNodes, (node: Node): MAC => node.mac); + const macs: MAC[] = _.map(realNodes, (node: StoredNode): MAC => node.mac); const nodeStateByMac = await MonitoringService.getByMacs(macs); - const enhancedNodes: EnhancedNode[] = _.map(realNodes, (node: Node): EnhancedNode => { - const nodeState = nodeStateByMac[node.mac]; - if (nodeState) { - return deepExtend({}, node, { - site: nodeState.site, - domain: nodeState.domain, - onlineState: nodeState.state - }); - } - - return node as EnhancedNode; + const domainSpecificNodes: DomainSpecificNodeResponse[] = _.map(realNodes, (node: StoredNode): DomainSpecificNodeResponse => { + const nodeState: NodeStateData = nodeStateByMac[node.mac] || {}; + return toDomainSpecificNodeResponse(node, nodeState); }); - const filteredNodes = Resources.filter( - enhancedNodes, + const filteredNodes = Resources.filter( + domainSpecificNodes, [ 'hostname', 'nickname', @@ -142,7 +133,7 @@ async function doGetAll(req: Request): Promise<{ total: number; pageNodes: any } export function getAll(req: Request, res: Response): void { doGetAll(req) - .then((result: {total: number, pageNodes: any[]}) => { + .then((result: { total: number, pageNodes: any[] }) => { res.set('X-Total-Count', result.total.toString(10)); return Resources.success(res, result.pageNodes); }) diff --git a/server/resources/statisticsResource.ts b/server/resources/statisticsResource.ts index b052e0f..6d32aa0 100644 --- a/server/resources/statisticsResource.ts +++ b/server/resources/statisticsResource.ts @@ -1,19 +1,16 @@ import ErrorTypes from "../utils/errorTypes"; import Logger from "../logger"; import {getNodeStatistics} from "../services/nodeService"; -import * as Resources from "../utils/resources"; -import {Request, Response} from "express"; +import {handleJSON} from "../utils/resources"; -export function get (req: Request, res: Response): void { - getNodeStatistics() - .then(nodeStatistics => Resources.success( - res, - { - nodes: nodeStatistics - } - )) - .catch(err => { - Logger.tag('statistics').error('Error getting statistics:', err); - return Resources.error(res, {data: 'Internal error.', type: ErrorTypes.internalError}); - }); -} +export const get = handleJSON(async () => { + try { + const nodeStatistics = await getNodeStatistics(); + return { + nodes: nodeStatistics + }; + } catch (error) { + Logger.tag('statistics').error('Error getting statistics:', error); + throw {data: 'Internal error.', type: ErrorTypes.internalError}; + } +}); diff --git a/server/resources/taskResource.ts b/server/resources/taskResource.ts index 49d5c75..e598bcb 100644 --- a/server/resources/taskResource.ts +++ b/server/resources/taskResource.ts @@ -3,16 +3,16 @@ import _ from "lodash"; import CONSTRAINTS from "../validation/constraints"; import ErrorTypes from "../utils/errorTypes"; import * as Resources from "../utils/resources"; -import {Entity} from "../utils/resources"; +import {Entity, handleJSONWithData, RequestData} from "../utils/resources"; import {getTasks, Task, TaskState} from "../jobs/scheduler"; import {normalizeString} from "../utils/strings"; import {forConstraint} from "../validation/validator"; import {Request, Response} from "express"; -import {isTaskSortField} from "../types"; +import {isString, isTaskSortField} from "../types"; const isValidId = forConstraint(CONSTRAINTS.id, false); -interface ExternalTask { +interface TaskResponse { id: number, name: string, description: string, @@ -26,7 +26,7 @@ interface ExternalTask { enabled: boolean, } -function toExternalTask(task: Task): ExternalTask { +function toTaskResponse(task: Task): TaskResponse { return { id: task.id, name: task.name, @@ -37,13 +37,16 @@ function toExternalTask(task: Task): ExternalTask { lastRunDuration: task.lastRunDuration || null, state: task.state, result: task.state !== TaskState.RUNNING && task.result ? task.result.state : null, - message:task.state !== TaskState.RUNNING && task.result ? task.result.message || null : null, + message: task.state !== TaskState.RUNNING && task.result ? task.result.message || null : null, enabled: task.enabled }; } -async function withValidTaskId(req: Request): Promise { - const id = normalizeString(Resources.getData(req).id); +async function withValidTaskId(data: RequestData): Promise { + if (!isString(data.id)) { + throw {data: 'Missing task id.', type: ErrorTypes.badRequest}; + } + const id = normalizeString(data.id); if (!isValidId(id)) { throw {data: 'Invalid task id.', type: ErrorTypes.badRequest}; @@ -63,21 +66,18 @@ async function getTask(id: string): Promise { return task; } -async function withTask(req: Request): Promise { - const id = await withValidTaskId(req); +async function withTask(data: RequestData): Promise { + const id = await withValidTaskId(data); return await getTask(id); } -function setTaskEnabled(req: Request, res: Response, enable: boolean) { - withTask(req) - .then(task => { - task.enabled = enable; - Resources.success(res, toExternalTask(task)) - }) - .catch(err => Resources.error(res, err)) +async function setTaskEnabled(data: RequestData, enable: boolean): Promise { + const task = await withTask(data); + task.enabled = enable; + return toTaskResponse(task); } -async function doGetAll(req: Request): Promise<{total: number, pageTasks: Entity[]}> { +async function doGetAll(req: Request): Promise<{ total: number, pageTasks: Entity[] }> { const restParams = await Resources.getValidRestParams('list', null, req); const tasks = Resources.sort( @@ -100,33 +100,30 @@ async function doGetAll(req: Request): Promise<{total: number, pageTasks: Entity }; } -export function getAll (req: Request, res: Response): void { +export function getAll(req: Request, res: Response): void { doGetAll(req) .then(({total, pageTasks}) => { res.set('X-Total-Count', total.toString(10)); - Resources.success(res, _.map(pageTasks, toExternalTask)); + Resources.success(res, _.map(pageTasks, toTaskResponse)); }) .catch(err => Resources.error(res, err)); } -export function run (req: Request, res: Response): void { - withTask(req) - .then(task => { - if (task.runningSince) { - return Resources.error(res, {data: 'Task already running.', type: ErrorTypes.conflict}); - } +export const run = handleJSONWithData(async data => { + const task = await withTask(data); - task.run(); + if (task.runningSince) { + throw {data: 'Task already running.', type: ErrorTypes.conflict}; + } - Resources.success(res, toExternalTask(task)); - }) - .catch(err => Resources.error(res, err)); -} + task.run(); + return toTaskResponse(task); +}); -export function enable (req: Request, res: Response): void { - setTaskEnabled(req, res, true); -} +export const enable = handleJSONWithData(async data => { + await setTaskEnabled(data, true); +}); -export function disable (req: Request, res: Response): void { - setTaskEnabled(req, res, false); -} +export const disable = handleJSONWithData(async data => { + await setTaskEnabled(data, false); +}); diff --git a/server/resources/versionResource.ts b/server/resources/versionResource.ts index 57d7091..75a4c00 100644 --- a/server/resources/versionResource.ts +++ b/server/resources/versionResource.ts @@ -1,12 +1,6 @@ -import {success} from "../utils/resources"; +import {handleJSON} from "../utils/resources"; import {version} from "../config"; -import {Request, Response} from "express"; -export function get (req: Request, res: Response): void { - success( - res, - { - version - } - ); -} +export const get = handleJSON(async () => ({ + version +})); diff --git a/server/services/monitoringService.test.ts b/server/services/monitoringService.test.ts index bb6259a..92486fe 100644 --- a/server/services/monitoringService.test.ts +++ b/server/services/monitoringService.test.ts @@ -1,8 +1,9 @@ import moment from 'moment'; -import {ParsedNode, parseNode, parseNodesJson, parseTimestamp} from "./monitoringService"; -import {Domain, MAC, OnlineState, Site} from "../types"; +import {ParsedNode, parseNode, parseNodesJson} from "./monitoringService"; +import {Domain, MAC, OnlineState, Site, UnixTimestampSeconds} from "../types"; import Logger from '../logger'; import {MockLogger} from "../__mocks__/logger"; +import {now, parseTimestamp} from "../utils/time"; const mockedLogger = Logger as MockLogger; @@ -15,60 +16,19 @@ const NODES_JSON_VALID_VERSION = 2; const TIMESTAMP_INVALID_STRING = "2020-01-02T42:99:23.000Z"; const TIMESTAMP_VALID_STRING = "2020-01-02T12:34:56.000Z"; +const PARSED_TIMESTAMP_VALID = parseTimestamp(TIMESTAMP_VALID_STRING); +if (PARSED_TIMESTAMP_VALID === null) { + fail("Should not happen: Parsed valid timestamp as invalid."); +} +const TIMESTAMP_VALID: UnixTimestampSeconds = PARSED_TIMESTAMP_VALID; beforeEach(() => { mockedLogger.reset(); }); -test('parseTimestamp() should fail parsing non-string timestamp', () => { - // given - const timestamp = {}; - - // when - const parsedTimestamp = parseTimestamp(timestamp); - - // then - expect(parsedTimestamp.isValid()).toBe(false); -}); - -test('parseTimestamp() should fail parsing empty timestamp string', () => { - // given - const timestamp = ""; - - // when - const parsedTimestamp = parseTimestamp(timestamp); - - // then - expect(parsedTimestamp.isValid()).toBe(false); -}); - -test('parseTimestamp() should fail parsing invalid timestamp string', () => { - // given - // noinspection UnnecessaryLocalVariableJS - const timestamp = TIMESTAMP_INVALID_STRING; - - // when - const parsedTimestamp = parseTimestamp(timestamp); - - // then - expect(parsedTimestamp.isValid()).toBe(false); -}); - -test('parseTimestamp() should succeed parsing valid timestamp string', () => { - // given - const timestamp = TIMESTAMP_VALID_STRING; - - // when - const parsedTimestamp = parseTimestamp(timestamp); - - // then - expect(parsedTimestamp.isValid()).toBe(true); - expect(parsedTimestamp.toISOString()).toEqual(timestamp); -}); - test('parseNode() should fail parsing node for undefined node data', () => { // given - const importTimestamp = moment(); + const importTimestamp = now(); const nodeData = undefined; // then @@ -77,7 +37,7 @@ test('parseNode() should fail parsing node for undefined node data', () => { test('parseNode() should fail parsing node for empty node data', () => { // given - const importTimestamp = moment(); + const importTimestamp = now(); const nodeData = {}; // then @@ -86,7 +46,7 @@ test('parseNode() should fail parsing node for empty node data', () => { test('parseNode() should fail parsing node for empty node info', () => { // given - const importTimestamp = moment(); + const importTimestamp = now(); const nodeData = { nodeinfo: {} }; @@ -97,7 +57,7 @@ test('parseNode() should fail parsing node for empty node info', () => { test('parseNode() should fail parsing node for non-string node id', () => { // given - const importTimestamp = moment(); + const importTimestamp = now(); const nodeData = { nodeinfo: { node_id: 42 @@ -110,7 +70,7 @@ test('parseNode() should fail parsing node for non-string node id', () => { test('parseNode() should fail parsing node for empty node id', () => { // given - const importTimestamp = moment(); + const importTimestamp = now(); const nodeData = { nodeinfo: { node_id: "" @@ -123,7 +83,7 @@ test('parseNode() should fail parsing node for empty node id', () => { test('parseNode() should fail parsing node for empty network info', () => { // given - const importTimestamp = moment(); + const importTimestamp = now(); const nodeData = { nodeinfo: { node_id: "1234567890ab", @@ -137,7 +97,7 @@ test('parseNode() should fail parsing node for empty network info', () => { test('parseNode() should fail parsing node for invalid mac', () => { // given - const importTimestamp = moment(); + const importTimestamp = now(); const nodeData = { nodeinfo: { node_id: "1234567890ab", @@ -153,7 +113,7 @@ test('parseNode() should fail parsing node for invalid mac', () => { test('parseNode() should fail parsing node for missing flags', () => { // given - const importTimestamp = moment(); + const importTimestamp = now(); const nodeData = { nodeinfo: { node_id: "1234567890ab", @@ -169,7 +129,7 @@ test('parseNode() should fail parsing node for missing flags', () => { test('parseNode() should fail parsing node for empty flags', () => { // given - const importTimestamp = moment(); + const importTimestamp = now(); const nodeData = { nodeinfo: { node_id: "1234567890ab", @@ -186,7 +146,7 @@ test('parseNode() should fail parsing node for empty flags', () => { test('parseNode() should fail parsing node for missing last seen timestamp', () => { // given - const importTimestamp = moment(); + const importTimestamp = now(); const nodeData = { nodeinfo: { node_id: "1234567890ab", @@ -205,7 +165,7 @@ test('parseNode() should fail parsing node for missing last seen timestamp', () test('parseNode() should fail parsing node for invalid last seen timestamp', () => { // given - const importTimestamp = moment(); + const importTimestamp = now(); const nodeData = { nodeinfo: { node_id: "1234567890ab", @@ -225,7 +185,7 @@ test('parseNode() should fail parsing node for invalid last seen timestamp', () test('parseNode() should succeed parsing node without site and domain', () => { // given - const importTimestamp = moment(); + const importTimestamp = now(); const nodeData = { nodeinfo: { node_id: "1234567890ab", @@ -244,7 +204,7 @@ test('parseNode() should succeed parsing node without site and domain', () => { mac: "12:34:56:78:90:AB" as MAC, importTimestamp: importTimestamp, state: OnlineState.ONLINE, - lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING), + lastSeen: TIMESTAMP_VALID, site: "" as Site, domain: "" as Domain, }; @@ -253,7 +213,7 @@ test('parseNode() should succeed parsing node without site and domain', () => { test('parseNode() should succeed parsing node with site and domain', () => { // given - const importTimestamp = moment(); + const importTimestamp = now(); const nodeData = { nodeinfo: { node_id: "1234567890ab", @@ -276,7 +236,7 @@ test('parseNode() should succeed parsing node with site and domain', () => { mac: "12:34:56:78:90:AB" as MAC, importTimestamp: importTimestamp, state: OnlineState.ONLINE, - lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING), + lastSeen: TIMESTAMP_VALID, site: "test-site" as Site, domain: "test-domain" as Domain, }; @@ -388,7 +348,6 @@ test('parseNodesJson() should succeed parsing no nodes', () => { const result = parseNodesJson(json); // then - expect(result.importTimestamp.isValid()).toBe(true); expect(result.nodes).toEqual([]); expect(result.failedNodesCount).toEqual(0); expect(result.totalNodesCount).toEqual(0); @@ -424,7 +383,6 @@ test('parseNodesJson() should skip parsing invalid nodes', () => { const result = parseNodesJson(json); // then - expect(result.importTimestamp.isValid()).toBe(true); expect(result.nodes).toEqual([]); expect(result.failedNodesCount).toEqual(2); expect(result.totalNodesCount).toEqual(2); @@ -463,14 +421,13 @@ test('parseNodesJson() should parse valid nodes', () => { // then const expectedParsedNode: ParsedNode = { mac: "12:34:56:78:90:AB" as MAC, - importTimestamp: parseTimestamp(TIMESTAMP_VALID_STRING), + importTimestamp: TIMESTAMP_VALID, state: OnlineState.ONLINE, - lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING), + lastSeen: TIMESTAMP_VALID, site: "test-site" as Site, domain: "test-domain" as Domain, }; - expect(result.importTimestamp.isValid()).toBe(true); expect(result.nodes).toEqual([expectedParsedNode]); expect(result.failedNodesCount).toEqual(1); expect(result.totalNodesCount).toEqual(2); diff --git a/server/services/monitoringService.ts b/server/services/monitoringService.ts index 40a7c70..e162fe6 100644 --- a/server/services/monitoringService.ts +++ b/server/services/monitoringService.ts @@ -1,5 +1,4 @@ import _ from "lodash"; -import moment, {Moment, unitOfTime} from "moment"; import request from "request"; import {config} from "../config"; @@ -18,21 +17,25 @@ import CONSTRAINTS from "../validation/constraints"; import {forConstraint} from "../validation/validator"; import { Domain, + DurationSeconds, Hostname, isMonitoringSortField, isOnlineState, MAC, MailType, MonitoringSortField, + MonitoringState, MonitoringToken, - Node, NodeId, NodeStateData, OnlineState, RunResult, Site, + StoredNode, + toCreateOrUpdateNode, UnixTimestampSeconds } from "../types"; +import {days, formatTimestamp, hours, now, parseTimestamp, subtract, weeks} from "../utils/time"; type NodeStateRow = { id: number, @@ -56,27 +59,24 @@ const MONITORING_MAILS_DB_BATCH_SIZE = 50; /** * Defines the intervals emails are sent if a node is offline */ -const MONITORING_OFFLINE_MAILS_SCHEDULE: { [key: number]: { amount: number, unit: unitOfTime.DurationConstructor } } = { - 1: {amount: 3, unit: 'hours'}, - 2: {amount: 1, unit: 'days'}, - 3: {amount: 7, unit: 'days'} -}; -const DELETE_OFFLINE_NODES_AFTER_DURATION: { amount: number, unit: unitOfTime.DurationConstructor } = { - amount: 100, - unit: 'days' +const MONITORING_OFFLINE_MAILS_SCHEDULE: Record = { + 1: hours(3), + 2: days(1), + 3: weeks(1), }; +const DELETE_OFFLINE_NODES_AFTER_DURATION: DurationSeconds = days(100); export type ParsedNode = { mac: MAC, - importTimestamp: Moment, + importTimestamp: UnixTimestampSeconds, state: OnlineState, - lastSeen: Moment, + lastSeen: UnixTimestampSeconds, site: Site, domain: Domain, }; export type NodesParsingResult = { - importTimestamp: Moment, + importTimestamp: UnixTimestampSeconds, nodes: ParsedNode[], failedNodesCount: number, totalNodesCount: number, @@ -87,9 +87,9 @@ export type RetrieveNodeInformationResult = { totalNodesCount: number, }; -let previousImportTimestamp: Moment | null = null; +let previousImportTimestamp: UnixTimestampSeconds | null = null; -async function insertNodeInformation(nodeData: ParsedNode, node: Node): Promise { +async function insertNodeInformation(nodeData: ParsedNode, node: StoredNode): Promise { Logger .tag('monitoring', 'information-retrieval') .debug('Node is new in monitoring, creating data: %s', nodeData.mac); @@ -105,20 +105,20 @@ async function insertNodeInformation(nodeData: ParsedNode, node: Node): Promise< nodeData.domain, node.monitoringState, nodeData.state, - nodeData.lastSeen.unix(), - nodeData.importTimestamp.unix(), + nodeData.lastSeen, + nodeData.importTimestamp, null, // new node so we haven't send a mail yet null // new node so we haven't send a mail yet ] ); } -async function updateNodeInformation(nodeData: ParsedNode, node: Node, row: any): Promise { +async function updateNodeInformation(nodeData: ParsedNode, node: StoredNode, row: any): Promise { Logger .tag('monitoring', 'informacallbacktion-retrieval') .debug('Node is known in monitoring: %s', nodeData.mac); - if (!moment(row.import_timestamp).isBefore(nodeData.importTimestamp)) { + if (row.import_timestamp >= nodeData.importTimestamp) { Logger .tag('monitoring', 'information-retrieval') .debug('No new data for node, skipping: %s', nodeData.mac); @@ -147,9 +147,9 @@ async function updateNodeInformation(nodeData: ParsedNode, node: Node, row: any) nodeData.domain || row.domain, node.monitoringState, nodeData.state, - nodeData.lastSeen.unix(), - nodeData.importTimestamp.unix(), - moment().unix(), + nodeData.lastSeen, + nodeData.importTimestamp, + now(), row.id, node.mac @@ -157,7 +157,7 @@ async function updateNodeInformation(nodeData: ParsedNode, node: Node, row: any) ); } -async function storeNodeInformation(nodeData: ParsedNode, node: Node): Promise { +async function storeNodeInformation(nodeData: ParsedNode, node: StoredNode): Promise { Logger.tag('monitoring', 'information-retrieval').debug('Storing status for node: %s', nodeData.mac); const row = await db.get('SELECT * FROM node_state WHERE mac = ?', [node.mac]); @@ -171,15 +171,8 @@ async function storeNodeInformation(nodeData: ParsedNode, node: Node): Promise { +async function updateSkippedNode(id: NodeId, node?: StoredNode): Promise { return await db.run( 'UPDATE node_state ' + 'SET hostname = ?, monitoring_state = ?, modified_at = ?' + 'WHERE id = ?', [ - node ? node.hostname : '', node ? node.monitoringState : '', moment().unix(), + node ? node.hostname : '', node ? node.monitoringState : '', now(), id ] ); @@ -328,7 +322,7 @@ async function sendMonitoringMailsBatched( const mac = nodeState.mac; Logger.tag('monitoring', 'mail-sending').debug('Loading node data for: %s', mac); - const result = await NodeService.getNodeDataWithSecretsByMac(mac); + const result = await NodeService.findNodeDataWithSecretsByMac(mac); if (!result) { Logger .tag('monitoring', 'mail-sending') @@ -341,11 +335,11 @@ async function sendMonitoringMailsBatched( const {node, nodeSecrets} = result; - if (!(node.monitoring && node.monitoringConfirmed)) { + if (node.monitoringState !== MonitoringState.ACTIVE) { Logger .tag('monitoring', 'mail-sending') .debug('Monitoring disabled, skipping "%s" mail for: %s', name, mac); - await updateSkippedNode(nodeState.id); + await updateSkippedNode(nodeState.id, node); continue; } @@ -354,7 +348,7 @@ async function sendMonitoringMailsBatched( Logger .tag('monitoring', 'mail-sending') .error('Node has no monitoring token. Cannot send mail "%s" for: %s', name, mac); - await updateSkippedNode(nodeState.id); + await updateSkippedNode(nodeState.id, node); continue; } @@ -377,13 +371,13 @@ async function sendMonitoringMailsBatched( .tag('monitoring', 'mail-sending') .debug('Updating node state: ', mac); - const now = moment().unix(); + const timestamp = now(); await db.run( 'UPDATE node_state ' + 'SET hostname = ?, monitoring_state = ?, modified_at = ?, last_status_mail_sent = ?, last_status_mail_type = ?' + 'WHERE id = ?', [ - node.hostname, node.monitoringState, now, now, mailType, + node.hostname, node.monitoringState, timestamp, timestamp, mailType, nodeState.id ] ); @@ -391,7 +385,7 @@ async function sendMonitoringMailsBatched( } } -async function sendOnlineAgainMails(startTime: Moment): Promise { +async function sendOnlineAgainMails(startTime: UnixTimestampSeconds): Promise { await sendMonitoringMailsBatched( 'online again', MailType.MONITORING_ONLINE_AGAIN, @@ -402,7 +396,7 @@ async function sendOnlineAgainMails(startTime: Moment): Promise { ')' + 'ORDER BY id ASC LIMIT ?', [ - startTime.unix(), + startTime, 'ONLINE', MONITORING_MAILS_DB_BATCH_SIZE @@ -411,7 +405,7 @@ async function sendOnlineAgainMails(startTime: Moment): Promise { ); } -async function sendOfflineMails(startTime: Moment, mailType: MailType): Promise { +async function sendOfflineMails(startTime: UnixTimestampSeconds, mailType: MailType): Promise { const mailNumber = parseInteger(mailType.split("-")[2]); await sendMonitoringMailsBatched( 'offline ' + mailNumber, @@ -424,7 +418,7 @@ async function sendOfflineMails(startTime: Moment, mailType: MailType): Promise< const allowNull = mailNumber === 1 ? ' OR last_status_mail_type IS NULL' : ''; const schedule = MONITORING_OFFLINE_MAILS_SCHEDULE[mailNumber]; - const scheduledTimeBefore = moment().subtract(schedule.amount, schedule.unit); + const scheduledTimeBefore = subtract(now(), schedule); return await db.all( 'SELECT * FROM node_state ' + @@ -432,11 +426,11 @@ async function sendOfflineMails(startTime: Moment, mailType: MailType): Promise< 'last_seen <= ? AND (last_status_mail_sent <= ? OR last_status_mail_sent IS NULL) ' + 'ORDER BY id ASC LIMIT ?', [ - startTime.unix(), + startTime, 'OFFLINE', previousType, - scheduledTimeBefore.unix(), - scheduledTimeBefore.unix(), + scheduledTimeBefore, + scheduledTimeBefore, MONITORING_MAILS_DB_BATCH_SIZE ], @@ -487,10 +481,10 @@ async function retrieveNodeInformationForUrls(urls: string[]): Promise= maxTimestamp) { maxTimestamp = data.importTimestamp; } - if (data.importTimestamp.isBefore(minTimestamp)) { + if (data.importTimestamp <= minTimestamp) { minTimestamp = data.importTimestamp; } @@ -498,13 +492,13 @@ async function retrieveNodeInformationForUrls(urls: string[]): Promise= previousImportTimestamp) { Logger .tag('monitoring', 'information-retrieval') .debug( 'No new data, skipping. Current timestamp: %s, previous timestamp: %s', - maxTimestamp.format(), - previousImportTimestamp.format() + formatTimestamp(maxTimestamp), + formatTimestamp(previousImportTimestamp) ); return { failedParsingNodesCount, @@ -518,7 +512,7 @@ async function retrieveNodeInformationForUrls(urls: string[]): Promise data.nodes); // Get rid of duplicates from different nodes.json files. Always use the one with the newest - const sortedNodes = _.orderBy(allNodes, [node => node.lastSeen.unix()], ['desc']); + const sortedNodes = _.orderBy(allNodes, [node => node.lastSeen], ['desc']); const uniqueNodes = _.uniqBy(sortedNodes, function (node) { return node.mac; }); @@ -526,7 +520,7 @@ async function retrieveNodeInformationForUrls(urls: string[]): Promise return nodeStateByMac; } -export async function confirm(token: MonitoringToken): Promise { +export async function confirm(token: MonitoringToken): Promise { const {node, nodeSecrets} = await NodeService.getNodeDataWithSecretsByMonitoringToken(token); - if (!node.monitoring || !nodeSecrets.monitoringToken || nodeSecrets.monitoringToken !== token) { + if (node.monitoringState === MonitoringState.DISABLED || !nodeSecrets.monitoringToken || nodeSecrets.monitoringToken !== token) { throw {data: 'Invalid token.', type: ErrorTypes.badRequest}; } - if (node.monitoringConfirmed) { + if (node.monitoringState === MonitoringState.ACTIVE) { return node; } - node.monitoringConfirmed = true; - - const {node: newNode} = await NodeService.internalUpdateNode(node.token, node, nodeSecrets); - return newNode; + node.monitoringState = MonitoringState.ACTIVE; + return await NodeService.internalUpdateNode( + node.token, + toCreateOrUpdateNode(node), + node.monitoringState, + nodeSecrets + ); } -export async function disable(token: MonitoringToken): Promise { +export async function disable(token: MonitoringToken): Promise { const {node, nodeSecrets} = await NodeService.getNodeDataWithSecretsByMonitoringToken(token); - if (!node.monitoring || !nodeSecrets.monitoringToken || nodeSecrets.monitoringToken !== token) { + if (node.monitoringState === MonitoringState.DISABLED || !nodeSecrets.monitoringToken || nodeSecrets.monitoringToken !== token) { throw {data: 'Invalid token.', type: ErrorTypes.badRequest}; } - node.monitoring = false; - node.monitoringConfirmed = false; + node.monitoringState = MonitoringState.DISABLED; nodeSecrets.monitoringToken = undefined; - const {node: newNode} = await NodeService.internalUpdateNode(node.token, node, nodeSecrets); - return newNode; + return await NodeService.internalUpdateNode( + node.token, + toCreateOrUpdateNode(node), + node.monitoringState, + nodeSecrets + ); } export async function retrieveNodeInformation(): Promise { @@ -669,7 +669,7 @@ export async function retrieveNodeInformation(): Promise { Logger.tag('monitoring', 'mail-sending').debug('Sending monitoring mails...'); - const startTime = moment(); + const startTime = now(); try { await sendOnlineAgainMails(startTime); @@ -696,24 +696,18 @@ export async function sendMonitoringMails(): Promise { } } -function toUnixTimestamp(moment: Moment): UnixTimestampSeconds { - return moment.unix() as UnixTimestampSeconds; -} - export async function deleteOfflineNodes(): Promise { Logger .tag('nodes', 'delete-offline') .info( - 'Deleting offline nodes older than ' + - DELETE_OFFLINE_NODES_AFTER_DURATION.amount + ' ' + - DELETE_OFFLINE_NODES_AFTER_DURATION.unit + `Deleting offline nodes older than ${DELETE_OFFLINE_NODES_AFTER_DURATION} seconds.` ); const deleteBefore = - toUnixTimestamp(moment().subtract( - DELETE_OFFLINE_NODES_AFTER_DURATION.amount, - DELETE_OFFLINE_NODES_AFTER_DURATION.unit - )); + subtract( + now(), + DELETE_OFFLINE_NODES_AFTER_DURATION, + ); await deleteNeverOnlineNodesBefore(deleteBefore); await deleteNodesOfflineSinceBefore(deleteBefore); @@ -727,7 +721,7 @@ async function deleteNeverOnlineNodesBefore(deleteBefore: UnixTimestampSeconds): deleteBefore ); - const deletionCandidates: Node[] = await NodeService.findNodesModifiedBefore(deleteBefore); + const deletionCandidates: StoredNode[] = await NodeService.findNodesModifiedBefore(deleteBefore); Logger .tag('nodes', 'delete-never-online') @@ -816,7 +810,7 @@ async function deleteNodeByMac(mac: MAC): Promise { let node; try { - node = await NodeService.getNodeDataByMac(mac); + node = await NodeService.findNodeDataByMac(mac); } catch (error) { // Only log error. We try to delete the nodes state anyways. Logger.tag('nodes', 'delete-offline').error('Could not find node to delete: ' + mac, error); diff --git a/server/services/nodeService.ts b/server/services/nodeService.ts index 0842df9..74adeaa 100644 --- a/server/services/nodeService.ts +++ b/server/services/nodeService.ts @@ -7,22 +7,26 @@ import glob from "glob"; import {config} from "../config"; import ErrorTypes from "../utils/errorTypes"; import Logger from "../logger"; +import logger from "../logger"; import * as MailService from "../services/mailService"; import {normalizeString} from "../utils/strings"; import {monitoringConfirmUrl, monitoringDisableUrl} from "../utils/urlBuilder"; import { + BaseNode, Coordinates, + CreateOrUpdateNode, EmailAddress, FastdKey, Hostname, + isStoredNode, MAC, MailType, MonitoringState, MonitoringToken, Nickname, - Node, NodeSecrets, NodeStatistics, + StoredNode, Token, toUnixTimestampSeconds, unhandledEnumField, @@ -117,7 +121,7 @@ function parseNodeFilename(filename: string): NodeFilenameParsed { return parsed; } -function isDuplicate(filter: NodeFilter, token: Token | null): boolean { +function isDuplicate(filter: NodeFilter, token?: Token): boolean { const files = findNodeFilesSync(filter); if (files.length === 0) { return false; @@ -130,7 +134,7 @@ function isDuplicate(filter: NodeFilter, token: Token | null): boolean { return parseNodeFilename(files[0]).token !== token; } -function checkNoDuplicates(token: Token | null, node: Node, nodeSecrets: NodeSecrets): void { +function checkNoDuplicates(token: Token | undefined, node: BaseNode, nodeSecrets: NodeSecrets): void { if (isDuplicate({hostname: node.hostname}, token)) { throw {data: {msg: 'Already exists.', field: 'hostname'}, type: ErrorTypes.conflict}; } @@ -150,7 +154,7 @@ function checkNoDuplicates(token: Token | null, node: Node, nodeSecrets: NodeSec } } -function toNodeFilename(token: Token, node: Node, nodeSecrets: NodeSecrets): string { +function toNodeFilename(token: Token, node: BaseNode, nodeSecrets: NodeSecrets): string { return config.server.peersPath + '/' + ( (node.hostname || '') + '@' + @@ -161,7 +165,13 @@ function toNodeFilename(token: Token, node: Node, nodeSecrets: NodeSecrets): str ).toLowerCase(); } -function getNodeValue(prefix: LINE_PREFIX, node: Node, nodeSecrets: NodeSecrets): string { +function getNodeValue( + prefix: LINE_PREFIX, + token: Token, + node: CreateOrUpdateNode, + monitoringState: MonitoringState, + nodeSecrets: NodeSecrets +): string { switch (prefix) { case LINE_PREFIX.HOSTNAME: return node.hostname; @@ -174,11 +184,11 @@ function getNodeValue(prefix: LINE_PREFIX, node: Node, nodeSecrets: NodeSecrets) case LINE_PREFIX.MAC: return node.mac; case LINE_PREFIX.TOKEN: - return node.token; + return token; case LINE_PREFIX.MONITORING: - if (node.monitoring && node.monitoringConfirmed) { + if (node.monitoring && monitoringState === MonitoringState.ACTIVE) { return "aktiv"; - } else if (node.monitoring && !node.monitoringConfirmed) { + } else if (node.monitoring && monitoringState === MonitoringState.PENDING) { return "pending"; } return ""; @@ -192,22 +202,22 @@ function getNodeValue(prefix: LINE_PREFIX, node: Node, nodeSecrets: NodeSecrets) async function writeNodeFile( isUpdate: boolean, token: Token, - node: Node, + node: CreateOrUpdateNode, + monitoringState: MonitoringState, nodeSecrets: NodeSecrets, -): Promise<{ token: Token, node: Node }> { +): Promise { const filename = toNodeFilename(token, node, nodeSecrets); let data = ''; for (const prefix of Object.values(LINE_PREFIX)) { - data += `${prefix}${getNodeValue(prefix, node, nodeSecrets)}\n`; + data += `${prefix}${getNodeValue(prefix, token, node, monitoringState, nodeSecrets)}\n`; } if (node.key) { data += `key "${node.key}";\n`; } - // since node.js is single threaded we don't need a lock - + // since node.js is single threaded we don't need a lock when working with synchronous operations if (isUpdate) { const files = findNodeFilesSync({token: token}); if (files.length !== 1) { @@ -224,12 +234,13 @@ async function writeNodeFile( throw {data: 'Could not remove old node data.', type: ErrorTypes.internalError}; } } else { - checkNoDuplicates(null, node, nodeSecrets); + checkNoDuplicates(undefined, node, nodeSecrets); } try { oldFs.writeFileSync(filename, data, 'utf8'); - return {token, node}; + const {node: storedNode} = await parseNodeFile(filename); + return storedNode; } catch (error) { Logger.tag('node', 'save').error('Could not write node file: ' + filename, error); throw {data: 'Could not write node data.', type: ErrorTypes.internalError}; @@ -257,7 +268,7 @@ async function deleteNodeFile(token: Token): Promise { } } -class NodeBuilder { +class StoredNodeBuilder { public token: Token = "" as Token; // FIXME: Either make token optional in Node or handle this! public nickname: Nickname = "" as Nickname; public email: EmailAddress = "" as EmailAddress; @@ -265,8 +276,6 @@ class NodeBuilder { public coords?: Coordinates; public key?: FastdKey; public mac: MAC = "" as MAC; // FIXME: Either make mac optional in Node or handle this! - public monitoring: boolean = false; - public monitoringConfirmed: boolean = false; public monitoringState: MonitoringState = MonitoringState.DISABLED; constructor( @@ -274,8 +283,8 @@ class NodeBuilder { ) { } - public build(): Node { - return { + public build(): StoredNode { + const node = { token: this.token, nickname: this.nickname, email: this.email, @@ -283,15 +292,20 @@ class NodeBuilder { coords: this.coords, key: this.key, mac: this.mac, - monitoring: this.monitoring, - monitoringConfirmed: this.monitoringConfirmed, monitoringState: this.monitoringState, modifiedAt: this.modifiedAt, + }; + + if (!isStoredNode(node)) { + logger.tag("NodeService").error("Not a valid StoredNode:", node); + throw {data: "Could not build StoredNode.", type: ErrorTypes.internalError}; } + + return node; } } -function setNodeValue(prefix: LINE_PREFIX, node: NodeBuilder, nodeSecrets: NodeSecrets, value: string) { +function setNodeValue(prefix: LINE_PREFIX, node: StoredNodeBuilder, nodeSecrets: NodeSecrets, value: string) { switch (prefix) { case LINE_PREFIX.HOSTNAME: node.hostname = value as Hostname; @@ -314,8 +328,6 @@ function setNodeValue(prefix: LINE_PREFIX, node: NodeBuilder, nodeSecrets: NodeS case LINE_PREFIX.MONITORING: const active = value === 'aktiv'; const pending = value === 'pending'; - node.monitoring = active || pending; - node.monitoringConfirmed = active; node.monitoringState = active ? MonitoringState.ACTIVE : (pending ? MonitoringState.PENDING : MonitoringState.DISABLED); break; @@ -332,13 +344,13 @@ async function getModifiedAt(file: string): Promise { return toUnixTimestampSeconds(modifiedAtMs); } -async function parseNodeFile(file: string): Promise<{ node: Node, nodeSecrets: NodeSecrets }> { +async function parseNodeFile(file: string): Promise<{ node: StoredNode, nodeSecrets: NodeSecrets }> { const contents = await fs.readFile(file); const modifiedAt = await getModifiedAt(file); const lines = contents.toString().split("\n"); - const node = new NodeBuilder(modifiedAt); + const node = new StoredNodeBuilder(modifiedAt); const nodeSecrets: NodeSecrets = {}; for (const line of lines) { @@ -361,7 +373,7 @@ async function parseNodeFile(file: string): Promise<{ node: Node, nodeSecrets: N }; } -async function findNodeDataByFilePattern(filter: NodeFilter): Promise<{ node: Node, nodeSecrets: NodeSecrets } | null> { +async function findNodeDataByFilePattern(filter: NodeFilter): Promise<{ node: StoredNode, nodeSecrets: NodeSecrets } | null> { const files = await findNodeFiles(filter); if (files.length !== 1) { @@ -372,7 +384,7 @@ async function findNodeDataByFilePattern(filter: NodeFilter): Promise<{ node: No return await parseNodeFile(file); } -async function getNodeDataByFilePattern(filter: NodeFilter): Promise<{ node: Node, nodeSecrets: NodeSecrets }> { +async function getNodeDataByFilePattern(filter: NodeFilter): Promise<{ node: StoredNode, nodeSecrets: NodeSecrets }> { const result = await findNodeDataByFilePattern(filter); if (!result) { throw {data: 'Node not found.', type: ErrorTypes.notFound}; @@ -381,7 +393,7 @@ async function getNodeDataByFilePattern(filter: NodeFilter): Promise<{ node: Nod return result; } -async function sendMonitoringConfirmationMail(node: Node, nodeSecrets: NodeSecrets): Promise { +async function sendMonitoringConfirmationMail(node: StoredNode, nodeSecrets: NodeSecrets): Promise { const monitoringToken = nodeSecrets.monitoringToken; if (!monitoringToken) { Logger @@ -405,76 +417,81 @@ async function sendMonitoringConfirmationMail(node: Node, nodeSecrets: NodeSecre ); } -export async function createNode(node: Node): Promise<{ token: Token, node: Node }> { +export async function createNode(node: CreateOrUpdateNode): Promise { const token: Token = generateToken(); const nodeSecrets: NodeSecrets = {}; - node.monitoringConfirmed = false; - + const monitoringState = node.monitoring ? MonitoringState.PENDING : MonitoringState.DISABLED; if (node.monitoring) { nodeSecrets.monitoringToken = generateToken(); } - const written = await writeNodeFile(false, token, node, nodeSecrets); + const createdNode = await writeNodeFile(false, token, node, monitoringState, nodeSecrets); - if (written.node.monitoring && !written.node.monitoringConfirmed) { - await sendMonitoringConfirmationMail(written.node, nodeSecrets) + if (createdNode.monitoringState == MonitoringState.PENDING) { + await sendMonitoringConfirmationMail(createdNode, nodeSecrets); } - return written; + return createdNode; } -export async function updateNode(token: Token, node: Node): Promise<{ token: Token, node: Node }> { +export async function updateNode(token: Token, node: CreateOrUpdateNode): Promise { const {node: currentNode, nodeSecrets} = await getNodeDataWithSecretsByToken(token); - let monitoringConfirmed = false; - let monitoringToken: MonitoringToken | undefined; + let monitoringState = MonitoringState.DISABLED; + let monitoringToken: MonitoringToken | undefined = undefined; if (node.monitoring) { - if (!currentNode.monitoring) { - // monitoring just has been enabled - monitoringConfirmed = false; - monitoringToken = generateToken(); - - } else { - // monitoring is still enabled - - if (currentNode.email !== node.email) { - // new email so we need a new token and a reconfirmation - monitoringConfirmed = false; + switch (currentNode.monitoringState) { + case MonitoringState.DISABLED: + // monitoring just has been enabled + monitoringState = MonitoringState.PENDING; monitoringToken = generateToken(); + break; - } else { - // email unchanged, keep token (fix if not set) and confirmation state - monitoringConfirmed = currentNode.monitoringConfirmed; - monitoringToken = nodeSecrets.monitoringToken || generateToken(); - } + case MonitoringState.PENDING: + case MonitoringState.ACTIVE: + if (currentNode.email !== node.email) { + // new email so we need a new token and a reconfirmation + monitoringState = MonitoringState.PENDING; + monitoringToken = generateToken(); + + } else { + // email unchanged, keep token (fix if not set) and confirmation state + monitoringState = currentNode.monitoringState; + monitoringToken = nodeSecrets.monitoringToken || generateToken(); + } + break; + + default: + unhandledEnumField(currentNode.monitoringState); } } - node.monitoringConfirmed = monitoringConfirmed; nodeSecrets.monitoringToken = monitoringToken; - const written = await writeNodeFile(true, token, node, nodeSecrets); - if (written.node.monitoring && !written.node.monitoringConfirmed) { - await sendMonitoringConfirmationMail(written.node, nodeSecrets) + const storedNode = await writeNodeFile(true, token, node, monitoringState, nodeSecrets); + if (storedNode.monitoringState === MonitoringState.PENDING) { + await sendMonitoringConfirmationMail(storedNode, nodeSecrets) } - return written; + return storedNode; } export async function internalUpdateNode( token: Token, - node: Node, nodeSecrets: NodeSecrets -): Promise<{ token: Token, node: Node }> { - return await writeNodeFile(true, token, node, nodeSecrets); + node: CreateOrUpdateNode, + monitoringState: MonitoringState, + nodeSecrets: NodeSecrets, +): Promise { + return await writeNodeFile(true, token, node, monitoringState, nodeSecrets); } export async function deleteNode(token: Token): Promise { await deleteNodeFile(token); } -export async function getAllNodes(): Promise { +export async function getAllNodes(): Promise { let files; try { files = await findNodeFiles({}); @@ -483,7 +500,7 @@ export async function getAllNodes(): Promise { throw {data: 'Internal error.', type: ErrorTypes.internalError}; } - const nodes: Node[] = []; + const nodes: StoredNode[] = []; for (const file of files) { try { const {node} = await parseNodeFile(file); @@ -497,33 +514,33 @@ export async function getAllNodes(): Promise { return nodes; } -export async function getNodeDataWithSecretsByMac(mac: MAC): Promise<{ node: Node, nodeSecrets: NodeSecrets } | null> { +export async function findNodeDataWithSecretsByMac(mac: MAC): Promise<{ node: StoredNode, nodeSecrets: NodeSecrets } | null> { return await findNodeDataByFilePattern({mac}); } -export async function getNodeDataByMac(mac: MAC): Promise { +export async function findNodeDataByMac(mac: MAC): Promise { const result = await findNodeDataByFilePattern({mac}); return result ? result.node : null; } -export async function getNodeDataWithSecretsByToken(token: Token): Promise<{ node: Node, nodeSecrets: NodeSecrets }> { +export async function getNodeDataWithSecretsByToken(token: Token): Promise<{ node: StoredNode, nodeSecrets: NodeSecrets }> { return await getNodeDataByFilePattern({token: token}); } -export async function getNodeDataByToken(token: Token): Promise { +export async function getNodeDataByToken(token: Token): Promise { const {node} = await getNodeDataByFilePattern({token: token}); return node; } export async function getNodeDataWithSecretsByMonitoringToken( monitoringToken: MonitoringToken -): Promise<{ node: Node, nodeSecrets: NodeSecrets }> { +): Promise<{ node: StoredNode, nodeSecrets: NodeSecrets }> { return await getNodeDataByFilePattern({monitoringToken: monitoringToken}); } export async function getNodeDataByMonitoringToken( monitoringToken: MonitoringToken -): Promise { +): Promise { const {node} = await getNodeDataByFilePattern({monitoringToken: monitoringToken}); return node; } @@ -547,7 +564,7 @@ export async function fixNodeFilenames(): Promise { } } -export async function findNodesModifiedBefore(timestamp: UnixTimestampSeconds): Promise { +export async function findNodesModifiedBefore(timestamp: UnixTimestampSeconds): Promise { const nodes = await getAllNodes(); return _.filter(nodes, node => node.modifiedAt < timestamp); } @@ -565,7 +582,7 @@ export async function getNodeStatistics(): Promise { } }; - _.each(nodes, function (node: Node): void { + for (const node of nodes) { if (node.key) { nodeStatistics.withVPN += 1; } @@ -589,7 +606,7 @@ export async function getNodeStatistics(): Promise { default: unhandledEnumField(monitoringState); } - }); + } return nodeStatistics; } diff --git a/server/types/index.ts b/server/types/index.ts index 1c28d29..48d2ac6 100644 --- a/server/types/index.ts +++ b/server/types/index.ts @@ -1,4 +1,19 @@ -import {Domain, EmailAddress, JSONObject, MonitoringToken, OnlineState, Site, toIsEnum} from "./shared"; +import { + CreateOrUpdateNode, + Domain, + DomainSpecificNodeResponse, + EmailAddress, + JSONObject, + MonitoringResponse, + MonitoringState, + MonitoringToken, + NodeResponse, + NodeTokenResponse, + OnlineState, + Site, + StoredNode, + toIsEnum, +} from "./shared"; export * from "./config"; export * from "./database"; @@ -11,6 +26,60 @@ export type NodeStateData = { state: OnlineState, } +export function toCreateOrUpdateNode(node: StoredNode): CreateOrUpdateNode { + return { + nickname: node.nickname, + email: node.email, + hostname: node.hostname, + coords: node.coords, + key: node.key, + mac: node.mac, + monitoring: node.monitoringState !== MonitoringState.DISABLED, + } +} + +export function toNodeResponse(node: StoredNode): NodeResponse { + return { + token: node.token, + nickname: node.nickname, + email: node.email, + hostname: node.hostname, + coords: node.coords, + key: node.key, + mac: node.mac, + monitoring: node.monitoringState !== MonitoringState.DISABLED, + monitoringConfirmed: node.monitoringState === MonitoringState.ACTIVE, + monitoringState: node.monitoringState, + modifiedAt: node.modifiedAt, + } +} + +export function toNodeTokenResponse(node: StoredNode): NodeTokenResponse { + return { + token: node.token, + node: toNodeResponse(node), + } +} + +export function toDomainSpecificNodeResponse(node: StoredNode, nodeStateData: NodeStateData): DomainSpecificNodeResponse { + return { + ...toNodeResponse(node), + site: nodeStateData.site, + domain: nodeStateData.domain, + onlineState: nodeStateData.state, + } +} + +export function toMonitoringResponse(node: StoredNode): MonitoringResponse { + return { + hostname: node.hostname, + mac: node.mac, + email: node.email, + monitoring: node.monitoringState !== MonitoringState.DISABLED, + monitoringConfirmed: node.monitoringState === MonitoringState.ACTIVE, + }; +} + // TODO: Complete interface / class declaration. export type NodeSecrets = { monitoringToken?: MonitoringToken, diff --git a/server/types/shared.ts b/server/types/shared.ts index 7aa2081..7edbbd3 100644 --- a/server/types/shared.ts +++ b/server/types/shared.ts @@ -1,5 +1,4 @@ import {ArrayField, Field, RawJsonField} from "sparkson"; -import exp from "constants"; // Types shared with the client. export type TypeGuard = (arg: unknown) => arg is T; @@ -140,7 +139,7 @@ export function isNodeStatistics(arg: unknown): arg is NodeStatistics { ); } -export interface Statistics { +export type Statistics = { nodes: NodeStatistics; } @@ -324,6 +323,9 @@ export const isFastdKey = isString; export type MAC = string & { readonly __tag: unique symbol }; export const isMAC = isString; +export type DurationSeconds = number & { readonly __tag: unique symbol }; +export const isDurationSeconds = isNumber; + export type UnixTimestampSeconds = number & { readonly __tag: unique symbol }; export const isUnixTimestampSeconds = isNumber; @@ -355,41 +357,105 @@ export const isNickname = isString; export type Coordinates = string & { readonly __tag: unique symbol }; export const isCoordinates = isString; -// TODO: More Newtypes -export type Node = { - token: Token; +/** + * Basic node data. + */ +export type BaseNode = { nickname: Nickname; email: EmailAddress; hostname: Hostname; coords?: Coordinates; key?: FastdKey; mac: MAC; - monitoring: boolean; - monitoringConfirmed: boolean; - monitoringState: MonitoringState; - modifiedAt: UnixTimestampSeconds; -}; +} -export function isNode(arg: unknown): arg is Node { +export function isBaseNode(arg: unknown): arg is BaseNode { if (!isObject(arg)) { return false; } - const node = arg as Node; + const node = arg as BaseNode; return ( - isToken(node.token) && isNickname(node.nickname) && isEmailAddress(node.email) && isHostname(node.hostname) && isOptional(node.coords, isCoordinates) && isOptional(node.key, isFastdKey) && - isMAC(node.mac) && - isBoolean(node.monitoring) && - isBoolean(node.monitoringConfirmed) && + isMAC(node.mac) + ); +} + +/** + * Node data used for creating or updating a node. + */ +export type CreateOrUpdateNode = BaseNode & { + monitoring: boolean; +} + +export function isCreateOrUpdateNode(arg: unknown): arg is CreateOrUpdateNode { + if (!isBaseNode(arg)) { + return false; + } + const node = arg as CreateOrUpdateNode; + return ( + isBoolean(node.monitoring) + ); +} + +/** + * Representation of a stored node. + */ +export type StoredNode = BaseNode & { + token: Token; + monitoringState: MonitoringState; + modifiedAt: UnixTimestampSeconds; +} + +export function isStoredNode(arg: unknown): arg is StoredNode { + if (!isObject(arg)) { + return false; + } + const node = arg as StoredNode; + return ( + isBaseNode(node) && + isToken(node.token) && isMonitoringState(node.monitoringState) && isUnixTimestampSeconds(node.modifiedAt) ); } +export type NodeResponse = StoredNode & { + monitoring: boolean; + monitoringConfirmed: boolean; +} + +export function isNodeResponse(arg: unknown): arg is NodeResponse { + if (!isStoredNode(arg)) { + return false; + } + const node = arg as NodeResponse; + return ( + isBoolean(node.monitoring) && + isBoolean(node.monitoringConfirmed) + ); +} + +export type NodeTokenResponse = { + token: Token; + node: NodeResponse; +} + +export function isNodeTokenResponse(arg: unknown): arg is NodeTokenResponse { + if (!isObject(arg)) { + return false; + } + const response = arg as NodeTokenResponse; + return ( + isToken(response.token) && + isNodeResponse(response.node) && + response.token === response.node.token + ); +} + export enum OnlineState { ONLINE = "ONLINE", OFFLINE = "OFFLINE", @@ -403,17 +469,20 @@ export const isSite = isString; export type Domain = string & { readonly __tag: unique symbol }; export const isDomain = isString; -export interface EnhancedNode extends Node { +/** + * Represents a node in the context of a Freifunk site and domain. + */ +export type DomainSpecificNodeResponse = NodeResponse & { site?: Site, domain?: Domain, onlineState?: OnlineState, } -export function isEnhancedNode(arg: unknown): arg is EnhancedNode { - if (!isNode(arg)) { +export function isDomainSpecificNodeResponse(arg: unknown): arg is DomainSpecificNodeResponse { + if (!isNodeResponse(arg)) { return false; } - const node = arg as EnhancedNode; + const node = arg as DomainSpecificNodeResponse; return ( isOptional(node.site, isSite) && isOptional(node.domain, isDomain) && @@ -421,6 +490,28 @@ export function isEnhancedNode(arg: unknown): arg is EnhancedNode { ); } +export type MonitoringResponse = { + hostname: Hostname, + mac: MAC, + email: EmailAddress, + monitoring: boolean, + monitoringConfirmed: boolean, +} + +export function isMonitoringResponse(arg: unknown): arg is MonitoringResponse { + if (!Object(arg)) { + return false; + } + const response = arg as MonitoringResponse; + return ( + isHostname(response.hostname) && + isMAC(response.mac) && + isEmailAddress(response.email) && + isBoolean(response.monitoring) && + isBoolean(response.monitoringConfirmed) + ); +} + export enum NodeSortField { HOSTNAME = 'hostname', NICKNAME = 'nickname', @@ -437,7 +528,7 @@ export enum NodeSortField { export const isNodeSortField = toIsEnum(NodeSortField); -export interface NodesFilter { +export type NodesFilter = { hasKey?: boolean; hasCoords?: boolean; monitoringState?: MonitoringState; diff --git a/server/utils/resources.ts b/server/utils/resources.ts index 38d0904..6ad82c4 100644 --- a/server/utils/resources.ts +++ b/server/utils/resources.ts @@ -5,7 +5,18 @@ import ErrorTypes from "../utils/errorTypes"; import Logger from "../logger"; import {Constraints, forConstraints, isConstraints} from "../validation/validator"; import {Request, Response} from "express"; -import {EnumTypeGuard, EnumValue, type GenericSortField, SortDirection, TypeGuard} from "../types"; +import { + EnumTypeGuard, + EnumValue, + type GenericSortField, + isJSONObject, + JSONObject, + SortDirection, + TypeGuard +} from "../types"; + +export type RequestData = JSONObject; +export type RequestHandler = (request: Request, response: Response) => void; export type Entity = { [key: string]: any }; @@ -110,8 +121,20 @@ function getConstrainedValues(data: { [key: string]: any }, constraints: Constra return values; } -export function getData(req: Request): any { - return _.extend({}, req.body, req.params, req.query); +function normalize(data: any): JSONObject { + return isJSONObject(data) ? data : {}; +} + +export function getData(req: Request): RequestData { + const body = normalize(req.body); + const params = normalize(req.params); + const query = normalize(req.query); + + return { + ...body, + ...params, + ...query, + }; } export async function getValidRestParams( @@ -197,7 +220,7 @@ export function filter(entities: ArrayLike, allowedFilterFields: string[], return true; } if (_.startsWith(key, 'has')) { - const entityKey = key.substr(3, 1).toLowerCase() + key.substr(4); + const entityKey = key.substring(3, 4).toLowerCase() + key.substring(4); return _.isEmpty(entity[entityKey]).toString() !== value; } return entity[key] === value; @@ -267,3 +290,19 @@ export function successHtml(res: Response, html: string) { export function error(res: Response, err: { data: any, type: { code: number } }) { respond(res, err.type.code, err.data, 'json'); } + +export function handleJSON(handler: () => Promise): RequestHandler { + return (request, response) => { + handler() + .then(data => success(response, data || {})) + .catch(error => error(response, error)); + }; +} + +export function handleJSONWithData(handler: (data: RequestData) => Promise): RequestHandler { + return (request, response) => { + handler(getData(request)) + .then(data => success(response, data || {})) + .catch(error => error(response, error)); + }; +} diff --git a/server/utils/time.test.ts b/server/utils/time.test.ts new file mode 100644 index 0000000..b0334e9 --- /dev/null +++ b/server/utils/time.test.ts @@ -0,0 +1,53 @@ +import {parseTimestamp} from "./time"; +import moment from "moment"; + +const TIMESTAMP_INVALID_STRING = "2020-01-02T42:99:23.000Z"; +const TIMESTAMP_VALID_STRING = "2020-01-02T12:34:56.000Z"; + +test('parseTimestamp() should fail parsing non-string timestamp', () => { + // given + const timestamp = {}; + + // when + const parsedTimestamp = parseTimestamp(timestamp); + + // then + expect(parsedTimestamp).toEqual(null); +}); + +test('parseTimestamp() should fail parsing empty timestamp string', () => { + // given + const timestamp = ""; + + // when + const parsedTimestamp = parseTimestamp(timestamp); + + // then + expect(parsedTimestamp).toEqual(null); +}); + +test('parseTimestamp() should fail parsing invalid timestamp string', () => { + // given + // noinspection UnnecessaryLocalVariableJS + const timestamp = TIMESTAMP_INVALID_STRING; + + // when + const parsedTimestamp = parseTimestamp(timestamp); + + // then + expect(parsedTimestamp).toEqual(null); +}); + +test('parseTimestamp() should succeed parsing valid timestamp string', () => { + // given + const timestamp = TIMESTAMP_VALID_STRING; + + // when + const parsedTimestamp = parseTimestamp(timestamp); + + // then + if (parsedTimestamp === null) { + fail('timestamp should not be null'); + } + expect(moment.unix(parsedTimestamp).toISOString()).toEqual(timestamp); +}); diff --git a/server/utils/time.ts b/server/utils/time.ts new file mode 100644 index 0000000..2dd4a42 --- /dev/null +++ b/server/utils/time.ts @@ -0,0 +1,57 @@ +import {DurationSeconds, UnixTimestampSeconds} from "../types"; +import _ from "lodash"; +import moment, {Moment} from "moment"; + +export function now(): UnixTimestampSeconds { + return Math.round(Date.now() / 1000.0) as UnixTimestampSeconds; +} + +export function subtract(timestamp: UnixTimestampSeconds, duration: DurationSeconds): UnixTimestampSeconds { + return (timestamp - duration) as UnixTimestampSeconds; +} + +const SECOND: DurationSeconds = 1 as DurationSeconds; +const MINUTE: DurationSeconds = (60 * SECOND) as DurationSeconds; +const HOUR: DurationSeconds = (60 * MINUTE) as DurationSeconds; +const DAY: DurationSeconds = (24 * HOUR) as DurationSeconds; +const WEEK: DurationSeconds = (7 * DAY) as DurationSeconds; + +export function seconds(n: number): DurationSeconds { + return (n * SECOND) as DurationSeconds; +} + +export function minutes(n: number): DurationSeconds { + return (n * MINUTE) as DurationSeconds; +} + +export function hours(n: number): DurationSeconds { + return (n * HOUR) as DurationSeconds; +} + +export function days(n: number): DurationSeconds { + return (n * DAY) as DurationSeconds; +} + +export function weeks(n: number): DurationSeconds { + return (n * WEEK) as DurationSeconds; +} + +export function unix(moment: Moment): UnixTimestampSeconds { + return moment.unix() as UnixTimestampSeconds; +} + +export function formatTimestamp(timestamp: UnixTimestampSeconds): string { + return moment.unix(timestamp).format(); +} + +export function parseTimestamp(timestamp: any): UnixTimestampSeconds | null { + if (!_.isString(timestamp)) { + return null; + } + const parsed = moment.utc(timestamp); + if (!parsed.isValid()) { + return null; + } + return unix(parsed); +} +