Merge branch 'main' into new-admin

This commit is contained in:
baldo 2022-09-14 16:17:51 +02:00
commit 7cb11259dc
37 changed files with 2960 additions and 1311 deletions

View file

@ -20,9 +20,9 @@ import {
isSortDirection,
isString,
type JSONValue,
parseJSON,
type TypeGuard,
} from "@/types";
import { parseJSON } from "@/shared/utils/json";
export interface Route {
name: RouteName;

View file

@ -1,10 +1,13 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/server'],
globals: {
'ts-jest': {
tsconfig: '<rootDir>/server/tsconfig.json'
}
}
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/server"],
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
tsconfig: "<rootDir>/server/tsconfig.json",
},
],
},
};

View file

@ -56,7 +56,7 @@
"serve-static": "^1.14.1",
"sparkson": "^1.3.6",
"sqlite": "^4.1.2",
"sqlite3": "^5.0.11"
"sqlite3": "^5.1.0"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.1.4",
@ -66,15 +66,15 @@
"@types/command-line-usage": "^5.0.2",
"@types/compression": "^1.7.2",
"@types/deep-extend": "^0.4.32",
"@types/express": "^4.17.13",
"@types/express": "^4.17.14",
"@types/glob": "^8.0.0",
"@types/graceful-fs": "^4.1.5",
"@types/html-to-text": "^8.1.1",
"@types/jest": "^29.0.0",
"@types/lodash": "^4.14.184",
"@types/node": "^18.7.15",
"@types/node-cron": "^3.0.3",
"@types/nodemailer": "^6.4.5",
"@types/jest": "^29.0.2",
"@types/lodash": "^4.14.185",
"@types/node": "^18.7.18",
"@types/node-cron": "^3.0.4",
"@types/nodemailer": "^6.4.6",
"@types/request": "^2.48.8",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.1",
@ -102,13 +102,13 @@
"grunt-rev": "^0.1.0",
"grunt-usemin": "^3.1.1",
"grunt-wiredep": "^3.0.1",
"jest": "^29.0.2",
"jest": "^29.0.3",
"jshint-stylish": "^2.2.1",
"load-grunt-tasks": "^5.1.0",
"prettier": "^2.7.1",
"time-grunt": "^2.0.0",
"ts-jest": "^28.0.8",
"typescript": "^4.8.2",
"ts-jest": "^29.0.1",
"typescript": "^4.8.3",
"yarn-audit-fix": "^9.3.5"
},
"resolutions": {

View file

@ -9,11 +9,7 @@ import FixNodeFilenamesJob from "./FixNodeFilenamesJob";
import NodeInformationRetrievalJob from "./NodeInformationRetrievalJob";
import MonitoringMailsSendingJob from "./MonitoringMailsSendingJob";
import OfflineNodesDeletionJob from "./OfflineNodesDeletionJob";
export enum JobResultState {
OKAY = "okay",
WARNING = "warning",
}
import { DurationSeconds, JobResultState, TaskState } from "../shared/types";
export type JobResult = {
state: JobResultState;
@ -41,12 +37,6 @@ export interface Job {
run(): Promise<JobResult>;
}
export enum TaskState {
IDLE = "idle",
RUNNING = "running",
FAILED = "failed",
}
export class Task {
constructor(
public id: number,
@ -56,7 +46,7 @@ export class Task {
public job: Job,
public runningSince: moment.Moment | null,
public lastRunStarted: moment.Moment | null,
public lastRunDuration: number | null,
public lastRunDuration: DurationSeconds | null,
public state: TaskState,
public result: JobResult | null,
public enabled: boolean
@ -74,7 +64,9 @@ export class Task {
const done = (state: TaskState, result: JobResult | null): void => {
const now = moment();
const duration = now.diff(this.runningSince || now);
const duration = now.diff(
this.runningSince || now
) as DurationSeconds;
Logger.tag("jobs").profile("[%sms]\t%s", duration, this.name);
this.runningSince = null;

View file

@ -10,7 +10,6 @@ import { Request, Response } from "express";
import {
CreateOrUpdateNode,
DomainSpecificNodeResponse,
filterUndefinedFromJSON,
isCreateOrUpdateNode,
isNodeSortField,
isString,
@ -27,6 +26,7 @@ import {
toNodeResponse,
toNodeTokenResponse,
} from "../types";
import { filterUndefinedFromJSON } from "../shared/utils/json";
const nodeFields = [
"hostname",

View file

@ -2,36 +2,33 @@ import CONSTRAINTS from "../shared/validation/constraints";
import ErrorTypes from "../utils/errorTypes";
import * as Resources from "../utils/resources";
import { handleJSONWithData, RequestData } from "../utils/resources";
import { getTasks, Task, TaskState } from "../jobs/scheduler";
import { getTasks, Task } from "../jobs/scheduler";
import { normalizeString } from "../shared/utils/strings";
import { forConstraint } from "../shared/validation/validator";
import { Request, Response } from "express";
import { isString, isTaskSortField } from "../types";
import {
isString,
isTaskSortField,
TaskResponse,
TaskSortField,
TaskState,
UnixTimestampSeconds,
} from "../types";
const isValidId = forConstraint(CONSTRAINTS.id, false);
type TaskResponse = {
id: number;
name: string;
description: string;
schedule: string;
runningSince: number | null;
lastRunStarted: number | null;
lastRunDuration: number | null;
state: string;
result: string | null;
message: string | null;
enabled: boolean;
};
function toTaskResponse(task: Task): TaskResponse {
return {
id: task.id,
name: task.name,
description: task.description,
schedule: task.schedule,
runningSince: task.runningSince && task.runningSince.unix(),
lastRunStarted: task.lastRunStarted && task.lastRunStarted.unix(),
runningSince:
task.runningSince &&
(task.runningSince.unix() as UnixTimestampSeconds),
lastRunStarted:
task.lastRunStarted &&
(task.lastRunStarted.unix() as UnixTimestampSeconds),
lastRunDuration: task.lastRunDuration || null,
state: task.state,
result:
@ -89,7 +86,7 @@ async function doGetAll(
): Promise<{ total: number; pageTasks: Task[] }> {
const restParams = await Resources.getValidRestParams("list", null, req);
const tasks = Resources.sort(
const tasks = Resources.sort<Task, TaskSortField>(
Object.values(getTasks()),
isTaskSortField,
restParams

View file

@ -14,12 +14,13 @@ import {
MailData,
MailId,
MailSortField,
MailSortFieldEnum,
MailType,
parseJSON,
UnixTimestampSeconds,
} from "../types";
import ErrorTypes from "../utils/errorTypes";
import { send } from "../mail";
import { parseJSON } from "../shared/utils/json";
type EmaiQueueRow = {
id: MailId;
@ -81,6 +82,8 @@ async function findPendingMailsBefore(
recipient: row.recipient,
data,
failures: row.failures,
created_at: row.created_at,
modified_at: row.modified_at,
};
});
}
@ -156,14 +159,14 @@ export async function getPendingMails(
const total = row?.total || 0;
const filter = Resources.filterClause(
const filter = Resources.filterClause<MailSortField>(
restParams,
MailSortField.ID,
MailSortFieldEnum.ID,
isMailSortField,
["id", "failures", "sender", "recipient", "email"]
);
const mails = await db.all(
const mails = await db.all<Mail>(
"SELECT * FROM email_queue WHERE " + filter.query,
filter.params
);

View file

@ -18,7 +18,6 @@ import { forConstraint } from "../shared/validation/validator";
import {
Domain,
DurationSeconds,
filterUndefinedFromJSON,
Hostname,
isBoolean,
isDomain,
@ -33,15 +32,14 @@ import {
JSONValue,
MAC,
MailType,
mapIdFromMAC,
MonitoringSortField,
MonitoringSortFieldEnum,
MonitoringState,
MonitoringToken,
NodeMonitoringStateResponse,
NodeStateData,
NodeStateId,
OnlineState,
parseJSON,
Site,
StoredNode,
toCreateOrUpdateNode,
@ -56,6 +54,8 @@ import {
subtract,
weeks,
} from "../utils/time";
import { filterUndefinedFromJSON, parseJSON } from "../shared/utils/json";
import { mapIdFromMAC } from "../shared/utils/node";
type NodeStateRow = {
id: NodeStateId;
@ -743,9 +743,9 @@ export async function getAll(
const total = row?.total || 0;
const filter = Resources.filterClause(
const filter = Resources.filterClause<MonitoringSortField>(
restParams,
MonitoringSortField.ID,
MonitoringSortFieldEnum.ID,
isMonitoringSortField,
filterFields
);

View file

@ -19,7 +19,6 @@ import {
CreateOrUpdateNode,
EmailAddress,
FastdKey,
filterUndefinedFromJSON,
Hostname,
isFastdKey,
isHostname,
@ -36,13 +35,14 @@ import {
NodeStatistics,
StoredNode,
Token,
toUnixTimestampSeconds,
TypeGuard,
unhandledEnumField,
UnixTimestampMilliseconds,
UnixTimestampSeconds,
} from "../types";
import util from "util";
import { filterUndefinedFromJSON } from "../shared/utils/json";
import { unhandledEnumField } from "../shared/utils/enums";
import { toUnixTimestampSeconds } from "../shared/utils/time";
const pglob = util.promisify(glob);

View file

@ -0,0 +1,39 @@
/**
* Contains type guards for arrays.
*
* @module arrays
*/
import type { TypeGuard } from "./helpers";
/**
* Type guard for an array with elements of type `<Element>`.
*
* @param arg - Value to check
* @param isElement - Type guard to check elements for type `<Element>`
*/
export function isArray<Element>(
arg: unknown,
isElement: TypeGuard<Element>
): arg is Array<Element> {
if (!Array.isArray(arg)) {
return false;
}
for (const element of arg) {
if (!isElement(element)) {
return false;
}
}
return true;
}
/**
* Helper function to construct array type guards.
*
* @param isElement - Type guard to check elements for type `<Element>`
* @returns A type guard for arrays with elements of type `<Element>`.
*/
export function toIsArray<Element>(
isElement: TypeGuard<Element>
): TypeGuard<Element[]> {
return (arg): arg is Element[] => isArray(arg, isElement);
}

View file

@ -0,0 +1,378 @@
/**
* Contains types and corresponding type guards for the client side configuration of ffffng.
*
* @module config
*/
import { ArrayField, Field, RawJsonField } from "sparkson";
import { isObject, isPlainObject } from "./objects";
import { isBoolean, isNumber, isString } from "./primitives";
import { isArray } from "./arrays";
import { isOptional } from "./helpers";
import { isJSONObject } from "./json";
import {
type Domain,
isDomain,
isSite,
isUrl,
type Site,
type Url,
} from "./newtypes";
import { type EmailAddress, isEmailAddress } from "./email";
/**
* Configuration for a single coordinate.
*
* See {@link CommunityConfig.constructor}.
*/
export class CoordinatesConfig {
/**
* @param lat - Latitude of the coordinate.
* @param lng - Longitude of the coordinate.
*/
constructor(
@Field("lat") public lat: number,
@Field("lng") public lng: number
) {}
}
/**
* Type guard for {@link CoordinatesConfig}.
*
* @param arg - Value to check.
*/
export function isCoordinatesConfig(arg: unknown): arg is CoordinatesConfig {
if (!isObject(arg)) {
return false;
}
const coords = arg as CoordinatesConfig;
return isNumber(coords.lat) && isNumber(coords.lng);
}
/**
* Configuration for checking if a node is inside the community boundaries.
*
* See {@link OtherCommunityInfoConfig.constructor}.
*/
export class OtherCommunityInfoConfig {
/**
* @param showInfo - Specifies if for nodes outside the community boundaries a confirmation screen should be shown.
* @param showBorderForDebugging - If set to `true` the outline of the community is rendered on the map.
* @param localCommunityPolygon - Boundaries of the community.
*/
constructor(
@Field("showInfo") public showInfo: boolean,
@Field("showBorderForDebugging") public showBorderForDebugging: boolean,
@ArrayField("localCommunityPolygon", CoordinatesConfig)
public localCommunityPolygon: CoordinatesConfig[]
) {}
}
/**
* Type guard for {@link OtherCommunityInfoConfig}.
*
* @param arg - Value to check.
*/
export function isOtherCommunityInfoConfig(
arg: unknown
): arg is OtherCommunityInfoConfig {
if (!isObject(arg)) {
return false;
}
const cfg = arg as OtherCommunityInfoConfig;
return (
isBoolean(cfg.showInfo) &&
isBoolean(cfg.showBorderForDebugging) &&
isArray(cfg.localCommunityPolygon, isCoordinatesConfig)
);
}
/**
* Options of a map layer.
*/
export type LayerOptions = {
/**
* Attribution shown for the map layer (HTML).
*/
attribution: string;
/**
* Subdomains used to load tiles for the map layer, e.g.: `"1234"` or `"abcd"`.
*/
subdomains?: string;
/**
* Maximum zoom level for the map layer.
*/
maxZoom: number;
};
/**
* Type guard for {@link LayerOptions}.
*
* @param arg - Value to check.
*/
export function isLayerOptions(arg: unknown): arg is LayerOptions {
if (!isPlainObject(arg)) {
return false;
}
const obj = arg as LayerOptions;
return (
isString(obj.attribution) &&
isOptional(obj.subdomains, isString) &&
isNumber(obj.maxZoom)
);
}
/**
* Configuration of a map layer.
*/
export type LayerConfig = {
/**
* Display name of the map layer.
*/
name: string;
/**
* Tiles URL of the layer.
*/
url: Url;
/**
* Type of the map (e.g. `"xyz"`). Unused in new frontend.
*/
type: string;
/**
* See {@link LayerOptions}.
*/
layerOptions: LayerOptions;
};
/**
* Type guard for {@link LayerConfig}.
*
* @param arg - Value to check.
*/
export function isLayerConfig(arg: unknown): arg is LayerConfig {
if (!isPlainObject(arg)) {
return false;
}
const obj = arg as LayerConfig;
return (
isString(obj.name) &&
isUrl(obj.url) &&
isString(obj.type) &&
isLayerOptions(obj.layerOptions)
);
}
/**
* Configuration of the map for picking node coordinates.
*
* See {@link CoordinatesSelectorConfig.constructor}
*/
export class CoordinatesSelectorConfig {
/**
* @param lat - Latitude to center the map on
* @param lng - Longitude to center the map on
* @param defaultZoom - Default zoom level of the map
* @param layers - Mapping of layer ids to layer configurations for the map
*/
constructor(
@Field("lat") public lat: number,
@Field("lng") public lng: number,
@Field("defaultZoom") public defaultZoom: number,
@RawJsonField("layers") public layers: Record<string, LayerConfig>
) {}
}
/**
* Type guard for {@link CoordinatesSelectorConfig}.
*
* @param arg - Value to check.
*/
export function isCoordinatesSelectorConfig(
arg: unknown
): arg is CoordinatesSelectorConfig {
if (!isObject(arg)) {
return false;
}
const cfg = arg as CoordinatesSelectorConfig;
return (
isNumber(cfg.lat) &&
isNumber(cfg.lng) &&
isNumber(cfg.defaultZoom) &&
isJSONObject(cfg.layers)
);
}
/**
* Configuration of monitoring options.
*
* See {@link MonitoringConfig.constructor}.
*/
export class MonitoringConfig {
/**
* @param enabled - Specifies if node owners may activate monitoring for their devices
*/
constructor(@Field("enabled") public enabled: boolean) {}
}
/**
* Type guard for {@link MonitoringConfig}.
*
* @param arg - Value to check.
*/
export function isMonitoringConfig(arg: unknown): arg is MonitoringConfig {
if (!isObject(arg)) {
return false;
}
const cfg = arg as MonitoringConfig;
return isBoolean(cfg.enabled);
}
/**
* Configuration of the community map instance.
*
* See {@link CommunityMapConfig.constructor}.
*/
export class CommunityMapConfig {
/**
* @param mapUrl - Base URL of the Freifunk community's node map
*/
constructor(@Field("mapUrl") public mapUrl: Url) {}
}
/**
* Type guard for {@link CommunityMapConfig}.
*
* @param arg - Value to check.
*/
export function isCommunityMapConfig(arg: unknown): arg is CommunityMapConfig {
if (!isObject(arg)) {
return false;
}
const cfg = arg as CommunityMapConfig;
return isUrl(cfg.mapUrl);
}
/**
* Configuration of URLs for legal information.
*
* See {@link LegalConfig.constructor}
*/
export class LegalConfig {
/**
* @param privacyUrl - Optional: URL to the privacy conditions
* @param imprintUrl - Optional: URL to the imprint
*/
constructor(
@Field("privacyUrl", true) public privacyUrl?: Url,
@Field("imprintUrl", true) public imprintUrl?: Url
) {}
}
/**
* Type guard for {@link LegalConfig}.
*
* @param arg - Value to check.
*/
export function isLegalConfig(arg: unknown): arg is LegalConfig {
if (!isObject(arg)) {
return false;
}
const cfg = arg as LegalConfig;
return (
isOptional(cfg.privacyUrl, isUrl) && isOptional(cfg.imprintUrl, isUrl)
);
}
/**
* Configuration for community settings.
*
* See: {@link CommunityConfig.constructor}
*/
export class CommunityConfig {
/**
* @param name - Name of the Freifunk community, e.g. `"Freifunk Musterstadt"`
* @param domain - Domain of the Freifunk community, e.g. `"musterstadt.freifunk.net"`
* @param contactEmail - Contact email address of the Freifunk community
* @param sites - Array of the valid site codes found in the `nodes.json`, e.g.: `["ffms-site1", "ffms-site2"]`
* @param domains - Array of the valid domain codes found in the `nodes.json`, e.g.: `["ffms-domain1", "ffms-domain2"]`
*/
constructor(
@Field("name") public name: string,
@Field("domain") public domain: string,
@Field("contactEmail") public contactEmail: EmailAddress,
@ArrayField("sites", String) public sites: Site[],
@ArrayField("domains", String) public domains: Domain[]
) {}
}
/**
* Type guard for {@link CommunityConfig}.
*
* @param arg - Value to check.
*/
export function isCommunityConfig(arg: unknown): arg is CommunityConfig {
if (!isObject(arg)) {
return false;
}
const cfg = arg as CommunityConfig;
return (
isString(cfg.name) &&
isString(cfg.domain) &&
isEmailAddress(cfg.contactEmail) &&
isArray(cfg.sites, isSite) &&
isArray(cfg.domains, isDomain)
);
}
/**
* Configuration shared with the client.
*
* See: {@link ClientConfig.constructor}
*/
export class ClientConfig {
/**
* @param community - See {@link CommunityConfig}
* @param legal - See {@link LegalConfig}
* @param map - See {@link CommunityMapConfig}
* @param monitoring - See {@link MonitoringConfig}
* @param coordsSelector - See {@link CoordinatesSelectorConfig}
* @param otherCommunityInfo - See {@link OtherCommunityInfoConfig}
* @param rootPath - Path under which ffffng is served.
*/
constructor(
@Field("community") public community: CommunityConfig,
@Field("legal") public legal: LegalConfig,
@Field("map") public map: CommunityMapConfig,
@Field("monitoring") public monitoring: MonitoringConfig,
@Field("coordsSelector")
public coordsSelector: CoordinatesSelectorConfig,
@Field("otherCommunityInfo")
public otherCommunityInfo: OtherCommunityInfoConfig,
@Field("rootPath", true, undefined, "/") public rootPath: string
) {}
}
/**
* Type guard for {@link ClientConfig}.
*
* @param arg - Value to check.
*/
export function isClientConfig(arg: unknown): arg is ClientConfig {
if (!isObject(arg)) {
return false;
}
const cfg = arg as ClientConfig;
return (
isCommunityConfig(cfg.community) &&
isLegalConfig(cfg.legal) &&
isCommunityMapConfig(cfg.map) &&
isMonitoringConfig(cfg.monitoring) &&
isCoordinatesSelectorConfig(cfg.coordsSelector) &&
isOtherCommunityInfoConfig(cfg.otherCommunityInfo) &&
isString(cfg.rootPath)
);
}

View file

@ -0,0 +1,183 @@
/**
* Contains types and type guards around emails.
*/
import { toIsNewtype } from "./newtypes";
import { isNumber, isString } from "./primitives";
import type { JSONObject } from "./json";
import { toIsEnum } from "./enums";
import { type SortFieldFor, toIsSortField } from "./sortfields";
import type { UnixTimestampSeconds } from "./time";
/**
* An email address.
*/
export type EmailAddress = string & { readonly __tag: unique symbol };
/**
* Type guard for {@link EmailAddress}.
*
* @param arg - Value to check.
*/
export const isEmailAddress = toIsNewtype(isString, "" as EmailAddress);
/**
* ID of an email in the mail queue waiting to be sent.
*/
export type MailId = number & { readonly __tag: unique symbol };
/**
* Type guard for {@link MailId}.
*
* @param arg - Value to check.
*/
export const isMailId = toIsNewtype(isNumber, NaN as MailId);
/**
* Data of an email in the mail queue waiting to be sent.
*/
export type Mail = {
/**
* ID of the email in the queue.
*/
id: MailId;
/**
* Type of the to be sent email.
*
* See {@link MailType}.
*/
email: MailType;
/**
* Sender address of the email.
*/
sender: EmailAddress;
/**
* Recipient address of the email.
*/
recipient: EmailAddress;
/**
* Data to be rendered into the email template. This is specific to the email's {@link MailType}.
*/
data: MailData;
/**
* Number of times trying to send the queued email has failed.
*/
failures: number;
/**
* Time the email has been queued first.
*/
created_at: UnixTimestampSeconds;
/**
* Last time the email has been modified inside the queue.
*/
modified_at: UnixTimestampSeconds;
};
/**
* Type of the email being sent. This determines which email template is being used and in which format the
* {@link MailData} is being expected.
*/
export enum MailType {
/**
* First monitoring email sent when a Freifunk node is offline.
*/
MONITORING_OFFLINE_1 = "monitoring-offline-1",
/**
* Second monitoring (first reminder) email sent when a Freifunk node is offline.
*/
MONITORING_OFFLINE_2 = "monitoring-offline-2",
/**
* Third monitoring (second and last reminder) email sent when a Freifunk node is offline.
*/
MONITORING_OFFLINE_3 = "monitoring-offline-3",
/**
* Email notifying the owner that their Freifunk node is back online.
*/
MONITORING_ONLINE_AGAIN = "monitoring-online-again",
/**
* Email holding a confirmation link to enable monitoring for a Freifunk node (double opt-in).
*/
MONITORING_CONFIRMATION = "monitoring-confirmation",
}
/**
* Type guard for {@link MailType}.
*
* @param arg - Value to check.
*/
export const isMailType = toIsEnum(MailType);
/**
* Type of data being rendered into an email template. This is specific to the email's {@link MailType}.
*/
export type MailData = JSONObject;
/**
* Enum specifying the allowed sort fields when retrieving the list of emails in the mail queue via
* the REST API.
*/
export enum MailSortFieldEnum {
// noinspection JSUnusedGlobalSymbols
/**
* See {@link Mail.id}.
*/
ID = "id",
/**
* See {@link Mail.failures}.
*/
FAILURES = "failures",
/**
* See {@link Mail.sender}.
*/
SENDER = "sender",
/**
* See {@link Mail.recipient}.
*/
RECIPIENT = "recipient",
/**
* See {@link Mail.email}.
*/
EMAIL = "email",
/**
* See {@link Mail.created_at}.
*/
CREATED_AT = "created_at",
/**
See {@link Mail.modified_at}.
*/
MODIFIED_AT = "modified_at",
}
/**
* Allowed sort fields when retrieving the list of emails in the mail queue via the REST API.
*/
export type MailSortField = SortFieldFor<Mail, MailSortFieldEnum>;
/**
* Type guard for {@link MailSortField}.
*
* @param arg - Value to check.
*/
export const isMailSortField = toIsSortField<
Mail,
MailSortFieldEnum,
typeof MailSortFieldEnum,
MailSortField
>(MailSortFieldEnum);

View file

@ -0,0 +1,25 @@
/**
* Contains type guards and helpers for enums.
*/
import type { TypeGuard, ValueOf } from "./helpers";
/**
* Shorthand type alias for enum {@link TypeGuard}s.
*/
export type EnumTypeGuard<E> = TypeGuard<ValueOf<E>>;
/**
* Shorthand type for descrbing enum objects.
*/
export type Enum<E> = Record<keyof E, ValueOf<E>>;
/**
* Helper function to construct enum type guards.
*
* @param enumDef - The enum object to check against.
* @returns A type guard for values of the enum `<Enum>`
*/
export function toIsEnum<E extends Enum<E>>(enumDef: E): EnumTypeGuard<E> {
return (arg): arg is ValueOf<E> =>
Object.values(enumDef).includes(arg as [keyof E]);
}

View file

@ -0,0 +1,45 @@
/**
* Contains helper types and type guards.
*/
import { isNull, isUndefined } from "./primitives";
/**
* Shorthand type alias for type guards checking for values of type `<ValueType>`.
*/
export type TypeGuard<ValueType> = (arg: unknown) => arg is ValueType;
/**
* Shorthand type alias for referencing values hold by an object of type `<Type>`.
*
* See it as an addition to typescript's `keyof`.
*/
export type ValueOf<Type> = Type[keyof Type];
/**
* Generic type guard to check for optional values of type `<Type>`.
* Optional means the value must either be `undefined` or a valid value of type `<Type>`.
*
* @param arg - Value to check
* @param isType - Type guard for checking for values of type `<Type>`
*/
export function isOptional<Type>(
arg: unknown,
isType: TypeGuard<Type>
): arg is Type | undefined {
return isUndefined(arg) || isType(arg);
}
/**
* Generic type guard to check for nullable values of type `<Type>`.
* The value must either be `null` or a valid value of type `<Type>`.
*
* @param arg - Value to check
* @param isType - Type guard for checking for values of type `<Type>`
*/
export function isNullable<Type>(
arg: unknown,
isType: TypeGuard<Type>
): arg is Type | null {
return isNull(arg) || isType(arg);
}

View file

@ -1,788 +1,25 @@
import { ArrayField, Field, RawJsonField } from "sparkson";
// Types shared with the client.
export type TypeGuard<T> = (arg: unknown) => arg is T;
export function parseJSON(str: string): JSONValue {
const json = JSON.parse(str);
if (!isJSONValue(json)) {
throw new Error("Invalid JSON returned. Should never happen.");
}
return json;
}
export function filterUndefinedFromJSON(obj: {
[key: string]: JSONValue | undefined;
}): JSONObject {
const result: JSONObject = {};
for (const [key, value] of Object.entries(obj)) {
if (value !== undefined) {
result[key] = value;
}
}
return result;
}
export type JSONValue =
| null
| string
| number
| boolean
| JSONObject
| JSONArray;
export function isJSONValue(arg: unknown): arg is JSONValue {
return (
arg === null ||
isString(arg) ||
isNumber(arg) ||
isBoolean(arg) ||
isJSONObject(arg) ||
isJSONArray(arg)
);
}
export interface JSONObject {
[x: string]: JSONValue;
}
export function isJSONObject(arg: unknown): arg is JSONObject {
if (!isObject(arg)) {
return false;
}
const obj = arg as object;
for (const [key, value] of Object.entries(obj)) {
if (!isString(key) || !isJSONValue(value)) {
return false;
}
}
return true;
}
export type JSONArray = Array<JSONValue>;
export const isJSONArray = toIsArray(isJSONValue);
export type ValueOf<T> = T[keyof T];
export type EnumTypeGuard<E> = TypeGuard<ValueOf<E>>;
export function unhandledEnumField(field: never): never {
throw new Error(`Unhandled enum field: ${field}`);
}
export function isObject(arg: unknown): arg is object {
return arg !== null && typeof arg === "object";
}
export function isPlainObject(arg: unknown): arg is { [key: string]: unknown } {
return isObject(arg) && !Array.isArray(arg);
}
export function hasOwnProperty<Key extends PropertyKey>(
arg: unknown,
key: Key
): arg is Record<Key, unknown> {
return isObject(arg) && key in arg;
}
export function getFieldIfExists(
arg: unknown,
key: PropertyKey
): unknown | undefined {
return hasOwnProperty(arg, key) ? arg[key] : undefined;
}
export function isArray<T>(arg: unknown, isT: TypeGuard<T>): arg is Array<T> {
if (!Array.isArray(arg)) {
return false;
}
for (const element of arg) {
if (!isT(element)) {
return false;
}
}
return true;
}
export function isMap(arg: unknown): arg is Map<unknown, unknown> {
return arg instanceof Map;
}
export function isString(arg: unknown): arg is string {
return typeof arg === "string";
}
// noinspection JSUnusedLocalSymbols
export function toIsNewtype<
Type extends Value & { readonly __tag: symbol },
Value
// eslint-disable-next-line @typescript-eslint/no-unused-vars
>(isValue: TypeGuard<Value>, example: Type): TypeGuard<Type> {
return (arg: unknown): arg is Type => isValue(arg);
}
export function isNumber(arg: unknown): arg is number {
return typeof arg === "number";
}
export function isBoolean(arg: unknown): arg is boolean {
return typeof arg === "boolean";
}
export function isUndefined(arg: unknown): arg is undefined {
return typeof arg === "undefined";
}
export function isNull(arg: unknown): arg is null {
return arg === null;
}
export function toIsArray<T>(isT: TypeGuard<T>): TypeGuard<T[]> {
return (arg): arg is T[] => isArray(arg, isT);
}
export function toIsEnum<E extends Record<keyof E, ValueOf<E>>>(
enumDef: E
): EnumTypeGuard<E> {
return (arg): arg is ValueOf<E> =>
Object.values(enumDef).includes(arg as [keyof E]);
}
export function isRegExp(arg: unknown): arg is RegExp {
return isObject(arg) && arg instanceof RegExp;
}
export function isOptional<T>(
arg: unknown,
isT: TypeGuard<T>
): arg is T | undefined {
return arg === undefined || isT(arg);
}
export type Url = string & { readonly __tag: unique symbol };
export const isUrl = toIsNewtype(isString, "" as Url);
export type Version = string & { readonly __tag: unique symbol };
export const isVersion = toIsNewtype(isString, "" as Version);
export type EmailAddress = string & { readonly __tag: unique symbol };
export const isEmailAddress = toIsNewtype(isString, "" as EmailAddress);
export type NodeStatistics = {
registered: number;
withVPN: number;
withCoords: number;
monitoring: {
active: number;
pending: number;
};
};
export function isNodeStatistics(arg: unknown): arg is NodeStatistics {
if (!isObject(arg)) {
return false;
}
const stats = arg as NodeStatistics;
return (
isNumber(stats.registered) &&
isNumber(stats.withVPN) &&
isNumber(stats.withCoords) &&
isObject(stats.monitoring) &&
isNumber(stats.monitoring.active) &&
isNumber(stats.monitoring.pending)
);
}
export type Statistics = {
nodes: NodeStatistics;
};
export function isStatistics(arg: unknown): arg is Statistics {
return isObject(arg) && isNodeStatistics((arg as Statistics).nodes);
}
export class CommunityConfig {
constructor(
@Field("name") public name: string,
@Field("domain") public domain: string,
@Field("contactEmail") public contactEmail: EmailAddress,
@ArrayField("sites", String) public sites: Site[],
@ArrayField("domains", String) public domains: Domain[]
) {}
}
export function isCommunityConfig(arg: unknown): arg is CommunityConfig {
if (!isObject(arg)) {
return false;
}
const cfg = arg as CommunityConfig;
return (
isString(cfg.name) &&
isString(cfg.domain) &&
isEmailAddress(cfg.contactEmail) &&
isArray(cfg.sites, isSite) &&
isArray(cfg.domains, isDomain)
);
}
export class LegalConfig {
constructor(
@Field("privacyUrl", true) public privacyUrl?: Url,
@Field("imprintUrl", true) public imprintUrl?: Url
) {}
}
export function isLegalConfig(arg: unknown): arg is LegalConfig {
if (!isObject(arg)) {
return false;
}
const cfg = arg as LegalConfig;
return (
isOptional(cfg.privacyUrl, isUrl) && isOptional(cfg.imprintUrl, isUrl)
);
}
export class ClientMapConfig {
constructor(@Field("mapUrl") public mapUrl: Url) {}
}
export function isClientMapConfig(arg: unknown): arg is ClientMapConfig {
if (!isObject(arg)) {
return false;
}
const cfg = arg as ClientMapConfig;
return isUrl(cfg.mapUrl);
}
export class MonitoringConfig {
constructor(@Field("enabled") public enabled: boolean) {}
}
export function isMonitoringConfig(arg: unknown): arg is MonitoringConfig {
if (!isObject(arg)) {
return false;
}
const cfg = arg as MonitoringConfig;
return isBoolean(cfg.enabled);
}
export class CoordinatesConfig {
constructor(
@Field("lat") public lat: number,
@Field("lng") public lng: number
) {}
}
export function isCoordinatesConfig(arg: unknown): arg is CoordinatesConfig {
if (!isObject(arg)) {
return false;
}
const coords = arg as CoordinatesConfig;
return isNumber(coords.lat) && isNumber(coords.lng);
}
export type LayerOptions = {
attribution: string;
subdomains?: string;
maxZoom: number;
};
export function isLayerOptions(arg: unknown): arg is LayerOptions {
if (!isPlainObject(arg)) {
return false;
}
const obj = arg as LayerOptions;
return (
isString(obj.attribution) &&
isOptional(obj.subdomains, isString) &&
isNumber(obj.maxZoom)
);
}
export type LayerConfig = {
name: string;
url: Url;
type: string;
layerOptions: LayerOptions;
};
export function isLayerConfig(arg: unknown): arg is LayerConfig {
if (!isPlainObject(arg)) {
return false;
}
const obj = arg as LayerConfig;
return (
isString(obj.name) &&
isUrl(obj.url) &&
isString(obj.type) &&
isLayerOptions(obj.layerOptions)
);
}
export class CoordinatesSelectorConfig {
constructor(
@Field("lat") public lat: number,
@Field("lng") public lng: number,
@Field("defaultZoom") public defaultZoom: number,
@RawJsonField("layers") public layers: Record<string, LayerConfig>
) {}
}
export function isCoordinatesSelectorConfig(
arg: unknown
): arg is CoordinatesSelectorConfig {
if (!isObject(arg)) {
return false;
}
const cfg = arg as CoordinatesSelectorConfig;
return (
isNumber(cfg.lat) &&
isNumber(cfg.lng) &&
isNumber(cfg.defaultZoom) &&
isJSONObject(cfg.layers)
);
}
export class OtherCommunityInfoConfig {
constructor(
@Field("showInfo") public showInfo: boolean,
@Field("showBorderForDebugging") public showBorderForDebugging: boolean,
@ArrayField("localCommunityPolygon", CoordinatesConfig)
public localCommunityPolygon: CoordinatesConfig[]
) {}
}
export function isOtherCommunityInfoConfig(
arg: unknown
): arg is OtherCommunityInfoConfig {
if (!isObject(arg)) {
return false;
}
const cfg = arg as OtherCommunityInfoConfig;
return (
isBoolean(cfg.showInfo) &&
isBoolean(cfg.showBorderForDebugging) &&
isArray(cfg.localCommunityPolygon, isCoordinatesConfig)
);
}
export class ClientConfig {
constructor(
@Field("community") public community: CommunityConfig,
@Field("legal") public legal: LegalConfig,
@Field("map") public map: ClientMapConfig,
@Field("monitoring") public monitoring: MonitoringConfig,
@Field("coordsSelector")
public coordsSelector: CoordinatesSelectorConfig,
@Field("otherCommunityInfo")
public otherCommunityInfo: OtherCommunityInfoConfig,
@Field("rootPath", true, undefined, "/") public rootPath: string
) {}
}
export function isClientConfig(arg: unknown): arg is ClientConfig {
if (!isObject(arg)) {
return false;
}
const cfg = arg as ClientConfig;
return (
isCommunityConfig(cfg.community) &&
isLegalConfig(cfg.legal) &&
isClientMapConfig(cfg.map) &&
isMonitoringConfig(cfg.monitoring) &&
isCoordinatesSelectorConfig(cfg.coordsSelector) &&
isOtherCommunityInfoConfig(cfg.otherCommunityInfo) &&
isString(cfg.rootPath)
);
}
export type Token = string & { readonly __tag: unique symbol };
export const isToken = toIsNewtype(isString, "" as Token);
export type FastdKey = string & { readonly __tag: unique symbol };
export const isFastdKey = toIsNewtype(isString, "" as FastdKey);
export type MAC = string & { readonly __tag: unique symbol };
export const isMAC = toIsNewtype(isString, "" as MAC);
export type MapId = string & { readonly __tag: unique symbol };
export const isMapId = toIsNewtype(isString, "" as MapId);
export function mapIdFromMAC(mac: MAC): MapId {
return mac.toLowerCase().replace(/:/g, "") as MapId;
}
export type DurationSeconds = number & { readonly __tag: unique symbol };
export const isDurationSeconds = toIsNewtype(isNumber, NaN as DurationSeconds);
export type DurationMilliseconds = number & { readonly __tag: unique symbol };
export const isDurationMilliseconds = toIsNewtype(
isNumber,
NaN as DurationMilliseconds
);
export type UnixTimestampSeconds = number & { readonly __tag: unique symbol };
export const isUnixTimestampSeconds = toIsNewtype(
isNumber,
NaN as UnixTimestampSeconds
);
export type UnixTimestampMilliseconds = number & {
readonly __tag: unique symbol;
};
export const isUnixTimestampMilliseconds = toIsNewtype(
isNumber,
NaN as UnixTimestampMilliseconds
);
export function toUnixTimestampSeconds(
ms: UnixTimestampMilliseconds
): UnixTimestampSeconds {
return Math.floor(ms) as UnixTimestampSeconds;
}
export type MonitoringToken = string & { readonly __tag: unique symbol };
export const isMonitoringToken = toIsNewtype(isString, "" as MonitoringToken);
export enum MonitoringState {
ACTIVE = "active",
PENDING = "pending",
DISABLED = "disabled",
}
export const isMonitoringState = toIsEnum(MonitoringState);
export type NodeId = string & { readonly __tag: unique symbol };
export const isNodeId = toIsNewtype(isString, "" as NodeId);
export type Hostname = string & { readonly __tag: unique symbol };
export const isHostname = toIsNewtype(isString, "" as Hostname);
export type Nickname = string & { readonly __tag: unique symbol };
export const isNickname = toIsNewtype(isString, "" as Nickname);
/**
* String representing geo coordinates. Latitude and longitude are delimited by one whitespace.
* E.g.: <code>"53.565278 10.001389"</code>
* This module and all submodules provide types that are being shared between client and server.
*/
export type Coordinates = string & { readonly __tag: unique symbol };
export const isCoordinates = toIsNewtype(isString, "" as Coordinates);
import { isString } from "./primitives";
/**
* Basic node data.
*/
export type BaseNode = {
nickname: Nickname;
email: EmailAddress;
hostname: Hostname;
coords: Coordinates | undefined;
key: FastdKey | undefined;
mac: MAC;
};
export function isBaseNode(arg: unknown): arg is BaseNode {
if (!isObject(arg)) {
return false;
}
const node = arg as BaseNode;
return (
isNickname(node.nickname) &&
isEmailAddress(node.email) &&
isHostname(node.hostname) &&
isOptional(node.coords, isCoordinates) &&
isOptional(node.key, isFastdKey) &&
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",
}
export const isOnlineState = toIsEnum(OnlineState);
export type Site = string & { readonly __tag: unique symbol };
export const isSite = toIsNewtype(isString, "" as Site);
export type Domain = string & { readonly __tag: unique symbol };
export const isDomain = toIsNewtype(isString, "" as Domain);
/**
* Represents a node in the context of a Freifunk site and domain.
*/
export type DomainSpecificNodeResponse = NodeResponse & {
site: Site | undefined;
domain: Domain | undefined;
onlineState: OnlineState | undefined;
};
export function isDomainSpecificNodeResponse(
arg: unknown
): arg is DomainSpecificNodeResponse {
if (!isNodeResponse(arg)) {
return false;
}
const node = arg as DomainSpecificNodeResponse;
return (
isOptional(node.site, isSite) &&
isOptional(node.domain, isDomain) &&
isOptional(node.onlineState, isOnlineState)
);
}
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 type NodeStateId = number & { readonly __tag: unique symbol };
export const isNodeStateId = toIsNewtype(isNumber, 0 as NodeStateId);
export type NodeMonitoringStateResponse = {
id: NodeStateId;
created_at: UnixTimestampSeconds;
domain?: Domain;
hostname?: Hostname;
import_timestamp: UnixTimestampSeconds;
last_seen: UnixTimestampSeconds;
last_status_mail_sent?: UnixTimestampSeconds;
last_status_mail_type?: MailType;
mac: MAC;
modified_at: UnixTimestampSeconds;
monitoring_state?: MonitoringState;
site?: Site;
state: OnlineState;
mapId: MapId;
};
export type MailId = number & { readonly __tag: unique symbol };
export const isMailId = toIsNewtype(isNumber, NaN as MailId);
export type MailData = JSONObject;
export enum MailType {
MONITORING_OFFLINE_1 = "monitoring-offline-1",
MONITORING_OFFLINE_2 = "monitoring-offline-2",
MONITORING_OFFLINE_3 = "monitoring-offline-3",
MONITORING_ONLINE_AGAIN = "monitoring-online-again",
MONITORING_CONFIRMATION = "monitoring-confirmation",
}
export const isMailType = toIsEnum(MailType);
export type Mail = {
id: MailId;
email: MailType;
sender: EmailAddress;
recipient: EmailAddress;
data: MailData;
failures: number;
};
// noinspection JSUnusedGlobalSymbols
export enum NodeSortFieldEnum {
HOSTNAME = "hostname",
NICKNAME = "nickname",
EMAIL = "email",
TOKEN = "token",
MAC = "mac",
KEY = "key",
SITE = "site",
DOMAIN = "domain",
COORDS = "coords",
ONLINE_STATE = "onlineState",
MONITORING_STATE = "monitoringState",
}
export type NodeSortField = keyof Pick<
DomainSpecificNodeResponse,
NodeSortFieldEnum
>;
export function isNodeSortField(arg: unknown): arg is NodeSortField {
if (!isString(arg)) {
return false;
}
return Object.values(NodeSortFieldEnum).includes(arg as NodeSortField);
}
export type NodesFilter = {
hasKey?: boolean;
hasCoords?: boolean;
monitoringState?: MonitoringState;
site?: Site;
domain?: Domain;
onlineState?: OnlineState;
};
export const NODES_FILTER_FIELDS: Record<
keyof NodesFilter,
| BooleanConstructor
| StringConstructor
| typeof MonitoringState
| typeof OnlineState
> = {
hasKey: Boolean,
hasCoords: Boolean,
monitoringState: MonitoringState,
site: String,
domain: String,
onlineState: OnlineState,
};
export function isNodesFilter(arg: unknown): arg is NodesFilter {
if (!isObject(arg)) {
return false;
}
const filter = arg as NodesFilter;
return (
isOptional(filter.hasKey, isBoolean) &&
isOptional(filter.hasCoords, isBoolean) &&
isOptional(filter.monitoringState, isMonitoringState) &&
isOptional(filter.site, isSite) &&
isOptional(filter.domain, isDomain) &&
isOptional(filter.onlineState, isOnlineState)
);
}
export * from "./arrays";
export * from "./config";
export * from "./email";
export * from "./enums";
export * from "./helpers";
export * from "./json";
export * from "./maps";
export * from "./monitoring";
export * from "./newtypes";
export * from "./node";
export * from "./objects";
export * from "./primitives";
export * from "./regexps";
export * from "./statistics";
export * from "./sortfields";
export * from "./task";
export * from "./time";
export type SearchTerm = string & { readonly __tag: unique symbol };
export const isSearchTerm = isString;
export enum MonitoringSortField {
ID = "id",
HOSTNAME = "hostname",
MAC = "mac",
SITE = "site",
DOMAIN = "domain",
MONITORING_STATE = "monitoring_state",
STATE = "state",
LAST_SEEN = "last_seen",
IMPORT_TIMESTAMP = "import_timestamp",
LAST_STATUS_MAIL_TYPE = "last_status_mail_type",
LAST_STATUS_MAIL_SENT = "last_status_mail_sent",
CREATED_AT = "created_at",
MODIFIED_AT = "modified_at",
}
export const isMonitoringSortField = toIsEnum(MonitoringSortField);
export enum TaskSortField {
ID = "id",
NAME = "name",
SCHEDULE = "schedule",
STATE = "state",
RUNNING_SINCE = "runningSince",
LAST_RUN_STARTED = "lastRunStarted",
}
export const isTaskSortField = toIsEnum(TaskSortField);
export enum MailSortField {
ID = "id",
FAILURES = "failures",
SENDER = "sender",
RECIPIENT = "recipient",
EMAIL = "email",
CREATED_AT = "created_at",
MODIFIED_AT = "modified_at",
}
export const isMailSortField = toIsEnum(MailSortField);
export type GenericSortField = {
value: string;
readonly __tag: unique symbol;
};
export enum SortDirection {
ASCENDING = "ASC",
DESCENDING = "DESC",
}
export const isSortDirection = toIsEnum(SortDirection);

View file

@ -0,0 +1,72 @@
/**
* Contains types and type guard for representing JSON values.
*/
import { isBoolean, isNull, isNumber, isString } from "./primitives";
import { toIsArray } from "./arrays";
import { isPlainObject } from "./objects";
/**
* Shorthand type alias representing a JSON value.
*/
export type JSONValue =
| null
| string
| number
| boolean
| JSONObject
| JSONArray;
/**
* Type guard checking the given value is a valid {@link JSONValue}.
*
* @param arg - Value to check.
*/
export function isJSONValue(arg: unknown): arg is JSONValue {
return (
isNull(arg) ||
isString(arg) ||
isNumber(arg) ||
isBoolean(arg) ||
isJSONObject(arg) ||
isJSONArray(arg)
);
}
/**
* Type representing a JSON object of `string` keys and values of type {@link JSONValue}.
*/
export interface JSONObject {
[x: string]: JSONValue;
}
/**
* Type guard checking the given value is a valid {@link JSONObject}.
*
* @param arg - Value to check.
*/
export function isJSONObject(arg: unknown): arg is JSONObject {
if (!isPlainObject(arg)) {
return false;
}
const obj = arg as object;
for (const [key, value] of Object.entries(obj)) {
if (!isString(key) || !isJSONValue(value)) {
return false;
}
}
return true;
}
/**
* Shorthand type alias representing a JSON array with elements of type {@link JSONValue}.
*/
export type JSONArray = Array<JSONValue>;
/**
* Type guard checking the given value is a valid {@link JSONArray}.
*
* @param arg - Value to check.
*/
export const isJSONArray = toIsArray(isJSONValue);

View file

@ -0,0 +1,12 @@
/**
* Contains type guards for regular expressions.
*/
/**
* Type guard for {@link Map}s.
*
* @param arg - Value to check.
*/
export function isMap(arg: unknown): arg is Map<unknown, unknown> {
return arg instanceof Map;
}

View file

@ -0,0 +1,343 @@
/**
* Contains types and type guards for monitoring data.
*/
import {
type Domain,
isDomain,
isMAC,
isSite,
type MAC,
type Site,
toIsNewtype,
} from "./newtypes";
import { isBoolean, isNumber, isString } from "./primitives";
import { toIsEnum } from "./enums";
import { type Hostname, isHostname, isMapId, type MapId } from "./node";
import {
type EmailAddress,
isEmailAddress,
isMailType,
MailType,
} from "./email";
import { isUnixTimestampSeconds, type UnixTimestampSeconds } from "./time";
import { isOptional } from "./helpers";
import { type SortFieldFor, toIsSortField } from "./sortfields";
/**
* Token for activating monitoring of a Freifunk node. This is being sent to verify the email address to use.
*/
export type MonitoringToken = string & { readonly __tag: unique symbol };
/**
* Type guard for {@link MonitoringToken}.
*
* @param arg - Value to check.
*/
export const isMonitoringToken = toIsNewtype(isString, "" as MonitoringToken);
/**
* The different states of monitoring of a Freifunk node.
*/
export enum MonitoringState {
/**
* The node's online state is being actively monitored. If the node goes offline for a certain period of time
* a notification email will be sent.
*/
ACTIVE = "active",
/**
* Monitoring has been activated by the user, but the email address used is not yet verified.
*/
PENDING = "pending",
/**
* Monitoring is disabled.
*/
DISABLED = "disabled",
}
/**
* Type guard for {@link MonitoringState}.
*
* @param arg - Value to check.
*/
export const isMonitoringState = toIsEnum(MonitoringState);
/**
* Online state of a Freifunk node.
*/
export enum OnlineState {
/**
* The node is currently online.
*/
ONLINE = "ONLINE",
/**
* The node is currently offline.
*/
OFFLINE = "OFFLINE",
}
/**
* Type guard for {@link OnlineState}.
*
* @param arg - Value to check.
*/
export const isOnlineState = toIsEnum(OnlineState);
/**
* Data of a Freifunk node as it is provided by the server's API when changing the nodes monitoring state.
*/
export type MonitoringResponse = {
/**
* Hostname of the node.
*/
hostname: Hostname;
/**
* MAC address of the node.
*/
mac: MAC;
/**
* Email address that is being used for monitoring.
*/
email: EmailAddress;
/**
* Whether monitoring is enabled.
*/
monitoring: boolean;
/**
* Whether the email address has been confirmed for use in monitoring the node.
*/
monitoringConfirmed: boolean;
};
/**
* Type guard for {@link MonitoringResponse}.
*
* @param arg - Value to check.
*/
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)
);
}
/**
* ID of the monitoring data of a Freifunk node stored in the database.
*/
export type NodeStateId = number & { readonly __tag: unique symbol };
/**
* Type guard for {@link NodeStateId}.
*
* @param arg - Value to check.
*/
export const isNodeStateId = toIsNewtype(isNumber, NaN as NodeStateId);
/**
* Monitoring related data of a Freifunk node as it is provided by the server's API to the admin frontend.
*/
export type NodeMonitoringStateResponse = {
/**
* ID of the monitoring data stored in the database.
*/
id: NodeStateId;
/**
* Time the monitoring data has first been stored.
*/
created_at: UnixTimestampSeconds;
/**
* Time the monitoring data has last been updated.
*/
modified_at: UnixTimestampSeconds;
/**
* Hostname of the Freifunk node.
*/
hostname?: Hostname;
/**
* MAC address of the Freifunk node.
*/
mac: MAC;
/**
* ID to identify a Freifunk node on the communities map.
*/
mapId: MapId;
/**
* Freifunk site as specified in the community map's `nodes.json`.
*/
site?: Site;
/**
* Freifunk domain as specified in the community map's `nodes.json`.
*/
domain?: Domain;
/**
* Time data for the node has last been imported from the community map's `nodes.json`.
*/
import_timestamp: UnixTimestampSeconds;
/**
* Time the node has last been seen online.
*/
last_seen: UnixTimestampSeconds;
/**
* Time the last monitoring notification email has been sent, if any.
*/
last_status_mail_sent?: UnixTimestampSeconds;
/**
* Type of the last monitoring notification email sent, if any.
*/
last_status_mail_type?: MailType;
/**
* Monitoring state of the node.
*/
monitoring_state?: MonitoringState;
/**
* Online state of the node.
*/
state: OnlineState;
};
/**
* Type guard for {@link NodeMonitoringStateResponse}.
*
* @param arg - Value to check.
*/
export function isNodeMonitoringStateResponse(
arg: unknown
): arg is NodeMonitoringStateResponse {
if (!Object(arg)) {
return false;
}
const response = arg as NodeMonitoringStateResponse;
return (
isNodeStateId(response.id) &&
isUnixTimestampSeconds(response.created_at) &&
isOptional(response.domain, isDomain) &&
isOptional(response.hostname, isHostname) &&
isUnixTimestampSeconds(response.import_timestamp) &&
isUnixTimestampSeconds(response.last_seen) &&
isOptional(response.last_status_mail_sent, isUnixTimestampSeconds) &&
isOptional(response.last_status_mail_type, isMailType) &&
isMAC(response.mac) &&
isUnixTimestampSeconds(response.modified_at) &&
isOptional(response.monitoring_state, isMonitoringState) &&
isOptional(response.site, isSite) &&
isOnlineState(response.state) &&
isMapId(response.mapId)
);
}
/**
* Enum specifying the allowed sort fields when retrieving the list of monitoring data via the REST API.
*/
export enum MonitoringSortFieldEnum {
// noinspection JSUnusedGlobalSymbols
/**
* See {@link NodeMonitoringStateResponse.id}.
*/
ID = "id",
/**
* See {@link NodeMonitoringStateResponse.hostname}.
*/
HOSTNAME = "hostname",
/**
* See {@link NodeMonitoringStateResponse.mac}.
*/
MAC = "mac",
/**
* See {@link NodeMonitoringStateResponse.site}.
*/
SITE = "site",
/**
* See {@link NodeMonitoringStateResponse.domain}.
*/
DOMAIN = "domain",
/**
* See {@link NodeMonitoringStateResponse.monitoring_state}.
*/
MONITORING_STATE = "monitoring_state",
/**
* See {@link NodeMonitoringStateResponse.state}.
*/
STATE = "state",
/**
* See {@link NodeMonitoringStateResponse.last_seen}.
*/
LAST_SEEN = "last_seen",
/**
* See {@link NodeMonitoringStateResponse.import_timestamp}.
*/
IMPORT_TIMESTAMP = "import_timestamp",
/**
* See {@link NodeMonitoringStateResponse.last_status_mail_type}.
*/
LAST_STATUS_MAIL_TYPE = "last_status_mail_type",
/**
* See {@link NodeMonitoringStateResponse.last_status_mail_sent}.
*/
LAST_STATUS_MAIL_SENT = "last_status_mail_sent",
/**
* See {@link NodeMonitoringStateResponse.created_at}.
*/
CREATED_AT = "created_at",
/**
* See {@link NodeMonitoringStateResponse.modified_at}.
*/
MODIFIED_AT = "modified_at",
}
/**
* Allowed sort fields when retrieving the list of monitoring data via the REST API.
*/
export type MonitoringSortField = SortFieldFor<
NodeMonitoringStateResponse,
MonitoringSortFieldEnum
>;
/**
* Type guard for {@link MonitoringSortField}.
*
* @param arg - Value to check.
*/
export const isMonitoringSortField = toIsSortField<
NodeMonitoringStateResponse,
MonitoringSortFieldEnum,
typeof MonitoringSortFieldEnum,
MonitoringSortField
>(MonitoringSortFieldEnum);

View file

@ -0,0 +1,142 @@
/**
* Contains type guards for newtypes. Newtypes are a way to strongly type strings, numbers, ...
*
* This is inspired by the tagged intersection types in
* {@link https://kubyshkin.name/posts/newtype-in-typescript/}.
*
* Also holds newtype definitions that don't fit elsewhere.
*/
import type { TypeGuard } from "./helpers";
import { isString } from "./primitives";
// =====================================================================================================================
// General newtype helpers.
// =====================================================================================================================
/**
* Helper function to generate type guards for newtypes of type `<Newtype>`.
*
* Newtypes can be defined as follows:
*
* @param isValue - Typeguard to check for the value-type (`<ValueType>`) of the newtype.
* @param example - An example value of type `<Newtype>`.
* @returns A type guard for `<Newtype>`.
*
* @example
* type StringNewtype = string & { readonly __tag: unique symbol };
* const isStringNewtype = toIsNewtype(isString, "" as StringNewtype);
*
* type NumberNewtype = number & { readonly __tag: unique symbol };
* const isNumberNewtype = toIsNewtype(isNumber, NaN as NumberNewtype);
*/
// noinspection JSUnusedLocalSymbols
export function toIsNewtype<
Newtype extends ValueType & { readonly __tag: symbol },
ValueType
// eslint-disable-next-line @typescript-eslint/no-unused-vars
>(isValue: TypeGuard<ValueType>, example: Newtype): TypeGuard<Newtype> {
return (arg: unknown): arg is Newtype => isValue(arg);
}
// =====================================================================================================================
// Newtype definitions.
// =====================================================================================================================
/**
* Version of ffffng.
*/
export type Version = string & { readonly __tag: unique symbol };
/**
* Type guard for {@link Version}.
*
* @param arg - Value to check.
*/
export const isVersion = toIsNewtype(isString, "" as Version);
/**
* Typesafe string representation of URLs.
*
* Note: Not to be confused with Javascript's own {@link URL} type.
*/
export type Url = string & { readonly __tag: unique symbol };
/**
* Type guard for {@link Url}.
*
* @param arg - Value to check.
*/
export const isUrl = toIsNewtype(isString, "" as Url);
/**
* Fastd VPN key of a Freifunk node. This is the key used by the node to open a VPN tunnel to Freifunk gateways.
*/
export type FastdKey = string & { readonly __tag: unique symbol };
/**
* Type guard for {@link FastdKey}.
*
* @param arg - Value to check.
*/
export const isFastdKey = toIsNewtype(isString, "" as FastdKey);
/**
* A MAC address.
*/
export type MAC = string & { readonly __tag: unique symbol };
/**
* Type guard for {@link MAC}.
*
* @param arg - Value to check.
*/
export const isMAC = toIsNewtype(isString, "" as MAC);
/**
* String representing geo coordinates. Latitude and longitude are delimited by exactly one whitespace.
* E.g.: <code>"53.565278 10.001389"</code>
*/
export type Coordinates = string & { readonly __tag: unique symbol };
/**
* Type guard for {@link Coordinates}.
*
* @param arg - Value to check.
*/
export const isCoordinates = toIsNewtype(isString, "" as Coordinates);
/**
* String representation of contact's name / nickname.
*/
export type Nickname = string & { readonly __tag: unique symbol };
/**
* Type guard for {@link Nickname}.
*
* @param arg - Value to check.
*/
export const isNickname = toIsNewtype(isString, "" as Nickname);
/**
* Freifunk site as specified in the community map's `nodes.json`.
*/
export type Site = string & { readonly __tag: unique symbol };
/**
* Type guard for {@link Site}.
*
* @param arg - Value to check.
*/
export const isSite = toIsNewtype(isString, "" as Site);
/**
* Freifunk domain as specified in the community map's `nodes.json`.
*/
export type Domain = string & { readonly __tag: unique symbol };
/**
* Type guard for {@link Domain}.
*
* @param arg - Value to check.
*/
export const isDomain = toIsNewtype(isString, "" as Domain);

485
server/shared/types/node.ts Normal file
View file

@ -0,0 +1,485 @@
/**
* Contains types and type guards for representing Freifunk nodes in various states.
*/
import { isObject } from "./objects";
import { isOptional } from "./helpers";
import type {
Coordinates,
Domain,
FastdKey,
MAC,
Nickname,
Site,
} from "./newtypes";
import {
isCoordinates,
isDomain,
isFastdKey,
isMAC,
isNickname,
isSite,
toIsNewtype,
} from "./newtypes";
import { isBoolean, isString } from "./primitives";
import { type SortFieldFor, toIsSortField } from "./sortfields";
import { type EmailAddress, isEmailAddress } from "./email";
import { isUnixTimestampSeconds, type UnixTimestampSeconds } from "./time";
import {
isMonitoringState,
isOnlineState,
MonitoringState,
OnlineState,
} from "./monitoring";
/**
* ID of a node in the context of the `nodes.json` of the Freifunk community's node map.
*
* This is typically the nodes lowercase MAC address without any delimiters.
*/
export type NodeId = string & { readonly __tag: unique symbol };
/**
* Type guard for {@link NodeId}.
*
* @param arg - Value to check.
*/
export const isNodeId = toIsNewtype(isString, "" as NodeId);
/**
* Token of a Freifunk node registered with ffffng. This is being used to authorize a user to delete or modify the
* data being stored for a node.
*
* This token should be kept secret by the owner of the node.
*/
export type Token = string & { readonly __tag: unique symbol };
/**
* Type guard for {@link Token}.
*
* @param arg - Value to check.
*/
export const isToken = toIsNewtype(isString, "" as Token);
/**
* Representation of a Freifunk node's hostname.
*/
export type Hostname = string & { readonly __tag: unique symbol };
/**
* Type guard for {@link Hostname}.
*
* @param arg - Value to check.
*/
export const isHostname = toIsNewtype(isString, "" as Hostname);
/**
* ID to identify a Freifunk node on the communities map.
*/
export type MapId = string & { readonly __tag: unique symbol };
/**
* Type guard for {@link MapId}.
*
* @param arg - Value to check.
*/
export const isMapId = toIsNewtype(isString, "" as MapId);
/**
* Most basic information of a Freifunk Node.
*/
export type BaseNode = {
/**
* Name / nickname that should be used to contact the node owner.
*/
nickname: Nickname;
/**
* Email address that should be used to contact the node owner.
*/
email: EmailAddress;
/**
* Hostname of the node that will be displayed on the community's node map.
*/
hostname: Hostname;
/**
* Optional coordinates of the node to position it on the community's node map.
*/
coords: Coordinates | undefined;
/**
* Optional fastd key of the node. This is the key used by the node to open a VPN tunnel to Freifunk gateways.
*/
key: FastdKey | undefined;
/**
* MAC address of the node. This MAC address is used to identify the node at various places, e.g. when retrieving
* information of the node from the `nodes.json` of the communities node map.
*/
mac: MAC;
};
/**
* Type guard for {@link BaseNode}.
*
* @param arg - Value to check.
*/
export function isBaseNode(arg: unknown): arg is BaseNode {
if (!isObject(arg)) {
return false;
}
const node = arg as BaseNode;
return (
isNickname(node.nickname) &&
isEmailAddress(node.email) &&
isHostname(node.hostname) &&
isOptional(node.coords, isCoordinates) &&
isOptional(node.key, isFastdKey) &&
isMAC(node.mac)
);
}
/**
* Node data used when creating or updating a node.
*/
export type CreateOrUpdateNode = BaseNode & {
/**
* Whether to monitor the nodes online state and notify its owner when it's offline for a longer period of time.
*/
monitoring: boolean;
};
/**
* Type guard for {@link CreateOrUpdateNode}.
*
* @param arg - Value to check.
*/
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 Freifunk node as it is stored on the server.
*/
export type StoredNode = BaseNode & {
/**
* Token used to authorize a user to delete or modify the data being stored for a node.
*
* This token should be kept secret by the owner of the node.
*/
token: Token;
/**
* State of the online monitoring for this node.
*
* See {@link MonitoringState}.
*/
monitoringState: MonitoringState;
/**
* Last time the node data has been updated on the server.
*/
modifiedAt: UnixTimestampSeconds;
};
/**
* Type guard for {@link StoredNode}.
*
* @param arg - Value to check.
*/
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)
);
}
/**
* Data of a Freifunk node as it is provided by the server's API.
*/
export type NodeResponse = StoredNode & {
/**
* Whether the node's online state should be monitored.
*/
monitoring: boolean;
/**
* Specifies if the node owner has clicked the email confirmation link to enable monitoring of the online state.
*/
monitoringConfirmed: boolean;
};
/**
* Type guard for {@link NodeResponse}.
*
* @param arg - Value to check.
*/
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);
}
/**
* Data of a Freifunk node as it is provided by the server's API also providing the token to authorize node owners.
*/
export type NodeTokenResponse = {
/**
* Token used to authorize a user to delete or modify the data being stored for a node.
*
* This token should be kept secret by the owner of the node.
*/
token: Token;
/**
* Data of the Freifunk node. See {@link NodeResponse}.
*/
node: NodeResponse;
};
/**
* Type guard for {@link NodeTokenResponse}.
*
* @param arg - Value to check.
*/
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
);
}
/**
* Represents a node in the context of a Freifunk site and domain.
*/
export type DomainSpecificNodeResponse = NodeResponse & {
/**
* Freifunk site the node resides in or `undefined` if unknown.
*/
site: Site | undefined;
/**
* Freifunk domain the node resides in or `undefined` if unknown.
*/
domain: Domain | undefined;
/**
* Online state of the Freifunk node or `undefined` if unknown.
*/
onlineState: OnlineState | undefined;
};
/**
* Type guard for {@link DomainSpecificNodeResponse}.
*
* @param arg - Value to check.
*/
export function isDomainSpecificNodeResponse(
arg: unknown
): arg is DomainSpecificNodeResponse {
if (!isNodeResponse(arg)) {
return false;
}
const node = arg as DomainSpecificNodeResponse;
return (
isOptional(node.site, isSite) &&
isOptional(node.domain, isDomain) &&
isOptional(node.onlineState, isOnlineState)
);
}
/**
* Enum specifying the allowed sort fields when retrieving the list of nodes via the REST API.
*/
export enum NodeSortFieldEnum {
// noinspection JSUnusedGlobalSymbols
/**
* See {@link BaseNode.hostname}.
*/
HOSTNAME = "hostname",
/**
* See {@link BaseNode.nickname}.
*/
NICKNAME = "nickname",
/**
* See {@link BaseNode.email}.
*/
EMAIL = "email",
/**
* See {@link StoredNode.token}.
*/
TOKEN = "token",
/**
* See {@link BaseNode.mac}.
*/
MAC = "mac",
/**
* See {@link BaseNode.key}.
*/
KEY = "key",
/**
* See {@link DomainSpecificNodeResponse.site}.
*/
SITE = "site",
/**
* See {@link DomainSpecificNodeResponse.domain}.
*/
DOMAIN = "domain",
/**
* See {@link BaseNode.coords}.
*/
COORDS = "coords",
/**
* See {@link DomainSpecificNodeResponse.onlineState}.
*/
ONLINE_STATE = "onlineState",
/**
* See {@link StoredNode.monitoringState}.
*/
MONITORING_STATE = "monitoringState",
}
/**
* Allowed sort fields when retrieving the list of nodes via the REST API.
*/
export type NodeSortField = SortFieldFor<
DomainSpecificNodeResponse,
NodeSortFieldEnum
>;
/**
* Type guard for {@link NodeSortField}.
*
* @param arg - Value to check.
*/
export const isNodeSortField = toIsSortField<
DomainSpecificNodeResponse,
NodeSortFieldEnum,
typeof NodeSortFieldEnum,
NodeSortField
>(NodeSortFieldEnum);
/**
* Allowed filters when retrieving the list of nodes via the REST API.
*/
export type NodesFilter = {
/**
* If set only nodes with / without a Fastd key will be returned.
*/
hasKey?: boolean;
/**
* If set only nodes with / without geo coordinates will be returned.
*/
hasCoords?: boolean;
/**
* If set only nodes having the given monitoring state will be returned.
*/
monitoringState?: MonitoringState;
/**
* If set only nodes belonging to the given Freifunk site will be returned.
*/
site?: Site;
/**
* If set only nodes belonging to the given Freifunk domain will be returned.
*/
domain?: Domain;
/**
* If set only nodes having the given online state will be returned.
*/
onlineState?: OnlineState;
};
/**
* Allowed filter fields when retrieving the list of nodes via the REST API.
*/
export const NODES_FILTER_FIELDS: Record<
keyof NodesFilter,
| BooleanConstructor
| StringConstructor
| typeof MonitoringState
| typeof OnlineState
> = {
/**
* See {@link NodesFilter.hasKey}.
*/
hasKey: Boolean,
/**
* See {@link NodesFilter.hasCoords}.
*/
hasCoords: Boolean,
/**
* See {@link NodesFilter.monitoringState}.
*/
monitoringState: MonitoringState,
/**
* See {@link NodesFilter.site}.
*/
site: String,
/**
* See {@link NodesFilter.domain}.
*/
domain: String,
/**
* See {@link NodesFilter.onlineState}.
*/
onlineState: OnlineState,
};
/**
* Type guard for {@link NodesFilter}.
*
* @param arg - Value to check.
*/
export function isNodesFilter(arg: unknown): arg is NodesFilter {
if (!isObject(arg)) {
return false;
}
const filter = arg as NodesFilter;
return (
isOptional(filter.hasKey, isBoolean) &&
isOptional(filter.hasCoords, isBoolean) &&
isOptional(filter.monitoringState, isMonitoringState) &&
isOptional(filter.site, isSite) &&
isOptional(filter.domain, isDomain) &&
isOptional(filter.onlineState, isOnlineState)
);
}

View file

@ -0,0 +1,36 @@
/**
* Contains type guards for objects.
*/
/**
* Type guard checking the given value is a non-null `object`.
*
* Warning: This is also true for e.g. arrays, so don't rely to heavily on this.
*
* @param arg - Value to check.
*/
export function isObject(arg: unknown): arg is object {
return arg !== null && typeof arg === "object";
}
/**
* Type guard checking the given value is a plain object (not an `array`).
*
* @param arg - Value to check.
*/
export function isPlainObject(arg: unknown): arg is { [key: string]: unknown } {
return isObject(arg) && !Array.isArray(arg);
}
/**
* Type guard checking if the given value is an object having the property specified by `key`.
*
* @param arg - Value to check.
* @param key - Key to index the property on the object.
*/
export function hasOwnProperty<Key extends PropertyKey>(
arg: unknown,
key: Key
): arg is Record<Key, unknown> {
return isObject(arg) && key in arg;
}

View file

@ -0,0 +1,73 @@
/**
* Contains type guards for primitive types and values.
*/
// =====================================================================================================================
// Numbers
// =====================================================================================================================
/**
* Type guard for numbers.
*
* @param arg - Value to check.
*/
export function isNumber(arg: unknown): arg is number {
return typeof arg === "number";
}
/**
* Type guard checking the given value is an integer `number`.
*
* @param arg - Value to check.
*/
export function isInteger(arg: unknown): arg is number {
return isNumber(arg) && Number.isInteger(arg);
}
// =====================================================================================================================
// Strings
// =====================================================================================================================
/**
* Type guard for strings.
*
* @param arg - Value to check.
*/
export function isString(arg: unknown): arg is string {
return typeof arg === "string";
}
// =====================================================================================================================
// Booleans
// =====================================================================================================================
/**
* Type guard for booleans.
*
* @param arg - Value to check.
*/
export function isBoolean(arg: unknown): arg is boolean {
return typeof arg === "boolean";
}
// =====================================================================================================================
// Primitive values
// =====================================================================================================================
/**
* Type guard for null values.
*
* @param arg - Value to check.
*/
export function isNull(arg: unknown): arg is null {
return arg === null;
}
/**
* Type guard for undefined values.
*
* @param arg - Value to check.
*/
export function isUndefined(arg: unknown): arg is undefined {
return typeof arg === "undefined";
}

View file

@ -0,0 +1,13 @@
/**
* Contains type guards for regular expressions.
*/
import { isObject } from "./objects";
/**
* Type guard for {@link RegExp}s.
*
* @param arg - Value to check.
*/
export function isRegExp(arg: unknown): arg is RegExp {
return isObject(arg) && arg instanceof RegExp;
}

View file

@ -0,0 +1,76 @@
/**
* Contains helper types and type guards for sort fields.
*/
import { type Enum, toIsEnum } from "./enums";
import { isString } from "./primitives";
import type { TypeGuard } from "./helpers";
/**
* Generic untyped sort field.
*/
export type GenericSortField = {
value: string;
readonly __tag: unique symbol;
};
/**
* Helper to define sort field types that match an enum `<SortFieldEnum>` listing allowed values to a type
* `<SortableType>` being indexed by those values.
*
* In short: If the enum values don't match the sortable type keys the compiler will complain.
*/
export type SortFieldFor<
SortableType,
SortFieldEnum extends keyof SortableType
> = keyof Pick<SortableType, SortFieldEnum>;
/**
* Helper function to construct a type guard for sort fields.
*
* Generic type parameters:
*
* * `<SortableType>` - The type to sort.
* * `<SortFieldEnum>` - The enum used to specify the field of `<SortableType>` to sort by.
* * `<SortFieldEnumType>` - The `typeof` the `<SortFieldEnum>`.
* * `<SortField>` - The type of the sort field.
*
* Warning: I could not get the compiler to ensure `<SortFieldEnum>` and `<SortFieldEnumType>` refer to the same enum!
*
* @param sortFieldEnumDef - Enum representing the allowed sort fields.
* @returns A type guard for sort fields of type `<SortField>`.
*/
export function toIsSortField<
SortableType,
SortFieldEnum extends keyof SortableType,
SortFieldEnumType extends Enum<SortFieldEnumType>,
SortField extends SortFieldFor<SortableType, SortFieldEnum>
>(sortFieldEnumDef: SortFieldEnumType): TypeGuard<SortField> {
return (arg: unknown): arg is SortField => {
if (!isString(arg)) {
return false;
}
return Object.values(sortFieldEnumDef).includes(arg as SortField);
};
}
/**
* Direction in which to sort.
*/
export enum SortDirection {
/**
* Sort in ascending order.
*/
ASCENDING = "ASC",
/**
* Sort in descending order.
*/
DESCENDING = "DESC",
}
/**
* Type guard for {@link SortDirection}.
*
* @param arg - Value to check.
*/
export const isSortDirection = toIsEnum(SortDirection);

View file

@ -0,0 +1,76 @@
/**
* Contains types and type guards for representing statistics information.
*/
import { isPlainObject } from "./objects";
import { isNumber } from "./primitives";
/**
* Some basic statistics of the known Freifunk nodes in the community.
*/
export type NodeStatistics = {
/**
* Number of nodes registered via ffffng.
*/
registered: number;
/**
* Number of nodes with {@link FastdKey}
*/
withVPN: number;
/**
* Number of nodes with geo-coordinates.
*/
withCoords: number;
/**
* Monitoring statistics.
*/
monitoring: {
/**
* Number of registered nodes with active monitoring.
*/
active: number;
/**
* Number of registered nodes with activated monitoring but pending email confirmation.
*/
pending: number;
};
};
/**
* Type guard for {@link NodeStatistics}.
*
* @param arg - Value to check.
*/
export function isNodeStatistics(arg: unknown): arg is NodeStatistics {
if (!isPlainObject(arg)) {
return false;
}
const stats = arg as NodeStatistics;
return (
isNumber(stats.registered) &&
isNumber(stats.withVPN) &&
isNumber(stats.withCoords) &&
isPlainObject(stats.monitoring) &&
isNumber(stats.monitoring.active) &&
isNumber(stats.monitoring.pending)
);
}
/**
* Statistics object wrapping {@link NodeStatistics} to be used a REST API response.
*/
export type Statistics = {
nodes: NodeStatistics;
};
/**
* Type guard for {@link Statistics}.
*
* @param arg - Value to check.
*/
export function isStatistics(arg: unknown): arg is Statistics {
return isPlainObject(arg) && isNodeStatistics((arg as Statistics).nodes);
}

207
server/shared/types/task.ts Normal file
View file

@ -0,0 +1,207 @@
/**
* Contains types and type guards all around tasks.
*/
import { toIsEnum } from "./enums";
import { isNullable } from "./helpers";
import { isPlainObject } from "./objects";
import { isBoolean, isNumber, isString } from "./primitives";
import { type SortFieldFor, toIsSortField } from "./sortfields";
import {
type DurationSeconds,
isDurationSeconds,
isUnixTimestampSeconds,
type UnixTimestampSeconds,
} from "./time";
// FIXME: Naming Task vs. Job
/**
* The state a task can be in.
*/
export enum TaskState {
/**
* The task is not currently running.
*/
IDLE = "idle",
/**
* The task is running.
*/
RUNNING = "running",
/**
* The task is idle but has had a failure on its last run.
*/
FAILED = "failed",
}
/**
* Type guard for {@link TaskState}.
*
* @param arg - Value to check.
*/
export const isTaskState = toIsEnum(TaskState);
/**
* State the last run of the task resulted in.
*/
export enum JobResultState {
/**
* The run did finish as expected.
*/
OKAY = "okay",
/**
* The run resulted in one or more warnings.
*/
WARNING = "warning",
}
/**
* Type guard for {@link JobResultState}.
*
* @param arg - Value to check.
*/
export const isJobResultState = toIsEnum(JobResultState);
/**
* Task as returned by the REST API.
*/
// TODO: Introduce newtypes.
export type TaskResponse = {
/**
* ID of the task.
*/
id: number;
/**
* Task name as displayed in the admin panel.
*/
name: string;
/**
* A short description of what the task does.
*/
description: string;
/**
* The schedule of the task in classical cronjob notation.
*/
schedule: string;
/**
* Time the current run of this task started. `null` if the task is not running.
*/
runningSince: UnixTimestampSeconds | null;
/**
* Time the last run of this task started. `null` if the task has not run before.
*/
lastRunStarted: UnixTimestampSeconds | null;
/**
* Duration of the last run in seconds. `null` if the task has not run before.
*/
lastRunDuration: DurationSeconds | null;
/**
* The state the task is in.
*/
state: TaskState;
/**
* State the last run of the task resulted in.
*/
result: JobResultState | null;
/**
* Message of the last run, e.g. a warning.
*/
message: string | null;
/**
* Whether the task is enabled and therefor may run.
*
* Note: A task may be running even if it is disabled if the run started befor disabling it.
*/
enabled: boolean;
};
/**
* Type guard for {@link TaskResponse}.
*
* @param arg - Value to check.
*/
export function isTaskResponse(arg: unknown): arg is TaskResponse {
if (!isPlainObject(arg)) {
return false;
}
const task = arg as TaskResponse;
return (
isNumber(task.id) &&
isString(task.name) &&
isString(task.description) &&
isString(task.schedule) &&
isNullable(task.runningSince, isUnixTimestampSeconds) &&
isNullable(task.lastRunStarted, isUnixTimestampSeconds) &&
isNullable(task.lastRunDuration, isDurationSeconds) &&
isTaskState(task.state) &&
isNullable(task.result, isJobResultState) &&
isNullable(task.message, isString) &&
isBoolean(task.enabled)
);
}
/**
* Enum specifying the allowed sort fields when retrieving the list of tasks via the REST API.
*/
export enum TaskSortFieldEnum {
// noinspection JSUnusedGlobalSymbols
/**
* See {@link TaskResponse.id}.
*/
ID = "id",
/**
* See {@link TaskResponse.name}.
*/
NAME = "name",
/**
* See {@link TaskResponse.schedule}.
*/
SCHEDULE = "schedule",
/**
* See {@link TaskResponse.state}.
*/
STATE = "state",
/**
* See {@link TaskResponse.runningSince}.
*/
RUNNING_SINCE = "runningSince",
/**
* See {@link TaskResponse.lastRunStarted}.
*/
LAST_RUN_STARTED = "lastRunStarted",
}
/**
* Allowed sort fields when retrieving the list of tasks via the REST API.
*/
export type TaskSortField = SortFieldFor<TaskResponse, TaskSortFieldEnum>;
/**
* Type guard for {@link TaskSortField}.
*
* @param arg - Value to check.
*/
export const isTaskSortField = toIsSortField<
TaskResponse,
TaskSortFieldEnum,
typeof TaskSortFieldEnum,
TaskSortField
>(TaskSortFieldEnum);

View file

@ -0,0 +1,66 @@
/**
* Contains types and type guards for "wibbly wobbly timey wimey" stuff.
*/
import { toIsNewtype } from "./newtypes";
import { isNumber } from "./primitives";
/**
* Duration of a period of time in seconds.
*/
export type DurationSeconds = number & { readonly __tag: unique symbol };
/**
* UnixTimestampMilliseconds}.
*
* @param arg - Value to check.
*/
export const isDurationSeconds = toIsNewtype(isNumber, NaN as DurationSeconds);
/**
* Duration of a period of time in milliseconds.
*/
export type DurationMilliseconds = number & { readonly __tag: unique symbol };
/**
* Type guard for {@UnixTimestampMilliseconds}.
*
* @param arg - Value to check.
*/
export const isDurationMilliseconds = toIsNewtype(
isNumber,
NaN as DurationMilliseconds
);
/**
* Timestamp representing a point in time specified by the number of seconds passed
* since the 1970-01-01 at 0:00:00 UTC.
*/
export type UnixTimestampSeconds = number & { readonly __tag: unique symbol };
/**
* Type guard for {@UnixTimestampMilliseconds}.
*
* @param arg - Value to check.
*/
export const isUnixTimestampSeconds = toIsNewtype(
isNumber,
NaN as UnixTimestampSeconds
);
/**
* Timestamp representing a point in time specified by the number of milliseconds passed
* since the 1970-01-01 at 0:00:00 UTC.
*/
export type UnixTimestampMilliseconds = number & {
readonly __tag: unique symbol;
};
/**
* Type guard for {@link UnixTimestampMilliseconds}.
*
* @param arg - Value to check.
*/
export const isUnixTimestampMilliseconds = toIsNewtype(
isNumber,
NaN as UnixTimestampMilliseconds
);

View file

@ -0,0 +1,30 @@
/**
* Utility functions for enums.
*/
/**
* Helper function to detect unhandled enum fields in `switch` statements at compile time. In case this function
* is called at runtime anyway (which should not happen) it throws a runtime error.
*
* In the example below the compiler will complain if not for all fields of `Enum` a corresponding `case` statement
* exists.
*
* @param field - Unhandled field, the value being switched over.
* @throws {@link Error} - If the function is called at runtime.
*
* @example
* switch (enumValue) {
* case Enum.FIELD1:
* return;
* case Enum.FIELD2:
* return;
*
* ...
*
* default:
* return unhandledEnumField(enumValue);
* }
*/
export function unhandledEnumField(field: never): never {
throw new Error(`Unhandled enum field: ${field}`);
}

View file

@ -0,0 +1,42 @@
/**
* Utility functions for JSON.
*/
import { isJSONValue, type JSONObject, type JSONValue } from "../types";
/**
* Parses the given `string` and converts it into a {@link JSONValue}.
*
* For the string to be considered valid JSON it has to satisfy the requirements for {@link JSON.parse}.
*
* @param str - `string` to parse.
* @returns The parsed integer JSON value.
* @throws {@link SyntaxError} - If the given `string` does not represent a valid JSON value.
*/
export function parseJSON(str: string): JSONValue {
const json = JSON.parse(str);
if (!isJSONValue(json)) {
throw new Error("Invalid JSON returned. Should never happen.");
}
return json;
}
/**
* Removes `undefined` fields from the given JSON'ish object to make it a valid {@link JSONObject}.
*
* Note: This only happens for fields directly belonging to the given object. No recursive cleanup is performed.
*
* @param obj - Object to remove `undefined` fields from.
* @returns Cleaned up JSON object.
*/
export function filterUndefinedFromJSON(obj: {
[key: string]: JSONValue | undefined;
}): JSONObject {
const result: JSONObject = {};
for (const [key, value] of Object.entries(obj)) {
if (value !== undefined) {
result[key] = value;
}
}
return result;
}

View file

@ -0,0 +1,14 @@
/**
* Utility functions for node related data.
*/
import type { MAC, MapId } from "../types";
/**
* Converts the MAC address of a Freifunk node to an id representing it on the community's node map.
*
* @param mac - MAC address of the node
* @returns ID of the node on the map
*/
export function mapIdFromMAC(mac: MAC): MapId {
return mac.toLowerCase().replace(/:/g, "") as MapId;
}

View file

@ -0,0 +1,19 @@
/**
* Helper functions for objects.
*/
import { hasOwnProperty } from "../types";
/**
* If the given value is an object this function returns the property specified by `key` if it exists.
*
* @param arg - Value to treat as an object to look up the property.
* @param key - Key indexing the property.
* @returns The property of the given object indexed by `key` or `undefined` if `arg` is not an object
* or has no property `key`.
*/
export function getFieldIfExists(
arg: unknown,
key: PropertyKey
): unknown | undefined {
return hasOwnProperty(arg, key) ? arg[key] : undefined;
}

View file

@ -1,9 +1,29 @@
import { isString, type MAC } from "../types";
/**
* Utility functions all around strings.
*/
import { isInteger, type MAC } from "../types";
/**
* Trims the given `string` and replaces multiple whitespaces by one space each.
*
* Can be used to make sure user input has a canonical form.
*
* @param str - `string` to normalize.
* @returns The normalized `string`.
*/
export function normalizeString(str: string): string {
return isString(str) ? str.trim().replace(/\s+/g, " ") : str;
return str.trim().replace(/\s+/g, " ");
}
/**
* Normalizes a {@link MAC} address so that it has a canonical format:
*
* The `MAC` address will be converted so that it is all uppercase with colon as the delimiter, e.g.:
* `12:34:56:78:9A:BC`.
*
* @param mac - `MAC` address to normalize.
* @returns The normalized `MAC` address.
*/
export function normalizeMac(mac: MAC): MAC {
// parts only contains values at odd indexes
const parts = mac
@ -20,9 +40,26 @@ export function normalizeMac(mac: MAC): MAC {
return macParts.join(":") as MAC;
}
/**
* Parses the given `string` and converts it into an integer.
*
* For a `string` to be considered a valid representation of an integer `number` it has to satisfy the
* following criteria:
*
* * The integer is base `10`.
* * The `string` starts with an optional `+` or `-` sign followed by one or more digits.
* * The first digit must not be `0`.
* * The `string` does not contain any other characters.
*
* @param str - `string` to parse.
* @returns The parsed integer `number`.
* @throws {@link SyntaxError} - If the given `string` does not represent a valid integer.
*/
export function parseInteger(str: string): number {
const parsed = parseInt(str, 10);
if (parsed.toString() === str) {
const original = str.startsWith("+") ? str.slice(1) : str;
if (isInteger(parsed) && parsed.toString() === original) {
return parsed;
} else {
throw new SyntaxError(

View file

@ -0,0 +1,28 @@
/**
* Utility functions for "wibbly wobbly timey wimey" stuff.
*/
import type { UnixTimestampMilliseconds, UnixTimestampSeconds } from "../types";
/**
* Converts an {@link UnixTimestampMilliseconds} to an {@link UnixTimestampSeconds} rounding down.
*
* @param ms - The timestamp in milliseconds.
* @returns - The timestamp in seconds.
*/
export function toUnixTimestampSeconds(
ms: UnixTimestampMilliseconds
): UnixTimestampSeconds {
return Math.floor(ms / 1000) as UnixTimestampSeconds;
}
/**
* Converts an {@link UnixTimestampSeconds} to an {@link UnixTimestampMilliseconds}.
*
* @param s - The timestamp in seconds.
* @returns - The timestamp in milliseconds.
*/
export function toUnixTimestampMilliseconds(
s: UnixTimestampSeconds
): UnixTimestampMilliseconds {
return (s * 1000) as UnixTimestampMilliseconds;
}

View file

@ -1,95 +1,5 @@
import {
CreateOrUpdateNode,
Domain,
DomainSpecificNodeResponse,
MonitoringResponse,
MonitoringState,
MonitoringToken,
NodeResponse,
NodeTokenResponse,
OnlineState,
Site,
StoredNode,
} from "../shared/types";
export * from "./config";
export * from "./database";
export * from "./logger";
export * from "./node";
export * from "../shared/types";
export type NodeStateData = {
site?: Site;
domain?: Domain;
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 {
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,
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,
};
}
export type NodeSecrets = {
monitoringToken?: MonitoringToken;
};

90
server/types/node.ts Normal file
View file

@ -0,0 +1,90 @@
import {
CreateOrUpdateNode,
Domain,
DomainSpecificNodeResponse,
MonitoringResponse,
MonitoringState,
MonitoringToken,
NodeResponse,
NodeTokenResponse,
OnlineState,
Site,
StoredNode,
} from "../shared/types";
export type NodeStateData = {
site?: Site;
domain?: Domain;
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 {
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,
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,
};
}
export type NodeSecrets = {
monitoringToken?: MonitoringToken;
};

View file

@ -11,10 +11,7 @@ import {
} from "../shared/validation/validator";
import { Request, Response } from "express";
import {
EnumTypeGuard,
ValueOf,
type GenericSortField,
getFieldIfExists,
isJSONObject,
isNumber,
isString,
@ -24,6 +21,7 @@ import {
SortDirection,
TypeGuard,
} from "../types";
import { getFieldIfExists } from "../shared/utils/objects";
export type RequestData = JSONObject;
export type RequestHandler = (request: Request, response: Response) => void;
@ -77,12 +75,12 @@ function respond(
}
}
function orderByClause<S>(
function orderByClause<SortField>(
restParams: RestParams,
defaultSortField: ValueOf<S>,
isSortField: EnumTypeGuard<S>
defaultSortField: SortField,
isSortField: TypeGuard<SortField>
): OrderByClause {
let sortField: ValueOf<S> | undefined = isSortField(restParams._sortField)
let sortField: SortField | undefined = isSortField(restParams._sortField)
? restParams._sortField
: undefined;
if (!sortField) {
@ -345,13 +343,17 @@ export function getPageEntities<Entity>(
export { filterCondition as whereCondition };
export function filterClause<S>(
export function filterClause<SortField>(
restParams: RestParams,
defaultSortField: ValueOf<S>,
isSortField: EnumTypeGuard<S>,
defaultSortField: SortField,
isSortField: TypeGuard<SortField>,
filterFields: string[]
): FilterClause {
const orderBy = orderByClause<S>(restParams, defaultSortField, isSortField);
const orderBy = orderByClause<SortField>(
restParams,
defaultSortField,
isSortField
);
const limitOffset = limitOffsetClause(restParams);
const filter = filterCondition(restParams, filterFields);

694
yarn.lock

File diff suppressed because it is too large Load diff