From 720acfb2766653e276f3aca9bac69c1729e6e026 Mon Sep 17 00:00:00 2001 From: baldo Date: Thu, 14 Jul 2022 20:06:05 +0200 Subject: [PATCH] Refactor some server-side string types into newtypes. --- server/app.ts | 14 +- server/resources/monitoringResource.ts | 7 +- server/resources/nodeResource.ts | 15 +- server/resources/statisticsResource.ts | 1 - server/services/monitoringService.test.ts | 20 +- server/services/monitoringService.ts | 29 +- server/services/nodeService.ts | 363 ++++++++++++---------- server/types/config.ts | 29 +- server/types/shared.ts | 81 ++++- server/utils/urlBuilder.ts | 12 +- 10 files changed, 346 insertions(+), 225 deletions(-) diff --git a/server/app.ts b/server/app.ts index 13e2a58..115bb78 100644 --- a/server/app.ts +++ b/server/app.ts @@ -9,7 +9,7 @@ import {promises as fs} from "graceful-fs"; import {config} from "./config"; import type {CleartextPassword, PasswordHash, Username} from "./types"; -import {isString} from "./types"; +import {isString, lift2, to} from "./types"; import Logger from "./logger"; export const app: Express = express(); @@ -17,7 +17,7 @@ export const app: Express = express(); /** * Used to have some password comparison in case the user does not exist to avoid timing attacks. */ -const INVALID_PASSWORD_HASH = "$2b$05$JebmV1q/ySuxa89GoJYlc.6SEnj1OZYBOfTf.TYAehcC5HLeJiWPi"; +const INVALID_PASSWORD_HASH: PasswordHash = to("$2b$05$JebmV1q/ySuxa89GoJYlc.6SEnj1OZYBOfTf.TYAehcC5HLeJiWPi"); /** * Trying to implement a timing safe string compare. @@ -50,15 +50,15 @@ async function isValidLogin(username: Username, password: CleartextPassword): Pr // Iterate over all users every time to reduce risk of timing attacks. for (const userConfig of config.server.internal.users) { - if (timingSafeEqual(username, userConfig.username)) { + if (lift2(timingSafeEqual)(username, userConfig.username)) { passwordHash = userConfig.passwordHash; } } // Always compare some password even if the user does not exist to reduce risk of timing attacks. const isValidPassword = await bcrypt.compare( - password, - passwordHash || INVALID_PASSWORD_HASH + password.value, + passwordHash?.value || INVALID_PASSWORD_HASH.value ); // Make sure password is only considered valid is user exists and therefor passwordHash is not undefined. @@ -73,8 +73,8 @@ export function init(): void { { realm: 'Knotenformular - Intern' }, - function (username: Username, password: CleartextPassword, callback: BasicAuthCheckerCallback): void { - isValidLogin(username, password) + function (username: string, password: string, callback: BasicAuthCheckerCallback): void { + isValidLogin(to(username), to(password)) .then(result => callback(result)) .catch(err => { Logger.tag('login').error(err); diff --git a/server/resources/monitoringResource.ts b/server/resources/monitoringResource.ts index 1cf5b5a..021c046 100644 --- a/server/resources/monitoringResource.ts +++ b/server/resources/monitoringResource.ts @@ -7,6 +7,7 @@ import * as Resources from "../utils/resources"; import {normalizeString} from "../utils/strings"; import {forConstraint} from "../validation/validator"; import {Request, Response} from "express"; +import {MonitoringToken, to} from "../types"; const isValidToken = forConstraint(CONSTRAINTS.token, false); @@ -38,8 +39,9 @@ export function confirm(req: Request, res: Response): void { if (!isValidToken(token)) { return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest}); } + const validatedToken: MonitoringToken = to(token); - MonitoringService.confirm(token) + MonitoringService.confirm(validatedToken) .then(node => Resources.success(res, { hostname: node.hostname, mac: node.mac, @@ -57,8 +59,9 @@ export function disable(req: Request, res: Response): void { if (!isValidToken(token)) { return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest}); } + const validatedToken: MonitoringToken = to(token); - MonitoringService.disable(token) + MonitoringService.disable(validatedToken) .then(node => Resources.success(res, { hostname: node.hostname, mac: node.mac, diff --git a/server/resources/nodeResource.ts b/server/resources/nodeResource.ts index a658303..dacd9cf 100644 --- a/server/resources/nodeResource.ts +++ b/server/resources/nodeResource.ts @@ -10,7 +10,7 @@ import {forConstraint, forConstraints} from "../validation/validator"; import * as Resources from "../utils/resources"; import {Entity} from "../utils/resources"; import {Request, Response} from "express"; -import {EnhancedNode, isNodeSortField, Node} from "../types"; +import {EnhancedNode, isNodeSortField, MAC, Node, to, Token} from "../types"; const nodeFields = ['hostname', 'key', 'email', 'nickname', 'mac', 'coords', 'monitoring']; @@ -49,13 +49,14 @@ export function update (req: Request, res: Response): void { if (!isValidToken(token)) { return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest}); } + const validatedToken: Token = to(token); const node = getNormalizedNodeData(data); if (!isValidNode(node)) { return Resources.error(res, {data: 'Invalid node data.', type: ErrorTypes.badRequest}); } - NodeService.updateNode(token, node) + NodeService.updateNode(validatedToken, node) .then(result => Resources.success(res, result)) .catch(err => Resources.error(res, err)); } @@ -67,8 +68,9 @@ export function remove(req: Request, res: Response): void { if (!isValidToken(token)) { return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest}); } + const validatedToken: Token = to(token); - NodeService.deleteNode(token) + NodeService.deleteNode(validatedToken) .then(() => Resources.success(res, {})) .catch(err => Resources.error(res, err)); } @@ -78,8 +80,9 @@ export function get(req: Request, res: Response): void { if (!isValidToken(token)) { return Resources.error(res, {data: 'Invalid token.', type: ErrorTypes.badRequest}); } + const validatedToken: Token = to(token); - NodeService.getNodeDataByToken(token) + NodeService.getNodeDataByToken(validatedToken) .then(node => Resources.success(res, node)) .catch(err => Resources.error(res, err)); } @@ -94,11 +97,11 @@ async function doGetAll(req: Request): Promise<{ total: number; pageNodes: any } !!node.token ); - const macs = _.map(realNodes, (node: Node): string => node.mac); + const macs: MAC[] = _.map(realNodes, (node: Node): MAC => node.mac); const nodeStateByMac = await MonitoringService.getByMacs(macs); const enhancedNodes: EnhancedNode[] = _.map(realNodes, (node: Node): EnhancedNode => { - const nodeState = nodeStateByMac[node.mac]; + const nodeState = nodeStateByMac[node.mac.value]; if (nodeState) { return deepExtend({}, node, { site: nodeState.site, diff --git a/server/resources/statisticsResource.ts b/server/resources/statisticsResource.ts index 9d3a0c9..b052e0f 100644 --- a/server/resources/statisticsResource.ts +++ b/server/resources/statisticsResource.ts @@ -5,7 +5,6 @@ import * as Resources from "../utils/resources"; import {Request, Response} from "express"; export function get (req: Request, res: Response): void { - // TODO: Promises and types. getNodeStatistics() .then(nodeStatistics => Resources.success( res, diff --git a/server/services/monitoringService.test.ts b/server/services/monitoringService.test.ts index 6159071..0b315c6 100644 --- a/server/services/monitoringService.test.ts +++ b/server/services/monitoringService.test.ts @@ -1,6 +1,6 @@ import moment from 'moment'; import {ParsedNode, parseNode, parseNodesJson, parseTimestamp} from "./monitoringService"; -import {OnlineState} from "../types"; +import {MAC, OnlineState, to} from "../types"; import Logger from '../logger'; import {MockLogger} from "../__mocks__/logger"; @@ -240,12 +240,12 @@ test('parseNode() should succeed parsing node without site and domain', () => { // then const expectedParsedNode: ParsedNode = { - mac: "12:34:56:78:90:AB", + mac: to("12:34:56:78:90:AB"), importTimestamp: importTimestamp, state: OnlineState.ONLINE, lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING), - site: '', - domain: '' + site: to(""), + domain: to(""), }; expect(parseNode(importTimestamp, nodeData)).toEqual(expectedParsedNode); }); @@ -272,12 +272,12 @@ test('parseNode() should succeed parsing node with site and domain', () => { // then const expectedParsedNode: ParsedNode = { - mac: "12:34:56:78:90:AB", + mac: to("12:34:56:78:90:AB"), importTimestamp: importTimestamp, state: OnlineState.ONLINE, lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING), - site: 'test-site', - domain: 'test-domain' + site: to("test-site"), + domain: to("test-domain") }; expect(parseNode(importTimestamp, nodeData)).toEqual(expectedParsedNode); }); @@ -461,12 +461,12 @@ test('parseNodesJson() should parse valid nodes', () => { // then const expectedParsedNode: ParsedNode = { - mac: "12:34:56:78:90:AB", + mac: to("12:34:56:78:90:AB"), importTimestamp: parseTimestamp(TIMESTAMP_VALID_STRING), state: OnlineState.ONLINE, lastSeen: parseTimestamp(TIMESTAMP_VALID_STRING), - site: 'test-site', - domain: 'test-domain' + site: to("test-site"), + domain: to("test-domain"), }; expect(result.importTimestamp.isValid()).toBe(true); diff --git a/server/services/monitoringService.ts b/server/services/monitoringService.ts index ae288fd..0cd4cda 100644 --- a/server/services/monitoringService.ts +++ b/server/services/monitoringService.ts @@ -17,14 +17,19 @@ import {monitoringDisableUrl} from "../utils/urlBuilder"; import CONSTRAINTS from "../validation/constraints"; import {forConstraint} from "../validation/validator"; import { + Domain, + equal, isMonitoringSortField, MAC, MailType, MonitoringSortField, + MonitoringToken, Node, NodeId, NodeStateData, OnlineState, + Site, + to, UnixTimestampSeconds } from "../types"; @@ -45,12 +50,12 @@ const DELETE_OFFLINE_NODES_AFTER_DURATION: { amount: number, unit: unitOfTime.Du }; export type ParsedNode = { - mac: string, + mac: MAC, importTimestamp: Moment, state: OnlineState, lastSeen: Moment, - site: string, - domain: string, + site: Site, + domain: Domain, }; export type NodesParsingResult = { @@ -220,12 +225,12 @@ export function parseNode(importTimestamp: Moment, nodeData: any): ParsedNode { } return { - mac: mac, + mac: to(mac), importTimestamp: importTimestamp, state: isOnline ? OnlineState.ONLINE : OnlineState.OFFLINE, lastSeen: lastSeen, - site: site || '', - domain: domain || '' + site: to(site || ''), // FIXME: Handle this + domain: to(domain || '') // FIXME: Handle this }; } @@ -573,7 +578,7 @@ export async function getAll(restParams: RestParams): Promise<{ total: number, m return {monitoringStates, total}; } -export async function getByMacs(macs: MAC[]): Promise<{ [key: string]: NodeStateData }> { +export async function getByMacs(macs: MAC[]): Promise> { if (_.isEmpty(macs)) { return {}; } @@ -596,9 +601,9 @@ export async function getByMacs(macs: MAC[]): Promise<{ [key: string]: NodeState return nodeStateByMac; } -export async function confirm(token: string): 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.monitoring || !nodeSecrets.monitoringToken || !equal(nodeSecrets.monitoringToken, token)) { throw {data: 'Invalid token.', type: ErrorTypes.badRequest}; } @@ -612,15 +617,15 @@ export async function confirm(token: string): Promise { return newNode; } -export async function disable(token: string): 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.monitoring || !nodeSecrets.monitoringToken || !equal(nodeSecrets.monitoringToken, token)) { throw {data: 'Invalid token.', type: ErrorTypes.badRequest}; } node.monitoring = false; node.monitoringConfirmed = false; - nodeSecrets.monitoringToken = ''; + nodeSecrets.monitoringToken = undefined; const {node: newNode} = await NodeService.internalUpdateNode(node.token, node, nodeSecrets); return newNode; diff --git a/server/services/nodeService.ts b/server/services/nodeService.ts index 9f20bc7..b741009 100644 --- a/server/services/nodeService.ts +++ b/server/services/nodeService.ts @@ -10,51 +10,69 @@ import Logger from "../logger"; import * as MailService from "../services/mailService"; import {normalizeString} from "../utils/strings"; import {monitoringConfirmUrl, monitoringDisableUrl} from "../utils/urlBuilder"; -import {FastdKey, MonitoringState, MonitoringToken, Node, NodeSecrets, NodeStatistics, UnixTimestampSeconds, Token} from "../types"; +import { + FastdKey, + MAC, + MonitoringState, + MonitoringToken, + Node, + NodeSecrets, + NodeStatistics, + to, + Token, + unhandledEnumField, + UnixTimestampSeconds +} from "../types"; import util from "util"; const pglob = util.promisify(glob); type NodeFilter = { + // TODO: Newtype hostname?: string, - mac?: string, - key?: string, + mac?: MAC, + key?: FastdKey, token?: Token, - monitoringToken?: string, + monitoringToken?: MonitoringToken, } +// TODO: Newtypes? type NodeFilenameParsed = { hostname?: string, mac?: string, key?: string, - token?: Token, + token?: string, monitoringToken?: string, } -const linePrefixes = { - hostname: '# Knotenname: ', - nickname: '# Ansprechpartner: ', - email: '# Kontakt: ', - coords: '# Koordinaten: ', - mac: '# MAC: ', - token: '# Token: ', - monitoring: '# Monitoring: ', - monitoringToken: '# Monitoring-Token: ' -}; +enum LINE_PREFIX { + HOSTNAME = "# Knotenname: ", + NICKNAME = "# Ansprechpartner: ", + EMAIL = "# Kontakt: ", + COORDS = "# Koordinaten: ", + MAC = "# MAC: ", + TOKEN = "# Token: ", + MONITORING = "# Monitoring: ", + MONITORING_TOKEN = "# Monitoring-Token: ", +} const filenameParts = ['hostname', 'mac', 'key', 'token', 'monitoringToken']; -function generateToken(): Token { - return crypto.randomBytes(8).toString('hex'); +function generateToken(): Type { + return to(crypto.randomBytes(8).toString('hex')); } function toNodeFilesPattern(filter: NodeFilter): string { - const pattern = _.join( - _.map( - filenameParts, - field => field in filter ? (filter as {[key: string]: string | undefined})[field] : '*'), - '@' - ); + const fields: (string | undefined)[] = [ + filter.hostname, + filter.mac?.value, + filter.key?.value, + filter.token?.value, + filter.monitoringToken?.value, + ]; + + const pattern = fields.map((value) => value || '*').join('@'); return config.server.peersPath + '/' + pattern.toLowerCase(); } @@ -83,7 +101,7 @@ async function findFilesInPeersPath(): Promise { function parseNodeFilename(filename: string): NodeFilenameParsed { const parts = _.split(filename, '@', filenameParts.length); - const parsed: {[key: string]: string | undefined} = {}; + const parsed: { [key: string]: string | undefined } = {}; const zippedParts = _.zip(filenameParts, parts); _.each(zippedParts, part => { const key = part[0]; @@ -104,25 +122,25 @@ function isDuplicate(filter: NodeFilter, token: Token | null): boolean { return true; } - return parseNodeFilename(files[0]).token !== token; + return parseNodeFilename(files[0]).token !== token.value; } function checkNoDuplicates(token: Token | null, node: Node, nodeSecrets: NodeSecrets): void { - if (isDuplicate({ hostname: node.hostname }, token)) { + if (isDuplicate({hostname: node.hostname}, token)) { throw {data: {msg: 'Already exists.', field: 'hostname'}, type: ErrorTypes.conflict}; } if (node.key) { - if (isDuplicate({ key: node.key }, token)) { + if (isDuplicate({key: node.key}, token)) { throw {data: {msg: 'Already exists.', field: 'key'}, type: ErrorTypes.conflict}; } } - if (isDuplicate({ mac: node.mac }, token)) { + if (isDuplicate({mac: node.mac}, token)) { throw {data: {msg: 'Already exists.', field: 'mac'}, type: ErrorTypes.conflict}; } - if (nodeSecrets.monitoringToken && isDuplicate({ monitoringToken: nodeSecrets.monitoringToken }, token)) { + if (nodeSecrets.monitoringToken && isDuplicate({monitoringToken: nodeSecrets.monitoringToken}, token)) { throw {data: {msg: 'Already exists.', field: 'monitoringToken'}, type: ErrorTypes.conflict}; } } @@ -138,49 +156,55 @@ function toNodeFilename(token: Token, node: Node, nodeSecrets: NodeSecrets): str ).toLowerCase(); } +function getNodeValue(prefix: LINE_PREFIX, node: Node, nodeSecrets: NodeSecrets): string { + switch (prefix) { + case LINE_PREFIX.HOSTNAME: + return node.hostname; + case LINE_PREFIX.NICKNAME: + return node.nickname; + case LINE_PREFIX.EMAIL: + return node.email; + case LINE_PREFIX.COORDS: + return node.coords || ""; + case LINE_PREFIX.MAC: + return node.mac.value; + case LINE_PREFIX.TOKEN: + return node.token.value; + case LINE_PREFIX.MONITORING: + if (node.monitoring && node.monitoringConfirmed) { + return "aktiv"; + } else if (node.monitoring && !node.monitoringConfirmed) { + return "pending"; + } + return ""; + case LINE_PREFIX.MONITORING_TOKEN: + return nodeSecrets.monitoringToken?.value || ""; + default: + return unhandledEnumField(prefix); + } +} + async function writeNodeFile( isUpdate: boolean, token: Token, node: Node, nodeSecrets: NodeSecrets, -): Promise<{token: Token, node: Node}> { +): Promise<{ token: Token, node: Node }> { const filename = toNodeFilename(token, node, nodeSecrets); let data = ''; - _.each(linePrefixes, function (prefix, key) { - let value; - switch (key) { - case 'monitoring': - if (node.monitoring && node.monitoringConfirmed) { - value = 'aktiv'; - } else if (node.monitoring && !node.monitoringConfirmed) { - value = 'pending'; - } else { - value = ''; - } - break; - case 'monitoringToken': - value = nodeSecrets.monitoringToken || ''; - break; + for (const prefix of Object.values(LINE_PREFIX)) { + data += `${prefix}${getNodeValue(prefix, node, nodeSecrets)}\n`; + } - default: - value = key === 'token' ? token : (node as {[key: string]: any})[key]; - if (_.isUndefined(value)) { - const nodeSecret = (nodeSecrets as {[key: string]: string})[key]; - value = _.isUndefined(nodeSecret) ? '' : nodeSecret; - } - break; - } - data += prefix + value + '\n'; - }); if (node.key) { - data += 'key "' + node.key + '";\n'; + data += `key "${node.key}";\n`; } // since node.js is single threaded we don't need a lock if (isUpdate) { - const files = findNodeFilesSync({ token: token }); + const files = findNodeFilesSync({token: token}); if (files.length !== 1) { throw {data: 'Node not found.', type: ErrorTypes.notFound}; } @@ -190,8 +214,7 @@ async function writeNodeFile( const file = files[0]; try { oldFs.unlinkSync(file); - } - catch (error) { + } catch (error) { Logger.tag('node', 'save').error('Could not delete old node file: ' + file, error); throw {data: 'Could not remove old node data.', type: ErrorTypes.internalError}; } @@ -202,8 +225,7 @@ async function writeNodeFile( try { oldFs.writeFileSync(filename, data, 'utf8'); return {token, node}; - } - catch (error) { + } catch (error) { Logger.tag('node', 'save').error('Could not write node file: ' + filename, error); throw {data: 'Could not write node data.', type: ErrorTypes.internalError}; } @@ -212,9 +234,8 @@ async function writeNodeFile( async function deleteNodeFile(token: Token): Promise { let files; try { - files = await findNodeFiles({ token: token }); - } - catch (error) { + files = await findNodeFiles({token: token}); + } catch (error) { Logger.tag('node', 'delete').error('Could not find node file: ' + files, error); throw {data: 'Could not delete node.', type: ErrorTypes.internalError}; } @@ -225,86 +246,113 @@ async function deleteNodeFile(token: Token): Promise { try { oldFs.unlinkSync(files[0]); - } - catch (error) { + } catch (error) { Logger.tag('node', 'delete').error('Could not delete node file: ' + files, error); throw {data: 'Could not delete node.', type: ErrorTypes.internalError}; } } -async function parseNodeFile(file: string): Promise<{node: Node, nodeSecrets: NodeSecrets}> { +class NodeBuilder { + public token: Token = to(""); // FIXME: Either make token optional in Node or handle this! + public nickname: string = ""; + public email: string = ""; + public hostname: string = ""; // FIXME: Either make hostname optional in Node or handle this! + public coords?: string; + public key?: FastdKey; + public mac: MAC = to(""); // FIXME: Either make mac optional in Node or handle this! + public monitoring: boolean = false; + public monitoringConfirmed: boolean = false; + public monitoringState: MonitoringState = MonitoringState.DISABLED; + + constructor( + public readonly modifiedAt: UnixTimestampSeconds, + ) { + } + + public build(): Node { + return { + token: this.token, + nickname: this.nickname, + email: this.email, + hostname: this.hostname, + coords: this.coords, + key: this.key, + mac: this.mac, + monitoring: this.monitoring, + monitoringConfirmed: this.monitoringConfirmed, + monitoringState: this.monitoringState, + modifiedAt: this.modifiedAt, + } + } +} + +function setNodeValue(prefix: LINE_PREFIX, node: NodeBuilder, nodeSecrets: NodeSecrets, value: string) { + switch (prefix) { + case LINE_PREFIX.HOSTNAME: + node.hostname = value; + break; + case LINE_PREFIX.NICKNAME: + node.nickname = value; + break; + case LINE_PREFIX.EMAIL: + node.email = value; + break; + case LINE_PREFIX.COORDS: + node.coords = value; + break; + case LINE_PREFIX.MAC: + node.mac = to(value); + break; + case LINE_PREFIX.TOKEN: + node.token = to(value); + break; + 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; + case LINE_PREFIX.MONITORING_TOKEN: + nodeSecrets.monitoringToken = to(value); + break; + default: + return unhandledEnumField(prefix); + } +} + +async function parseNodeFile(file: string): Promise<{ node: Node, nodeSecrets: NodeSecrets }> { const contents = await fs.readFile(file); const stats = await fs.lstat(file); const modifiedAt = Math.floor(stats.mtimeMs / 1000); - const lines = contents.toString(); + const lines = contents.toString().split("\n"); - const node: {[key: string]: any} = {}; - const nodeSecrets: {[key: string]: any} = {}; + const node = new NodeBuilder(modifiedAt); + const nodeSecrets: NodeSecrets = {}; - _.each(lines.split('\n'), function (line) { - const entries: {[key: string]: string} = {}; - - for (const key of Object.keys(linePrefixes)) { - const prefix = (linePrefixes as {[key: string]: string})[key]; - if (line.substring(0, prefix.length) === prefix) { - entries[key] = normalizeString(line.substr(prefix.length)); - break; + for (const line of lines) { + if (line.substring(0, 5) === 'key "') { + node.key = to(normalizeString(line.split('"')[1])); + } else { + for (const prefix of Object.values(LINE_PREFIX)) { + if (line.substring(0, prefix.length) === prefix) { + const value = normalizeString(line.substring(prefix.length)); + setNodeValue(prefix, node, nodeSecrets, value); + break; + } } } - - if (_.isEmpty(entries) && line.substring(0, 5) === 'key "') { - entries.key = normalizeString(line.split('"')[1]); - } - - _.each(entries, function (value, key) { - switch (key) { - case 'mac': - node.mac = value; - node.mapId = _.toLower(value).replace(/:/g, ''); - break; - - case '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; - - case 'monitoringToken': - nodeSecrets.monitoringToken = value; - break; - - default: - node[key] = value; - break; - } - }); - }); + } return { - node: { - token: node.token as Token || '', - nickname: node.nickname as string || '', - email: node.email as string || '', - hostname: node.hostname as string || '', - coords: node.coords as string || undefined, - key: node.key as FastdKey || undefined, - mac: node.mac as string || '', - monitoring: !!node.monitoring, - monitoringConfirmed: !!node.monitoringConfirmed, - monitoringState: node.monitoringState as MonitoringState || MonitoringState.DISABLED, - modifiedAt, - }, - nodeSecrets: { - monitoringToken: nodeSecrets.monitoringToken as MonitoringToken || undefined, - }, + node: node.build(), + nodeSecrets, }; } -async function findNodeDataByFilePattern(filter: NodeFilter): Promise<{node: Node, nodeSecrets: NodeSecrets} | null> { +async function findNodeDataByFilePattern(filter: NodeFilter): Promise<{ node: Node, nodeSecrets: NodeSecrets } | null> { const files = await findNodeFiles(filter); if (files.length !== 1) { @@ -315,7 +363,7 @@ async function findNodeDataByFilePattern(filter: NodeFilter): Promise<{node: Nod return await parseNodeFile(file); } -async function getNodeDataByFilePattern(filter: NodeFilter): Promise<{node: Node, nodeSecrets: NodeSecrets}> { +async function getNodeDataByFilePattern(filter: NodeFilter): Promise<{ node: Node, nodeSecrets: NodeSecrets }> { const result = await findNodeDataByFilePattern(filter); if (!result) { throw {data: 'Node not found.', type: ErrorTypes.notFound}; @@ -348,14 +396,14 @@ async function sendMonitoringConfirmationMail(node: Node, nodeSecrets: NodeSecre ); } -export async function createNode (node: Node): Promise<{token: Token, node: Node}> { - const token = generateToken(); +export async function createNode(node: Node): Promise<{ token: Token, node: Node }> { + const token: Token = generateToken(); const nodeSecrets: NodeSecrets = {}; node.monitoringConfirmed = false; if (node.monitoring) { - nodeSecrets.monitoringToken = generateToken(); + nodeSecrets.monitoringToken = generateToken(); } const written = await writeNodeFile(false, token, node, nodeSecrets); @@ -367,17 +415,17 @@ export async function createNode (node: Node): Promise<{token: Token, node: Node return written; } -export async function updateNode (token: Token, node: Node): Promise<{token: Token, node: Node}> { +export async function updateNode(token: Token, node: Node): Promise<{ token: Token, node: Node }> { const {node: currentNode, nodeSecrets} = await getNodeDataWithSecretsByToken(token); let monitoringConfirmed = false; - let monitoringToken = ''; + let monitoringToken: MonitoringToken | undefined; if (node.monitoring) { if (!currentNode.monitoring) { // monitoring just has been enabled monitoringConfirmed = false; - monitoringToken = generateToken(); + monitoringToken = generateToken(); } else { // monitoring is still enabled @@ -385,12 +433,12 @@ export async function updateNode (token: Token, node: Node): Promise<{token: Tok if (currentNode.email !== node.email) { // new email so we need a new token and a reconfirmation monitoringConfirmed = false; - monitoringToken = generateToken(); + monitoringToken = generateToken(); } else { // email unchanged, keep token (fix if not set) and confirmation state monitoringConfirmed = currentNode.monitoringConfirmed; - monitoringToken = nodeSecrets.monitoringToken || generateToken(); + monitoringToken = nodeSecrets.monitoringToken || generateToken(); } } } @@ -409,11 +457,11 @@ export async function updateNode (token: Token, node: Node): Promise<{token: Tok export async function internalUpdateNode( token: Token, node: Node, nodeSecrets: NodeSecrets -): Promise<{token: Token, node: Node}> { +): Promise<{ token: Token, node: Node }> { return await writeNodeFile(true, token, node, nodeSecrets); } -export async function deleteNode (token: Token): Promise { +export async function deleteNode(token: Token): Promise { await deleteNodeFile(token); } @@ -440,34 +488,34 @@ export async function getAllNodes(): Promise { return nodes; } -export async function getNodeDataWithSecretsByMac (mac: string): Promise<{node: Node, nodeSecrets: NodeSecrets} | null> { - return await findNodeDataByFilePattern({ mac: mac }); +export async function getNodeDataWithSecretsByMac(mac: MAC): Promise<{ node: Node, nodeSecrets: NodeSecrets } | null> { + return await findNodeDataByFilePattern({mac}); } -export async function getNodeDataByMac (mac: string): Promise { - const result = await findNodeDataByFilePattern({ mac: mac }); +export async function getNodeDataByMac(mac: MAC): Promise { + const result = await findNodeDataByFilePattern({mac}); return result ? result.node : null; } -export async function getNodeDataWithSecretsByToken (token: Token): Promise<{node: Node, nodeSecrets: NodeSecrets}> { - return await getNodeDataByFilePattern({ token: token }); +export async function getNodeDataWithSecretsByToken(token: Token): Promise<{ node: Node, nodeSecrets: NodeSecrets }> { + return await getNodeDataByFilePattern({token: token}); } -export async function getNodeDataByToken (token: Token): Promise { - const {node} = await getNodeDataByFilePattern({ token: token }); +export async function getNodeDataByToken(token: Token): Promise { + const {node} = await getNodeDataByFilePattern({token: token}); return node; } -export async function getNodeDataWithSecretsByMonitoringToken ( +export async function getNodeDataWithSecretsByMonitoringToken( monitoringToken: MonitoringToken -): Promise<{node: Node, nodeSecrets: NodeSecrets}> { - return await getNodeDataByFilePattern({ monitoringToken: monitoringToken }); +): Promise<{ node: Node, nodeSecrets: NodeSecrets }> { + return await getNodeDataByFilePattern({monitoringToken: monitoringToken}); } -export async function getNodeDataByMonitoringToken ( +export async function getNodeDataByMonitoringToken( monitoringToken: MonitoringToken ): Promise { - const {node} = await getNodeDataByFilePattern({ monitoringToken: monitoringToken }); + const {node} = await getNodeDataByFilePattern({monitoringToken: monitoringToken}); return node; } @@ -481,8 +529,7 @@ export async function fixNodeFilenames(): Promise { if (file !== expectedFilename) { try { await fs.rename(file, expectedFilename); - } - catch (error) { + } catch (error) { throw new Error( 'Cannot rename file ' + file + ' to ' + expectedFilename + ' => ' + error ); @@ -518,24 +565,20 @@ export async function getNodeStatistics(): Promise { nodeStatistics.withCoords += 1; } - function ensureExhaustive(monitoringState: never): void { - throw new Error('Add missing case for monitoring stat below: ' + monitoringState); - } - const monitoringState = node.monitoringState; switch (monitoringState) { case MonitoringState.ACTIVE: nodeStatistics.monitoring.active += 1; - break; + break; case MonitoringState.PENDING: nodeStatistics.monitoring.pending += 1; - break; + break; case MonitoringState.DISABLED: // Not counted seperately. - break; + break; default: - ensureExhaustive(monitoringState); + unhandledEnumField(monitoringState); } }); diff --git a/server/types/config.ts b/server/types/config.ts index 2bd40de..d2cc41d 100644 --- a/server/types/config.ts +++ b/server/types/config.ts @@ -1,17 +1,32 @@ import {ArrayField, Field, RawJsonField} from "sparkson" -import {ClientConfig} from "./shared"; +import {ClientConfig, to} from "./shared"; // TODO: Replace string types by more specific types like URL, Password, etc. -export type Username = string; -export type CleartextPassword = string; -export type PasswordHash = string; +export type Username = { + value: string; + readonly __tag: unique symbol +}; +export type CleartextPassword = { + value: string; + readonly __tag: unique symbol +}; +export type PasswordHash = { + value: string; + readonly __tag: unique symbol +}; export class UsersConfig { + public username: Username; + public passwordHash: PasswordHash; + constructor( - @Field("user") public username: Username, - @Field("passwordHash") public passwordHash: PasswordHash, - ) {} + @Field("user") username: string, + @Field("passwordHash") passwordHash: string, + ) { + this.username = to(username); + this.passwordHash = to(passwordHash); + } } export class LoggingConfig { diff --git a/server/types/shared.ts b/server/types/shared.ts index 5903603..9ddc35f 100644 --- a/server/types/shared.ts +++ b/server/types/shared.ts @@ -6,10 +6,37 @@ export type TypeGuard = (arg: unknown) => arg is T; export type EnumValue = E[keyof E]; export type EnumTypeGuard = TypeGuard>; +export function unhandledEnumField(field: never): never { + throw new Error(`Unhandled enum field: ${field}`); +} + +export function to(value: Type['value']): Type { + return value as any as Type; +} + +export function lift2(callback: (a: Type["value"], b: Type["value"]) => Result): (newtype1: Type, newtype2: Type) => Result { + return (a, b) => callback(a.value, b.value); +} + +export function equal(a: Type, b: Type): boolean { + return lift2((a, b) => a === b)(a, b); +} + export function isObject(arg: unknown): arg is object { return arg !== null && typeof arg === "object"; } +export function toIsNewtype(isValue: TypeGuard): TypeGuard { + // TODO: Add validation pattern. + return (arg: unknown): arg is Type => { + if (!isObject(arg)) { + return false; + } + const newtype = arg as Type; + return isValue(newtype.value); + } +} + export function isArray(arg: unknown, isT: TypeGuard): arg is Array { if (!Array.isArray(arg)) { return false; @@ -248,19 +275,32 @@ export function isClientConfig(arg: unknown): arg is ClientConfig { } // TODO: Token type. -export type Token = string; -export const isToken = isString; +export type Token = { + value: string; + readonly __tag: unique symbol +}; +export const isToken = toIsNewtype(isString); -export type FastdKey = string; -export const isFastdKey = isString; +export type FastdKey = { + value: string; + readonly __tag: unique symbol +}; +export const isFastdKey = toIsNewtype(isString); -export type MAC = string; -export const isMAC = isString; +export type MAC = { + value: string; + readonly __tag: unique symbol +}; +export const isMAC = toIsNewtype(isString); export type UnixTimestampSeconds = number; export type UnixTimestampMilliseconds = number; -export type MonitoringToken = string; +export type MonitoringToken = { + value: string; + readonly __tag: unique symbol +}; + export enum MonitoringState { ACTIVE = "active", PENDING = "pending", @@ -269,9 +309,12 @@ export enum MonitoringState { export const isMonitoringState = toIsEnum(MonitoringState); -export type NodeId = string; -export const isNodeId = isString; +export type NodeId = { + value: string; + readonly __tag: unique symbol +}; +// TODO: More Newtypes export interface Node { token: Token; nickname: string; @@ -310,13 +353,20 @@ export enum OnlineState { ONLINE = "ONLINE", OFFLINE = "OFFLINE", } + export const isOnlineState = toIsEnum(OnlineState); -export type Site = string; -export const isSite = isString; +export type Site = { + value: string; + readonly __tag: unique symbol +}; +export const isSite = toIsNewtype(isString); -export type Domain = string; -export const isDomain = isString; +export type Domain = { + value: string; + readonly __tag: unique symbol +}; +export const isDomain = toIsNewtype(isString); export interface EnhancedNode extends Node { site?: Site, @@ -426,7 +476,10 @@ export enum MailSortField { export const isMailSortField = toIsEnum(MailSortField); -export type GenericSortField = string; +export type GenericSortField = { + value: string; + readonly __tag: unique symbol +}; export enum SortDirection { ASCENDING = "ASC", diff --git a/server/utils/urlBuilder.ts b/server/utils/urlBuilder.ts index a852ecd..dc244aa 100644 --- a/server/utils/urlBuilder.ts +++ b/server/utils/urlBuilder.ts @@ -4,7 +4,7 @@ import {MonitoringToken} from "../types" // TODO: Typed URLs -function formUrl(route: string, queryParams?: {[key: string]: string}): string { +function formUrl(route: string, queryParams?: { [key: string]: string }): string { let url = config.server.baseUrl; if (route || queryParams) { url += '/#/'; @@ -27,14 +27,14 @@ function formUrl(route: string, queryParams?: {[key: string]: string}): string { return url; } -export function editNodeUrl (): string { +export function editNodeUrl(): string { return formUrl('update'); } -export function monitoringConfirmUrl (monitoringToken: MonitoringToken): string { - return formUrl('monitoring/confirm', { token: monitoringToken }); +export function monitoringConfirmUrl(monitoringToken: MonitoringToken): string { + return formUrl('monitoring/confirm', {token: monitoringToken.value}); } -export function monitoringDisableUrl (monitoringToken: MonitoringToken): string { - return formUrl('monitoring/disable', { token: monitoringToken }); +export function monitoringDisableUrl(monitoringToken: MonitoringToken): string { + return formUrl('monitoring/disable', {token: monitoringToken.value}); }