diff --git a/server/jobs/scheduler.ts b/server/jobs/scheduler.ts index 76a3672..871aac9 100644 --- a/server/jobs/scheduler.ts +++ b/server/jobs/scheduler.ts @@ -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; } -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; diff --git a/server/resources/nodeResource.ts b/server/resources/nodeResource.ts index 45964c6..2f1808f 100644 --- a/server/resources/nodeResource.ts +++ b/server/resources/nodeResource.ts @@ -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", diff --git a/server/resources/taskResource.ts b/server/resources/taskResource.ts index 035d7bd..dfadc96 100644 --- a/server/resources/taskResource.ts +++ b/server/resources/taskResource.ts @@ -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( Object.values(getTasks()), isTaskSortField, restParams diff --git a/server/services/mailService.ts b/server/services/mailService.ts index f45f535..351f791 100644 --- a/server/services/mailService.ts +++ b/server/services/mailService.ts @@ -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( restParams, - MailSortField.ID, + MailSortFieldEnum.ID, isMailSortField, ["id", "failures", "sender", "recipient", "email"] ); - const mails = await db.all( + const mails = await db.all( "SELECT * FROM email_queue WHERE " + filter.query, filter.params ); diff --git a/server/services/monitoringService.ts b/server/services/monitoringService.ts index 897c8da..a8dd83e 100644 --- a/server/services/monitoringService.ts +++ b/server/services/monitoringService.ts @@ -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( restParams, - MonitoringSortField.ID, + MonitoringSortFieldEnum.ID, isMonitoringSortField, filterFields ); diff --git a/server/services/nodeService.ts b/server/services/nodeService.ts index 3e9c06e..6b92e05 100644 --- a/server/services/nodeService.ts +++ b/server/services/nodeService.ts @@ -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); diff --git a/server/shared/types/arrays.ts b/server/shared/types/arrays.ts new file mode 100644 index 0000000..49c0dec --- /dev/null +++ b/server/shared/types/arrays.ts @@ -0,0 +1,39 @@ +/** + * Contains type guards for arrays. + * + * @module arrays + */ +import { TypeGuard } from "./helpers"; + +/** + * Type guard for an array with elements of type ``. + * + * @param arg - Value to check + * @param isElement - Type guard to check elements for type `` + */ +export function isArray( + arg: unknown, + isElement: TypeGuard +): arg is Array { + 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 `` + * @returns A type guard for arrays with elements of type ``. + */ +export function toIsArray( + isElement: TypeGuard +): TypeGuard { + return (arg): arg is Element[] => isArray(arg, isElement); +} diff --git a/server/shared/types/config.ts b/server/shared/types/config.ts new file mode 100644 index 0000000..96ec9e3 --- /dev/null +++ b/server/shared/types/config.ts @@ -0,0 +1,371 @@ +/** + * 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 { Domain, isDomain, isSite, isUrl, Site, Url } from "./newtypes"; +import { 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 + ) {} +} + +/** + * 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) + ); +} diff --git a/server/shared/types/email.ts b/server/shared/types/email.ts new file mode 100644 index 0000000..842cda6 --- /dev/null +++ b/server/shared/types/email.ts @@ -0,0 +1,183 @@ +/** + * Contains types and type guards around emails. + */ +import { toIsNewtype } from "./newtypes"; +import { isNumber, isString } from "./primitives"; +import { JSONObject } from "./json"; +import { toIsEnum } from "./enums"; +import { SortFieldFor, toIsSortField } from "./sortfields"; +import { 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; + +/** + * Type guard for {@link MailSortField}. + * + * @param arg - Value to check. + */ +export const isMailSortField = toIsSortField< + Mail, + MailSortFieldEnum, + typeof MailSortFieldEnum, + MailSortField +>(MailSortFieldEnum); diff --git a/server/shared/types/enums.ts b/server/shared/types/enums.ts new file mode 100644 index 0000000..7a5a1fd --- /dev/null +++ b/server/shared/types/enums.ts @@ -0,0 +1,25 @@ +/** + * Contains type guards and helpers for enums. + */ +import { TypeGuard, ValueOf } from "./helpers"; + +/** + * Shorthand type alias for enum {@link TypeGuard}s. + */ +export type EnumTypeGuard = TypeGuard>; + +/** + * Shorthand type for descrbing enum objects. + */ +export type Enum = Record>; + +/** + * Helper function to construct enum type guards. + * + * @param enumDef - The enum object to check against. + * @returns A type guard for values of the enum `` + */ +export function toIsEnum>(enumDef: E): EnumTypeGuard { + return (arg): arg is ValueOf => + Object.values(enumDef).includes(arg as [keyof E]); +} diff --git a/server/shared/types/helpers.ts b/server/shared/types/helpers.ts new file mode 100644 index 0000000..981705c --- /dev/null +++ b/server/shared/types/helpers.ts @@ -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 ``. + */ +export type TypeGuard = (arg: unknown) => arg is ValueType; + +/** + * Shorthand type alias for referencing values hold by an object of type ``. + * + * See it as an addition to typescript's `keyof`. + */ +export type ValueOf = Type[keyof Type]; + +/** + * Generic type guard to check for optional values of type ``. + * Optional means the value must either be `undefined` or a valid value of type ``. + * + * @param arg - Value to check + * @param isType - Type guard for checking for values of type `` + */ +export function isOptional( + arg: unknown, + isType: TypeGuard +): arg is Type | undefined { + return isUndefined(arg) || isType(arg); +} + +/** + * Generic type guard to check for nullable values of type ``. + * The value must either be `null` or a valid value of type ``. + * + * @param arg - Value to check + * @param isType - Type guard for checking for values of type `` + */ +export function isNullable( + arg: unknown, + isType: TypeGuard +): arg is Type | null { + return isNull(arg) || isType(arg); +} diff --git a/server/shared/types/index.ts b/server/shared/types/index.ts index 80e17b1..3150ef5 100644 --- a/server/shared/types/index.ts +++ b/server/shared/types/index.ts @@ -1,785 +1,20 @@ -import { ArrayField, Field, RawJsonField } from "sparkson"; - -// Types shared with the client. -export type TypeGuard = (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; - -export const isJSONArray = toIsArray(isJSONValue); - -export type ValueOf = T[keyof T]; -export type EnumTypeGuard = TypeGuard>; - -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( - arg: unknown, - key: Key -): arg is Record { - 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(arg: unknown, isT: TypeGuard): arg is Array { - 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 { - 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, example: Type): TypeGuard { - 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(isT: TypeGuard): TypeGuard { - return (arg): arg is T[] => isArray(arg, isT); -} - -export function toIsEnum>>( - enumDef: E -): EnumTypeGuard { - return (arg): arg is ValueOf => - 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( - arg: unknown, - isT: TypeGuard -): 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 - ) {} -} - -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.: "53.565278 10.001389" + * 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); - -/** - * 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 -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 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); +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"; diff --git a/server/shared/types/json.ts b/server/shared/types/json.ts new file mode 100644 index 0000000..77c805d --- /dev/null +++ b/server/shared/types/json.ts @@ -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; + +/** + * Type guard checking the given value is a valid {@link JSONArray}. + * + * @param arg - Value to check. + */ +export const isJSONArray = toIsArray(isJSONValue); diff --git a/server/shared/types/maps.ts b/server/shared/types/maps.ts new file mode 100644 index 0000000..7dbd050 --- /dev/null +++ b/server/shared/types/maps.ts @@ -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 { + return arg instanceof Map; +} diff --git a/server/shared/types/monitoring.ts b/server/shared/types/monitoring.ts new file mode 100644 index 0000000..baf496f --- /dev/null +++ b/server/shared/types/monitoring.ts @@ -0,0 +1,338 @@ +/** + * Contains types and type guards for monitoring data. + */ +import { + Domain, + isDomain, + isMAC, + isSite, + MAC, + Site, + toIsNewtype, +} from "./newtypes"; +import { isBoolean, isNumber, isString } from "./primitives"; +import { toIsEnum } from "./enums"; +import { Hostname, isHostname, isMapId, MapId } from "./node"; +import { EmailAddress, isEmailAddress, isMailType, MailType } from "./email"; +import { isUnixTimestampSeconds, UnixTimestampSeconds } from "./time"; +import { isOptional } from "./helpers"; +import { 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); diff --git a/server/shared/types/newtypes.ts b/server/shared/types/newtypes.ts new file mode 100644 index 0000000..9a0a609 --- /dev/null +++ b/server/shared/types/newtypes.ts @@ -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 { TypeGuard } from "./helpers"; +import { isString } from "./primitives"; + +// ===================================================================================================================== +// General newtype helpers. +// ===================================================================================================================== + +/** + * Helper function to generate type guards for newtypes of type ``. + * + * Newtypes can be defined as follows: + * + * @param isValue - Typeguard to check for the value-type (``) of the newtype. + * @param example - An example value of type ``. + * @returns A type guard for ``. + * + * @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, example: Newtype): TypeGuard { + 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.: "53.565278 10.001389" + */ +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); diff --git a/server/shared/types/node.ts b/server/shared/types/node.ts new file mode 100644 index 0000000..ac204f4 --- /dev/null +++ b/server/shared/types/node.ts @@ -0,0 +1,483 @@ +/** + * Contains types and type guards for representing Freifunk nodes in various states. + */ +import { isObject } from "./objects"; +import { isOptional } from "./helpers"; +import { + Coordinates, + Domain, + FastdKey, + isCoordinates, + isDomain, + isFastdKey, + isMAC, + isNickname, + isSite, + MAC, + Nickname, + Site, + toIsNewtype, +} from "./newtypes"; +import { isBoolean, isString } from "./primitives"; +import { SortFieldFor, toIsSortField } from "./sortfields"; +import { EmailAddress, isEmailAddress } from "./email"; +import { isUnixTimestampSeconds, 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) + ); +} diff --git a/server/shared/types/objects.ts b/server/shared/types/objects.ts new file mode 100644 index 0000000..8e27c92 --- /dev/null +++ b/server/shared/types/objects.ts @@ -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( + arg: unknown, + key: Key +): arg is Record { + return isObject(arg) && key in arg; +} diff --git a/server/shared/types/primitives.ts b/server/shared/types/primitives.ts new file mode 100644 index 0000000..d9a6acf --- /dev/null +++ b/server/shared/types/primitives.ts @@ -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"; +} diff --git a/server/shared/types/regexps.ts b/server/shared/types/regexps.ts new file mode 100644 index 0000000..2395871 --- /dev/null +++ b/server/shared/types/regexps.ts @@ -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; +} diff --git a/server/shared/types/sortfields.ts b/server/shared/types/sortfields.ts new file mode 100644 index 0000000..46c8601 --- /dev/null +++ b/server/shared/types/sortfields.ts @@ -0,0 +1,76 @@ +/** + * Contains helper types and type guards for sort fields. + */ +import { Enum, toIsEnum } from "./enums"; +import { isString } from "./primitives"; +import { 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 `` listing allowed values to a type + * `` 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; + +/** + * Helper function to construct a type guard for sort fields. + * + * Generic type parameters: + * + * * `` - The type to sort. + * * `` - The enum used to specify the field of `` to sort by. + * * `` - The `typeof` the ``. + * * `` - The type of the sort field. + * + * Warning: I could not get the compiler to ensure `` and `` refer to the same enum! + * + * @param sortFieldEnumDef - Enum representing the allowed sort fields. + * @returns A type guard for sort fields of type ``. + */ +export function toIsSortField< + SortableType, + SortFieldEnum extends keyof SortableType, + SortFieldEnumType extends Enum, + SortField extends SortFieldFor +>(sortFieldEnumDef: SortFieldEnumType): TypeGuard { + 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); diff --git a/server/shared/types/statistics.ts b/server/shared/types/statistics.ts new file mode 100644 index 0000000..17aa233 --- /dev/null +++ b/server/shared/types/statistics.ts @@ -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); +} diff --git a/server/shared/types/task.ts b/server/shared/types/task.ts new file mode 100644 index 0000000..c482cf0 --- /dev/null +++ b/server/shared/types/task.ts @@ -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 { SortFieldFor, toIsSortField } from "./sortfields"; +import { + DurationSeconds, + isDurationSeconds, + isUnixTimestampSeconds, + 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; + +/** + * Type guard for {@link TaskSortField}. + * + * @param arg - Value to check. + */ +export const isTaskSortField = toIsSortField< + TaskResponse, + TaskSortFieldEnum, + typeof TaskSortFieldEnum, + TaskSortField +>(TaskSortFieldEnum); diff --git a/server/shared/types/time.ts b/server/shared/types/time.ts new file mode 100644 index 0000000..5c2fa20 --- /dev/null +++ b/server/shared/types/time.ts @@ -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 +); diff --git a/server/shared/utils/enums.ts b/server/shared/utils/enums.ts new file mode 100644 index 0000000..17fb5cf --- /dev/null +++ b/server/shared/utils/enums.ts @@ -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}`); +} diff --git a/server/shared/utils/json.ts b/server/shared/utils/json.ts new file mode 100644 index 0000000..93d2791 --- /dev/null +++ b/server/shared/utils/json.ts @@ -0,0 +1,42 @@ +/** + * Utility functions for JSON. + */ +import { isJSONValue, JSONObject, 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; +} diff --git a/server/shared/utils/node.ts b/server/shared/utils/node.ts new file mode 100644 index 0000000..d3fa480 --- /dev/null +++ b/server/shared/utils/node.ts @@ -0,0 +1,14 @@ +/** + * Utility functions for node related data. + */ +import { 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; +} diff --git a/server/shared/utils/objects.ts b/server/shared/utils/objects.ts new file mode 100644 index 0000000..ae21b70 --- /dev/null +++ b/server/shared/utils/objects.ts @@ -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; +} diff --git a/server/shared/utils/strings.ts b/server/shared/utils/strings.ts index ff92f2d..5413888 100644 --- a/server/shared/utils/strings.ts +++ b/server/shared/utils/strings.ts @@ -1,9 +1,29 @@ -import { isString, MAC } from "../types"; +/** + * Utility functions all around strings. + */ +import { isInteger, 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( diff --git a/server/shared/utils/time.ts b/server/shared/utils/time.ts new file mode 100644 index 0000000..e530397 --- /dev/null +++ b/server/shared/utils/time.ts @@ -0,0 +1,28 @@ +/** + * Utility functions for "wibbly wobbly timey wimey" stuff. + */ +import { 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; +} diff --git a/server/utils/resources.ts b/server/utils/resources.ts index e218f1a..4ebcbbf 100644 --- a/server/utils/resources.ts +++ b/server/utils/resources.ts @@ -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( +function orderByClause( restParams: RestParams, - defaultSortField: ValueOf, - isSortField: EnumTypeGuard + defaultSortField: SortField, + isSortField: TypeGuard ): OrderByClause { - let sortField: ValueOf | undefined = isSortField(restParams._sortField) + let sortField: SortField | undefined = isSortField(restParams._sortField) ? restParams._sortField : undefined; if (!sortField) { @@ -345,13 +343,17 @@ export function getPageEntities( export { filterCondition as whereCondition }; -export function filterClause( +export function filterClause( restParams: RestParams, - defaultSortField: ValueOf, - isSortField: EnumTypeGuard, + defaultSortField: SortField, + isSortField: TypeGuard, filterFields: string[] ): FilterClause { - const orderBy = orderByClause(restParams, defaultSortField, isSortField); + const orderBy = orderByClause( + restParams, + defaultSortField, + isSortField + ); const limitOffset = limitOffsetClause(restParams); const filter = filterCondition(restParams, filterFields);