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.
This commit is contained in:
baldo 2022-07-21 18:39:33 +02:00
parent cfa784dfe2
commit 250353edbf
16 changed files with 676 additions and 455 deletions

View file

@ -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;
}

View file

@ -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);

View file

@ -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<MailId> {
const id = normalizeString(Resources.getData(req).id);
async function withValidMailId(data: RequestData): Promise<MailId> {
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<MailId> {
return parseInteger(id) as MailId;
}
async function doGet(req: Request): Promise<Mail> {
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<void> {
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<Mail> {
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));
}
});

View file

@ -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<MonitoringResponse>(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<MonitoringResponse>(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);
});

View file

@ -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<NodeTokenResponse>(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<NodeTokenResponse>(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<void>(async data => {
const validatedToken = getValidatedToken(data);
await NodeService.deleteNode(validatedToken);
});
export const get = handleJSONWithData<NodeResponse>(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<EnhancedNode>(
enhancedNodes,
const filteredNodes = Resources.filter<DomainSpecificNodeResponse>(
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);
})

View file

@ -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};
}
});

View file

@ -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<string> {
const id = normalizeString(Resources.getData(req).id);
async function withValidTaskId(data: RequestData): Promise<string> {
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<Task> {
return task;
}
async function withTask(req: Request): Promise<Task> {
const id = await withValidTaskId(req);
async function withTask(data: RequestData): Promise<Task> {
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<TaskResponse> {
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);
});

View file

@ -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
}));

View file

@ -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: "<unknown-site>" as Site,
domain: "<unknown-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);

View file

