2026-01-14 22:44:23 +00:00
|
|
|
import { AppError } from "@/utils/AppError.js";
|
2026-07-24 11:32:00 -07:00
|
|
|
import { Monitor, MonitorTypes, type MonitorType } from "@/domain/monitors/monitor.type.js";
|
2026-06-09 12:34:15 -07:00
|
|
|
import { UserRole } from "@/domain/users/user.type.js";
|
2026-03-11 10:48:12 -07:00
|
|
|
import sslChecker, { SSLDetails } from "ssl-checker";
|
2026-08-11 00:34:21 +08:00
|
|
|
import * as whoiser from "whoiser";
|
2026-08-13 12:02:49 +08:00
|
|
|
import { parse as parseDomain } from "tldts";
|
2026-03-11 10:48:12 -07:00
|
|
|
type SSLCheckerType = typeof sslChecker;
|
2026-08-11 00:34:21 +08:00
|
|
|
type WhoisModule = typeof whoiser;
|
2026-01-14 22:44:23 +00:00
|
|
|
|
2026-08-18 02:41:06 -04:00
|
|
|
const DEFAULT_HTTPS_PORT = 443;
|
2026-08-20 12:37:37 -04:00
|
|
|
// Ports are user-defined, so a probe can dial something that accepts the
|
|
|
|
|
// connection and never completes the handshake. Bound the wait explicitly.
|
|
|
|
|
const CERTIFICATE_TIMEOUT_MS = 10_000;
|
2026-08-18 02:41:06 -04:00
|
|
|
|
2026-03-11 11:35:36 -07:00
|
|
|
export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: Monitor): Promise<SSLDetails> => {
|
2025-04-20 11:29:53 -07:00
|
|
|
const monitorUrl = new URL(monitor.url);
|
2026-08-18 02:47:10 -04:00
|
|
|
// A plain-HTTP monitor serves no certificate. Reject before dialing, so the
|
|
|
|
|
// caller gets this instead of a raw OpenSSL handshake error from the socket.
|
|
|
|
|
if (monitorUrl.protocol !== "https:") {
|
|
|
|
|
throw new AppError({ message: "Certificate is only available for HTTPS monitors", status: 400 });
|
|
|
|
|
}
|
2025-04-20 11:29:53 -07:00
|
|
|
const hostname = monitorUrl.hostname;
|
2026-08-18 02:47:10 -04:00
|
|
|
// URL.port is "" for the scheme's default port, so fall back to 443; without
|
|
|
|
|
// this the certificate of a monitor on a non-standard port was probed on 443.
|
2026-08-18 02:41:06 -04:00
|
|
|
const port = monitorUrl.port === "" ? DEFAULT_HTTPS_PORT : Number(monitorUrl.port);
|
2026-08-20 12:37:37 -04:00
|
|
|
const cert = await checker(hostname, { port, timeout: CERTIFICATE_TIMEOUT_MS });
|
2025-04-20 11:29:53 -07:00
|
|
|
if (cert?.validTo === null || cert?.validTo === undefined) {
|
2025-07-24 15:06:46 -07:00
|
|
|
throw new Error("Certificate not found");
|
2025-04-20 11:29:53 -07:00
|
|
|
}
|
|
|
|
|
return cert;
|
|
|
|
|
};
|
2026-08-11 00:34:21 +08:00
|
|
|
|
|
|
|
|
const WHOIS_TIMEOUT_MS = 10_000;
|
2026-08-13 12:02:49 +08:00
|
|
|
const DOMAIN_EXPIRY_CACHE_POSITIVE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
|
|
|
const DOMAIN_EXPIRY_CACHE_NEGATIVE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
2026-08-11 00:34:21 +08:00
|
|
|
const EXPIRY_DATE_KEY_PATTERN = /(expiry date|expiration|paid-?till|registration expiration)/i;
|
|
|
|
|
|
2026-08-13 12:02:49 +08:00
|
|
|
export interface DomainExpiryResult {
|
|
|
|
|
domain: string | null;
|
|
|
|
|
expiryDate: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface DomainExpiryCacheEntry extends DomainExpiryResult {
|
|
|
|
|
expiresAt: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface DomainExpiryCache {
|
|
|
|
|
get(domain: string): DomainExpiryResult | undefined;
|
|
|
|
|
set(domain: string, result: DomainExpiryResult): void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const createDomainExpiryCache = (positiveTtlMs: number, negativeTtlMs: number): DomainExpiryCache => {
|
|
|
|
|
const entries = new Map<string, DomainExpiryCacheEntry>();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
get(domain: string): DomainExpiryResult | undefined {
|
|
|
|
|
const entry = entries.get(domain);
|
|
|
|
|
if (entry === undefined) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
if (entry.expiresAt <= Date.now()) {
|
|
|
|
|
entries.delete(domain);
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
return { domain: entry.domain, expiryDate: entry.expiryDate };
|
|
|
|
|
},
|
|
|
|
|
set(domain: string, result: DomainExpiryResult): void {
|
|
|
|
|
entries.set(domain, {
|
|
|
|
|
...result,
|
|
|
|
|
expiresAt: Date.now() + (result.expiryDate === null ? negativeTtlMs : positiveTtlMs),
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const domainExpiryCache = createDomainExpiryCache(DOMAIN_EXPIRY_CACHE_POSITIVE_TTL_MS, DOMAIN_EXPIRY_CACHE_NEGATIVE_TTL_MS);
|
|
|
|
|
|
2026-08-11 00:34:21 +08:00
|
|
|
export const extractDomainExpiryDate = (whoisData: object): string | null => {
|
|
|
|
|
const matchingKey = Object.keys(whoisData).find((key) => EXPIRY_DATE_KEY_PATTERN.test(key));
|
|
|
|
|
if (!matchingKey) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const raw = extractString((whoisData as Record<string, unknown>)[matchingKey] ?? null);
|
|
|
|
|
if (!raw) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parsed = parseDomainExpiryValue(raw);
|
|
|
|
|
return parsed === null ? null : parsed.toISOString();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parseDomainExpiryValue = (raw: string): Date | null => {
|
|
|
|
|
// Some registries (e.g. Nominet for .uk) return "14-Feb-2027" style values.
|
|
|
|
|
const monthMatch = /^(\d{1,2})-([A-Za-z]{3})-(\d{4})$/.exec(raw.trim());
|
|
|
|
|
if (monthMatch) {
|
|
|
|
|
const months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
|
|
|
const monthIndex = months.indexOf((monthMatch[2] ?? "").toLowerCase());
|
|
|
|
|
if (monthIndex !== -1) {
|
|
|
|
|
const parsed = new Date(Date.UTC(Number(monthMatch[3]), monthIndex, Number(monthMatch[1])));
|
|
|
|
|
if (!Number.isNaN(parsed.getTime())) {
|
|
|
|
|
return parsed;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const date = new Date(raw);
|
|
|
|
|
if (!Number.isNaN(date.getTime())) {
|
|
|
|
|
return date;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-13 12:02:49 +08:00
|
|
|
export const fetchMonitorDomain = async (
|
|
|
|
|
client: WhoisModule,
|
|
|
|
|
monitor: Monitor,
|
|
|
|
|
cache: DomainExpiryCache = domainExpiryCache
|
|
|
|
|
): Promise<DomainExpiryResult> => {
|
2026-08-11 00:34:21 +08:00
|
|
|
const monitorUrl = new URL(monitor.url);
|
2026-08-13 12:02:49 +08:00
|
|
|
const registrableDomain = parseDomain(monitorUrl.hostname).domain;
|
|
|
|
|
if (registrableDomain === null) {
|
|
|
|
|
// Bare IP addresses, localhost, and other non-domain hosts have no WHOIS expiry.
|
|
|
|
|
return { domain: null, expiryDate: null };
|
2026-08-11 00:34:21 +08:00
|
|
|
}
|
2026-08-13 12:02:49 +08:00
|
|
|
|
|
|
|
|
const cached = cache.get(registrableDomain);
|
|
|
|
|
if (cached !== undefined) {
|
|
|
|
|
return cached;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await client.domain(registrableDomain, { timeout: WHOIS_TIMEOUT_MS });
|
|
|
|
|
const firstServerData = Object.values(result)[0];
|
|
|
|
|
const expiryDate = extractDomainExpiryDate(
|
|
|
|
|
typeof firstServerData === "object" && firstServerData !== null && !Array.isArray(firstServerData) ? firstServerData : {}
|
|
|
|
|
);
|
|
|
|
|
const domainExpiry: DomainExpiryResult = { domain: registrableDomain, expiryDate };
|
|
|
|
|
cache.set(registrableDomain, domainExpiry);
|
|
|
|
|
return domainExpiry;
|
2026-08-11 00:34:21 +08:00
|
|
|
};
|
|
|
|
|
|
2026-03-11 11:35:36 -07:00
|
|
|
export const requireString = (value: unknown, fieldName: string): string => {
|
2026-01-14 22:44:23 +00:00
|
|
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
throw new AppError({ message: `${fieldName} is required`, status: 400 });
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-11 11:35:36 -07:00
|
|
|
export const optionalString = (value: unknown, fieldName: string): string | undefined => {
|
2026-01-14 22:44:23 +00:00
|
|
|
if (value === undefined) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
if (typeof value === "string") {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
throw new AppError({ message: `${fieldName} must be a string`, status: 400 });
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-23 11:07:01 -07:00
|
|
|
export const extractString = (value: unknown): string | undefined => {
|
|
|
|
|
const candidate = Array.isArray(value) ? value[0] : value;
|
|
|
|
|
if (typeof candidate !== "string" || candidate.length === 0) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
return candidate;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-11 11:35:36 -07:00
|
|
|
export const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
|
2026-01-14 22:44:23 +00:00
|
|
|
if (value === undefined) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
if (typeof value === "string" && value.trim() !== "") {
|
|
|
|
|
const parsed = Number(value);
|
|
|
|
|
if (!Number.isNaN(parsed)) {
|
|
|
|
|
return parsed;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
throw new AppError({ message: `${fieldName} must be a number`, status: 400 });
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-11 11:35:36 -07:00
|
|
|
export const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
|
2026-01-14 22:44:23 +00:00
|
|
|
const parseSingle = (input: unknown): MonitorType => {
|
|
|
|
|
if (typeof input !== "string") {
|
|
|
|
|
throw new AppError({ message: "Monitor type must be a string", status: 400 });
|
|
|
|
|
}
|
|
|
|
|
if (!MonitorTypes.includes(input as MonitorType)) {
|
|
|
|
|
throw new AppError({ message: `Invalid monitor type: ${input}`, status: 400 });
|
|
|
|
|
}
|
|
|
|
|
return input as MonitorType;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (value === undefined) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
if (Array.isArray(value)) {
|
|
|
|
|
return value.map((entry) => parseSingle(entry));
|
|
|
|
|
}
|
|
|
|
|
return parseSingle(value);
|
|
|
|
|
};
|
2025-04-20 11:29:53 -07:00
|
|
|
|
2026-03-11 11:35:36 -07:00
|
|
|
export const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
|
2026-01-14 22:44:23 +00:00
|
|
|
if (value === undefined) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
if (value === "asc" || value === "desc") {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
throw new AppError({ message: "order must be either 'asc' or 'desc'", status: 400 });
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-11 11:35:36 -07:00
|
|
|
export const requireTeamId = (teamId?: string): string => {
|
2026-01-14 22:44:23 +00:00
|
|
|
if (!teamId) {
|
|
|
|
|
throw new AppError({ message: "Team ID is required", status: 400 });
|
|
|
|
|
}
|
|
|
|
|
return teamId;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-11 11:35:36 -07:00
|
|
|
export const requireUserId = (userId?: string): string => {
|
2026-01-22 20:57:20 +00:00
|
|
|
if (!userId) {
|
|
|
|
|
throw new AppError({ message: "User ID is required", status: 400 });
|
|
|
|
|
}
|
|
|
|
|
return userId;
|
|
|
|
|
};
|
2026-03-11 11:35:36 -07:00
|
|
|
export const requireUserEmail = (userEmail?: string): string => {
|
2026-02-02 06:08:51 +00:00
|
|
|
if (!userEmail) {
|
|
|
|
|
throw new AppError({ message: "User email is required", status: 400 });
|
|
|
|
|
}
|
|
|
|
|
return userEmail;
|
|
|
|
|
};
|
2026-01-22 20:57:20 +00:00
|
|
|
|
2026-03-11 11:35:36 -07:00
|
|
|
export const requireFirstName = (firstName?: string): string => {
|
|
|
|
|
if (!firstName) {
|
|
|
|
|
throw new AppError({ message: "First name is required", status: 400 });
|
|
|
|
|
}
|
|
|
|
|
return firstName;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-10 11:18:59 -07:00
|
|
|
export const requireUserRoles = (userRoles?: UserRole[]): UserRole[] => {
|
2026-02-04 17:49:19 +00:00
|
|
|
if (!userRoles || userRoles.length === 0) {
|
|
|
|
|
throw new AppError({ message: "User roles are required", status: 400 });
|
|
|
|
|
}
|
|
|
|
|
return userRoles;
|
2026-02-04 18:10:52 +00:00
|
|
|
};
|