diff --git a/db/knex_migrations/2026-02-07-0000-disable-domain-expiry-unsupported-tlds.js b/db/knex_migrations/2026-02-07-0000-disable-domain-expiry-unsupported-tlds.js new file mode 100644 index 000000000..9685d46cc --- /dev/null +++ b/db/knex_migrations/2026-02-07-0000-disable-domain-expiry-unsupported-tlds.js @@ -0,0 +1,85 @@ +const { parse: parseTld } = require("tldts"); +const rdapDnsData = require("../../server/model/rdap-dns.json"); + +const TYPES_WITH_DOMAIN_EXPIRY_SUPPORT_VIA_FIELD = { + http: "url", + keyword: "url", + "json-query": "url", + "real-browser": "url", + "websocket-upgrade": "url", + port: "hostname", + ping: "hostname", + "grpc-keyword": "grpc_url", + dns: "hostname", + smtp: "hostname", + snmp: "hostname", + gamedig: "hostname", + steam: "hostname", + mqtt: "hostname", + radius: "hostname", + "tailscale-ping": "hostname", + "sip-options": "hostname", +}; + +/** + * Build set of root TLDs that have RDAP support + * @returns {Set} Set of supported root TLDs + */ +function getSupportedTlds() { + const supported = new Set(); + const services = rdapDnsData["services"] ?? []; + for (const [tlds] of services) { + for (const tld of tlds) { + supported.add(tld); + } + } + return supported; +} + +/** + * Check if a target URL/hostname has RDAP support + * @param {string} target URL or hostname + * @param {Set} supportedTlds Set of supported root TLDs + * @returns {boolean} Whether the target's TLD has RDAP support + */ +function hasRdapSupport(target, supportedTlds) { + if (!target || typeof target !== "string") { + return false; + } + const tld = parseTld(target); + if (!tld.publicSuffix || !tld.isIcann) { + return false; + } + const rootTld = tld.publicSuffix.split(".").pop(); + return supportedTlds.has(rootTld); +} + +exports.up = async function (knex) { + const supportedTlds = getSupportedTlds(); + + const monitors = await knex("monitor") + .where("domain_expiry_notification", 1) + .select("id", "type", "url", "hostname", "grpc_url"); + + const idsToDisable = []; + for (const monitor of monitors) { + const targetField = TYPES_WITH_DOMAIN_EXPIRY_SUPPORT_VIA_FIELD[monitor.type]; + if (!targetField || !hasRdapSupport(monitor[targetField], supportedTlds)) { + idsToDisable.push(monitor.id); + } + } + + if (idsToDisable.length > 0) { + await knex("monitor").whereIn("id", idsToDisable).update("domain_expiry_notification", 0); + } + + await knex.schema.alterTable("monitor", function (table) { + table.boolean("domain_expiry_notification").defaultTo(0).alter(); + }); +}; + +exports.down = async function (knex) { + await knex.schema.alterTable("monitor", function (table) { + table.boolean("domain_expiry_notification").defaultTo(1).alter(); + }); +}; diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 9fd33720d..9c2f76ec5 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -2761,7 +2761,7 @@ const monitorDefaults = { ignoreTls: false, upsideDown: false, expiryNotification: false, - domainExpiryNotification: true, + domainExpiryNotification: false, maxredirects: 10, accepted_statuscodes: ["200-299"], saveResponse: false, @@ -3199,7 +3199,11 @@ message HealthCheckResponse { this.checkMonitorDebounce = setTimeout(() => { this.$root.getSocket().emit("checkMointor", data, (res) => { + const wasSupported = this.hasDomain; this.hasDomain = !!res?.ok; + if (this.hasDomain !== wasSupported) { + this.monitor.domainExpiryNotification = this.hasDomain; + } this.domainExpiryUnsupportedReason = res.msgi18n ? this.$t(res.msg, res.meta) : res.msg; }); }, 500); diff --git a/test/e2e/specs/domain-expiry-notification.spec.js b/test/e2e/specs/domain-expiry-notification.spec.js new file mode 100644 index 000000000..9ac15ec21 --- /dev/null +++ b/test/e2e/specs/domain-expiry-notification.spec.js @@ -0,0 +1,105 @@ +import { expect, test } from "@playwright/test"; +import { login, restoreSqliteSnapshot, screenshot } from "../util-test"; + +test.describe("Domain Expiry Notification", () => { + test.beforeEach(async ({ page }) => { + await restoreSqliteSnapshot(page); + }); + + test("supported TLD auto-enables checkbox", async ({ page }, testInfo) => { + await page.goto("./add"); + await login(page); + + const monitorTypeSelect = page.getByTestId("monitor-type-select"); + await monitorTypeSelect.selectOption("http"); + + await page.getByTestId("url-input").fill("https://example.com"); + + const checkbox = page.getByLabel("Domain Name Expiry Notification"); + await expect(checkbox).toBeChecked(); + await expect(checkbox).toBeEnabled(); + + await screenshot(testInfo, page); + }); + + test("unsupported TLD leaves checkbox disabled", async ({ page }, testInfo) => { + await page.goto("./add"); + await login(page); + + const monitorTypeSelect = page.getByTestId("monitor-type-select"); + await monitorTypeSelect.selectOption("http"); + + await page.getByTestId("url-input").fill("https://example.co"); + + const checkbox = page.getByLabel("Domain Name Expiry Notification"); + await expect(checkbox).not.toBeChecked(); + await expect(checkbox).toBeDisabled(); + + await screenshot(testInfo, page); + }); + + test("switching from supported to unsupported TLD disables checkbox", async ({ page }, testInfo) => { + await page.goto("./add"); + await login(page); + + const monitorTypeSelect = page.getByTestId("monitor-type-select"); + await monitorTypeSelect.selectOption("http"); + + const urlInput = page.getByTestId("url-input"); + const checkbox = page.getByLabel("Domain Name Expiry Notification"); + + await urlInput.fill("https://example.com"); + await expect(checkbox).toBeChecked(); + + await urlInput.fill("https://example.co"); + await expect(checkbox).not.toBeChecked(); + await expect(checkbox).toBeDisabled(); + + await screenshot(testInfo, page); + }); + + test("switching from unsupported to supported TLD enables checkbox", async ({ page }, testInfo) => { + await page.goto("./add"); + await login(page); + + const monitorTypeSelect = page.getByTestId("monitor-type-select"); + await monitorTypeSelect.selectOption("http"); + + const urlInput = page.getByTestId("url-input"); + const checkbox = page.getByLabel("Domain Name Expiry Notification"); + + await urlInput.fill("https://example.co"); + await expect(checkbox).not.toBeChecked(); + + await urlInput.fill("https://example.com"); + await expect(checkbox).toBeChecked(); + await expect(checkbox).toBeEnabled(); + + await screenshot(testInfo, page); + }); + + test("manual uncheck preserved when URL changes within same TLD", async ({ page }, testInfo) => { + await page.goto("./add"); + await login(page); + + const monitorTypeSelect = page.getByTestId("monitor-type-select"); + await monitorTypeSelect.selectOption("http"); + + const urlInput = page.getByTestId("url-input"); + const checkbox = page.getByLabel("Domain Name Expiry Notification"); + + await urlInput.fill("https://example.com"); + await expect(checkbox).toBeChecked(); + + await checkbox.uncheck(); + await expect(checkbox).not.toBeChecked(); + + await urlInput.fill("https://example.com/different-path"); + // Wait for debounce to fire and verify checkbox stays unchecked + await page.waitForTimeout(600); + await expect(checkbox).not.toBeChecked(); + await expect(checkbox).toBeEnabled(); + + await screenshot(testInfo, page); + }); +});