@ -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<number, DurationSeconds> = {
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<void> {
async function insertNodeInformation(nodeData: ParsedNode, node: StoredNode): Promise<void> {
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<void> {
async function updateNodeInformation(nodeData: ParsedNode, node: StoredNode, row: any): Promise<void> {
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<void> {
async function storeNodeInformation(nodeData: ParsedNode, node: StoredNode): Promise<void> {
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<v
const isValidMac = forConstraint(CONSTRAINTS.node.mac, false);
export function parseTimestamp(timestamp: any): Moment {
if (!_.isString(timestamp)) {
return moment.invalid();
}
return moment.utc(timestamp);
}
// TODO: Use sparkson for JSON parsing.
export function parseNode(importTimestamp: Moment, nodeData: any): ParsedNode {
export function parseNode(importTimestamp: UnixTimestampSeconds, nodeData: any): ParsedNode {
if (!_.isPlainObject(nodeData)) {
throw new Error(
'Unexpected node type: ' + (typeof nodeData)
@ -225,7 +218,7 @@ export function parseNode(importTimestamp: Moment, nodeData: any): ParsedNode {
const isOnline = nodeData.flags.online;
const lastSeen = parseTimestamp(nodeData.lastseen);
if (!lastSeen.isValid()) {
if (lastSeen === null) {
throw new Error(
'Node ' + nodeId + ': Invalid lastseen timestamp: ' + nodeData.lastseen
);
@ -245,7 +238,7 @@ export function parseNode(importTimestamp: Moment, nodeData: any): ParsedNode {
mac,
importTimestamp: importTimestamp,
state: isOnline ? OnlineState.ONLINE : OnlineState.OFFLINE,
lastSeen: lastSeen,
lastSeen,
site,
domain,
};
@ -266,17 +259,18 @@ export function parseNodesJson(body: string): NodesParsingResult {
throw new Error(`Unexpected nodes.json version "${json.version}". Expected: "${expectedVersion}"`);
}
const importTimestamp = parseTimestamp(json.timestamp);
if (importTimestamp === null) {
throw new Error('Invalid timestamp: ' + json.timestamp);
}
const result: NodesParsingResult = {
importTimestamp: parseTimestamp(json.timestamp),
importTimestamp,
nodes: [],
failedNodesCount: 0,
totalNodesCount: 0,
};
if (!result.importTimestamp.isValid()) {
throw new Error('Invalid timestamp: ' + json.timestamp);
}
if (!_.isArray(json.nodes)) {
throw new Error('Invalid nodes array type: ' + (typeof json.nodes));
}
@ -296,13 +290,13 @@ export function parseNodesJson(body: string): NodesParsingResult {
return result;
}
async function updateSkippedNode(id: NodeId, node?: Node): Promise<RunResult> {
async function updateSkippedNode(id: NodeId, node?: StoredNode): Promise<RunResult> {
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<void> {
async function sendOnlineAgainMails(startTime: UnixTimestampSeconds): Promise<void> {
await sendMonitoringMailsBatched(
'online again',
MailType.MONITORING_ONLINE_AGAIN,
@ -402,7 +396,7 @@ async function sendOnlineAgainMails(startTime: Moment): Promise<void> {
')' +
'ORDER BY id ASC LIMIT ?',
[
startTime.unix(),
startTime,
'ONLINE',
MONITORING_MAILS_DB_BATCH_SIZE
@ -411,7 +405,7 @@ async function sendOnlineAgainMails(startTime: Moment): Promise<void> {
);
}
async function sendOfflineMails(startTime: Moment, mailType: MailType): Promise<void> {
async function sendOfflineMails(startTime: UnixTimestampSeconds, mailType: MailType): Promise<void> {
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<RetrieveN
let totalNodesCount = 0;
for (const data of datas) {
if (data.importTimestamp.isAfter(maxTimestamp)) {
if (data.importTimestamp >= 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<RetrieveN
totalNodesCount += data.totalNodesCount;
}
if (previousImportTimestamp !== null && !maxTimestamp.isAfter(previousImportTimestamp)) {
if (previousImportTimestamp !== null && maxTimestamp >= 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<RetrieveN
const allNodes = _.flatMap(datas, data => 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<RetrieveN
for (const nodeData of uniqueNodes) {
Logger.tag('monitoring', 'information-retrieval').debug('Importing: %s', nodeData.mac);
const result = await NodeService.getNodeDataByMac(nodeData.mac);
const result = await NodeService.findNodeDataByMac(nodeData.mac);
if (!result) {
Logger
.tag('monitoring', 'information-retrieval')
@ -551,8 +545,8 @@ async function retrieveNodeInformationForUrls(urls: string[]): Promise<RetrieveN
'SET state = ?, modified_at = ?' +
'WHERE import_timestamp < ?',
[
OnlineState.OFFLINE, moment().unix(),
minTimestamp.unix()
OnlineState.OFFLINE, now(),
minTimestamp
]
);
@ -627,34 +621,40 @@ export async function getByMacs(macs: MAC[]): Promise<Record<MAC, NodeStateData>
return nodeStateByMac;
}
export async function confirm(token: MonitoringToken): Promise<Node> {
export async function confirm(token: MonitoringToken): Promise<StoredNode> {
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<Node> {
export async function disable(token: MonitoringToken): Promise<StoredNode> {
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<RetrieveNodeInformationResult> {
@ -669,7 +669,7 @@ export async function retrieveNodeInformation(): Promise<RetrieveNodeInformation
export async function sendMonitoringMails(): Promise<void> {
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<void> {
}
}
function toUnixTimestamp(moment: Moment): UnixTimestampSeconds {
return moment.unix() as UnixTimestampSeconds;
}
export async function deleteOfflineNodes(): Promise<void> {
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<void> {
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);

View file

@ -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<StoredNode> {
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<void> {
}
}
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<UnixTimestampSeconds> {
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<void> {
async function sendMonitoringConfirmationMail(node: StoredNode, nodeSecrets: NodeSecrets): Promise<void> {
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<StoredNode> {
const token: Token = generateToken();
const nodeSecrets: NodeSecrets = {};
node.monitoringConfirmed = false;
const monitoringState = node.monitoring ? MonitoringState.PENDING : MonitoringState.DISABLED;
if (node.monitoring) {
nodeSecrets.monitoringToken = generateToken<MonitoringToken>();
}
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<StoredNode> {
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<MonitoringToken>();
} 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<MonitoringToken>();
break;
} else {
// email unchanged, keep token (fix if not set) and confirmation state
monitoringConfirmed = currentNode.monitoringConfirmed;
monitoringToken = nodeSecrets.monitoringToken || generateToken<MonitoringToken>();
}
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<MonitoringToken>();
} else {
// email unchanged, keep token (fix if not set) and confirmation state
monitoringState = currentNode.monitoringState;
monitoringToken = nodeSecrets.monitoringToken || generateToken<MonitoringToken>();
}
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<StoredNode> {
return await writeNodeFile(true, token, node, monitoringState, nodeSecrets);
}
export async function deleteNode(token: Token): Promise<void> {
await deleteNodeFile(token);
}
export async function getAllNodes(): Promise<Node[]> {
export async function getAllNodes(): Promise<StoredNode[]> {
let files;
try {
files = await findNodeFiles({});
@ -483,7 +500,7 @@ export async function getAllNodes(): Promise<Node[]> {
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<Node[]> {
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<Node | null> {
export async function findNodeDataByMac(mac: MAC): Promise<StoredNode | null> {
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<Node> {
export async function getNodeDataByToken(token: Token): Promise<StoredNode> {
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<Node> {
): Promise<StoredNode> {
const {node} = await getNodeDataByFilePattern({monitoringToken: monitoringToken});
return node;
}
@ -547,7 +564,7 @@ export async function fixNodeFilenames(): Promise<void> {
}
}
export async function findNodesModifiedBefore(timestamp: UnixTimestampSeconds): Promise<Node[]> {
export async function findNodesModifiedBefore(timestamp: UnixTimestampSeconds): Promise<StoredNode[]> {
const nodes = await getAllNodes();
return _.filter(nodes, node => node.modifiedAt < timestamp);
}
@ -565,7 +582,7 @@ export async function getNodeStatistics(): Promise<NodeStatistics> {
}
};
_.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<NodeStatistics> {
default:
unhandledEnumField(monitoringState);
}
});
}
return nodeStatistics;
}

View file

@ -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,

View file

@ -1,5 +1,4 @@
import {ArrayField, Field, RawJsonField} from "sparkson";
import exp from "constants";
// Types shared with the client.
export type TypeGuard<T> = (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;

View file

@ -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<E>(entities: ArrayLike<E>, 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<Response>(handler: () => Promise<Response>): RequestHandler {
return (request, response) => {
handler()
.then(data => success(response, data || {}))
.catch(error => error(response, error));
};
}
export function handleJSONWithData<Response>(handler: (data: RequestData) => Promise<Response>): RequestHandler {
return (request, response) => {
handler(getData(request))
.then(data => success(response, data || {}))
.catch(error => error(response, error));
};
}

53
server/utils/time.test.ts Normal file
View file

@ -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);
});

57
server/utils/time.ts Normal file
View file

@ -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);
}